From 1f9aec5ba32abd1507e367060ea6cd7f127a8836 Mon Sep 17 00:00:00 2001 From: "Russ T. Fugal" Date: Thu, 16 Jul 2026 00:07:21 -0600 Subject: [PATCH] feat(leveling): declarative RunConfig with runFromConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One JSON document now fully describes a leveling run — resolve options, blocks by versioned registry code (reusing the PipelineGraph format as the config's pipeline section), and objective weights (null = compute and report but don't optimize). runFromConfig fills the config (block-schema defaults expanded, levelset version stamped — the filled config is the persistable artifact), resolves the project, compiles blocks with instanceIds doubling as constraint names for attribution, statically analyzes the run (throwing coded RunAnalysisError on errors), and returns a live run: stream/best/scores/materialize plus the combined weightedScorer objective. Framework-owned config sections are strict so a typo'd key is an error, not a silently unconstrained run. Graph instances gain an optional enabled lever (undefined = enabled, so existing graphs and constructors stay valid). --- src/level-blocks/graph.ts | 5 + src/level-blocks/runConfig.ts | 275 +++++++++++++++++++++++++ src/level-core/weightedScorer.ts | 49 +++++ src/leveling.ts | 3 + src/version.ts | 3 + test/level-blocks/runConfig.test.ts | 173 ++++++++++++++++ test/level-core/weightedScorer.test.ts | 50 +++++ test/levelsetVersion.test.ts | 11 + 8 files changed, 569 insertions(+) create mode 100644 src/level-blocks/runConfig.ts create mode 100644 src/level-core/weightedScorer.ts create mode 100644 src/version.ts create mode 100644 test/level-blocks/runConfig.test.ts create mode 100644 test/level-core/weightedScorer.test.ts create mode 100644 test/levelsetVersion.test.ts diff --git a/src/level-blocks/graph.ts b/src/level-blocks/graph.ts index bdded32..6a686b3 100644 --- a/src/level-blocks/graph.ts +++ b/src/level-blocks/graph.ts @@ -19,6 +19,11 @@ export const blockInstanceSchema = z.object({ provenance: z.enum(["curated", "candidate"]).default("curated"), /** True while the instance still carries an unreplaced curated default. */ shadow: z.boolean().default(false), + /** The present-but-inactive lever: a disabled block stays in the graph + * (for what-if toggling) but compiles to nothing enforced/scored. + * Optional with "undefined = enabled" (rather than defaulted) so + * existing BlockInstance constructions stay valid. */ + enabled: z.boolean().optional(), }); export type BlockInstance = z.infer; diff --git a/src/level-blocks/runConfig.ts b/src/level-blocks/runConfig.ts new file mode 100644 index 0000000..c9ae408 --- /dev/null +++ b/src/level-blocks/runConfig.ts @@ -0,0 +1,275 @@ +// Declarative run config — spaCy's `config.cfg` for leveling. +// +// One JSON document fully describes a leveling run: how to resolve the +// project, which blocks (by versioned registry code) with which params, and +// how scorer outputs combine into the objective. `runFromConfig` builds a +// live run from it; the run carries back the *filled* config (every default +// expanded), which is what gets persisted as provenance — reproducibility +// comes from saving the expanded config with the artifact, not from +// re-deriving defaults at load time. +// +// The block list reuses the existing PipelineGraph format rather than +// inventing a second serializable pipeline shape: the graph is the config's +// `pipeline` section, and instanceIds double as constraint instance names +// for failure attribution. + +import { z } from "zod"; + +import { analyzeRun, type RunAnalysis } from "../level-core/analyzeRun.ts"; +import { RunAnalysisError } from "../level-core/errors.ts"; +import { materialize } from "../level-core/materialize.ts"; +import { resolveCalendar } from "../level-core/resolveCalendar.ts"; +import { streamFromFactory } from "../level-core/scheduleStream.ts"; +import type { + Constraint, + Project, + ResolvedProject, + Schedule, + ScheduleStream, + Scorer, + Search, +} from "../level-core/types.ts"; +import { weightedScorer } from "../level-core/weightedScorer.ts"; +import { LEVELSET_VERSION } from "../version.ts"; + +import { compileGraph, GraphValidationError, type CompileTarget } from "./compile.ts"; +import { pipelineGraphSchema, validateGraph } from "./graph.ts"; +import type { BlockRegistry } from "./registry.ts"; +import { standardRegistry } from "./standard.ts"; + +// WorkUnit as JSON (mirrors the WorkUnit interface in level-core/types.ts). +export const workUnitSchema = z.object({ + id: z.number().int(), + location: z.string().optional(), + productType: z.string().optional(), + serial: z.string().optional(), + taskUniqueIds: z.array(z.number().int()), +}); + +// Framework-owned sections are strict (a typo'd key is an error, not a +// silently-ignored one); the pipeline section keeps the graph format's own +// validation. +export const runConfigSchema = z.strictObject({ + formatVersion: z.literal(1), + /** levelset version that filled this config; stamped by fillRunConfig. */ + levelset: z.string().optional(), + /** Recorded for stochastic searches/transformers; serialSGS ignores it. */ + seed: z.number().int().optional(), + resolve: z + .strictObject({ + /** ISO date string for ResolveOptions.epoch. */ + epoch: z + .string() + .refine((s) => !Number.isNaN(new Date(s).getTime()), "not a parseable date") + .optional(), + horizonDays: z.number().int().positive().optional(), + workUnits: z.array(workUnitSchema).optional(), + }) + .optional(), + pipeline: pipelineGraphSchema, + /** Objective weights keyed by scorer instanceId. Missing = 1, null = + * compute and report but don't optimize on it. */ + weights: z.record(z.string(), z.number().nullable()).optional(), +}); +export type RunConfig = z.infer; + +export interface WeightedScorerEntry { + readonly instanceId: string; + readonly scorer: Scorer; + readonly weight: number | null; +} + +export interface LevelingRun { + /** The filled config: every block's params expanded through its schema + * defaults, levelset version stamped. Persist this, not the input. */ + readonly config: RunConfig; + readonly resolved: ResolvedProject; + /** Static analysis result (warnings only — errors throw in runFromConfig). */ + readonly analysis: RunAnalysis; + readonly constraints: ReadonlyArray; + readonly scorers: ReadonlyArray; + readonly search: Search; + /** The combined weighted objective (falls back to makespan when the + * config declares no weighted scorers). */ + readonly objective: Scorer; + /** Lazy stream of candidate schedules from the configured search. */ + stream(): ScheduleStream; + /** Drain the stream and keep the best schedule by the objective. */ + best(): Promise; + /** Every scorer's raw value, keyed by instanceId — the reported view. */ + scores(schedule: Schedule): Record; + /** Write the schedule back onto the source project. */ + materialize(schedule: Schedule): Project; +} + +/** Validate a config and expand it: block params parsed through each + * block's input schema (defaults applied), levelset version stamped. + * Throws GraphValidationError / ZodError on invalid input. */ +export function fillRunConfig(config: unknown, registry?: BlockRegistry): RunConfig { + const reg = registry ?? standardRegistry(); + const parsed = runConfigSchema.parse(config); + const validated = validateGraph(reg, parsed.pipeline); + if (!validated.ok) throw new GraphValidationError(validated.issues); + + const weightKeys = Object.keys(parsed.weights ?? {}); + const scorerIds = new Set( + validated.graph.blocks + .filter((b) => reg.get(b.code)?.family === "scorer") + .map((b) => b.instanceId), + ); + const badKeys = weightKeys.filter((k) => !scorerIds.has(k)); + if (badKeys.length > 0) { + throw new GraphValidationError( + badKeys.map((k) => ({ + instanceId: k, + message: `weights key "${k}" does not reference a scorer-family block instance`, + })), + ); + } + + return { + ...parsed, + levelset: LEVELSET_VERSION, + pipeline: { + ...validated.graph, + blocks: validated.graph.blocks.map((instance) => ({ + ...instance, + params: validated.parsedParams.get(instance.instanceId) as Record, + })), + }, + }; +} + +interface CompiledRun { + constraints: Constraint[]; + scorers: WeightedScorerEntry[]; + search: Search; +} + +function runTarget(config: RunConfig): CompileTarget { + return { + fromFamilies(families): CompiledRun { + const constraints: Constraint[] = []; + for (const compiled of families.constraints) { + const constraint = compiled.output as Constraint; + constraints.push({ + ...constraint, + name: constraint.name ?? compiled.instance.instanceId, + enabled: compiled.instance.enabled, + }); + } + + const scorers: WeightedScorerEntry[] = []; + const weights = config.weights ?? {}; + for (const compiled of families.scorers) { + if (compiled.instance.enabled === false) continue; + const id = compiled.instance.instanceId; + scorers.push({ + instanceId: id, + scorer: compiled.output as Scorer, + // `null` is a deliberate value (report, don't optimize) — only a + // genuinely absent key falls back to weight 1. + weight: Object.hasOwn(weights, id) ? (weights[id] as number | null) : 1, + }); + } + + if (families.search.instance.enabled === false) { + throw new GraphValidationError([ + { + instanceId: families.search.instance.instanceId, + message: "the search block cannot be disabled — a run needs exactly one active search", + }, + ]); + } + const search = families.search.output as Search; + if (typeof search.run !== "function") { + throw new GraphValidationError([ + { + instanceId: families.search.instance.instanceId, + code: families.search.instance.code, + message: + "search block did not produce a runnable Search (SearchTransformers need an inner search and are not yet supported in run configs)", + }, + ]); + } + + return { constraints, scorers, search }; + }, + }; +} + +export interface RunFromConfigOptions { + readonly registry?: BlockRegistry | undefined; +} + +/** + * Build a live leveling run from a declarative config: fill the config, + * resolve the project, compile the blocks, and statically analyze the run. + * Throws RunAnalysisError when analysis finds errors (unknown references, + * infeasible deadline/release pairs, cycles); analysis warnings ride on + * `run.analysis` and into provenance. + */ +export function runFromConfig( + project: Project, + config: unknown, + opts?: RunFromConfigOptions, +): LevelingRun { + const registry = opts?.registry ?? standardRegistry(); + const filled = fillRunConfig(config, registry); + + const resolved = resolveCalendar(project, { + ...(filled.resolve?.epoch !== undefined ? { epoch: new Date(filled.resolve.epoch) } : {}), + ...(filled.resolve?.horizonDays !== undefined + ? { horizonDays: filled.resolve.horizonDays } + : {}), + workUnits: filled.resolve?.workUnits, + }); + + const { constraints, scorers, search } = compileGraph(registry, filled.pipeline, runTarget(filled)); + + const analysis = analyzeRun(resolved, constraints, { + scorers: scorers.map((s) => s.scorer), + search, + }); + if (!analysis.ok) throw new RunAnalysisError(analysis.issues); + + const objective = + scorers.some((s) => s.weight !== null && s.weight !== 0) + ? weightedScorer(scorers, "objective") + : { name: "Makespan", direction: "min" as const, score: (s: Schedule) => s.makespan }; + + const run: LevelingRun = { + config: filled, + resolved, + analysis, + constraints, + scorers, + search, + objective, + stream(): ScheduleStream { + return streamFromFactory(() => stripReturn(search.run(resolved, constraints))); + }, + async best(): Promise { + return run.stream().bestBy(objective); + }, + scores(schedule: Schedule): Record { + const report: Record = {}; + for (const entry of scorers) report[entry.instanceId] = entry.scorer.score(schedule); + return report; + }, + materialize(schedule: Schedule): Project { + return materialize(schedule); + }, + }; + return run; +} + +// 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 +// documented contract). +async function* stripReturn( + gen: AsyncGenerator, +): AsyncGenerator { + for await (const schedule of gen) yield schedule; +} diff --git a/src/level-core/weightedScorer.ts b/src/level-core/weightedScorer.ts new file mode 100644 index 0000000..677593f --- /dev/null +++ b/src/level-core/weightedScorer.ts @@ -0,0 +1,49 @@ +// Multi-objective combination as data — spaCy's `default_score_weights`. +// +// A weight map, not a hand-composed closure, so the combination serializes +// into a run config. `null` means "compute and report, but don't optimize +// on it" — how you watch a tradeoff before committing to it. + +import type { Schedule, Scorer } from "./types.ts"; + +export interface WeightedEntry { + readonly scorer: Scorer; + /** Contribution multiplier; null = reported but unweighted. */ + readonly weight: number | null; +} + +/** Combine scorers into one minimizing objective. `max`-direction scorers + * contribute negated, so a higher raw value still improves the combined + * score. Entries with null/zero weight are excluded from the objective + * (use `scoreReport` to still see their values). */ +export function weightedScorer( + entries: ReadonlyArray, + name = "objective", +): Scorer { + const active = entries.filter( + (e): e is WeightedEntry & { weight: number } => e.weight !== null && e.weight !== 0, + ); + return { + name, + direction: "min", + score(schedule: Schedule): number { + let total = 0; + for (const e of active) { + const sign = e.scorer.direction === "min" ? 1 : -1; + total += sign * e.weight * e.scorer.score(schedule); + } + return total; + }, + }; +} + +/** Every scorer's raw value for a schedule, keyed by scorer name — the + * reported-but-unweighted view that rides along with the objective. */ +export function scoreReport( + schedule: Schedule, + scorers: ReadonlyArray, +): Record { + const report: Record = {}; + for (const scorer of scorers) report[scorer.name] = scorer.score(schedule); + return report; +} diff --git a/src/leveling.ts b/src/leveling.ts index 9300671..55edfa4 100644 --- a/src/leveling.ts +++ b/src/leveling.ts @@ -62,6 +62,8 @@ export * from "./level-core/weeklyProfile.ts"; export * from "./level-core/profiles.ts"; export * from "./level-core/errors.ts"; export * from "./level-core/analyzeRun.ts"; +export * from "./level-core/weightedScorer.ts"; +export { LEVELSET_VERSION } from "./version.ts"; export { serialSGS } from "./level-core/search/serialSGS.ts"; // ── Pipeline-as-data (registry, graph format, compile traversal) ───── @@ -69,6 +71,7 @@ export * from "./level-blocks/registry.ts"; export * from "./level-blocks/graph.ts"; export * from "./level-blocks/compile.ts"; export * from "./level-blocks/standard.ts"; +export * from "./level-blocks/runConfig.ts"; // ── Blocks (configured constraints & scorers + MiniZinc fragments) ─── export * from "./level-blocks/types.ts"; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..d412c7a --- /dev/null +++ b/src/version.ts @@ -0,0 +1,3 @@ +// Kept in sync with package.json by test/levelsetVersion.test.ts (the value +// is embedded in run provenance, so it must not drift silently). +export const LEVELSET_VERSION = "0.1.0"; diff --git a/test/level-blocks/runConfig.test.ts b/test/level-blocks/runConfig.test.ts new file mode 100644 index 0000000..a2cc3d0 --- /dev/null +++ b/test/level-blocks/runConfig.test.ts @@ -0,0 +1,173 @@ +// Declarative run configs: fill (defaults expanded, version stamped), +// runFromConfig end-to-end, weights, enabled levers, and coded failures. + +import { describe, expect, test } from "bun:test"; + +import { GraphValidationError } from "../../src/level-blocks/compile.ts"; +import { fillRunConfig, runFromConfig } from "../../src/level-blocks/runConfig.ts"; +import { STANDARD_CODES } from "../../src/level-blocks/standard.ts"; +import { RunAnalysisError } from "../../src/level-core/errors.ts"; +import { LEVELSET_VERSION } from "../../src/version.ts"; + +import { + MON_JAN_5, + SAT_JAN_10, + makeAssignment, + makeProject, + makeResource, + makeTask, +} from "../level-core/fixtures.ts"; + +function crewProject() { + const t1 = makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }); + const t2 = makeTask({ uniqueId: 2, start: MON_JAN_5, finish: SAT_JAN_10 }); + return makeProject([t1, t2], { + resources: [makeResource(100, "Crew")], + assignments: [makeAssignment(1, 100), makeAssignment(2, 100)], + }); +} + +interface TestBlock { + instanceId: string; + code: string; + params: Record; + enabled?: boolean; +} + +function baseConfig(): { + formatVersion: 1; + pipeline: { formatVersion: 1; blocks: TestBlock[] }; +} { + return { + formatVersion: 1, + pipeline: { + formatVersion: 1, + blocks: [ + { + instanceId: "crew-cap", + code: STANDARD_CODES.maxConcurrentResource, + params: { resourceUniqueId: 100, max: 1 }, + }, + { + instanceId: "wip-cost", + code: STANDARD_CODES.concurrentResourceCost, + params: { resourceUniqueId: 100, basePrice: 10, growthBase: 2 }, + }, + { instanceId: "makespan", code: STANDARD_CODES.makespan, params: {} }, + { instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }, + ], + }, + }; +} + +describe("fillRunConfig", () => { + test("expands block-schema defaults and stamps the levelset version", () => { + const filled = fillRunConfig(baseConfig()); + expect(filled.levelset).toBe(LEVELSET_VERSION); + const wipCost = filled.pipeline.blocks.find((b) => b.instanceId === "wip-cost")!; + // threshold has a schema default of 0 the input config never mentioned. + expect(wipCost.params).toEqual({ + resourceUniqueId: 100, + basePrice: 10, + growthBase: 2, + threshold: 0, + }); + // Instance-level defaults fill too. + expect(wipCost.provenance).toBe("curated"); + }); + + test("rejects unknown top-level keys (strict framework sections)", () => { + expect(() => fillRunConfig({ ...baseConfig(), wieghts: {} })).toThrow(); + }); + + test("rejects weights keyed by non-scorer instances", () => { + expect(() => + fillRunConfig({ ...baseConfig(), weights: { "crew-cap": 1 } }), + ).toThrow(GraphValidationError); + }); +}); + +describe("runFromConfig", () => { + test("builds and runs a full pipeline from one JSON document", async () => { + const run = runFromConfig(crewProject(), baseConfig()); + expect(run.search.name).toBe("serial-SGS"); + // instanceIds double as constraint names for attribution. + expect(run.constraints.map((c) => c.name)).toEqual(["crew-cap"]); + expect(run.analysis.ok).toBe(true); + + const best = await run.best(); + expect(best).not.toBeNull(); + // crew-cap max 1 forces serialization. + const starts = best!.tasks.map((t) => t.startDay).sort((a, b) => a - b); + expect(starts[1]).toBeGreaterThan(0); + + const scores = run.scores(best!); + expect(Object.keys(scores).sort()).toEqual(["makespan", "wip-cost"]); + expect(scores["makespan"]).toBe(best!.makespan); + + const project = run.materialize(best!); + expect(project.tasks).toHaveLength(2); + }); + + test("null weight means reported but not optimized", () => { + const run = runFromConfig(crewProject(), { + ...baseConfig(), + weights: { "wip-cost": null, makespan: 1 }, + }); + expect(run.scorers.find((s) => s.instanceId === "wip-cost")!.weight).toBeNull(); + expect(run.objective.name).toBe("objective"); + }); + + test("a disabled constraint block compiles to a disabled constraint", () => { + const config = baseConfig(); + config.pipeline.blocks[0] = { ...config.pipeline.blocks[0]!, enabled: false }; + const run = runFromConfig(crewProject(), config); + expect(run.constraints[0]!.enabled).toBe(false); + }); + + test("filled config round-trips: running it again reproduces the schedule", async () => { + const run1 = runFromConfig(crewProject(), baseConfig()); + const best1 = await run1.best(); + const run2 = runFromConfig(crewProject(), run1.config); + const best2 = await run2.best(); + expect(best2!.tasks).toEqual(best1!.tasks); + expect(run2.config).toEqual(run1.config); + }); + + test("analysis errors throw RunAnalysisError with coded issues", () => { + const config = baseConfig(); + config.pipeline.blocks[0] = { + instanceId: "ghost-cap", + code: STANDARD_CODES.maxConcurrentResource, + params: { resourceUniqueId: 999, max: 1 }, + }; + let caught: unknown; + try { + runFromConfig(crewProject(), config); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(RunAnalysisError); + const issues = (caught as RunAnalysisError).issues; + expect(issues.some((i) => i.code === "LEVELSET_E101" && i.source === "ghost-cap")).toBe(true); + }); + + test("unknown block codes fail graph validation", () => { + const config = baseConfig(); + config.pipeline.blocks[0] = { + instanceId: "x", + code: "levelset.DoesNotExist.v1", + params: {}, + }; + expect(() => runFromConfig(crewProject(), config)).toThrow(GraphValidationError); + }); + + test("resolve options flow through (workUnits become resolvable refs)", () => { + const config = { + ...baseConfig(), + resolve: { workUnits: [{ id: 10, taskUniqueIds: [1, 2] }] }, + }; + const run = runFromConfig(crewProject(), config); + expect(run.resolved.workUnits.get(10)).toBeDefined(); + }); +}); diff --git a/test/level-core/weightedScorer.test.ts b/test/level-core/weightedScorer.test.ts new file mode 100644 index 0000000..b9b0010 --- /dev/null +++ b/test/level-core/weightedScorer.test.ts @@ -0,0 +1,50 @@ +// weightedScorer / scoreReport — multi-objective combination as data. + +import { describe, expect, test } from "bun:test"; + +import { scoreReport, weightedScorer } from "../../src/level-core/weightedScorer.ts"; +import type { Schedule, Scorer } from "../../src/level-core/types.ts"; + +const fakeSchedule = { makespan: 10 } as Schedule; + +function constScorer(name: string, value: number, direction: "min" | "max" = "min"): Scorer { + return { name, direction, score: () => value }; +} + +describe("weightedScorer", () => { + test("sums weighted min-direction scores", () => { + const combined = weightedScorer([ + { scorer: constScorer("a", 10), weight: 1 }, + { scorer: constScorer("b", 100), weight: 0.5 }, + ]); + expect(combined.direction).toBe("min"); + expect(combined.score(fakeSchedule)).toBe(60); + }); + + test("max-direction scorers contribute negated", () => { + const combined = weightedScorer([ + { scorer: constScorer("cost", 10, "min"), weight: 1 }, + { scorer: constScorer("value", 4, "max"), weight: 2 }, + ]); + expect(combined.score(fakeSchedule)).toBe(10 - 8); + }); + + test("null and zero weights are excluded from the objective", () => { + const combined = weightedScorer([ + { scorer: constScorer("optimized", 5), weight: 1 }, + { scorer: constScorer("watched", 1000), weight: null }, + { scorer: constScorer("off", 999), weight: 0 }, + ]); + expect(combined.score(fakeSchedule)).toBe(5); + }); +}); + +describe("scoreReport", () => { + test("reports every scorer's raw value regardless of weight", () => { + const report = scoreReport(fakeSchedule, [ + constScorer("a", 5), + constScorer("b", 7, "max"), + ]); + expect(report).toEqual({ a: 5, b: 7 }); + }); +}); diff --git a/test/levelsetVersion.test.ts b/test/levelsetVersion.test.ts new file mode 100644 index 0000000..08882db --- /dev/null +++ b/test/levelsetVersion.test.ts @@ -0,0 +1,11 @@ +// LEVELSET_VERSION is embedded in run provenance; keep it locked to +// package.json so persisted configs never carry a stale version. + +import { expect, test } from "bun:test"; + +import { LEVELSET_VERSION } from "../src/version.ts"; +import pkg from "../package.json"; + +test("LEVELSET_VERSION matches package.json", () => { + expect(LEVELSET_VERSION).toBe(pkg.version); +}); -- 2.54.0