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:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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
|
## v1.3.2 — 2026-05-09
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ function App() {
|
|||||||
loading={treeLoading}
|
loading={treeLoading}
|
||||||
progress={searchProgress}
|
progress={searchProgress}
|
||||||
pinnedCourses={state.pinnedCourses}
|
pinnedCourses={state.pinnedCourses}
|
||||||
|
ranking={state.ranking}
|
||||||
onAdopt={adoptPlan}
|
onAdopt={adoptPlan}
|
||||||
onPin={pinCourse}
|
onPin={pinCourse}
|
||||||
onUnpin={unpinCourse}
|
onUnpin={unpinCourse}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { coursesBySet } from '../data/lookups';
|
|||||||
import { COURSE_DESCRIPTIONS } from '../data/courseDescriptions';
|
import { COURSE_DESCRIPTIONS } from '../data/courseDescriptions';
|
||||||
import { courseById } from '../data/lookups';
|
import { courseById } from '../data/lookups';
|
||||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||||
import { makePriorityScorer } from '../solver/priority';
|
import { makePriorityScorer, makePriorityRankWeight } from '../solver/priority';
|
||||||
import { specColor } from '../data/specColors';
|
import { specColor } from '../data/specColors';
|
||||||
import type { OptimizationMode, Term } from '../data/types';
|
import type { OptimizationMode, Term } from '../data/types';
|
||||||
import type { SetAnalysis } from '../solver/decisionTree';
|
import type { SetAnalysis } from '../solver/decisionTree';
|
||||||
@@ -214,6 +214,7 @@ function ElectiveSet({
|
|||||||
loading,
|
loading,
|
||||||
disabledCourseIds,
|
disabledCourseIds,
|
||||||
scorer,
|
scorer,
|
||||||
|
rankWeight,
|
||||||
mode,
|
mode,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
@@ -230,6 +231,7 @@ function ElectiveSet({
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
disabledCourseIds: Set<string>;
|
disabledCourseIds: Set<string>;
|
||||||
scorer: (specs: string[]) => number;
|
scorer: (specs: string[]) => number;
|
||||||
|
rankWeight: (specs: string[]) => number;
|
||||||
mode: OptimizationMode;
|
mode: OptimizationMode;
|
||||||
onPin: (courseId: string) => void;
|
onPin: (courseId: string) => void;
|
||||||
onUnpin: () => void;
|
onUnpin: () => void;
|
||||||
@@ -250,23 +252,24 @@ function ElectiveSet({
|
|||||||
const hasHighImpact = analysis && analysis.impact > 0;
|
const hasHighImpact = analysis && analysis.impact > 0;
|
||||||
|
|
||||||
// Determine the recommended choice. Mode-dependent comparison matches the
|
// 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;
|
let recommendedCourseId: string | null = null;
|
||||||
if (analysis && analysis.choices.length > 0) {
|
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) {
|
for (const ch of analysis.choices) {
|
||||||
if (!ch.evaluated) continue;
|
if (!ch.evaluated) continue;
|
||||||
const score = scorer(ch.ceilingSpecs);
|
const weight = rankWeight(ch.ceilingSpecs);
|
||||||
const isBetter =
|
const isBetter =
|
||||||
!best ||
|
!best ||
|
||||||
(mode === 'priority-order'
|
(mode === 'priority-order'
|
||||||
? score > best.score || (score === best.score && ch.ceilingCount > best.count)
|
? weight > best.weight || (weight === best.weight && ch.ceilingCount > best.count)
|
||||||
: ch.ceilingCount > best.count || (ch.ceilingCount === best.count && score > best.score));
|
: ch.ceilingCount > best.count || (ch.ceilingCount === best.count && weight > best.weight));
|
||||||
if (isBetter) {
|
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
|
// 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) {
|
export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disabledCourseIds, ranking, mode, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
|
||||||
const scorer = useMemo(() => makePriorityScorer(ranking), [ranking]);
|
const scorer = useMemo(() => makePriorityScorer(ranking), [ranking]);
|
||||||
|
const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]);
|
||||||
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
|
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
|
||||||
const hasPinned = Object.keys(pinnedCourses).length > 0;
|
const hasPinned = Object.keys(pinnedCourses).length > 0;
|
||||||
|
|
||||||
@@ -580,6 +584,7 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
|
|||||||
loading={treeLoading}
|
loading={treeLoading}
|
||||||
disabledCourseIds={disabledCourseIds}
|
disabledCourseIds={disabledCourseIds}
|
||||||
scorer={scorer}
|
scorer={scorer}
|
||||||
|
rankWeight={rankWeight}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onPin={(courseId) => onPin(set.id, courseId)}
|
onPin={(courseId) => onPin(set.id, courseId)}
|
||||||
onUnpin={() => onUnpin(set.id)}
|
onUnpin={() => onUnpin(set.id)}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||||
import { SPECIALIZATIONS } from '../data/specializations';
|
import { SPECIALIZATIONS } from '../data/specializations';
|
||||||
import { courseById } from '../data/lookups';
|
import { courseById } from '../data/lookups';
|
||||||
import { specColor } from '../data/specColors';
|
import { specColor } from '../data/specColors';
|
||||||
|
import { makePriorityRankWeight } from '../solver/priority';
|
||||||
import type { PlanOutcome } from '../solver/decisionTree';
|
import type { PlanOutcome } from '../solver/decisionTree';
|
||||||
|
|
||||||
const setNameById: Record<string, string> = {};
|
const setNameById: Record<string, string> = {};
|
||||||
@@ -16,6 +18,7 @@ interface TopPlansProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
progress: { iterations: number; iterationsTotal: number } | null;
|
progress: { iterations: number; iterationsTotal: number } | null;
|
||||||
pinnedCourses: Record<string, string | null>;
|
pinnedCourses: Record<string, string | null>;
|
||||||
|
ranking: string[];
|
||||||
onAdopt: (assignments: Record<string, string>) => void;
|
onAdopt: (assignments: Record<string, string>) => void;
|
||||||
onPin: (setId: string, courseId: string) => void;
|
onPin: (setId: string, courseId: string) => void;
|
||||||
onUnpin: (setId: string) => void;
|
onUnpin: (setId: string) => void;
|
||||||
@@ -25,7 +28,15 @@ function formatNum(n: number): string {
|
|||||||
return n.toLocaleString();
|
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 visible = plans.filter((p) => p.achievedSpecs.length > 0);
|
||||||
|
|
||||||
const pct = progress && progress.iterationsTotal > 0
|
const pct = progress && progress.iterationsTotal > 0
|
||||||
@@ -97,6 +108,7 @@ export function TopPlans({ plans, partial, loading, progress, pinnedCourses, onA
|
|||||||
plan={plan}
|
plan={plan}
|
||||||
rank={i + 1}
|
rank={i + 1}
|
||||||
pinnedCourses={pinnedCourses}
|
pinnedCourses={pinnedCourses}
|
||||||
|
rankWeight={rankWeight}
|
||||||
onAdopt={onAdopt}
|
onAdopt={onAdopt}
|
||||||
onPin={onPin}
|
onPin={onPin}
|
||||||
onUnpin={onUnpin}
|
onUnpin={onUnpin}
|
||||||
@@ -111,6 +123,7 @@ function PlanRow({
|
|||||||
plan,
|
plan,
|
||||||
rank,
|
rank,
|
||||||
pinnedCourses,
|
pinnedCourses,
|
||||||
|
rankWeight,
|
||||||
onAdopt,
|
onAdopt,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
@@ -118,10 +131,12 @@ function PlanRow({
|
|||||||
plan: PlanOutcome;
|
plan: PlanOutcome;
|
||||||
rank: number;
|
rank: number;
|
||||||
pinnedCourses: Record<string, string | null>;
|
pinnedCourses: Record<string, string | null>;
|
||||||
|
rankWeight: (specs: string[]) => number;
|
||||||
onAdopt: (assignments: Record<string, string>) => void;
|
onAdopt: (assignments: Record<string, string>) => void;
|
||||||
onPin: (setId: string, courseId: string) => void;
|
onPin: (setId: string, courseId: string) => void;
|
||||||
onUnpin: (setId: string) => void;
|
onUnpin: (setId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const weight = rankWeight(plan.achievedSpecs);
|
||||||
// Combine the plan's open-set assignments with the user's currently-pinned
|
// 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.
|
// courses so the row shows the full sequence across all 12 sets.
|
||||||
const assignmentEntries: [string, string][] = ELECTIVE_SETS
|
const assignmentEntries: [string, string][] = ELECTIVE_SETS
|
||||||
@@ -169,8 +184,11 @@ function PlanRow({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<span style={{ fontSize: '10px', color: '#888', marginLeft: 'auto' }}>
|
<span
|
||||||
score {plan.priorityScore}
|
title={`Lexicographic priority weight: ${weight.toLocaleString()}`}
|
||||||
|
style={{ fontSize: '10px', color: '#888', marginLeft: 'auto', fontVariantNumeric: 'tabular-nums' }}
|
||||||
|
>
|
||||||
|
score {formatScore(weight)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAdopt(plan.courseAssignments)}
|
onClick={() => onAdopt(plan.courseAssignments)}
|
||||||
|
|||||||
@@ -61,6 +61,24 @@ describe('BoundedRankedList', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('priorityRankWeight (lex compare)', () => {
|
||||||
|
it('a single top-ranked spec outweighs any combination of lower-ranked specs', async () => {
|
||||||
|
const { priorityRankWeight } = await import('../priority');
|
||||||
|
const ranking = ['HCR', 'BNK', 'BRM', 'CRF', 'EMT', 'ENT', 'FIN', 'FIM', 'GLB', 'LCM', 'MGT', 'MKT', 'MTO', 'SBI', 'STR'];
|
||||||
|
const justHCR = priorityRankWeight(['HCR'], ranking);
|
||||||
|
const allOthers = priorityRankWeight(['BNK', 'BRM', 'CRF', 'EMT', 'ENT', 'FIN', 'FIM', 'GLB', 'LCM', 'MGT', 'MKT', 'MTO', 'SBI', 'STR'], ranking);
|
||||||
|
expect(justHCR).toBeGreaterThan(allOthers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('among plans containing the top spec, the next-ranked spec is the tiebreaker', async () => {
|
||||||
|
const { priorityRankWeight } = await import('../priority');
|
||||||
|
const ranking = ['HCR', 'BNK', 'CRF'];
|
||||||
|
const hcrBnk = priorityRankWeight(['HCR', 'BNK'], ranking);
|
||||||
|
const hcrCrf = priorityRankWeight(['HCR', 'CRF'], ranking);
|
||||||
|
expect(hcrBnk).toBeGreaterThan(hcrCrf);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('compareOutcomes', () => {
|
describe('compareOutcomes', () => {
|
||||||
const make = (specs: string[], score: number, key: string): PlanOutcome => ({
|
const make = (specs: string[], score: number, key: string): PlanOutcome => ({
|
||||||
courseAssignments: { spr1: key },
|
courseAssignments: { spr1: key },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { coursesBySet } from '../data/lookups';
|
|||||||
import type { Course, OptimizationMode } from '../data/types';
|
import type { Course, OptimizationMode } from '../data/types';
|
||||||
import { maximizeCount, priorityOrder } from './optimizer';
|
import { maximizeCount, priorityOrder } from './optimizer';
|
||||||
import { computeUpperBounds } from './feasibility';
|
import { computeUpperBounds } from './feasibility';
|
||||||
import { makePriorityScorer } from './priority';
|
import { makePriorityScorer, makePriorityRankWeight } from './priority';
|
||||||
|
|
||||||
export interface ChoiceOutcome {
|
export interface ChoiceOutcome {
|
||||||
courseId: string;
|
courseId: string;
|
||||||
@@ -137,21 +137,27 @@ export class BoundedRankedList<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comparator for plan outcomes. Mode-dependent ordering:
|
* Comparator for plan outcomes. Mode-dependent ordering uses lexicographic
|
||||||
* - priority-order mode: (priorityScore desc, count desc, key asc)
|
* rank-weight (top-ranked spec dominates any combination of lower-ranked
|
||||||
* - maximize-count mode: (count desc, priorityScore desc, key asc)
|
* 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.
|
* Returns negative if a is better, positive if b is better.
|
||||||
*/
|
*/
|
||||||
export function makeOutcomeComparator(
|
export function makeOutcomeComparator(
|
||||||
mode: OptimizationMode,
|
mode: OptimizationMode,
|
||||||
|
ranking: string[],
|
||||||
): (a: PlanOutcome, b: PlanOutcome) => number {
|
): (a: PlanOutcome, b: PlanOutcome) => number {
|
||||||
|
const rankWeight = makePriorityRankWeight(ranking);
|
||||||
return (a, b) => {
|
return (a, b) => {
|
||||||
|
const aw = rankWeight(a.achievedSpecs);
|
||||||
|
const bw = rankWeight(b.achievedSpecs);
|
||||||
if (mode === 'priority-order') {
|
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;
|
if (a.achievedSpecs.length !== b.achievedSpecs.length) return b.achievedSpecs.length - a.achievedSpecs.length;
|
||||||
} else {
|
} else {
|
||||||
if (a.achievedSpecs.length !== b.achievedSpecs.length) return b.achievedSpecs.length - a.achievedSpecs.length;
|
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(
|
return assignmentKey(a.courseAssignments).localeCompare(
|
||||||
assignmentKey(b.courseAssignments),
|
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 {
|
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 {
|
interface CeilingComparable {
|
||||||
count: number;
|
count: number;
|
||||||
score: number;
|
specs: string[];
|
||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCeilingComparator(
|
function makeCeilingComparator(
|
||||||
mode: OptimizationMode,
|
mode: OptimizationMode,
|
||||||
|
ranking: string[],
|
||||||
): (a: CeilingComparable, b: CeilingComparable) => number {
|
): (a: CeilingComparable, b: CeilingComparable) => number {
|
||||||
|
const rankWeight = makePriorityRankWeight(ranking);
|
||||||
return (a, b) => {
|
return (a, b) => {
|
||||||
|
const aw = rankWeight(a.specs);
|
||||||
|
const bw = rankWeight(b.specs);
|
||||||
if (mode === 'priority-order') {
|
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;
|
if (a.count !== b.count) return b.count - a.count;
|
||||||
} else {
|
} else {
|
||||||
if (a.count !== b.count) return b.count - a.count;
|
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);
|
return a.key.localeCompare(b.key);
|
||||||
};
|
};
|
||||||
@@ -235,8 +247,8 @@ export function searchDecisionTree(
|
|||||||
}
|
}
|
||||||
const choiceKey: Record<string, string> = {};
|
const choiceKey: Record<string, string> = {};
|
||||||
|
|
||||||
const outcomeComparator = makeOutcomeComparator(mode);
|
const outcomeComparator = makeOutcomeComparator(mode, ranking);
|
||||||
const ceilingComparator = makeCeilingComparator(mode);
|
const ceilingComparator = makeCeilingComparator(mode, ranking);
|
||||||
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
|
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
const partial = false;
|
const partial = false;
|
||||||
@@ -290,12 +302,12 @@ export function searchDecisionTree(
|
|||||||
const currentKey = `${setId}:${courseId}`;
|
const currentKey = `${setId}:${courseId}`;
|
||||||
const existing: CeilingComparable = {
|
const existing: CeilingComparable = {
|
||||||
count: choice.ceilingCount,
|
count: choice.ceilingCount,
|
||||||
score: scorer(choice.ceilingSpecs),
|
specs: choice.ceilingSpecs,
|
||||||
key: choiceKey[currentKey] ?? '',
|
key: choiceKey[currentKey] ?? '',
|
||||||
};
|
};
|
||||||
const candidate: CeilingComparable = {
|
const candidate: CeilingComparable = {
|
||||||
count: result.achieved.length,
|
count: result.achieved.length,
|
||||||
score,
|
specs: result.achieved,
|
||||||
key: aKey,
|
key: aKey,
|
||||||
};
|
};
|
||||||
const ceilingImproved = ceilingComparator(candidate, existing) < 0;
|
const ceilingImproved = ceilingComparator(candidate, existing) < 0;
|
||||||
@@ -376,7 +388,6 @@ export function deriveFromLeaves(
|
|||||||
openSetIds: string[],
|
openSetIds: string[],
|
||||||
excludedCourseIds?: Set<string>,
|
excludedCourseIds?: Set<string>,
|
||||||
): { topK: PlanOutcome[]; setAnalyses: SetAnalysis[] } {
|
): { topK: PlanOutcome[]; setAnalyses: SetAnalysis[] } {
|
||||||
const scorer = makePriorityScorer(ranking);
|
|
||||||
const upperBounds = computeUpperBounds([], openSetIds, excludedCourseIds);
|
const upperBounds = computeUpperBounds([], openSetIds, excludedCourseIds);
|
||||||
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
||||||
|
|
||||||
@@ -401,8 +412,8 @@ export function deriveFromLeaves(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const choiceKey: Record<string, string> = {};
|
const choiceKey: Record<string, string> = {};
|
||||||
const ceilingComparator = makeCeilingComparator(mode);
|
const ceilingComparator = makeCeilingComparator(mode, ranking);
|
||||||
const outcomeComparator = makeOutcomeComparator(mode);
|
const outcomeComparator = makeOutcomeComparator(mode, ranking);
|
||||||
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
|
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
|
||||||
|
|
||||||
for (const leaf of leaves) {
|
for (const leaf of leaves) {
|
||||||
@@ -417,12 +428,12 @@ export function deriveFromLeaves(
|
|||||||
const currentKey = `${setId}:${courseId}`;
|
const currentKey = `${setId}:${courseId}`;
|
||||||
const existing: CeilingComparable = {
|
const existing: CeilingComparable = {
|
||||||
count: choice.ceilingCount,
|
count: choice.ceilingCount,
|
||||||
score: scorer(choice.ceilingSpecs),
|
specs: choice.ceilingSpecs,
|
||||||
key: choiceKey[currentKey] ?? '',
|
key: choiceKey[currentKey] ?? '',
|
||||||
};
|
};
|
||||||
const candidate: CeilingComparable = {
|
const candidate: CeilingComparable = {
|
||||||
count: leaf.achievedSpecs.length,
|
count: leaf.achievedSpecs.length,
|
||||||
score: leaf.priorityScore,
|
specs: leaf.achievedSpecs,
|
||||||
key: aKey,
|
key: aKey,
|
||||||
};
|
};
|
||||||
if (ceilingComparator(candidate, existing) < 0) {
|
if (ceilingComparator(candidate, existing) < 0) {
|
||||||
|
|||||||
@@ -19,3 +19,35 @@ export function makePriorityScorer(ranking: string[]): (specs: string[]) => numb
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lexicographic comparison weight: each spec encoded as a bit, top-ranked
|
||||||
|
* spec = highest bit. Higher rankWeight means a strictly preferred plan
|
||||||
|
* under priority order (a single top-ranked spec outweighs any combination
|
||||||
|
* of lower-ranked specs). Used by comparators; not for display.
|
||||||
|
*/
|
||||||
|
export function priorityRankWeight(specs: string[], ranking: string[]): number {
|
||||||
|
const rankIndex = new Map(ranking.map((id, i) => [id, i]));
|
||||||
|
const N = ranking.length;
|
||||||
|
let w = 0;
|
||||||
|
for (const id of specs) {
|
||||||
|
const r = rankIndex.get(id);
|
||||||
|
if (r === undefined) continue;
|
||||||
|
w += 1 << (N - 1 - r);
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makePriorityRankWeight(ranking: string[]): (specs: string[]) => number {
|
||||||
|
const rankIndex = new Map(ranking.map((id, i) => [id, i]));
|
||||||
|
const N = ranking.length;
|
||||||
|
return (specs) => {
|
||||||
|
let w = 0;
|
||||||
|
for (const id of specs) {
|
||||||
|
const r = rankIndex.get(id);
|
||||||
|
if (r === undefined) continue;
|
||||||
|
w += 1 << (N - 1 - r);
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -258,10 +258,12 @@ export function useAppState() {
|
|||||||
setSearchProgress({ iterations: e.data.iterations, iterationsTotal: e.data.iterationsTotal });
|
setSearchProgress({ iterations: e.data.iterations, iterationsTotal: e.data.iterationsTotal });
|
||||||
} else if (e.data.type === 'leafEvaluated') {
|
} else if (e.data.type === 'leafEvaluated') {
|
||||||
const cache = leafCacheRef.current;
|
const cache = leafCacheRef.current;
|
||||||
|
// Stop accepting new entries once the cap is reached; retain
|
||||||
|
// existing entries as a warm starting point for subsequent
|
||||||
|
// pin/unpin operations.
|
||||||
|
if (cache.leaves.size < LEAF_CACHE_CAP) {
|
||||||
const key = assignmentKey(e.data.leaf.courseAssignments);
|
const key = assignmentKey(e.data.leaf.courseAssignments);
|
||||||
cache.leaves.set(key, e.data.leaf);
|
cache.leaves.set(key, e.data.leaf);
|
||||||
if (cache.leaves.size > LEAF_CACHE_CAP) {
|
|
||||||
cache.leaves.clear();
|
|
||||||
}
|
}
|
||||||
} else if (e.data.type === 'allComplete') {
|
} else if (e.data.type === 'allComplete') {
|
||||||
setTreeResults(e.data.setAnalyses);
|
setTreeResults(e.data.setAnalyses);
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify('1.3.2'),
|
__APP_VERSION__: JSON.stringify('1.3.3'),
|
||||||
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
|
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user