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); +});