From f12a355dcd53807327cc51e61488efbc2883e0a4 Mon Sep 17 00:00:00 2001 From: "Russ T. Fugal" Date: Thu, 16 Jul 2026 11:20:49 -0600 Subject: [PATCH 1/3] 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. --- README.md | 1 + src/level-blocks/kBest.ts | 47 ++++++++++++++ src/level-blocks/pareto.ts | 44 +++++++++++++ src/level-blocks/runConfig.ts | 88 ++++++++++++++++++++++++- src/level-blocks/standard.ts | 21 ++++++ src/level-blocks/types.ts | 13 +++- src/level-core/scheduleStream.ts | 17 ++++- src/level-core/types.ts | 44 +++++++++++++ src/leveling.ts | 12 +++- test/level-blocks/collectors.test.ts | 97 ++++++++++++++++++++++++++++ test/level-blocks/runConfig.test.ts | 93 ++++++++++++++++++++++++++ 11 files changed, 471 insertions(+), 6 deletions(-) create mode 100644 src/level-blocks/kBest.ts create mode 100644 src/level-blocks/pareto.ts create mode 100644 test/level-blocks/collectors.test.ts diff --git a/README.md b/README.md index f9116a1..028cb1d 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,7 @@ The moving parts, each usable on its own: - **`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. - **`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". - **`weightedScorer` / `bestByLex`** — multi-objective combination as data: a weight map (with `null` = reported-but-unweighted) or ordered lexicographic tiers. +- **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. - **`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. - **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. diff --git a/src/level-blocks/kBest.ts b/src/level-blocks/kBest.ts new file mode 100644 index 0000000..a019795 --- /dev/null +++ b/src/level-blocks/kBest.ts @@ -0,0 +1,47 @@ +// KBest — Collector block. +// +// Keeps the best `k` schedules out of a candidate stream, ranked +// lexicographically over the referenced scorer instances: the second scorer +// only breaks ties on the first, and so on (order matters). Unlike a +// Scorer, which ranks a single schedule, a Collector selects a *set* — the +// run consumption path (`LevelingRun.collectors()`) executes it. +// +// The `by` entries are scorer instanceIds from the same pipeline, not Scorer +// closures: they resolve to compiled scorers only at run-wiring time (as +// `objective.lexicographic` does), so the emitted spec stays plain JSON. +// +// Collectors are a selection layer above the solver, so `toMiniZinc` emits +// an explanatory comment rather than a constraint fragment. + +import { z } from "zod"; + +import type { KBestCollector } from "../level-core/types.ts"; + +import type { CollectorBlock, MiniZincFragment } from "./types.ts"; + +export const KBestInputSchema = z.object({ + k: z.number().int().positive(), + /** Scorer instanceIds in tie-break priority order; at least one. */ + by: z.array(z.string()).min(1), +}); + +export type KBestInput = z.infer; + +const KBestOutputSchema: z.ZodType = z.object({ + kind: z.literal("KBest"), + k: z.number().int().positive(), + by: z.array(z.string()).min(1), +}); + +export const KBestBlock: CollectorBlock = { + id: "KBest", + schema: { input: KBestInputSchema, output: KBestOutputSchema }, + apply: ({ k, by }): KBestCollector => ({ kind: "KBest", k, by }), + toMiniZinc: ({ k }): MiniZincFragment => ({ + text: `% KBest(k=${String(k)}) — selection layer; keeps the best k schedules, emits no solver constraint.`, + }), + doc: { + nl: "Keeps the best k schedules, ranked lexicographically over the referenced scorers (order matters).", + pseudocode: "sort(candidates, lex(by)).slice(0, k)", + }, +}; diff --git a/src/level-blocks/pareto.ts b/src/level-blocks/pareto.ts new file mode 100644 index 0000000..4c316c8 --- /dev/null +++ b/src/level-blocks/pareto.ts @@ -0,0 +1,44 @@ +// Pareto — Collector block. +// +// Keeps the non-dominated set across the referenced scorer instances (the +// Pareto frontier): a schedule survives unless another is at least as good +// on every scorer and strictly better on at least one. Two or more scorers +// are the sensible case; one degenerates to "the best by that scorer". +// +// The `over` entries are scorer instanceIds from the same pipeline, not +// Scorer closures: they resolve to compiled scorers only at run-wiring time +// (as `objective.lexicographic` does), so the emitted spec stays plain JSON. +// +// Collectors are a selection layer above the solver, so `toMiniZinc` emits +// an explanatory comment rather than a constraint fragment. + +import { z } from "zod"; + +import type { ParetoCollector } from "../level-core/types.ts"; + +import type { CollectorBlock, MiniZincFragment } from "./types.ts"; + +export const ParetoInputSchema = z.object({ + /** Scorer instanceIds spanning the objective space; at least one. */ + over: z.array(z.string()).min(1), +}); + +export type ParetoInput = z.infer; + +const ParetoOutputSchema: z.ZodType = z.object({ + kind: z.literal("Pareto"), + over: z.array(z.string()).min(1), +}); + +export const ParetoBlock: CollectorBlock = { + id: "Pareto", + schema: { input: ParetoInputSchema, output: ParetoOutputSchema }, + apply: ({ over }): ParetoCollector => ({ kind: "Pareto", over }), + toMiniZinc: (): MiniZincFragment => ({ + text: `% Pareto — selection layer; keeps the non-dominated set across the referenced scorers, emits no solver constraint.`, + }), + doc: { + nl: "Keeps the non-dominated set across the referenced scorers — the Pareto frontier.", + pseudocode: "candidates.filter(c => !candidates.some(o => dominates(o, c, over)))", + }, +}; diff --git a/src/level-blocks/runConfig.ts b/src/level-blocks/runConfig.ts index 205df19..438c6f4 100644 --- a/src/level-blocks/runConfig.ts +++ b/src/level-blocks/runConfig.ts @@ -21,6 +21,7 @@ import { materialize } from "../level-core/materialize.ts"; import { resolveCalendar } from "../level-core/resolveCalendar.ts"; import { streamFromFactory } from "../level-core/scheduleStream.ts"; import type { + Collector, Constraint, Project, ResolvedProject, @@ -89,6 +90,22 @@ export interface WeightedScorerEntry { readonly weight: number | null; } +/** One collector block's output, resolved and executed against the search + * stream. Labeled by the graph `instanceId` so callers can tell two + * collectors of the same kind apart. */ +export interface CollectorResult { + readonly instanceId: string; + readonly kind: Collector["kind"]; + readonly schedules: ReadonlyArray; +} + +/** A compiled, enabled collector paired with its graph instanceId — the + * spec still references scorers by instanceId; `collectors()` binds them. */ +interface CompiledCollector { + readonly instanceId: string; + readonly collector: Collector; +} + export interface LevelingRun { /** The filled config: every block's params expanded through its schema * defaults, levelset version stamped. Persist this, not the input. */ @@ -106,6 +123,10 @@ export interface LevelingRun { stream(): ScheduleStream; /** Drain the stream and keep the best schedule by the objective. */ best(): Promise; + /** Run every enabled collector block against a fresh search stream, + * returning each one's selected schedule set keyed by graph instanceId. + * Empty when the pipeline declares no collectors. */ + collectors(): Promise>; /** Every scorer's raw value, keyed by instanceId — the reported view. */ scores(schedule: Schedule): Record; /** Write the schedule back onto the source project, embedding run @@ -128,12 +149,26 @@ export function fillRunConfig(config: unknown, registry?: BlockRegistry): RunCon .filter((b) => reg.get(b.code)?.family === "scorer") .map((b) => b.instanceId), ); + // Collector blocks reference scorers by instanceId (KBest.by / Pareto.over); + // like weights and lexicographic tiers those refs must land on scorer-family + // instances. Read the parsed params so defaults/coercions are applied. + const collectorRefs = validated.graph.blocks + .filter((b) => reg.get(b.code)?.family === "collector") + .flatMap((b) => { + const params = validated.parsedParams.get(b.instanceId) as { + by?: ReadonlyArray; + over?: ReadonlyArray; + }; + const refs = params.by ?? params.over ?? []; + return refs.map((k) => ({ k, where: `collector "${b.instanceId}" scorer reference` })); + }); const badRefs = [ ...Object.keys(parsed.weights ?? {}).map((k) => ({ k, where: "weights key" })), ...(parsed.objective?.lexicographic ?? []).map((k) => ({ k, where: "objective.lexicographic entry", })), + ...collectorRefs, ].filter(({ k }) => !scorerIds.has(k)); if (badRefs.length > 0) { throw new GraphValidationError( @@ -161,6 +196,7 @@ interface CompiledRun { constraints: Constraint[]; scorers: WeightedScorerEntry[]; search: Search; + collectors: CompiledCollector[]; } function runTarget(config: RunConfig): CompileTarget { @@ -210,7 +246,18 @@ function runTarget(config: RunConfig): CompileTarget { ]); } - return { constraints, scorers, search }; + // Disabled collectors stay in the graph (what-if toggling) but don't + // run — same present-but-inactive lever as scorers. + const collectors: CompiledCollector[] = []; + for (const compiled of families.collectors) { + if (compiled.instance.enabled === false) continue; + collectors.push({ + instanceId: compiled.instance.instanceId, + collector: compiled.output as Collector, + }); + } + + return { constraints, scorers, search, collectors }; }, }; } @@ -242,7 +289,7 @@ export function runFromConfig( workUnits: filled.resolve?.workUnits, }); - const { constraints, scorers, search } = compileGraph( + const { constraints, scorers, search, collectors } = compileGraph( registry, filled.pipeline, runTarget(filled), @@ -263,6 +310,23 @@ export function runFromConfig( ); } + // Same active-scorer requirement for collector references: fillRunConfig + // proved they point at scorer instances; an *enabled* collector can only + // select against *enabled* scorers. + const inactiveCollectorRefs = collectors.flatMap((c) => + collectorScorerRefs(c.collector) + .filter((id) => !activeScorerIds.has(id)) + .map((id) => ({ instanceId: c.instanceId, id })), + ); + if (inactiveCollectorRefs.length > 0) { + throw new GraphValidationError( + inactiveCollectorRefs.map(({ instanceId, id }) => ({ + instanceId, + message: `collector "${instanceId}" references a disabled scorer instance "${id}"`, + })), + ); + } + const analysis = analyzeRun(resolved, constraints, { scorers: scorers.map((s) => s.scorer), search, @@ -292,6 +356,21 @@ export function runFromConfig( } return run.stream().bestBy(objective); }, + async collectors(): Promise> { + const byId = new Map(scorers.map((s) => [s.instanceId, s.scorer])); + // Each collector drains its own fresh stream (stream() rebuilds the + // generator), so they run independently. + return Promise.all( + collectors.map(async (c): Promise => { + const refs = collectorScorerRefs(c.collector).map((id) => byId.get(id)!); + const schedules = + c.collector.kind === "KBest" + ? await run.stream().kBestByLex(refs, c.collector.k) + : await run.stream().paretoFrontier(refs); + return { instanceId: c.instanceId, kind: c.collector.kind, schedules }; + }), + ); + }, scores(schedule: Schedule): Record { const report: Record = {}; for (const entry of scorers) report[entry.instanceId] = entry.scorer.score(schedule); @@ -318,6 +397,11 @@ export function runFromConfig( return run; } +/** The scorer instanceIds a collector references, whatever its kind. */ +function collectorScorerRefs(collector: Collector): ReadonlyArray { + return collector.kind === "KBest" ? collector.by : collector.over; +} + // Search.run returns a Failure as the generator's return value; // streamFromFactory expects a plain AsyncGenerator. Re-yield to // drop the return value (the stream just ends, matching ScheduleStream's diff --git a/src/level-blocks/standard.ts b/src/level-blocks/standard.ts index 4294ec0..6f6fc08 100644 --- a/src/level-blocks/standard.ts +++ b/src/level-blocks/standard.ts @@ -13,9 +13,11 @@ import { ConcurrentResourceCostBlock } from "./concurrentResourceCost.ts"; import { ConcurrentUnitsLimitBlock } from "./concurrentUnitsLimit.ts"; import { DeadlineBlock } from "./deadline.ts"; import { HiringLagPenaltyBlock } from "./hiringLagPenalty.ts"; +import { KBestBlock } from "./kBest.ts"; import { MakespanBlock } from "./makespan.ts"; import { MaxConcurrentResourceBlock } from "./maxConcurrentResource.ts"; import { OpenUnitPenaltyBlock } from "./openUnitPenalty.ts"; +import { ParetoBlock } from "./pareto.ts"; import { PeakCapBlock } from "./peakCap.ts"; import { createRegistry, type BlockDef, type BlockRegistry } from "./registry.ts"; import { ReleaseBlock } from "./release.ts"; @@ -35,6 +37,8 @@ export const STANDARD_CODES = { unimodalDeviation: "levelset.UnimodalDeviation.v1", makespan: "levelset.Makespan.v1", serialSGS: "levelset.SerialSGS.v1", + kBest: "levelset.KBest.v1", + pareto: "levelset.Pareto.v1", } as const; export function standardBlockDefs(): BlockDef[] { @@ -121,6 +125,23 @@ export function standardBlockDefs(): BlockDef[] { block: SerialSGSBlock as never, defaultShadow: {}, }, + { + code: STANDARD_CODES.kBest, + family: "collector", + tier: "engineer", + block: KBestBlock as never, + // Placeholder scorer reference — the app rewires `by` to real scorer + // instanceIds before building a config (same spirit as the id-0 + // placeholders on the constraint shadows). + defaultShadow: { k: 3, by: ["makespan"] }, + }, + { + code: STANDARD_CODES.pareto, + family: "collector", + tier: "engineer", + block: ParetoBlock as never, + defaultShadow: { over: ["makespan", "cost"] }, + }, ]; } diff --git a/src/level-blocks/types.ts b/src/level-blocks/types.ts index 9ff55d0..111369f 100644 --- a/src/level-blocks/types.ts +++ b/src/level-blocks/types.ts @@ -2,18 +2,26 @@ // // A Block is the LCNC composition unit: a typed quadruple shared by the // visual editor, the LLM agent, and the compiler. -// Three families differ only in the output type `O`: +// Four families differ only in the output type `O`: // // • Constraint blocks — O = Constraint // • Scoring blocks — O = Scorer // • Search blocks — O = Search | SearchTransformer +// • Collector blocks — O = Collector (selection layer, not a solver +// constraint — `toMiniZinc` emits an explanatory `% comment` fragment) // // Constraint variants are the *interchange format* between Constraint blocks // and Search; the ADT is the wire, the Block is the cable. import type { ZodType } from "zod"; -import type { Constraint, Scorer, Search, SearchTransformer } from "../level-core/types.ts"; +import type { + Collector, + Constraint, + Scorer, + Search, + SearchTransformer, +} from "../level-core/types.ts"; /** Top-level MiniZinc text emitted for a single block — `constraint`, * `var`, `array`, etc. The compiler concatenates fragments and resolves @@ -48,3 +56,4 @@ export interface Block { export type ConstraintBlock = Block; export type ScoringBlock = Block; export type SearchBlock = Block; +export type CollectorBlock = Block; diff --git a/src/level-core/scheduleStream.ts b/src/level-core/scheduleStream.ts index d594d1e..7d5669e 100644 --- a/src/level-core/scheduleStream.ts +++ b/src/level-core/scheduleStream.ts @@ -1,6 +1,7 @@ // Lazy schedule enumeration. // Wraps an AsyncIterable so that filter/map/take/branch stay lazy -// and only bestBy/paretoFrontier/collect actually drain. +// and only the terminal ops (bestBy, bestByLex, kBestByLex, paretoFrontier, +// collect) actually drain. import type { Schedule, ScheduleStream, Scorer } from "./types.ts"; @@ -85,6 +86,20 @@ export class ScheduleStreamImpl implements ScheduleStream { return bestSchedule; } + async kBestByLex(scorers: ReadonlyArray, k: number): Promise> { + if (k <= 0) return []; + const scored: { schedule: Schedule; scores: number[] }[] = []; + for await (const s of this.source) { + scored.push({ schedule: s, scores: scorers.map((sc) => sc.score(s)) }); + } + // Stable sort (V8/Bun guarantee it): equal-on-all-tiers keeps stream + // order, matching bestByLex where first seen wins. + scored.sort((a, b) => + lexBetter(a.scores, b.scores, scorers) ? -1 : lexBetter(b.scores, a.scores, scorers) ? 1 : 0, + ); + return scored.slice(0, k).map((x) => x.schedule); + } + async paretoFrontier(scorers: ReadonlyArray): Promise> { const collected: { schedule: Schedule; scores: number[] }[] = []; for await (const s of this.source) { diff --git a/src/level-core/types.ts b/src/level-core/types.ts index 6b47de5..2800285 100644 --- a/src/level-core/types.ts +++ b/src/level-core/types.ts @@ -287,6 +287,46 @@ export interface Scorer { score(schedule: Schedule): T; } +// ───────────────────────────────────────────────────────────────── +// Collector ADT +// +// Selection-layer specs, one step above scoring: where a Scorer ranks a +// single Schedule, a Collector picks a *set* out of a candidate stream +// (top-k, Pareto frontier). Like the Constraint ADT it is the interchange +// format — collector *blocks* emit these specs, the run consumption path +// (`LevelingRun.collectors()`) executes them. The scorer references are +// instanceIds (strings), not Scorer closures: they resolve to compiled +// scorers only at run-wiring time, exactly as `objective.lexicographic` +// does — so the spec stays plain JSON and round-trips through a run config +// untouched. +// ───────────────────────────────────────────────────────────────── + +export type Collector = KBestCollector | ParetoCollector; + +/** Keep the best `k` schedules, ordered lexicographically over `by` — the + * second scorer only breaks ties on the first (order matters). */ +export interface KBestCollector { + readonly kind: "KBest"; + readonly k: number; + /** Scorer instanceIds, in tie-break priority order. */ + readonly by: ReadonlyArray; +} + +/** Keep the non-dominated set across the `over` scorers (Pareto frontier). + * Two or more scorers are the sensible case; one degenerates to "the best + * by that scorer". */ +export interface ParetoCollector { + readonly kind: "Pareto"; + /** Scorer instanceIds spanning the objective space. */ + readonly over: ReadonlyArray; +} + +/** Forces a compile error if a new Collector variant is added but a switch + * downstream isn't updated. */ +export function assertNeverCollector(c: never): never { + throw new Error(`unhandled Collector variant: ${JSON.stringify(c)}`); +} + // ───────────────────────────────────────────────────────────────── // Failure & explanations // @@ -385,6 +425,10 @@ export interface ScheduleStream { /** Best schedule by ordered tiers: scorers compared lexicographically — * the second scorer only breaks ties on the first, and so on. */ bestByLex(scorers: ReadonlyArray): Promise; + /** The best `k` schedules by those same ordered tiers, best first. Yields + * fewer than `k` when the stream is shorter, and none when `k <= 0`. + * Ties keep first-seen order (matching `bestByLex`'s determinism). */ + kBestByLex(scorers: ReadonlyArray, k: number): Promise>; paretoFrontier(scorers: ReadonlyArray): Promise>; /** Bounded materialization — caller asserts the stream terminates. */ collect(limit?: number): Promise>; diff --git a/src/leveling.ts b/src/leveling.ts index 55edfa4..4ba0910 100644 --- a/src/leveling.ts +++ b/src/leveling.ts @@ -34,6 +34,9 @@ export type { CrewFlowContinuityConstraint, DeadlineConstraint, ReleaseConstraint, + Collector, + KBestCollector, + ParetoCollector, Scorer, ScorerReads, Explanation, @@ -50,7 +53,12 @@ export type { Pipeline, ResolveOptions, } from "./level-core/types.ts"; -export { pipe, assertNeverConstraint, constraintLabel } from "./level-core/types.ts"; +export { + pipe, + assertNeverConstraint, + assertNeverCollector, + constraintLabel, +} from "./level-core/types.ts"; // ── Pipeline implementations ───────────────────────────────────────── export * from "./level-core/calendarDays.ts"; @@ -86,3 +94,5 @@ export * from "./level-blocks/openUnitPenalty.ts"; export * from "./level-blocks/unimodalDeviation.ts"; export * from "./level-blocks/makespan.ts"; export * from "./level-blocks/serialSgsSearch.ts"; +export * from "./level-blocks/kBest.ts"; +export * from "./level-blocks/pareto.ts"; diff --git a/test/level-blocks/collectors.test.ts b/test/level-blocks/collectors.test.ts new file mode 100644 index 0000000..13600a8 --- /dev/null +++ b/test/level-blocks/collectors.test.ts @@ -0,0 +1,97 @@ +// Collector blocks: KBest / Pareto schema + apply, and the kBestByLex stream +// primitive they lean on. + +import { describe, expect, test } from "bun:test"; + +import { KBestBlock, KBestInputSchema } from "../../src/level-blocks/kBest.ts"; +import { ParetoBlock, ParetoInputSchema } from "../../src/level-blocks/pareto.ts"; +import { ScheduleStreamImpl } from "../../src/level-core/scheduleStream.ts"; +import type { Schedule, Scorer } from "../../src/level-core/types.ts"; + +// A minimal Schedule stand-in carrying just the makespan the scorers read. +function sched(makespan: number, cost: number): Schedule { + return { + resolved: undefined as never, + tasks: [], + makespan, + annotations: new Map([["cost", cost]]), + }; +} + +const byMakespan: Scorer = { + name: "makespan", + direction: "min", + score: (s) => s.makespan, +}; +const byCost: Scorer = { + name: "cost", + direction: "min", + score: (s) => s.annotations.get("cost") as number, +}; + +function streamOf(schedules: ReadonlyArray): ScheduleStreamImpl { + return new ScheduleStreamImpl({ + async *[Symbol.asyncIterator]() { + for (const s of schedules) yield s; + }, + }); +} + +describe("KBestBlock", () => { + test("apply returns a KBest collector spec (plain JSON, no closure)", () => { + expect(KBestBlock.apply({ k: 3, by: ["makespan", "cost"] })).toEqual({ + kind: "KBest", + k: 3, + by: ["makespan", "cost"], + }); + }); + + test("schema requires a positive k and at least one scorer reference", () => { + expect(KBestInputSchema.safeParse({ k: 0, by: ["makespan"] }).success).toBe(false); + expect(KBestInputSchema.safeParse({ k: 2, by: [] }).success).toBe(false); + expect(KBestInputSchema.safeParse({ k: 2, by: ["makespan"] }).success).toBe(true); + }); + + test("toMiniZinc emits a comment, not a constraint", () => { + expect(KBestBlock.toMiniZinc({ k: 3, by: ["makespan"] }).text).toStartWith("%"); + }); +}); + +describe("ParetoBlock", () => { + test("apply returns a Pareto collector spec", () => { + expect(ParetoBlock.apply({ over: ["makespan", "cost"] })).toEqual({ + kind: "Pareto", + over: ["makespan", "cost"], + }); + }); + + test("schema requires at least one scorer reference", () => { + expect(ParetoInputSchema.safeParse({ over: [] }).success).toBe(false); + expect(ParetoInputSchema.safeParse({ over: ["makespan"] }).success).toBe(true); + }); + + test("toMiniZinc emits a comment, not a constraint", () => { + expect(ParetoBlock.toMiniZinc({ over: ["makespan"] }).text).toStartWith("%"); + }); +}); + +describe("kBestByLex", () => { + test("keeps k schedules ordered lexicographically, tie-broken by later tiers", async () => { + // makespan ties at 5 between b and c; cost breaks the tie (b before c). + const a = sched(4, 100); + const b = sched(5, 10); + const c = sched(5, 20); + const d = sched(7, 1); + const top = await streamOf([c, d, a, b]).kBestByLex([byMakespan, byCost], 2); + expect(top.map((s) => [s.makespan, s.annotations.get("cost")])).toEqual([ + [4, 100], + [5, 10], + ]); + }); + + test("returns the whole stream when k exceeds its length, and none when k <= 0", async () => { + const all = await streamOf([sched(2, 1), sched(1, 2)]).kBestByLex([byMakespan], 9); + expect(all).toHaveLength(2); + expect(await streamOf([sched(1, 1)]).kBestByLex([byMakespan], 0)).toEqual([]); + }); +}); diff --git a/test/level-blocks/runConfig.test.ts b/test/level-blocks/runConfig.test.ts index 9fe2e1c..f934dc3 100644 --- a/test/level-blocks/runConfig.test.ts +++ b/test/level-blocks/runConfig.test.ts @@ -174,6 +174,99 @@ describe("runFromConfig", () => { ).toThrow(/disabled scorer/); }); + test("collectors run against the search stream, labeled by instanceId", async () => { + const config = baseConfig(); + config.pipeline.blocks.push( + { + instanceId: "top3", + code: STANDARD_CODES.kBest, + params: { k: 3, by: ["makespan", "wip-cost"] }, + }, + { + instanceId: "frontier", + code: STANDARD_CODES.pareto, + params: { over: ["makespan", "wip-cost"] }, + }, + ); + const run = runFromConfig(crewProject(), config); + const results = await run.collectors(); + expect(results.map((r) => r.instanceId).sort()).toEqual(["frontier", "top3"]); + + const kbest = results.find((r) => r.instanceId === "top3")!; + const pareto = results.find((r) => r.instanceId === "frontier")!; + expect(kbest.kind).toBe("KBest"); + expect(pareto.kind).toBe("Pareto"); + // serialSGS emits a single schedule, so both collectors select it — + // KBest of a 1-schedule stream is that schedule; it is the Pareto set too. + const best = await run.best(); + expect(kbest.schedules).toHaveLength(1); + expect(kbest.schedules[0]!.tasks).toEqual(best!.tasks); + expect(pareto.schedules).toHaveLength(1); + expect(pareto.schedules[0]!.tasks).toEqual(best!.tasks); + }); + + test("a run with no collectors returns an empty collector set", async () => { + const run = runFromConfig(crewProject(), baseConfig()); + expect(await run.collectors()).toEqual([]); + }); + + test("collector params round-trip through fillRunConfig untouched", () => { + const config = baseConfig(); + config.pipeline.blocks.push({ + instanceId: "top3", + code: STANDARD_CODES.kBest, + params: { k: 3, by: ["makespan"] }, + }); + const filled = fillRunConfig(config); + const kbest = filled.pipeline.blocks.find((b) => b.instanceId === "top3")!; + expect(kbest.params).toEqual({ k: 3, by: ["makespan"] }); + }); + + test("a disabled collector does not run", async () => { + const config = baseConfig(); + config.pipeline.blocks.push({ + instanceId: "top3", + code: STANDARD_CODES.kBest, + params: { k: 3, by: ["makespan"] }, + enabled: false, + }); + const run = runFromConfig(crewProject(), config); + expect(await run.collectors()).toEqual([]); + }); + + test("a collector referencing a non-scorer instance fails validation", () => { + const config = baseConfig(); + config.pipeline.blocks.push({ + instanceId: "bad", + code: STANDARD_CODES.kBest, + // "crew-cap" is a constraint instance, not a scorer. + params: { k: 2, by: ["crew-cap"] }, + }); + expect(() => runFromConfig(crewProject(), config)).toThrow(GraphValidationError); + }); + + test("a collector referencing a missing instance fails validation", () => { + const config = baseConfig(); + config.pipeline.blocks.push({ + instanceId: "bad", + code: STANDARD_CODES.pareto, + params: { over: ["nope"] }, + }); + expect(() => runFromConfig(crewProject(), config)).toThrow(GraphValidationError); + }); + + test("a collector referencing a disabled scorer fails validation", () => { + const config = baseConfig(); + // Disable the wip-cost scorer, then reference it from a collector. + config.pipeline.blocks[1] = { ...config.pipeline.blocks[1]!, enabled: false }; + config.pipeline.blocks.push({ + instanceId: "top3", + code: STANDARD_CODES.kBest, + params: { k: 2, by: ["wip-cost"] }, + }); + expect(() => runFromConfig(crewProject(), config)).toThrow(/disabled scorer/); + }); + test("analysis errors throw RunAnalysisError with coded issues", () => { const config = baseConfig(); config.pipeline.blocks[0] = { -- 2.54.0 From 4ff45d436b136a6e5925fac96d73433a11432656 Mon Sep 17 00:00:00 2001 From: "Russ T. Fugal" Date: Thu, 16 Jul 2026 11:44:59 -0600 Subject: [PATCH 2/3] fix(level-blocks): validate collector refs off the compiled spec; exhaustive dispatch Extract collector scorer refs from the compiled Collector spec (via the block's pure apply()) instead of duck-typing parsed params as {by, over}: a custom collector naming its refs under a different param no longer skips layer-1 validation, and a non-array by/over can't surface as a raw TypeError. Distinguish a missing scorer from a disabled one in the layer-2 runFromConfig check. Convert both collector dispatch ternaries to exhaustive switches ending in assertNeverCollector so a future variant is a compile error. Fix stale docs (collectors labeled by, not keyed by; single-scorer Pareto keeps all co-best; ES2019 sort stability). --- README.md | 2 +- src/level-blocks/pareto.ts | 4 +- src/level-blocks/runConfig.ts | 75 ++++++++++++++++++++++------ src/level-core/scheduleStream.ts | 2 +- src/level-core/types.ts | 4 +- test/level-blocks/collectors.test.ts | 31 ++++++++++++ test/level-blocks/runConfig.test.ts | 50 ++++++++++++++++++- 7 files changed, 146 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 028cb1d..76041d0 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ The moving parts, each usable on its own: - **`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. - **`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". - **`weightedScorer` / `bestByLex`** — multi-objective combination as data: a weight map (with `null` = reported-but-unweighted) or ordered lexicographic tiers. -- **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. +- **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, each result labeled by its 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. - **`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. - **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. diff --git a/src/level-blocks/pareto.ts b/src/level-blocks/pareto.ts index 4c316c8..0875f69 100644 --- a/src/level-blocks/pareto.ts +++ b/src/level-blocks/pareto.ts @@ -3,7 +3,9 @@ // Keeps the non-dominated set across the referenced scorer instances (the // Pareto frontier): a schedule survives unless another is at least as good // on every scorer and strictly better on at least one. Two or more scorers -// are the sensible case; one degenerates to "the best by that scorer". +// are the sensible case; with a single scorer nothing dominates a co-best +// schedule, so the frontier keeps every schedule tied at that scorer's best +// value. // // The `over` entries are scorer instanceIds from the same pipeline, not // Scorer closures: they resolve to compiled scorers only at run-wiring time diff --git a/src/level-blocks/runConfig.ts b/src/level-blocks/runConfig.ts index 438c6f4..949c385 100644 --- a/src/level-blocks/runConfig.ts +++ b/src/level-blocks/runConfig.ts @@ -30,6 +30,7 @@ import type { Scorer, Search, } from "../level-core/types.ts"; +import { assertNeverCollector } from "../level-core/types.ts"; import { weightedScorer } from "../level-core/weightedScorer.ts"; import type { RunProvenance } from "../schema/provenance.ts"; import { LEVELSET_VERSION } from "../version.ts"; @@ -124,8 +125,8 @@ export interface LevelingRun { /** Drain the stream and keep the best schedule by the objective. */ best(): Promise; /** Run every enabled collector block against a fresh search stream, - * returning each one's selected schedule set keyed by graph instanceId. - * Empty when the pipeline declares no collectors. */ + * returning each one's selected schedule set, each result labeled by its + * graph instanceId. Empty when the pipeline declares no collectors. */ collectors(): Promise>; /** Every scorer's raw value, keyed by instanceId — the reported view. */ scores(schedule: Schedule): Record; @@ -151,16 +152,35 @@ export function fillRunConfig(config: unknown, registry?: BlockRegistry): RunCon ); // Collector blocks reference scorers by instanceId (KBest.by / Pareto.over); // like weights and lexicographic tiers those refs must land on scorer-family - // instances. Read the parsed params so defaults/coercions are applied. + // instances. Read the refs off the *compiled* Collector spec (via apply) + // rather than duck-typing the parsed params: the closed Collector ADT + // guarantees by/over are string[], so a custom collector that names its refs + // under a different param still gets layer-1 validation and a non-array + // by/over can't leak through as a raw TypeError. apply() is pure and + // compileGraph re-applies it later, so calling it here costs nothing. const collectorRefs = validated.graph.blocks .filter((b) => reg.get(b.code)?.family === "collector") .flatMap((b) => { - const params = validated.parsedParams.get(b.instanceId) as { - by?: ReadonlyArray; - over?: ReadonlyArray; - }; - const refs = params.by ?? params.over ?? []; - return refs.map((k) => ({ k, where: `collector "${b.instanceId}" scorer reference` })); + const def = reg.get(b.code)!; + const params = validated.parsedParams.get(b.instanceId); + let collector: Collector; + try { + collector = (def.block as { apply(input: unknown): Collector }).apply(params); + } catch (err) { + throw new GraphValidationError([ + { + instanceId: b.instanceId, + code: b.code, + message: `collector "${b.instanceId}" failed to compile its spec: ${ + err instanceof Error ? err.message : String(err) + }`, + }, + ]); + } + return collectorScorerRefs(collector).map((k) => ({ + k, + where: `collector "${b.instanceId}" scorer reference`, + })); }); const badRefs = [ ...Object.keys(parsed.weights ?? {}).map((k) => ({ k, where: "weights key" })), @@ -312,7 +332,14 @@ export function runFromConfig( // Same active-scorer requirement for collector references: fillRunConfig // proved they point at scorer instances; an *enabled* collector can only - // select against *enabled* scorers. + // select against *enabled* scorers. Distinguish disabled from truly absent: + // the ref exists in the full scorer set (compiled away by `enabled: false`) + // versus never a scorer instance at all. + const allScorerIds = new Set( + filled.pipeline.blocks + .filter((b) => registry.get(b.code)?.family === "scorer") + .map((b) => b.instanceId), + ); const inactiveCollectorRefs = collectors.flatMap((c) => collectorScorerRefs(c.collector) .filter((id) => !activeScorerIds.has(id)) @@ -322,7 +349,9 @@ export function runFromConfig( throw new GraphValidationError( inactiveCollectorRefs.map(({ instanceId, id }) => ({ instanceId, - message: `collector "${instanceId}" references a disabled scorer instance "${id}"`, + message: allScorerIds.has(id) + ? `collector "${instanceId}" references a disabled scorer instance "${id}"` + : `collector "${instanceId}" references scorer instance "${id}", which does not exist`, })), ); } @@ -363,10 +392,17 @@ export function runFromConfig( return Promise.all( collectors.map(async (c): Promise => { const refs = collectorScorerRefs(c.collector).map((id) => byId.get(id)!); - const schedules = - c.collector.kind === "KBest" - ? await run.stream().kBestByLex(refs, c.collector.k) - : await run.stream().paretoFrontier(refs); + let schedules: ReadonlyArray; + switch (c.collector.kind) { + case "KBest": + schedules = await run.stream().kBestByLex(refs, c.collector.k); + break; + case "Pareto": + schedules = await run.stream().paretoFrontier(refs); + break; + default: + assertNeverCollector(c.collector); + } return { instanceId: c.instanceId, kind: c.collector.kind, schedules }; }), ); @@ -399,7 +435,14 @@ export function runFromConfig( /** The scorer instanceIds a collector references, whatever its kind. */ function collectorScorerRefs(collector: Collector): ReadonlyArray { - return collector.kind === "KBest" ? collector.by : collector.over; + switch (collector.kind) { + case "KBest": + return collector.by; + case "Pareto": + return collector.over; + default: + return assertNeverCollector(collector); + } } // Search.run returns a Failure as the generator's return value; diff --git a/src/level-core/scheduleStream.ts b/src/level-core/scheduleStream.ts index 7d5669e..239f4e5 100644 --- a/src/level-core/scheduleStream.ts +++ b/src/level-core/scheduleStream.ts @@ -92,7 +92,7 @@ export class ScheduleStreamImpl implements ScheduleStream { for await (const s of this.source) { scored.push({ schedule: s, scores: scorers.map((sc) => sc.score(s)) }); } - // Stable sort (V8/Bun guarantee it): equal-on-all-tiers keeps stream + // Stable sort (ES2019 guarantees it): equal-on-all-tiers keeps stream // order, matching bestByLex where first seen wins. scored.sort((a, b) => lexBetter(a.scores, b.scores, scorers) ? -1 : lexBetter(b.scores, a.scores, scorers) ? 1 : 0, diff --git a/src/level-core/types.ts b/src/level-core/types.ts index 2800285..8bf44de 100644 --- a/src/level-core/types.ts +++ b/src/level-core/types.ts @@ -313,8 +313,8 @@ export interface KBestCollector { } /** Keep the non-dominated set across the `over` scorers (Pareto frontier). - * Two or more scorers are the sensible case; one degenerates to "the best - * by that scorer". */ + * Two or more scorers are the sensible case; with a single scorer the + * frontier keeps every schedule tied at that scorer's best value. */ export interface ParetoCollector { readonly kind: "Pareto"; /** Scorer instanceIds spanning the objective space. */ diff --git a/test/level-blocks/collectors.test.ts b/test/level-blocks/collectors.test.ts index 13600a8..8dc4634 100644 --- a/test/level-blocks/collectors.test.ts +++ b/test/level-blocks/collectors.test.ts @@ -28,6 +28,12 @@ const byCost: Scorer = { direction: "min", score: (s) => s.annotations.get("cost") as number, }; +// A max-direction scorer: higher is better (unlike makespan/cost). +const byGain: Scorer = { + name: "gain", + direction: "max", + score: (s) => s.annotations.get("gain") as number, +}; function streamOf(schedules: ReadonlyArray): ScheduleStreamImpl { return new ScheduleStreamImpl({ @@ -94,4 +100,29 @@ describe("kBestByLex", () => { expect(all).toHaveLength(2); expect(await streamOf([sched(1, 1)]).kBestByLex([byMakespan], 0)).toEqual([]); }); + + test("honors a direction:max tier when breaking ties (higher wins)", async () => { + // makespan ties at 5 between the two gain schedules; the max-direction + // gain scorer breaks it, so the higher gain (30) ranks above the lower. + const lowGain: Schedule = { ...sched(5, 0), annotations: new Map([["gain", 10]]) }; + const highGain: Schedule = { ...sched(5, 0), annotations: new Map([["gain", 30]]) }; + const clearWin: Schedule = { ...sched(4, 0), annotations: new Map([["gain", 1]]) }; + const top = await streamOf([lowGain, highGain, clearWin]).kBestByLex([byMakespan, byGain], 2); + expect(top.map((s) => [s.makespan, s.annotations.get("gain")])).toEqual([ + [4, 1], + [5, 30], + ]); + }); + + test("a full tie on every tier keeps first-seen stream order", async () => { + // All three tie on makespan and cost; a stable sort must keep them in + // stream order, so kBest returns the first two seen (by identity). + const first = sched(5, 10); + const second = sched(5, 10); + const third = sched(5, 10); + const top = await streamOf([first, second, third]).kBestByLex([byMakespan, byCost], 2); + expect(top).toHaveLength(2); + expect(top[0]).toBe(first); + expect(top[1]).toBe(second); + }); }); diff --git a/test/level-blocks/runConfig.test.ts b/test/level-blocks/runConfig.test.ts index f934dc3..6cdca27 100644 --- a/test/level-blocks/runConfig.test.ts +++ b/test/level-blocks/runConfig.test.ts @@ -2,10 +2,12 @@ // runFromConfig end-to-end, weights, enabled levers, and coded failures. import { describe, expect, test } from "bun:test"; +import { z } from "zod"; import { GraphValidationError } from "../../src/level-blocks/compile.ts"; +import type { BlockDef } from "../../src/level-blocks/registry.ts"; import { fillRunConfig, runFromConfig } from "../../src/level-blocks/runConfig.ts"; -import { STANDARD_CODES } from "../../src/level-blocks/standard.ts"; +import { STANDARD_CODES, standardRegistry } from "../../src/level-blocks/standard.ts"; import { RunAnalysisError } from "../../src/level-core/errors.ts"; import { LEVELSET_VERSION } from "../../src/version.ts"; @@ -255,6 +257,52 @@ describe("runFromConfig", () => { expect(() => runFromConfig(crewProject(), config)).toThrow(GraphValidationError); }); + // A custom collector whose input carries scorer refs under a param name + // other than by/over: its apply() maps `scorers` into the KBest `by` field. + // fillRunConfig must extract the ref off this compiled spec (not the param + // shape), so a dangling ref is a GraphValidationError — not a silent pass + // (the old duck-typing missed non-by/over params) and not a raw TypeError. + const pickByDef: BlockDef = { + code: "myapp.PickBy.v1", + family: "collector", + tier: "engineer", + block: { + id: "PickBy", + schema: { + input: z.object({ scorers: z.array(z.string()).min(1) }), + output: z.object({ + kind: z.literal("KBest"), + k: z.number(), + by: z.array(z.string()), + }), + }, + apply: ({ scorers }: { scorers: string[] }) => ({ kind: "KBest", k: 1, by: scorers }), + toMiniZinc: () => ({ text: "% pick" }), + doc: { nl: "pick", pseudocode: "pick" }, + } as never, + defaultShadow: { scorers: ["makespan"] }, + }; + + test("a custom collector's refs are validated off the compiled spec, not the param name", () => { + const registry = standardRegistry([pickByDef]); + + const dangling = baseConfig(); + dangling.pipeline.blocks.push({ + instanceId: "pick", + code: "myapp.PickBy.v1", + params: { scorers: ["ghost"] }, + }); + expect(() => fillRunConfig(dangling, registry)).toThrow(GraphValidationError); + + const valid = baseConfig(); + valid.pipeline.blocks.push({ + instanceId: "pick", + code: "myapp.PickBy.v1", + params: { scorers: ["makespan"] }, + }); + expect(() => fillRunConfig(valid, registry)).not.toThrow(); + }); + test("a collector referencing a disabled scorer fails validation", () => { const config = baseConfig(); // Disable the wip-cost scorer, then reference it from a collector. -- 2.54.0 From 90dd0440506f3e3123504e53f8b889fd9e5cc180 Mon Sep 17 00:00:00 2001 From: "Russ T. Fugal" Date: Thu, 16 Jul 2026 12:04:46 -0600 Subject: [PATCH 3/3] feat(level-blocks): add Gate feasibility-gate collector block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate keeps only schedules that satisfy every limit, where a limit bounds one scorer's value by max and/or min (an absent bound is unbounded on that side, never Infinity; at least one bound is required per limit). When at least one schedule passes, the passing set (order-preserving) is the result. When none pass, fallback decides: "none" yields the empty set; "leastBad" yields the single least-bad schedule. Least-bad compares schedules lexicographically by per-limit violation in the limits' declared order (first limit = binding). Violations of different scorers are not commensurable, so they are prioritized by order, never summed across scorers. Extends the Collector ADT with the Gate variant and GateLimit — the exhaustive collectorScorerRefs / collectors() switches (guarded by assertNeverCollector) force both dispatch sites to handle it. Adds the pure selectGate engine, levelset.Gate.v1 registration, leveling.ts re-exports, and README. Gate execution collects the stream once and filters/falls back in memory. --- README.md | 2 +- src/level-blocks/gate.ts | 142 +++++++++++++++++++++++++++ src/level-blocks/runConfig.ts | 24 ++++- src/level-blocks/standard.ts | 11 +++ src/level-core/types.ts | 38 +++++-- src/leveling.ts | 3 + test/level-blocks/collectors.test.ts | 103 ++++++++++++++++++- test/level-blocks/runConfig.test.ts | 36 +++++++ 8 files changed, 347 insertions(+), 12 deletions(-) create mode 100644 src/level-blocks/gate.ts diff --git a/README.md b/README.md index 76041d0..063acb7 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ The moving parts, each usable on its own: - **`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. - **`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". - **`weightedScorer` / `bestByLex`** — multi-objective combination as data: a weight map (with `null` = reported-but-unweighted) or ordered lexicographic tiers. -- **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, each result labeled by its 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. +- **Collector blocks (`KBestBlock`, `ParetoBlock`, `GateBlock`)** — 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; `Gate` (`levelset.Gate.v1`, params `{ limits: [{ scorer, max?, min? }…], fallback: "leastBad" | "none" }`) is a hard feasibility gate — it keeps only schedules satisfying every limit (an absent bound is unbounded on that side, never `Infinity`), and when none pass it either yields nothing (`"none"`) or the single least-bad schedule (`"leastBad"`). Least-bad compares schedules **lexicographically** by per-limit violation in the limits' declared order — the first limit is the binding one — because violations of different scorers (a day over a duration cap vs a person over a headcount cap) are not commensurable and must be prioritized, never summed. All three 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, each result labeled by its 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. - **`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. - **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. diff --git a/src/level-blocks/gate.ts b/src/level-blocks/gate.ts new file mode 100644 index 0000000..a0b32dc --- /dev/null +++ b/src/level-blocks/gate.ts @@ -0,0 +1,142 @@ +// Gate — Collector block (hard feasibility gate). +// +// Keeps only the schedules that satisfy *every* limit; a limit bounds one +// scorer's value by `max` and/or `min` (an absent bound is unbounded on that +// side — never ±Infinity). This is the feasibility-filter shape: "keep the +// schedules whose makespan and peak headcount both stay under their caps", +// generalized to N scorer-referencing limits with two-sided bounds. +// +// When at least one schedule passes, the passing set (order-preserving) is +// the result. When none pass, `fallback` decides: +// • "none" — the empty set (a strict gate: nothing feasible, nothing out). +// • "leastBad" — the single least-bad schedule (the "closest" fallback). +// +// The least-bad measure is the interesting part. Violations of different +// scorers are not commensurable (a day past a duration cap is not a person +// past a headcount cap), so we do NOT sum them. Instead we compare schedules +// *lexicographically* over the limits in declared order — the first limit is +// the binding one. Ties (equal violation on every limit) keep first-seen +// order. +// +// The `scorer` entries are instanceIds from the same pipeline, not Scorer +// closures: they resolve to compiled scorers only at run-wiring time (as +// `objective.lexicographic` does), so the emitted spec stays plain JSON. +// +// Collectors are a selection layer above the solver, so `toMiniZinc` emits an +// explanatory comment rather than a constraint fragment. + +import { z } from "zod"; + +import type { GateCollector, Schedule, Scorer } from "../level-core/types.ts"; + +import type { CollectorBlock, MiniZincFragment } from "./types.ts"; + +const GateLimitInputSchema = z + .object({ + /** Scorer instanceId this limit bounds. */ + scorer: z.string(), + /** Upper bound (value must be <= max). Absent = unbounded above. */ + max: z.number().optional(), + /** Lower bound (value must be >= min). Absent = unbounded below. */ + min: z.number().optional(), + }) + .refine((l) => l.max !== undefined || l.min !== undefined, { + message: "a gate limit needs at least one of max or min (an unbounded side is simply absent)", + }); + +export const GateInputSchema = z.object({ + limits: z.array(GateLimitInputSchema).min(1), + /** What to return when no schedule passes every limit. */ + fallback: z.enum(["leastBad", "none"]), +}); + +export type GateInput = z.infer; + +const GateOutputSchema: z.ZodType = z.object({ + kind: z.literal("Gate"), + limits: z.array( + z.object({ + scorer: z.string(), + max: z.number().optional(), + min: z.number().optional(), + }), + ), + fallback: z.enum(["leastBad", "none"]), +}); + +export const GateBlock: CollectorBlock = { + id: "Gate", + schema: { input: GateInputSchema, output: GateOutputSchema }, + apply: ({ limits, fallback }): GateCollector => ({ kind: "Gate", limits, fallback }), + toMiniZinc: (): MiniZincFragment => ({ + text: `% Gate — selection layer; keeps schedules satisfying every limit, emits no solver constraint.`, + }), + doc: { + nl: "Keeps only schedules that satisfy every limit; when none pass, returns nothing or the single least-bad schedule.", + pseudocode: + "pass = candidates.filter(within all limits); pass.length ? pass : (fallback == 'leastBad' ? [leastViolating(candidates)] : [])", + }, +}; + +/** A Gate limit with its scorer instanceId already resolved to the compiled + * Scorer — what `selectGate` evaluates against. */ +export interface ResolvedGateLimit { + readonly scorer: Scorer; + readonly max?: number | undefined; + readonly min?: number | undefined; +} + +function withinBounds(value: number, limit: ResolvedGateLimit): boolean { + return ( + (limit.max === undefined || value <= limit.max) && + (limit.min === undefined || value >= limit.min) + ); +} + +// Per-limit violation: how far the scorer value lies outside [min, max], 0 +// when within. The two one-sided terms are the SAME scorer's units, so adding +// them here is legitimate (and min <= max means at most one is ever nonzero). +function limitViolation(value: number, limit: ResolvedGateLimit): number { + let out = 0; + if (limit.max !== undefined && value > limit.max) out += value - limit.max; + if (limit.min !== undefined && value < limit.min) out += limit.min - value; + return out; +} + +// `a` is less bad than `b` iff its violation vector is lexicographically +// smaller: at the first limit where they differ, `a` violates less. Equal on +// every limit is not less bad (first-seen wins, keeping selection stable). +function lexLessBad(a: ReadonlyArray, b: ReadonlyArray): boolean { + for (let i = 0; i < a.length; i++) { + if (a[i]! < b[i]!) return true; + if (a[i]! > b[i]!) return false; + } + return false; +} + +/** + * Execute a Gate over a materialized candidate set. Returns the schedules + * passing every limit (order-preserving) when any pass; otherwise applies the + * fallback — the empty set ("none") or the single least-bad schedule + * ("leastBad", lexicographic over per-limit violation in declared order). + */ +export function selectGate( + schedules: ReadonlyArray, + limits: ReadonlyArray, + fallback: "leastBad" | "none", +): ReadonlyArray { + const passing = schedules.filter((s) => limits.every((l) => withinBounds(l.scorer.score(s), l))); + if (passing.length > 0) return passing; + if (fallback === "none" || schedules.length === 0) return []; + + let best = schedules[0]!; + let bestViol = limits.map((l) => limitViolation(l.scorer.score(best), l)); + for (let i = 1; i < schedules.length; i++) { + const viol = limits.map((l) => limitViolation(l.scorer.score(schedules[i]!), l)); + if (lexLessBad(viol, bestViol)) { + best = schedules[i]!; + bestViol = viol; + } + } + return [best]; +} diff --git a/src/level-blocks/runConfig.ts b/src/level-blocks/runConfig.ts index 949c385..de6f335 100644 --- a/src/level-blocks/runConfig.ts +++ b/src/level-blocks/runConfig.ts @@ -36,6 +36,7 @@ import type { RunProvenance } from "../schema/provenance.ts"; import { LEVELSET_VERSION } from "../version.ts"; import { compileGraph, GraphValidationError, type CompileTarget } from "./compile.ts"; +import { selectGate, type ResolvedGateLimit } from "./gate.ts"; import { pipelineGraphSchema, validateGraph } from "./graph.ts"; import type { BlockRegistry } from "./registry.ts"; import { standardRegistry } from "./standard.ts"; @@ -391,15 +392,30 @@ export function runFromConfig( // generator), so they run independently. return Promise.all( collectors.map(async (c): Promise => { - const refs = collectorScorerRefs(c.collector).map((id) => byId.get(id)!); let schedules: ReadonlyArray; switch (c.collector.kind) { case "KBest": - schedules = await run.stream().kBestByLex(refs, c.collector.k); + schedules = await run.stream().kBestByLex( + c.collector.by.map((id) => byId.get(id)!), + c.collector.k, + ); break; case "Pareto": - schedules = await run.stream().paretoFrontier(refs); + schedules = await run + .stream() + .paretoFrontier(c.collector.over.map((id) => byId.get(id)!)); break; + case "Gate": { + // Gate selects over a materialized set (its limits carry bounds, + // not just scorers), so collect once, then filter/fall back. + const limits: ResolvedGateLimit[] = c.collector.limits.map((l) => ({ + scorer: byId.get(l.scorer)!, + max: l.max, + min: l.min, + })); + schedules = selectGate(await run.stream().collect(), limits, c.collector.fallback); + break; + } default: assertNeverCollector(c.collector); } @@ -440,6 +456,8 @@ function collectorScorerRefs(collector: Collector): ReadonlyArray { return collector.by; case "Pareto": return collector.over; + case "Gate": + return collector.limits.map((l) => l.scorer); default: return assertNeverCollector(collector); } diff --git a/src/level-blocks/standard.ts b/src/level-blocks/standard.ts index 6f6fc08..315968c 100644 --- a/src/level-blocks/standard.ts +++ b/src/level-blocks/standard.ts @@ -12,6 +12,7 @@ import { ConcurrentResourceCostBlock } from "./concurrentResourceCost.ts"; import { ConcurrentUnitsLimitBlock } from "./concurrentUnitsLimit.ts"; import { DeadlineBlock } from "./deadline.ts"; +import { GateBlock } from "./gate.ts"; import { HiringLagPenaltyBlock } from "./hiringLagPenalty.ts"; import { KBestBlock } from "./kBest.ts"; import { MakespanBlock } from "./makespan.ts"; @@ -39,6 +40,7 @@ export const STANDARD_CODES = { serialSGS: "levelset.SerialSGS.v1", kBest: "levelset.KBest.v1", pareto: "levelset.Pareto.v1", + gate: "levelset.Gate.v1", } as const; export function standardBlockDefs(): BlockDef[] { @@ -142,6 +144,15 @@ export function standardBlockDefs(): BlockDef[] { block: ParetoBlock as never, defaultShadow: { over: ["makespan", "cost"] }, }, + { + code: STANDARD_CODES.gate, + family: "collector", + tier: "engineer", + block: GateBlock as never, + // Placeholder scorer reference + bound — the app rewires `limits` to + // real scorer instanceIds and caps before building a config. + defaultShadow: { limits: [{ scorer: "makespan", max: 0 }], fallback: "none" }, + }, ]; } diff --git a/src/level-core/types.ts b/src/level-core/types.ts index 8bf44de..703998a 100644 --- a/src/level-core/types.ts +++ b/src/level-core/types.ts @@ -292,16 +292,16 @@ export interface Scorer { // // Selection-layer specs, one step above scoring: where a Scorer ranks a // single Schedule, a Collector picks a *set* out of a candidate stream -// (top-k, Pareto frontier). Like the Constraint ADT it is the interchange -// format — collector *blocks* emit these specs, the run consumption path -// (`LevelingRun.collectors()`) executes them. The scorer references are -// instanceIds (strings), not Scorer closures: they resolve to compiled -// scorers only at run-wiring time, exactly as `objective.lexicographic` +// (top-k, Pareto frontier, feasibility gate). Like the Constraint ADT it is +// the interchange format — collector *blocks* emit these specs, the run +// consumption path (`LevelingRun.collectors()`) executes them. The scorer +// references are instanceIds (strings), not Scorer closures: they resolve to +// compiled scorers only at run-wiring time, exactly as `objective.lexicographic` // does — so the spec stays plain JSON and round-trips through a run config // untouched. // ───────────────────────────────────────────────────────────────── -export type Collector = KBestCollector | ParetoCollector; +export type Collector = KBestCollector | ParetoCollector | GateCollector; /** Keep the best `k` schedules, ordered lexicographically over `by` — the * second scorer only breaks ties on the first (order matters). */ @@ -321,6 +321,32 @@ export interface ParetoCollector { readonly over: ReadonlyArray; } +/** Feasibility gate: keep only schedules that satisfy *every* limit, where a + * limit bounds one scorer's value by `max` and/or `min`. When at least one + * schedule passes, the passing set (order-preserving) is the result. When + * none pass, `fallback` decides: + * • "none" — the empty set. + * • "leastBad" — the single schedule with the smallest limit violation, + * compared *lexicographically* over the limits in declared order. Because + * violations of different scorers are not commensurable (a day over a + * makespan cap is not a person over a headcount cap), they are prioritized + * by declared order — the first limit is the binding one — and never + * summed across scorers. */ +export interface GateCollector { + readonly kind: "Gate"; + readonly limits: ReadonlyArray; + readonly fallback: "leastBad" | "none"; +} + +/** One bound in a Gate: the scorer named by `scorer` (an instanceId) must + * stay within [min, max]. At least one of max/min is set; an absent bound is + * unbounded on that side — never encoded as ±Infinity. */ +export interface GateLimit { + readonly scorer: string; + readonly max?: number | undefined; + readonly min?: number | undefined; +} + /** Forces a compile error if a new Collector variant is added but a switch * downstream isn't updated. */ export function assertNeverCollector(c: never): never { diff --git a/src/leveling.ts b/src/leveling.ts index 4ba0910..86a0f0e 100644 --- a/src/leveling.ts +++ b/src/leveling.ts @@ -37,6 +37,8 @@ export type { Collector, KBestCollector, ParetoCollector, + GateCollector, + GateLimit, Scorer, ScorerReads, Explanation, @@ -96,3 +98,4 @@ export * from "./level-blocks/makespan.ts"; export * from "./level-blocks/serialSgsSearch.ts"; export * from "./level-blocks/kBest.ts"; export * from "./level-blocks/pareto.ts"; +export * from "./level-blocks/gate.ts"; diff --git a/test/level-blocks/collectors.test.ts b/test/level-blocks/collectors.test.ts index 8dc4634..6edd0b6 100644 --- a/test/level-blocks/collectors.test.ts +++ b/test/level-blocks/collectors.test.ts @@ -1,8 +1,14 @@ -// Collector blocks: KBest / Pareto schema + apply, and the kBestByLex stream -// primitive they lean on. +// Collector blocks: KBest / Pareto / Gate schema + apply, the kBestByLex +// stream primitive, and the selectGate feasibility engine they lean on. import { describe, expect, test } from "bun:test"; +import { + GateBlock, + GateInputSchema, + selectGate, + type ResolvedGateLimit, +} from "../../src/level-blocks/gate.ts"; import { KBestBlock, KBestInputSchema } from "../../src/level-blocks/kBest.ts"; import { ParetoBlock, ParetoInputSchema } from "../../src/level-blocks/pareto.ts"; import { ScheduleStreamImpl } from "../../src/level-core/scheduleStream.ts"; @@ -81,6 +87,99 @@ describe("ParetoBlock", () => { }); }); +describe("GateBlock", () => { + test("apply returns a Gate collector spec (absent bound stays absent)", () => { + expect( + GateBlock.apply({ limits: [{ scorer: "makespan", max: 5 }], fallback: "leastBad" }), + ).toEqual({ + kind: "Gate", + limits: [{ scorer: "makespan", max: 5 }], + fallback: "leastBad", + }); + }); + + test("schema rejects a limit with neither max nor min, accepts one with either", () => { + expect( + GateInputSchema.safeParse({ limits: [{ scorer: "makespan" }], fallback: "none" }).success, + ).toBe(false); + expect( + GateInputSchema.safeParse({ limits: [{ scorer: "makespan", max: 5 }], fallback: "none" }) + .success, + ).toBe(true); + expect( + GateInputSchema.safeParse({ limits: [{ scorer: "makespan", min: 1 }], fallback: "none" }) + .success, + ).toBe(true); + }); + + test("schema requires at least one limit and a known fallback", () => { + expect(GateInputSchema.safeParse({ limits: [], fallback: "none" }).success).toBe(false); + expect( + GateInputSchema.safeParse({ limits: [{ scorer: "makespan", max: 5 }], fallback: "wat" }) + .success, + ).toBe(false); + }); + + test("toMiniZinc emits a comment, not a constraint", () => { + expect( + GateBlock.toMiniZinc({ limits: [{ scorer: "makespan", max: 5 }], fallback: "none" }).text, + ).toStartWith("%"); + }); +}); + +describe("selectGate", () => { + const a = sched(4, 100); + const b = sched(5, 10); + const c = sched(7, 50); + + test("keeps every schedule satisfying all limits, order-preserving", () => { + // makespan <= 5 admits a(4) and b(5); c(7) fails. Two-sided/multi-limit: + // cost in [20,60] admits only c. + const upper: ResolvedGateLimit[] = [{ scorer: byMakespan, max: 5 }]; + expect(selectGate([a, b, c], upper, "none").map((s) => s.makespan)).toEqual([4, 5]); + + const band: ResolvedGateLimit[] = [{ scorer: byCost, min: 20, max: 60 }]; + expect(selectGate([a, b, c], band, "none").map((s) => s.makespan)).toEqual([7]); + + const both: ResolvedGateLimit[] = [ + { scorer: byMakespan, max: 6 }, + { scorer: byCost, max: 60 }, + ]; + expect(selectGate([a, b, c], both, "none").map((s) => s.makespan)).toEqual([5]); + }); + + test("fallback 'none' yields the empty set when nothing passes", () => { + const impossible: ResolvedGateLimit[] = [{ scorer: byMakespan, max: 3 }]; + expect(selectGate([a, b, c], impossible, "none")).toEqual([]); + }); + + test("fallback 'leastBad' picks the lexicographic-least-violating schedule", () => { + // No schedule passes. Limits declared makespan-first (the binding one), + // then cost. Violations: + // x = (4,100): makespan 4-3=1, cost 100-5=95 -> [1, 95] + // y = (5, 2): makespan 5-3=2, cost 0 -> [2, 0] + // z = (4, 1): makespan 4-3=1, cost 0 -> [1, 0] + // Lexicographic min prioritizes makespan: y (viol 2) loses to x and z + // despite y's zero cost violation — proving violations are NOT summed + // (a sum would rank y=2 best). Between x and z, cost breaks the tie -> z. + const x = sched(4, 100); + const y = sched(5, 2); + const z = sched(4, 1); + const limits: ResolvedGateLimit[] = [ + { scorer: byMakespan, max: 3 }, + { scorer: byCost, max: 5 }, + ]; + const picked = selectGate([x, y, z], limits, "leastBad"); + expect(picked).toHaveLength(1); + expect(picked[0]).toBe(z); + }); + + test("fallback 'leastBad' over an empty stream is still empty", () => { + const limits: ResolvedGateLimit[] = [{ scorer: byMakespan, max: 3 }]; + expect(selectGate([], limits, "leastBad")).toEqual([]); + }); +}); + describe("kBestByLex", () => { test("keeps k schedules ordered lexicographically, tie-broken by later tiers", async () => { // makespan ties at 5 between b and c; cost breaks the tie (b before c). diff --git a/test/level-blocks/runConfig.test.ts b/test/level-blocks/runConfig.test.ts index 6cdca27..890dc4e 100644 --- a/test/level-blocks/runConfig.test.ts +++ b/test/level-blocks/runConfig.test.ts @@ -315,6 +315,42 @@ describe("runFromConfig", () => { expect(() => runFromConfig(crewProject(), config)).toThrow(/disabled scorer/); }); + test("a Gate collector filters the stream and honors its fallback", async () => { + // serialSGS emits one schedule; learn its makespan to set gate bounds. + const m = (await runFromConfig(crewProject(), baseConfig()).best())!.makespan; + + const withGate = (max: number, fallback: "leastBad" | "none") => { + const config = baseConfig(); + config.pipeline.blocks.push({ + instanceId: "gate", + code: STANDARD_CODES.gate, + params: { limits: [{ scorer: "makespan", max }], fallback }, + }); + return runFromConfig(crewProject(), config).collectors(); + }; + + // makespan <= m admits the one schedule. + const pass = await withGate(m, "none"); + expect(pass).toHaveLength(1); + expect(pass[0]!.kind).toBe("Gate"); + expect(pass[0]!.schedules).toHaveLength(1); + + // makespan <= m-1 admits nothing: "none" empties, "leastBad" keeps one. + expect((await withGate(m - 1, "none"))[0]!.schedules).toEqual([]); + expect((await withGate(m - 1, "leastBad"))[0]!.schedules).toHaveLength(1); + }); + + test("a Gate referencing a non-scorer instance fails validation", () => { + const config = baseConfig(); + config.pipeline.blocks.push({ + instanceId: "bad-gate", + // "crew-cap" is a constraint instance, not a scorer. + code: STANDARD_CODES.gate, + params: { limits: [{ scorer: "crew-cap", max: 5 }], fallback: "none" }, + }); + expect(() => runFromConfig(crewProject(), config)).toThrow(GraphValidationError); + }); + test("analysis errors throw RunAnalysisError with coded issues", () => { const config = baseConfig(); config.pipeline.blocks[0] = { -- 2.54.0