v1.3.3: Lex priority comparator + warm-cache cap + score display
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.
This commit is contained in:
@@ -3,7 +3,7 @@ import { coursesBySet } from '../data/lookups';
|
||||
import type { Course, OptimizationMode } from '../data/types';
|
||||
import { maximizeCount, priorityOrder } from './optimizer';
|
||||
import { computeUpperBounds } from './feasibility';
|
||||
import { makePriorityScorer } from './priority';
|
||||
import { makePriorityScorer, makePriorityRankWeight } from './priority';
|
||||
|
||||
export interface ChoiceOutcome {
|
||||
courseId: string;
|
||||
@@ -137,21 +137,27 @@ export class BoundedRankedList<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparator for plan outcomes. Mode-dependent ordering:
|
||||
* - priority-order mode: (priorityScore desc, count desc, key asc)
|
||||
* - maximize-count mode: (count desc, priorityScore desc, key asc)
|
||||
* Comparator for plan outcomes. Mode-dependent ordering uses lexicographic
|
||||
* rank-weight (top-ranked spec dominates any combination of lower-ranked
|
||||
* specs):
|
||||
* - priority-order mode: (rankWeight desc, count desc, key asc)
|
||||
* - maximize-count mode: (count desc, rankWeight desc, key asc)
|
||||
* Returns negative if a is better, positive if b is better.
|
||||
*/
|
||||
export function makeOutcomeComparator(
|
||||
mode: OptimizationMode,
|
||||
ranking: string[],
|
||||
): (a: PlanOutcome, b: PlanOutcome) => number {
|
||||
const rankWeight = makePriorityRankWeight(ranking);
|
||||
return (a, b) => {
|
||||
const aw = rankWeight(a.achievedSpecs);
|
||||
const bw = rankWeight(b.achievedSpecs);
|
||||
if (mode === 'priority-order') {
|
||||
if (a.priorityScore !== b.priorityScore) return b.priorityScore - a.priorityScore;
|
||||
if (aw !== bw) return bw - aw;
|
||||
if (a.achievedSpecs.length !== b.achievedSpecs.length) return b.achievedSpecs.length - a.achievedSpecs.length;
|
||||
} else {
|
||||
if (a.achievedSpecs.length !== b.achievedSpecs.length) return b.achievedSpecs.length - a.achievedSpecs.length;
|
||||
if (a.priorityScore !== b.priorityScore) return b.priorityScore - a.priorityScore;
|
||||
if (aw !== bw) return bw - aw;
|
||||
}
|
||||
return assignmentKey(a.courseAssignments).localeCompare(
|
||||
assignmentKey(b.courseAssignments),
|
||||
@@ -159,27 +165,33 @@ export function makeOutcomeComparator(
|
||||
};
|
||||
}
|
||||
|
||||
/** Default count-first comparator, retained for backward compatibility with tests. */
|
||||
/** Default count-first comparator (uses default ranking), retained for backward compatibility with tests. */
|
||||
export function compareOutcomes(a: PlanOutcome, b: PlanOutcome): number {
|
||||
return makeOutcomeComparator('maximize-count')(a, b);
|
||||
// Default to alphabetical ranking; tests using this directly only exercise
|
||||
// simple count/key cases that are insensitive to ranking.
|
||||
return makeOutcomeComparator('maximize-count', [])(a, b);
|
||||
}
|
||||
|
||||
interface CeilingComparable {
|
||||
count: number;
|
||||
score: number;
|
||||
specs: string[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
function makeCeilingComparator(
|
||||
mode: OptimizationMode,
|
||||
ranking: string[],
|
||||
): (a: CeilingComparable, b: CeilingComparable) => number {
|
||||
const rankWeight = makePriorityRankWeight(ranking);
|
||||
return (a, b) => {
|
||||
const aw = rankWeight(a.specs);
|
||||
const bw = rankWeight(b.specs);
|
||||
if (mode === 'priority-order') {
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
if (aw !== bw) return bw - aw;
|
||||
if (a.count !== b.count) return b.count - a.count;
|
||||
} else {
|
||||
if (a.count !== b.count) return b.count - a.count;
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
if (aw !== bw) return bw - aw;
|
||||
}
|
||||
return a.key.localeCompare(b.key);
|
||||
};
|
||||
@@ -235,8 +247,8 @@ export function searchDecisionTree(
|
||||
}
|
||||
const choiceKey: Record<string, string> = {};
|
||||
|
||||
const outcomeComparator = makeOutcomeComparator(mode);
|
||||
const ceilingComparator = makeCeilingComparator(mode);
|
||||
const outcomeComparator = makeOutcomeComparator(mode, ranking);
|
||||
const ceilingComparator = makeCeilingComparator(mode, ranking);
|
||||
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
|
||||
let iterations = 0;
|
||||
const partial = false;
|
||||
@@ -290,12 +302,12 @@ export function searchDecisionTree(
|
||||
const currentKey = `${setId}:${courseId}`;
|
||||
const existing: CeilingComparable = {
|
||||
count: choice.ceilingCount,
|
||||
score: scorer(choice.ceilingSpecs),
|
||||
specs: choice.ceilingSpecs,
|
||||
key: choiceKey[currentKey] ?? '',
|
||||
};
|
||||
const candidate: CeilingComparable = {
|
||||
count: result.achieved.length,
|
||||
score,
|
||||
specs: result.achieved,
|
||||
key: aKey,
|
||||
};
|
||||
const ceilingImproved = ceilingComparator(candidate, existing) < 0;
|
||||
@@ -376,7 +388,6 @@ export function deriveFromLeaves(
|
||||
openSetIds: string[],
|
||||
excludedCourseIds?: Set<string>,
|
||||
): { topK: PlanOutcome[]; setAnalyses: SetAnalysis[] } {
|
||||
const scorer = makePriorityScorer(ranking);
|
||||
const upperBounds = computeUpperBounds([], openSetIds, excludedCourseIds);
|
||||
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
||||
|
||||
@@ -401,8 +412,8 @@ export function deriveFromLeaves(
|
||||
};
|
||||
}
|
||||
const choiceKey: Record<string, string> = {};
|
||||
const ceilingComparator = makeCeilingComparator(mode);
|
||||
const outcomeComparator = makeOutcomeComparator(mode);
|
||||
const ceilingComparator = makeCeilingComparator(mode, ranking);
|
||||
const outcomeComparator = makeOutcomeComparator(mode, ranking);
|
||||
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
|
||||
|
||||
for (const leaf of leaves) {
|
||||
@@ -417,12 +428,12 @@ export function deriveFromLeaves(
|
||||
const currentKey = `${setId}:${courseId}`;
|
||||
const existing: CeilingComparable = {
|
||||
count: choice.ceilingCount,
|
||||
score: scorer(choice.ceilingSpecs),
|
||||
specs: choice.ceilingSpecs,
|
||||
key: choiceKey[currentKey] ?? '',
|
||||
};
|
||||
const candidate: CeilingComparable = {
|
||||
count: leaf.achievedSpecs.length,
|
||||
score: leaf.priorityScore,
|
||||
specs: leaf.achievedSpecs,
|
||||
key: aKey,
|
||||
};
|
||||
if (ceilingComparator(candidate, existing) < 0) {
|
||||
|
||||
Reference in New Issue
Block a user