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)}