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(level-blocks): add KBest/Pareto collector blocks and run consumption path

Collectors are the selection layer above scoring: a collector picks a set
out of the candidate stream instead of ranking one schedule. KBest keeps
the best k schedules lexicographically over referenced scorer instances;
Pareto keeps the non-dominated set. Both reference scorers by instanceId,
resolved at run-wiring time (like objective.lexicographic) so the spec
stays plain JSON and round-trips through fillRunConfig untouched.

Adds the Collector ADT and assertNeverCollector, ScheduleStream.kBestByLex
(reusing lexBetter), the KBest/Pareto blocks under levelset.KBest.v1 /
levelset.Pareto.v1, and LevelingRun.collectors() which runs every enabled
collector against a fresh stream keyed by graph instanceId. Validation
mirrors the lexicographic path: fillRunConfig rejects collector references
to missing/non-scorer instances, runFromConfig rejects references to
disabled scorers.

Russ T. Fugal (Jul 16, 2026, 4:14 PM -0600) f12a355d 99b3ec41

+471 -6
+1
README.md
··· 266 266 - **`analyzeRun(resolved, constraints, { scorers, search })`** — static validation _before_ searching: unknown resource/task/unit references, contradictory or unfittable deadline/release pairs, precedence and unit-precedence cycles, kinds the chosen search won't enforce. Issues carry stable `LEVELSET_E###`/`W###` codes and attribute to the **named constraint instance** (`name` on any constraint; instanceIds fill in for config-built runs). `runFromConfig` throws `RunAnalysisError` on errors; warnings ride along. 267 267 - **`enabled: false`** on a constraint or block — the present-but-inactive lever for what-if comparisons; searches skip it and record the skip as a `disabledConstraints` annotation, so "disabled" stays distinguishable from "absent". 268 268 - **`weightedScorer` / `bestByLex`** — multi-objective combination as data: a weight map (with `null` = reported-but-unweighted) or ordered lexicographic tiers. 269 + - **Collector blocks (`KBestBlock`, `ParetoBlock`)** — the selection layer, one step above scoring: where a scorer ranks a single schedule, a collector picks a _set_ out of the candidate stream. `KBest` (`levelset.KBest.v1`, params `{ k, by: [scorerInstanceId…] }`) keeps the best `k` schedules ranked lexicographically over the referenced scorers; `Pareto` (`levelset.Pareto.v1`, params `{ over: [scorerInstanceId…] }`) keeps the non-dominated set. They reference scorers by instanceId (resolved at run-wiring time, like `objective.lexicographic`), so the spec stays plain JSON. `run.collectors()` runs every enabled collector against a fresh stream and returns each one's schedule set keyed by instanceId. Since `serialSGS` emits a single schedule today, a collector currently selects that one schedule — collectors become a genuine lever once a multi-candidate search lands. 269 270 - **`scheduleProfiles(schedule)`** — shared derived data (per-resource day histograms, per-unit open spans) computed once per schedule; every scoring block reads these slots instead of recomputing. 270 271 - **Provenance** — `run.materialize(schedule)` embeds a `levelset` block in the output `ProjectFile`: engine version, the _filled_ run config (defaults expanded — rerunning it reproduces the schedule), seed, achieved scores, warnings, and search annotations. The JSON writer/reader round-trips it, so "why does this schedule look like this?" is answerable from the file itself. 271 272
+11 -1
src/leveling.ts
··· 34 34 CrewFlowContinuityConstraint, 35 35 DeadlineConstraint, 36 36 ReleaseConstraint, 37 + Collector, 38 + KBestCollector, 39 + ParetoCollector, 37 40 Scorer, 38 41 ScorerReads, 39 42 Explanation, ··· 50 53 Pipeline, 51 54 ResolveOptions, 52 55 } from "./level-core/types.ts"; 53 - export { pipe, assertNeverConstraint, constraintLabel } from "./level-core/types.ts"; 56 + export { 57 + pipe, 58 + assertNeverConstraint, 59 + assertNeverCollector, 60 + constraintLabel, 61 + } from "./level-core/types.ts"; 54 62 55 63 // ── Pipeline implementations ───────────────────────────────────────── 56 64 export * from "./level-core/calendarDays.ts"; ··· 86 94 export * from "./level-blocks/unimodalDeviation.ts"; 87 95 export * from "./level-blocks/makespan.ts"; 88 96 export * from "./level-blocks/serialSgsSearch.ts"; 97 + export * from "./level-blocks/kBest.ts"; 98 + export * from "./level-blocks/pareto.ts";
+47
src/level-blocks/kBest.ts
··· 1 + // KBest — Collector block. 2 + // 3 + // Keeps the best `k` schedules out of a candidate stream, ranked 4 + // lexicographically over the referenced scorer instances: the second scorer 5 + // only breaks ties on the first, and so on (order matters). Unlike a 6 + // Scorer, which ranks a single schedule, a Collector selects a *set* — the 7 + // run consumption path (`LevelingRun.collectors()`) executes it. 8 + // 9 + // The `by` entries are scorer instanceIds from the same pipeline, not Scorer 10 + // closures: they resolve to compiled scorers only at run-wiring time (as 11 + // `objective.lexicographic` does), so the emitted spec stays plain JSON. 12 + // 13 + // Collectors are a selection layer above the solver, so `toMiniZinc` emits 14 + // an explanatory comment rather than a constraint fragment. 15 + 16 + import { z } from "zod"; 17 + 18 + import type { KBestCollector } from "../level-core/types.ts"; 19 + 20 + import type { CollectorBlock, MiniZincFragment } from "./types.ts"; 21 + 22 + export const KBestInputSchema = z.object({ 23 + k: z.number().int().positive(), 24 + /** Scorer instanceIds in tie-break priority order; at least one. */ 25 + by: z.array(z.string()).min(1), 26 + }); 27 + 28 + export type KBestInput = z.infer<typeof KBestInputSchema>; 29 + 30 + const KBestOutputSchema: z.ZodType<KBestCollector> = z.object({ 31 + kind: z.literal("KBest"), 32 + k: z.number().int().positive(), 33 + by: z.array(z.string()).min(1), 34 + }); 35 + 36 + export const KBestBlock: CollectorBlock<KBestInput> = { 37 + id: "KBest", 38 + schema: { input: KBestInputSchema, output: KBestOutputSchema }, 39 + apply: ({ k, by }): KBestCollector => ({ kind: "KBest", k, by }), 40 + toMiniZinc: ({ k }): MiniZincFragment => ({ 41 + text: `% KBest(k=${String(k)}) — selection layer; keeps the best k schedules, emits no solver constraint.`, 42 + }), 43 + doc: { 44 + nl: "Keeps the best k schedules, ranked lexicographically over the referenced scorers (order matters).", 45 + pseudocode: "sort(candidates, lex(by)).slice(0, k)", 46 + }, 47 + };
+44
src/level-blocks/pareto.ts
··· 1 + // Pareto — Collector block. 2 + // 3 + // Keeps the non-dominated set across the referenced scorer instances (the 4 + // Pareto frontier): a schedule survives unless another is at least as good 5 + // on every scorer and strictly better on at least one. Two or more scorers 6 + // are the sensible case; one degenerates to "the best by that scorer". 7 + // 8 + // The `over` entries are scorer instanceIds from the same pipeline, not 9 + // Scorer closures: they resolve to compiled scorers only at run-wiring time 10 + // (as `objective.lexicographic` does), so the emitted spec stays plain JSON. 11 + // 12 + // Collectors are a selection layer above the solver, so `toMiniZinc` emits 13 + // an explanatory comment rather than a constraint fragment. 14 + 15 + import { z } from "zod"; 16 + 17 + import type { ParetoCollector } from "../level-core/types.ts"; 18 + 19 + import type { CollectorBlock, MiniZincFragment } from "./types.ts"; 20 + 21 + export const ParetoInputSchema = z.object({ 22 + /** Scorer instanceIds spanning the objective space; at least one. */ 23 + over: z.array(z.string()).min(1), 24 + }); 25 + 26 + export type ParetoInput = z.infer<typeof ParetoInputSchema>; 27 + 28 + const ParetoOutputSchema: z.ZodType<ParetoCollector> = z.object({ 29 + kind: z.literal("Pareto"), 30 + over: z.array(z.string()).min(1), 31 + }); 32 + 33 + export const ParetoBlock: CollectorBlock<ParetoInput> = { 34 + id: "Pareto", 35 + schema: { input: ParetoInputSchema, output: ParetoOutputSchema }, 36 + apply: ({ over }): ParetoCollector => ({ kind: "Pareto", over }), 37 + toMiniZinc: (): MiniZincFragment => ({ 38 + text: `% Pareto — selection layer; keeps the non-dominated set across the referenced scorers, emits no solver constraint.`, 39 + }), 40 + doc: { 41 + nl: "Keeps the non-dominated set across the referenced scorers — the Pareto frontier.", 42 + pseudocode: "candidates.filter(c => !candidates.some(o => dominates(o, c, over)))", 43 + }, 44 + };
+86 -2
src/level-blocks/runConfig.ts
··· 21 21 import { resolveCalendar } from "../level-core/resolveCalendar.ts"; 22 22 import { streamFromFactory } from "../level-core/scheduleStream.ts"; 23 23 import type { 24 + Collector, 24 25 Constraint, 25 26 Project, 26 27 ResolvedProject, ··· 89 90 readonly weight: number | null; 90 91 } 91 92 93 + /** One collector block's output, resolved and executed against the search 94 + * stream. Labeled by the graph `instanceId` so callers can tell two 95 + * collectors of the same kind apart. */ 96 + export interface CollectorResult { 97 + readonly instanceId: string; 98 + readonly kind: Collector["kind"]; 99 + readonly schedules: ReadonlyArray<Schedule>; 100 + } 101 + 102 + /** A compiled, enabled collector paired with its graph instanceId — the 103 + * spec still references scorers by instanceId; `collectors()` binds them. */ 104 + interface CompiledCollector { 105 + readonly instanceId: string; 106 + readonly collector: Collector; 107 + } 108 + 92 109 export interface LevelingRun { 93 110 /** The filled config: every block's params expanded through its schema 94 111 * defaults, levelset version stamped. Persist this, not the input. */ ··· 106 123 stream(): ScheduleStream; 107 124 /** Drain the stream and keep the best schedule by the objective. */ 108 125 best(): Promise<Schedule | null>; 126 + /** Run every enabled collector block against a fresh search stream, 127 + * returning each one's selected schedule set keyed by graph instanceId. 128 + * Empty when the pipeline declares no collectors. */ 129 + collectors(): Promise<ReadonlyArray<CollectorResult>>; 109 130 /** Every scorer's raw value, keyed by instanceId — the reported view. */ 110 131 scores(schedule: Schedule): Record<string, number>; 111 132 /** Write the schedule back onto the source project, embedding run ··· 128 149 .filter((b) => reg.get(b.code)?.family === "scorer") 129 150 .map((b) => b.instanceId), 130 151 ); 152 + // Collector blocks reference scorers by instanceId (KBest.by / Pareto.over); 153 + // like weights and lexicographic tiers those refs must land on scorer-family 154 + // instances. Read the parsed params so defaults/coercions are applied. 155 + const collectorRefs = validated.graph.blocks 156 + .filter((b) => reg.get(b.code)?.family === "collector") 157 + .flatMap((b) => { 158 + const params = validated.parsedParams.get(b.instanceId) as { 159 + by?: ReadonlyArray<string>; 160 + over?: ReadonlyArray<string>; 161 + }; 162 + const refs = params.by ?? params.over ?? []; 163 + return refs.map((k) => ({ k, where: `collector "${b.instanceId}" scorer reference` })); 164 + }); 131 165 const badRefs = [ 132 166 ...Object.keys(parsed.weights ?? {}).map((k) => ({ k, where: "weights key" })), 133 167 ...(parsed.objective?.lexicographic ?? []).map((k) => ({ 134 168 k, 135 169 where: "objective.lexicographic entry", 136 170 })), 171 + ...collectorRefs, 137 172 ].filter(({ k }) => !scorerIds.has(k)); 138 173 if (badRefs.length > 0) { 139 174 throw new GraphValidationError( ··· 161 196 constraints: Constraint[]; 162 197 scorers: WeightedScorerEntry[]; 163 198 search: Search; 199 + collectors: CompiledCollector[]; 164 200 } 165 201 166 202 function runTarget(config: RunConfig): CompileTarget<CompiledRun> { ··· 210 246 ]); 211 247 } 212 248 213 - return { constraints, scorers, search }; 249 + // Disabled collectors stay in the graph (what-if toggling) but don't 250 + // run — same present-but-inactive lever as scorers. 251 + const collectors: CompiledCollector[] = []; 252 + for (const compiled of families.collectors) { 253 + if (compiled.instance.enabled === false) continue; 254 + collectors.push({ 255 + instanceId: compiled.instance.instanceId, 256 + collector: compiled.output as Collector, 257 + }); 258 + } 259 + 260 + return { constraints, scorers, search, collectors }; 214 261 }, 215 262 }; 216 263 } ··· 242 289 workUnits: filled.resolve?.workUnits, 243 290 }); 244 291 245 - const { constraints, scorers, search } = compileGraph( 292 + const { constraints, scorers, search, collectors } = compileGraph( 246 293 registry, 247 294 filled.pipeline, 248 295 runTarget(filled), ··· 259 306 inactiveLex.map((id) => ({ 260 307 instanceId: id, 261 308 message: `objective.lexicographic entry "${id}" references a disabled scorer instance`, 309 + })), 310 + ); 311 + } 312 + 313 + // Same active-scorer requirement for collector references: fillRunConfig 314 + // proved they point at scorer instances; an *enabled* collector can only 315 + // select against *enabled* scorers. 316 + const inactiveCollectorRefs = collectors.flatMap((c) => 317 + collectorScorerRefs(c.collector) 318 + .filter((id) => !activeScorerIds.has(id)) 319 + .map((id) => ({ instanceId: c.instanceId, id })), 320 + ); 321 + if (inactiveCollectorRefs.length > 0) { 322 + throw new GraphValidationError( 323 + inactiveCollectorRefs.map(({ instanceId, id }) => ({ 324 + instanceId, 325 + message: `collector "${instanceId}" references a disabled scorer instance "${id}"`, 262 326 })), 263 327 ); 264 328 } ··· 292 356 } 293 357 return run.stream().bestBy(objective); 294 358 }, 359 + async collectors(): Promise<ReadonlyArray<CollectorResult>> { 360 + const byId = new Map(scorers.map((s) => [s.instanceId, s.scorer])); 361 + // Each collector drains its own fresh stream (stream() rebuilds the 362 + // generator), so they run independently. 363 + return Promise.all( 364 + collectors.map(async (c): Promise<CollectorResult> => { 365 + const refs = collectorScorerRefs(c.collector).map((id) => byId.get(id)!); 366 + const schedules = 367 + c.collector.kind === "KBest" 368 + ? await run.stream().kBestByLex(refs, c.collector.k) 369 + : await run.stream().paretoFrontier(refs); 370 + return { instanceId: c.instanceId, kind: c.collector.kind, schedules }; 371 + }), 372 + ); 373 + }, 295 374 scores(schedule: Schedule): Record<string, number> { 296 375 const report: Record<string, number> = {}; 297 376 for (const entry of scorers) report[entry.instanceId] = entry.scorer.score(schedule); ··· 316 395 }, 317 396 }; 318 397 return run; 398 + } 399 + 400 + /** The scorer instanceIds a collector references, whatever its kind. */ 401 + function collectorScorerRefs(collector: Collector): ReadonlyArray<string> { 402 + return collector.kind === "KBest" ? collector.by : collector.over; 319 403 } 320 404 321 405 // Search.run returns a Failure as the generator's return value;
+21
src/level-blocks/standard.ts
··· 13 13 import { ConcurrentUnitsLimitBlock } from "./concurrentUnitsLimit.ts"; 14 14 import { DeadlineBlock } from "./deadline.ts"; 15 15 import { HiringLagPenaltyBlock } from "./hiringLagPenalty.ts"; 16 + import { KBestBlock } from "./kBest.ts"; 16 17 import { MakespanBlock } from "./makespan.ts"; 17 18 import { MaxConcurrentResourceBlock } from "./maxConcurrentResource.ts"; 18 19 import { OpenUnitPenaltyBlock } from "./openUnitPenalty.ts"; 20 + import { ParetoBlock } from "./pareto.ts"; 19 21 import { PeakCapBlock } from "./peakCap.ts"; 20 22 import { createRegistry, type BlockDef, type BlockRegistry } from "./registry.ts"; 21 23 import { ReleaseBlock } from "./release.ts"; ··· 35 37 unimodalDeviation: "levelset.UnimodalDeviation.v1", 36 38 makespan: "levelset.Makespan.v1", 37 39 serialSGS: "levelset.SerialSGS.v1", 40 + kBest: "levelset.KBest.v1", 41 + pareto: "levelset.Pareto.v1", 38 42 } as const; 39 43 40 44 export function standardBlockDefs(): BlockDef[] { ··· 120 124 tier: "engineer", 121 125 block: SerialSGSBlock as never, 122 126 defaultShadow: {}, 127 + }, 128 + { 129 + code: STANDARD_CODES.kBest, 130 + family: "collector", 131 + tier: "engineer", 132 + block: KBestBlock as never, 133 + // Placeholder scorer reference — the app rewires `by` to real scorer 134 + // instanceIds before building a config (same spirit as the id-0 135 + // placeholders on the constraint shadows). 136 + defaultShadow: { k: 3, by: ["makespan"] }, 137 + }, 138 + { 139 + code: STANDARD_CODES.pareto, 140 + family: "collector", 141 + tier: "engineer", 142 + block: ParetoBlock as never, 143 + defaultShadow: { over: ["makespan", "cost"] }, 123 144 }, 124 145 ]; 125 146 }
+11 -2
src/level-blocks/types.ts
··· 2 2 // 3 3 // A Block is the LCNC composition unit: a typed quadruple shared by the 4 4 // visual editor, the LLM agent, and the compiler. 5 - // Three families differ only in the output type `O`: 5 + // Four families differ only in the output type `O`: 6 6 // 7 7 // • Constraint blocks — O = Constraint 8 8 // • Scoring blocks — O = Scorer 9 9 // • Search blocks — O = Search | SearchTransformer 10 + // • Collector blocks — O = Collector (selection layer, not a solver 11 + // constraint — `toMiniZinc` emits an explanatory `% comment` fragment) 10 12 // 11 13 // Constraint variants are the *interchange format* between Constraint blocks 12 14 // and Search; the ADT is the wire, the Block is the cable. 13 15 14 16 import type { ZodType } from "zod"; 15 17 16 - import type { Constraint, Scorer, Search, SearchTransformer } from "../level-core/types.ts"; 18 + import type { 19 + Collector, 20 + Constraint, 21 + Scorer, 22 + Search, 23 + SearchTransformer, 24 + } from "../level-core/types.ts"; 17 25 18 26 /** Top-level MiniZinc text emitted for a single block — `constraint`, 19 27 * `var`, `array`, etc. The compiler concatenates fragments and resolves ··· 48 56 export type ConstraintBlock<I> = Block<I, Constraint>; 49 57 export type ScoringBlock<I> = Block<I, Scorer>; 50 58 export type SearchBlock<I> = Block<I, Search | SearchTransformer>; 59 + export type CollectorBlock<I> = Block<I, Collector>;
+16 -1
src/level-core/scheduleStream.ts
··· 1 1 // Lazy schedule enumeration. 2 2 // Wraps an AsyncIterable<Schedule> so that filter/map/take/branch stay lazy 3 - // and only bestBy/paretoFrontier/collect actually drain. 3 + // and only the terminal ops (bestBy, bestByLex, kBestByLex, paretoFrontier, 4 + // collect) actually drain. 4 5 5 6 import type { Schedule, ScheduleStream, Scorer } from "./types.ts"; 6 7 ··· 83 84 } 84 85 } 85 86 return bestSchedule; 87 + } 88 + 89 + async kBestByLex(scorers: ReadonlyArray<Scorer>, k: number): Promise<ReadonlyArray<Schedule>> { 90 + if (k <= 0) return []; 91 + const scored: { schedule: Schedule; scores: number[] }[] = []; 92 + for await (const s of this.source) { 93 + scored.push({ schedule: s, scores: scorers.map((sc) => sc.score(s)) }); 94 + } 95 + // Stable sort (V8/Bun guarantee it): equal-on-all-tiers keeps stream 96 + // order, matching bestByLex where first seen wins. 97 + scored.sort((a, b) => 98 + lexBetter(a.scores, b.scores, scorers) ? -1 : lexBetter(b.scores, a.scores, scorers) ? 1 : 0, 99 + ); 100 + return scored.slice(0, k).map((x) => x.schedule); 86 101 } 87 102 88 103 async paretoFrontier(scorers: ReadonlyArray<Scorer>): Promise<ReadonlyArray<Schedule>> {
+44
src/level-core/types.ts
··· 288 288 } 289 289 290 290 // ───────────────────────────────────────────────────────────────── 291 + // Collector ADT 292 + // 293 + // Selection-layer specs, one step above scoring: where a Scorer ranks a 294 + // single Schedule, a Collector picks a *set* out of a candidate stream 295 + // (top-k, Pareto frontier). Like the Constraint ADT it is the interchange 296 + // format — collector *blocks* emit these specs, the run consumption path 297 + // (`LevelingRun.collectors()`) executes them. The scorer references are 298 + // instanceIds (strings), not Scorer closures: they resolve to compiled 299 + // scorers only at run-wiring time, exactly as `objective.lexicographic` 300 + // does — so the spec stays plain JSON and round-trips through a run config 301 + // untouched. 302 + // ───────────────────────────────────────────────────────────────── 303 + 304 + export type Collector = KBestCollector | ParetoCollector; 305 + 306 + /** Keep the best `k` schedules, ordered lexicographically over `by` — the 307 + * second scorer only breaks ties on the first (order matters). */ 308 + export interface KBestCollector { 309 + readonly kind: "KBest"; 310 + readonly k: number; 311 + /** Scorer instanceIds, in tie-break priority order. */ 312 + readonly by: ReadonlyArray<string>; 313 + } 314 + 315 + /** Keep the non-dominated set across the `over` scorers (Pareto frontier). 316 + * Two or more scorers are the sensible case; one degenerates to "the best 317 + * by that scorer". */ 318 + export interface ParetoCollector { 319 + readonly kind: "Pareto"; 320 + /** Scorer instanceIds spanning the objective space. */ 321 + readonly over: ReadonlyArray<string>; 322 + } 323 + 324 + /** Forces a compile error if a new Collector variant is added but a switch 325 + * downstream isn't updated. */ 326 + export function assertNeverCollector(c: never): never { 327 + throw new Error(`unhandled Collector variant: ${JSON.stringify(c)}`); 328 + } 329 + 330 + // ───────────────────────────────────────────────────────────────── 291 331 // Failure & explanations 292 332 // 293 333 // Explanations describe *why* a search step rejected a partial schedule. ··· 385 425 /** Best schedule by ordered tiers: scorers compared lexicographically — 386 426 * the second scorer only breaks ties on the first, and so on. */ 387 427 bestByLex(scorers: ReadonlyArray<Scorer>): Promise<Schedule | null>; 428 + /** The best `k` schedules by those same ordered tiers, best first. Yields 429 + * fewer than `k` when the stream is shorter, and none when `k <= 0`. 430 + * Ties keep first-seen order (matching `bestByLex`'s determinism). */ 431 + kBestByLex(scorers: ReadonlyArray<Scorer>, k: number): Promise<ReadonlyArray<Schedule>>; 388 432 paretoFrontier(scorers: ReadonlyArray<Scorer>): Promise<ReadonlyArray<Schedule>>; 389 433 /** Bounded materialization — caller asserts the stream terminates. */ 390 434 collect(limit?: number): Promise<ReadonlyArray<Schedule>>;
+97
test/level-blocks/collectors.test.ts
··· 1 + // Collector blocks: KBest / Pareto schema + apply, and the kBestByLex stream 2 + // primitive they lean on. 3 + 4 + import { describe, expect, test } from "bun:test"; 5 + 6 + import { KBestBlock, KBestInputSchema } from "../../src/level-blocks/kBest.ts"; 7 + import { ParetoBlock, ParetoInputSchema } from "../../src/level-blocks/pareto.ts"; 8 + import { ScheduleStreamImpl } from "../../src/level-core/scheduleStream.ts"; 9 + import type { Schedule, Scorer } from "../../src/level-core/types.ts"; 10 + 11 + // A minimal Schedule stand-in carrying just the makespan the scorers read. 12 + function sched(makespan: number, cost: number): Schedule { 13 + return { 14 + resolved: undefined as never, 15 + tasks: [], 16 + makespan, 17 + annotations: new Map([["cost", cost]]), 18 + }; 19 + } 20 + 21 + const byMakespan: Scorer = { 22 + name: "makespan", 23 + direction: "min", 24 + score: (s) => s.makespan, 25 + }; 26 + const byCost: Scorer = { 27 + name: "cost", 28 + direction: "min", 29 + score: (s) => s.annotations.get("cost") as number, 30 + }; 31 + 32 + function streamOf(schedules: ReadonlyArray<Schedule>): ScheduleStreamImpl { 33 + return new ScheduleStreamImpl({ 34 + async *[Symbol.asyncIterator]() { 35 + for (const s of schedules) yield s; 36 + }, 37 + }); 38 + } 39 + 40 + describe("KBestBlock", () => { 41 + test("apply returns a KBest collector spec (plain JSON, no closure)", () => { 42 + expect(KBestBlock.apply({ k: 3, by: ["makespan", "cost"] })).toEqual({ 43 + kind: "KBest", 44 + k: 3, 45 + by: ["makespan", "cost"], 46 + }); 47 + }); 48 + 49 + test("schema requires a positive k and at least one scorer reference", () => { 50 + expect(KBestInputSchema.safeParse({ k: 0, by: ["makespan"] }).success).toBe(false); 51 + expect(KBestInputSchema.safeParse({ k: 2, by: [] }).success).toBe(false); 52 + expect(KBestInputSchema.safeParse({ k: 2, by: ["makespan"] }).success).toBe(true); 53 + }); 54 + 55 + test("toMiniZinc emits a comment, not a constraint", () => { 56 + expect(KBestBlock.toMiniZinc({ k: 3, by: ["makespan"] }).text).toStartWith("%"); 57 + }); 58 + }); 59 + 60 + describe("ParetoBlock", () => { 61 + test("apply returns a Pareto collector spec", () => { 62 + expect(ParetoBlock.apply({ over: ["makespan", "cost"] })).toEqual({ 63 + kind: "Pareto", 64 + over: ["makespan", "cost"], 65 + }); 66 + }); 67 + 68 + test("schema requires at least one scorer reference", () => { 69 + expect(ParetoInputSchema.safeParse({ over: [] }).success).toBe(false); 70 + expect(ParetoInputSchema.safeParse({ over: ["makespan"] }).success).toBe(true); 71 + }); 72 + 73 + test("toMiniZinc emits a comment, not a constraint", () => { 74 + expect(ParetoBlock.toMiniZinc({ over: ["makespan"] }).text).toStartWith("%"); 75 + }); 76 + }); 77 + 78 + describe("kBestByLex", () => { 79 + test("keeps k schedules ordered lexicographically, tie-broken by later tiers", async () => { 80 + // makespan ties at 5 between b and c; cost breaks the tie (b before c). 81 + const a = sched(4, 100); 82 + const b = sched(5, 10); 83 + const c = sched(5, 20); 84 + const d = sched(7, 1); 85 + const top = await streamOf([c, d, a, b]).kBestByLex([byMakespan, byCost], 2); 86 + expect(top.map((s) => [s.makespan, s.annotations.get("cost")])).toEqual([ 87 + [4, 100], 88 + [5, 10], 89 + ]); 90 + }); 91 + 92 + test("returns the whole stream when k exceeds its length, and none when k <= 0", async () => { 93 + const all = await streamOf([sched(2, 1), sched(1, 2)]).kBestByLex([byMakespan], 9); 94 + expect(all).toHaveLength(2); 95 + expect(await streamOf([sched(1, 1)]).kBestByLex([byMakespan], 0)).toEqual([]); 96 + }); 97 + });
+93
test/level-blocks/runConfig.test.ts
··· 174 174 ).toThrow(/disabled scorer/); 175 175 }); 176 176 177 + test("collectors run against the search stream, labeled by instanceId", async () => { 178 + const config = baseConfig(); 179 + config.pipeline.blocks.push( 180 + { 181 + instanceId: "top3", 182 + code: STANDARD_CODES.kBest, 183 + params: { k: 3, by: ["makespan", "wip-cost"] }, 184 + }, 185 + { 186 + instanceId: "frontier", 187 + code: STANDARD_CODES.pareto, 188 + params: { over: ["makespan", "wip-cost"] }, 189 + }, 190 + ); 191 + const run = runFromConfig(crewProject(), config); 192 + const results = await run.collectors(); 193 + expect(results.map((r) => r.instanceId).sort()).toEqual(["frontier", "top3"]); 194 + 195 + const kbest = results.find((r) => r.instanceId === "top3")!; 196 + const pareto = results.find((r) => r.instanceId === "frontier")!; 197 + expect(kbest.kind).toBe("KBest"); 198 + expect(pareto.kind).toBe("Pareto"); 199 + // serialSGS emits a single schedule, so both collectors select it — 200 + // KBest of a 1-schedule stream is that schedule; it is the Pareto set too. 201 + const best = await run.best(); 202 + expect(kbest.schedules).toHaveLength(1); 203 + expect(kbest.schedules[0]!.tasks).toEqual(best!.tasks); 204 + expect(pareto.schedules).toHaveLength(1); 205 + expect(pareto.schedules[0]!.tasks).toEqual(best!.tasks); 206 + }); 207 + 208 + test("a run with no collectors returns an empty collector set", async () => { 209 + const run = runFromConfig(crewProject(), baseConfig()); 210 + expect(await run.collectors()).toEqual([]); 211 + }); 212 + 213 + test("collector params round-trip through fillRunConfig untouched", () => { 214 + const config = baseConfig(); 215 + config.pipeline.blocks.push({ 216 + instanceId: "top3", 217 + code: STANDARD_CODES.kBest, 218 + params: { k: 3, by: ["makespan"] }, 219 + }); 220 + const filled = fillRunConfig(config); 221 + const kbest = filled.pipeline.blocks.find((b) => b.instanceId === "top3")!; 222 + expect(kbest.params).toEqual({ k: 3, by: ["makespan"] }); 223 + }); 224 + 225 + test("a disabled collector does not run", async () => { 226 + const config = baseConfig(); 227 + config.pipeline.blocks.push({ 228 + instanceId: "top3", 229 + code: STANDARD_CODES.kBest, 230 + params: { k: 3, by: ["makespan"] }, 231 + enabled: false, 232 + }); 233 + const run = runFromConfig(crewProject(), config); 234 + expect(await run.collectors()).toEqual([]); 235 + }); 236 + 237 + test("a collector referencing a non-scorer instance fails validation", () => { 238 + const config = baseConfig(); 239 + config.pipeline.blocks.push({ 240 + instanceId: "bad", 241 + code: STANDARD_CODES.kBest, 242 + // "crew-cap" is a constraint instance, not a scorer. 243 + params: { k: 2, by: ["crew-cap"] }, 244 + }); 245 + expect(() => runFromConfig(crewProject(), config)).toThrow(GraphValidationError); 246 + }); 247 + 248 + test("a collector referencing a missing instance fails validation", () => { 249 + const config = baseConfig(); 250 + config.pipeline.blocks.push({ 251 + instanceId: "bad", 252 + code: STANDARD_CODES.pareto, 253 + params: { over: ["nope"] }, 254 + }); 255 + expect(() => runFromConfig(crewProject(), config)).toThrow(GraphValidationError); 256 + }); 257 + 258 + test("a collector referencing a disabled scorer fails validation", () => { 259 + const config = baseConfig(); 260 + // Disable the wip-cost scorer, then reference it from a collector. 261 + config.pipeline.blocks[1] = { ...config.pipeline.blocks[1]!, enabled: false }; 262 + config.pipeline.blocks.push({ 263 + instanceId: "top3", 264 + code: STANDARD_CODES.kBest, 265 + params: { k: 2, by: ["wip-cost"] }, 266 + }); 267 + expect(() => runFromConfig(crewProject(), config)).toThrow(/disabled scorer/); 268 + }); 269 + 177 270 test("analysis errors throw RunAnalysisError with coded issues", () => { 178 271 const config = baseConfig(); 179 272 config.pipeline.blocks[0] = {