A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(leveling): lexicographic objectives in streams and run configs

ScheduleStream.bestByLex selects by ordered tiers — the second scorer
only breaks ties on the first — and RunConfig gains an
objective.lexicographic section (ordered scorer instanceIds) that best()
honors, with weights staying report-only. Motivated by a reference
pipeline whose selection is a 4-tuple (lateness, labor cost, peak
headcount, makespan) that a single weighted sum cannot express.

Russ T. Fugal (Jul 16, 2026, 12:12 AM -0600) 2604949b a8817373

+119 -5
+39 -5
src/level-blocks/runConfig.ts
··· 71 71 /** Objective weights keyed by scorer instanceId. Missing = 1, null = 72 72 * compute and report but don't optimize on it. */ 73 73 weights: z.record(z.string(), z.number().nullable()).optional(), 74 + /** Tiered selection: ordered scorer instanceIds compared 75 + * lexicographically — the second tier only breaks ties on the first. 76 + * When present, `best()` selects by these tiers and `weights` remain 77 + * report-only. */ 78 + objective: z 79 + .strictObject({ 80 + lexicographic: z.array(z.string()).min(1), 81 + }) 82 + .optional(), 74 83 }); 75 84 export type RunConfig = z.infer<typeof runConfigSchema>; 76 85 ··· 114 123 const validated = validateGraph(reg, parsed.pipeline); 115 124 if (!validated.ok) throw new GraphValidationError(validated.issues); 116 125 117 - const weightKeys = Object.keys(parsed.weights ?? {}); 118 126 const scorerIds = new Set( 119 127 validated.graph.blocks 120 128 .filter((b) => reg.get(b.code)?.family === "scorer") 121 129 .map((b) => b.instanceId), 122 130 ); 123 - const badKeys = weightKeys.filter((k) => !scorerIds.has(k)); 124 - if (badKeys.length > 0) { 131 + const badRefs = [ 132 + ...Object.keys(parsed.weights ?? {}).map((k) => ({ k, where: "weights key" })), 133 + ...(parsed.objective?.lexicographic ?? []).map((k) => ({ 134 + k, 135 + where: "objective.lexicographic entry", 136 + })), 137 + ].filter(({ k }) => !scorerIds.has(k)); 138 + if (badRefs.length > 0) { 125 139 throw new GraphValidationError( 126 - badKeys.map((k) => ({ 140 + badRefs.map(({ k, where }) => ({ 127 141 instanceId: k, 128 - message: `weights key "${k}" does not reference a scorer-family block instance`, 142 + message: `${where} "${k}" does not reference a scorer-family block instance`, 129 143 })), 130 144 ); 131 145 } ··· 230 244 231 245 const { constraints, scorers, search } = compileGraph(registry, filled.pipeline, runTarget(filled)); 232 246 247 + // fillRunConfig checked lexicographic ids reference scorer blocks; they 248 + // must also be *active* (a disabled scorer can't drive selection). 249 + const activeScorerIds = new Set(scorers.map((s) => s.instanceId)); 250 + const inactiveLex = (filled.objective?.lexicographic ?? []).filter( 251 + (id) => !activeScorerIds.has(id), 252 + ); 253 + if (inactiveLex.length > 0) { 254 + throw new GraphValidationError( 255 + inactiveLex.map((id) => ({ 256 + instanceId: id, 257 + message: `objective.lexicographic entry "${id}" references a disabled scorer instance`, 258 + })), 259 + ); 260 + } 261 + 233 262 const analysis = analyzeRun(resolved, constraints, { 234 263 scorers: scorers.map((s) => s.scorer), 235 264 search, ··· 253 282 return streamFromFactory(() => stripReturn(search.run(resolved, constraints))); 254 283 }, 255 284 async best(): Promise<Schedule | null> { 285 + const lex = filled.objective?.lexicographic; 286 + if (lex) { 287 + const byId = new Map(scorers.map((s) => [s.instanceId, s.scorer])); 288 + return run.stream().bestByLex(lex.map((id) => byId.get(id)!)); 289 + } 256 290 return run.stream().bestBy(objective); 257 291 }, 258 292 scores(schedule: Schedule): Record<string, number> {
+29
src/level-core/scheduleStream.ts
··· 72 72 return bestSchedule; 73 73 } 74 74 75 + async bestByLex(scorers: ReadonlyArray<Scorer>): Promise<Schedule | null> { 76 + let bestSchedule: Schedule | null = null; 77 + let bestScores: number[] | null = null; 78 + for await (const s of this.source) { 79 + const scores = scorers.map((sc) => sc.score(s)); 80 + if (bestScores === null || lexBetter(scores, bestScores, scorers)) { 81 + bestScores = scores; 82 + bestSchedule = s; 83 + } 84 + } 85 + return bestSchedule; 86 + } 87 + 75 88 async paretoFrontier(scorers: ReadonlyArray<Scorer>): Promise<ReadonlyArray<Schedule>> { 76 89 const collected: { schedule: Schedule; scores: number[] }[] = []; 77 90 for await (const s of this.source) { ··· 103 116 } 104 117 return out; 105 118 } 119 + } 120 + 121 + // `a` beats `b` on the first tier where they differ; equal on all tiers is 122 + // not better (first seen wins, keeping selection deterministic). 123 + function lexBetter( 124 + a: ReadonlyArray<number>, 125 + b: ReadonlyArray<number>, 126 + scorers: ReadonlyArray<Scorer>, 127 + ): boolean { 128 + for (let i = 0; i < scorers.length; i++) { 129 + const ai = a[i]!; 130 + const bi = b[i]!; 131 + if (ai === bi) continue; 132 + return scorers[i]!.direction === "min" ? ai < bi : ai > bi; 133 + } 134 + return false; 106 135 } 107 136 108 137 // `a` dominates `b` iff a is at least as good on every scorer and strictly
+3
src/level-core/types.ts
··· 382 382 branch(fork: (s: Schedule) => ScheduleStream): ScheduleStream; 383 383 384 384 bestBy(scorer: Scorer): Promise<Schedule | null>; 385 + /** Best schedule by ordered tiers: scorers compared lexicographically — 386 + * the second scorer only breaks ties on the first, and so on. */ 387 + bestByLex(scorers: ReadonlyArray<Scorer>): Promise<Schedule | null>; 385 388 paretoFrontier(scorers: ReadonlyArray<Scorer>): Promise<ReadonlyArray<Schedule>>; 386 389 /** Bounded materialization — caller asserts the stream terminates. */ 387 390 collect(limit?: number): Promise<ReadonlyArray<Schedule>>;
+25
test/level-blocks/runConfig.test.ts
··· 134 134 expect(run2.config).toEqual(run1.config); 135 135 }); 136 136 137 + test("lexicographic objective drives best() and validates its references", async () => { 138 + const run = runFromConfig(crewProject(), { 139 + ...baseConfig(), 140 + objective: { lexicographic: ["makespan", "wip-cost"] }, 141 + }); 142 + const best = await run.best(); 143 + expect(best).not.toBeNull(); 144 + 145 + expect(() => 146 + runFromConfig(crewProject(), { 147 + ...baseConfig(), 148 + objective: { lexicographic: ["crew-cap"] }, 149 + }), 150 + ).toThrow(GraphValidationError); 151 + 152 + const config = baseConfig(); 153 + config.pipeline.blocks[1] = { ...config.pipeline.blocks[1]!, enabled: false }; 154 + expect(() => 155 + runFromConfig(crewProject(), { 156 + ...config, 157 + objective: { lexicographic: ["wip-cost"] }, 158 + }), 159 + ).toThrow(/disabled scorer/); 160 + }); 161 + 137 162 test("analysis errors throw RunAnalysisError with coded issues", () => { 138 163 const config = baseConfig(); 139 164 config.pipeline.blocks[0] = {
+23
test/level-core/scheduleStream.test.ts
··· 127 127 expect(frontier).toContain(a); 128 128 expect(frontier).toContain(d); 129 129 }); 130 + 131 + test("bestByLex breaks first-tier ties on the second tier", async () => { 132 + // makespan ties at 5; peak decides. (5,3) vs (5,2) vs (6,0) → (5,2). 133 + const a = fakeSchedule(5, 3); 134 + const b = fakeSchedule(5, 2); 135 + const c = fakeSchedule(6, 0); 136 + expect(await streamOf(a, b, c).bestByLex([makespanScorer, peakScorer])).toBe(b); 137 + }); 138 + 139 + test("bestByLex respects per-tier direction and keeps the first on full ties", async () => { 140 + const maxPeak: Scorer = { ...peakScorer, direction: "max" }; 141 + const a = fakeSchedule(5, 2); 142 + const b = fakeSchedule(5, 4); 143 + expect(await streamOf(a, b).bestByLex([makespanScorer, maxPeak])).toBe(b); 144 + 145 + const dupA = fakeSchedule(5, 2); 146 + const dupB = fakeSchedule(5, 2); 147 + expect(await streamOf(dupA, dupB).bestByLex([makespanScorer, peakScorer])).toBe(dupA); 148 + }); 149 + 150 + test("bestByLex on an empty stream returns null", async () => { 151 + expect(await streamOf().bestByLex([makespanScorer])).toBeNull(); 152 + }); 130 153 }); 131 154 132 155 describe("ScheduleStream — reusability", () => {