From b282709476fafea262dc622d7abb6a9572e2a5fa Mon Sep 17 00:00:00 2001 From: Bill Ballou Date: Sat, 9 May 2026 16:51:54 -0400 Subject: [PATCH] v1.3.3: Lex priority comparator + warm-cache cap + score display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1.3.1 comparator used a sum-of-weights priorityScore. With weights 15..1 across 15 specs, three lower-priority specs (BNK+BRM+CRF, sum 39) could outrank a single top-priority spec (HCR alone, sum 15). In priority-order mode this surfaced lower-priority plans above the user's top spec — the opposite of intent. Fix: replace sum-of-weights with a lexicographic rank weight. Each spec encodes as a bit, top-ranked spec = highest bit. So [HCR] = 16384 beats [BNK,BRM,CRF,EMT,ENT,FIN,FIM,GLB,LCM,MGT,MKT,MTO,SBI,STR] = 16383. A plan containing a higher-ranked spec ALWAYS outranks any plan that doesn't, regardless of how many lower-ranked specs the latter contains. Lower specs only act as tiebreakers among plans that all contain the same higher-ranked spec. Both modes use lex weight as the priority key; modes still differ in ordering: priority-order: (rankWeight desc, count desc, key asc) maximize-count: (count desc, rankWeight desc, key asc) Score display changes from the legacy sum (e.g. "score 29") to the lex weight in compact form (e.g. "score 24.6k"). Hover for full integer. The display now actually corresponds to ranking order. Other: - Cache cap (500k leaves) now retains existing entries instead of clearing on overflow. New entries past the cap are dropped; the cached subset stays available as a warm starting point. - Two new lex-weight tests in searchDecisionTree.test.ts: - single top-ranked spec outweighs all 14 others combined - tiebreaker is the next-ranked spec - All 84 tests pass; cached leaves stay valid across the comparator change since achievedSpecs (the input to lex compare) is unchanged. Files: solver/priority.ts (new functions), solver/decisionTree.ts (comparators take ranking), components/{TopPlans,CourseSelection}.tsx (score display + Recommended badge), state/appState.ts (cache-cap behavior), vite.config.ts, CHANGELOG.md. --- CHANGELOG.md | 9 ++++ app/src/App.tsx | 1 + app/src/components/CourseSelection.tsx | 21 +++++--- app/src/components/TopPlans.tsx | 24 +++++++-- .../__tests__/searchDecisionTree.test.ts | 18 +++++++ app/src/solver/decisionTree.ts | 51 +++++++++++-------- app/src/solver/priority.ts | 32 ++++++++++++ app/src/state/appState.ts | 10 ++-- app/vite.config.ts | 2 +- 9 files changed, 132 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 200a7a9..e78406b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v1.3.3 — 2026-05-09 + +### Changes + +- **Lexicographic priority comparison** — fixes a scoring bug where combinations of lower-priority specializations could outrank a single higher-priority specialization in priority-order mode. The comparator now uses lex-by-rank: a plan containing a higher-ranked specialization always beats a plan that doesn't, regardless of how many lower-ranked specializations the latter contains. Lower-ranked specializations only act as tiebreakers among plans that all contain the same higher-ranked specs. Same logic also tiebreaks within maximize-count mode. +- **Score display matches the comparator** — the per-plan score now shows the lexicographic rank weight in compact form (e.g. `score 24.6k`) instead of the legacy sum-of-weights. Hover the score for the full integer. +- **Cache cap retains warm entries** — when the leaf cache hits the 500k cap, new entries are now dropped instead of clearing the cache; the existing 500k stay as a starting point for subsequent pin/unpin operations. +- **Cache stays valid** across the comparator change — leaves cached under v1.3.2 still produce correct rankings under the new comparator since `achievedSpecs` (the input to lex compare) is unchanged. + ## v1.3.2 — 2026-05-09 ### Changes diff --git a/app/src/App.tsx b/app/src/App.tsx index e9b60bb..dad5d10 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -139,6 +139,7 @@ function App() { loading={treeLoading} progress={searchProgress} pinnedCourses={state.pinnedCourses} + ranking={state.ranking} onAdopt={adoptPlan} onPin={pinCourse} onUnpin={unpinCourse} diff --git a/app/src/components/CourseSelection.tsx b/app/src/components/CourseSelection.tsx index debf2cf..465ebe4 100644 --- a/app/src/components/CourseSelection.tsx +++ b/app/src/components/CourseSelection.tsx @@ -5,7 +5,7 @@ import { coursesBySet } from '../data/lookups'; import { COURSE_DESCRIPTIONS } from '../data/courseDescriptions'; import { courseById } from '../data/lookups'; import { useMediaQuery } from '../hooks/useMediaQuery'; -import { makePriorityScorer } from '../solver/priority'; +import { makePriorityScorer, makePriorityRankWeight } from '../solver/priority'; import { specColor } from '../data/specColors'; import type { OptimizationMode, Term } from '../data/types'; import type { SetAnalysis } from '../solver/decisionTree'; @@ -214,6 +214,7 @@ function ElectiveSet({ loading, disabledCourseIds, scorer, + rankWeight, mode, onPin, onUnpin, @@ -230,6 +231,7 @@ function ElectiveSet({ loading: boolean; disabledCourseIds: Set; scorer: (specs: string[]) => number; + rankWeight: (specs: string[]) => number; mode: OptimizationMode; onPin: (courseId: string) => void; onUnpin: () => void; @@ -250,23 +252,24 @@ function ElectiveSet({ const hasHighImpact = analysis && analysis.impact > 0; // Determine the recommended choice. Mode-dependent comparison matches the - // top-K comparator: priority-order ranks by (score, count); max-count by (count, score). + // top-K comparator: priority-order ranks by (rankWeight, count); max-count by (count, rankWeight). + // Lex rank weight: a single high-priority spec dominates any combination of lower ones. let recommendedCourseId: string | null = null; if (analysis && analysis.choices.length > 0) { - let best: { id: string; count: number; score: number } | null = null; + let best: { id: string; count: number; weight: number } | null = null; for (const ch of analysis.choices) { if (!ch.evaluated) continue; - const score = scorer(ch.ceilingSpecs); + const weight = rankWeight(ch.ceilingSpecs); const isBetter = !best || (mode === 'priority-order' - ? score > best.score || (score === best.score && ch.ceilingCount > best.count) - : ch.ceilingCount > best.count || (ch.ceilingCount === best.count && score > best.score)); + ? weight > best.weight || (weight === best.weight && ch.ceilingCount > best.count) + : ch.ceilingCount > best.count || (ch.ceilingCount === best.count && weight > best.weight)); if (isBetter) { - best = { id: ch.courseId, count: ch.ceilingCount, score }; + best = { id: ch.courseId, count: ch.ceilingCount, weight }; } } - if (best && (best.count > 0 || best.score > 0)) recommendedCourseId = best.id; + if (best && (best.count > 0 || best.weight > 0)) recommendedCourseId = best.id; } // Per-set still-searching indicator: search in progress AND at least one cell unevaluated @@ -500,6 +503,7 @@ const skeletonStyle = ` export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disabledCourseIds, ranking, mode, onPin, onUnpin, onClearAll }: CourseSelectionProps) { const scorer = useMemo(() => makePriorityScorer(ranking), [ranking]); + const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]); const terms: Term[] = ['Spring', 'Summer', 'Fall']; const hasPinned = Object.keys(pinnedCourses).length > 0; @@ -580,6 +584,7 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab loading={treeLoading} disabledCourseIds={disabledCourseIds} scorer={scorer} + rankWeight={rankWeight} mode={mode} onPin={(courseId) => onPin(set.id, courseId)} onUnpin={() => onUnpin(set.id)} diff --git a/app/src/components/TopPlans.tsx b/app/src/components/TopPlans.tsx index ee81891..d03c603 100644 --- a/app/src/components/TopPlans.tsx +++ b/app/src/components/TopPlans.tsx @@ -1,7 +1,9 @@ +import { useMemo } from 'react'; import { ELECTIVE_SETS } from '../data/electiveSets'; import { SPECIALIZATIONS } from '../data/specializations'; import { courseById } from '../data/lookups'; import { specColor } from '../data/specColors'; +import { makePriorityRankWeight } from '../solver/priority'; import type { PlanOutcome } from '../solver/decisionTree'; const setNameById: Record = {}; @@ -16,6 +18,7 @@ interface TopPlansProps { loading: boolean; progress: { iterations: number; iterationsTotal: number } | null; pinnedCourses: Record; + ranking: string[]; onAdopt: (assignments: Record) => void; onPin: (setId: string, courseId: string) => void; onUnpin: (setId: string) => void; @@ -25,7 +28,15 @@ function formatNum(n: number): string { return n.toLocaleString(); } -export function TopPlans({ plans, partial, loading, progress, pinnedCourses, onAdopt, onPin, onUnpin }: TopPlansProps) { +/** Compact form for the lex rank-weight, e.g. 16384 → "16.4k". */ +function formatScore(n: number): string { + if (n < 1000) return String(n); + const k = n / 1000; + return `${k.toFixed(k >= 100 ? 0 : 1)}k`; +} + +export function TopPlans({ plans, partial, loading, progress, pinnedCourses, ranking, onAdopt, onPin, onUnpin }: TopPlansProps) { + const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]); const visible = plans.filter((p) => p.achievedSpecs.length > 0); const pct = progress && progress.iterationsTotal > 0 @@ -97,6 +108,7 @@ export function TopPlans({ plans, partial, loading, progress, pinnedCourses, onA plan={plan} rank={i + 1} pinnedCourses={pinnedCourses} + rankWeight={rankWeight} onAdopt={onAdopt} onPin={onPin} onUnpin={onUnpin} @@ -111,6 +123,7 @@ function PlanRow({ plan, rank, pinnedCourses, + rankWeight, onAdopt, onPin, onUnpin, @@ -118,10 +131,12 @@ function PlanRow({ plan: PlanOutcome; rank: number; pinnedCourses: Record; + rankWeight: (specs: string[]) => number; onAdopt: (assignments: Record) => void; onPin: (setId: string, courseId: string) => void; onUnpin: (setId: string) => void; }) { + const weight = rankWeight(plan.achievedSpecs); // Combine the plan's open-set assignments with the user's currently-pinned // courses so the row shows the full sequence across all 12 sets. const assignmentEntries: [string, string][] = ELECTIVE_SETS @@ -169,8 +184,11 @@ function PlanRow({ ); })} - - score {plan.priorityScore} + + score {formatScore(weight)}