diff --git a/src/level-blocks/standard.ts b/src/level-blocks/standard.ts new file mode 100644 index 0000000..4294ec0 --- /dev/null +++ b/src/level-blocks/standard.ts @@ -0,0 +1,131 @@ +// The standard block registry — every shipping block under a *versioned* +// registry code (`levelset.PeakCap.v1`). Versioned names are the +// forward-compatibility seam: when a block's semantics change, ship a `.v2` +// and keep `.v1` resolving, so a run config saved in March still produces +// the same schedule in September. Renames get an alias entry, not a break. +// +// `createRegistry` stays side-effect-free and singleton-less; this module +// just curates the defs. Apps with their own catalogs (codes, tiers, +// shadows) keep building their own registries — or extend this one via +// `standardRegistry(extraDefs)`. + +import { ConcurrentResourceCostBlock } from "./concurrentResourceCost.ts"; +import { ConcurrentUnitsLimitBlock } from "./concurrentUnitsLimit.ts"; +import { DeadlineBlock } from "./deadline.ts"; +import { HiringLagPenaltyBlock } from "./hiringLagPenalty.ts"; +import { MakespanBlock } from "./makespan.ts"; +import { MaxConcurrentResourceBlock } from "./maxConcurrentResource.ts"; +import { OpenUnitPenaltyBlock } from "./openUnitPenalty.ts"; +import { PeakCapBlock } from "./peakCap.ts"; +import { createRegistry, type BlockDef, type BlockRegistry } from "./registry.ts"; +import { ReleaseBlock } from "./release.ts"; +import { SerialSGSBlock } from "./serialSgsSearch.ts"; +import { UnimodalDeviationBlock } from "./unimodalDeviation.ts"; + +/** Versioned registry codes for the shipping blocks. */ +export const STANDARD_CODES = { + maxConcurrentResource: "levelset.MaxConcurrentResource.v1", + peakCap: "levelset.PeakCap.v1", + concurrentUnitsLimit: "levelset.ConcurrentUnitsLimit.v1", + deadline: "levelset.Deadline.v1", + release: "levelset.Release.v1", + concurrentResourceCost: "levelset.ConcurrentResourceCost.v1", + openUnitPenalty: "levelset.OpenUnitPenalty.v1", + hiringLagPenalty: "levelset.HiringLagPenalty.v1", + unimodalDeviation: "levelset.UnimodalDeviation.v1", + makespan: "levelset.Makespan.v1", + serialSGS: "levelset.SerialSGS.v1", +} as const; + +export function standardBlockDefs(): BlockDef[] { + return [ + { + code: STANDARD_CODES.maxConcurrentResource, + family: "constraint", + tier: "estimator", + block: MaxConcurrentResourceBlock as never, + defaultShadow: { resourceUniqueId: 0, max: 1 }, + assumptionTemplate: "at most {max} concurrent tasks on resource {resourceUniqueId}", + }, + { + code: STANDARD_CODES.peakCap, + family: "constraint", + tier: "estimator", + block: PeakCapBlock as never, + defaultShadow: { resourceUniqueId: 0, cap: 1 }, + assumptionTemplate: "peak demand on resource {resourceUniqueId} capped at {cap} units", + }, + { + code: STANDARD_CODES.concurrentUnitsLimit, + family: "constraint", + tier: "estimator", + block: ConcurrentUnitsLimitBlock as never, + defaultShadow: { unitIds: [], max: 1 }, + assumptionTemplate: "at most {max} work units open at once", + }, + { + code: STANDARD_CODES.deadline, + family: "constraint", + tier: "estimator", + block: DeadlineBlock as never, + defaultShadow: { taskUniqueId: 0, latestFinish: 0 }, + assumptionTemplate: "task {taskUniqueId} finishes by day {latestFinish}", + }, + { + code: STANDARD_CODES.release, + family: "constraint", + tier: "estimator", + block: ReleaseBlock as never, + defaultShadow: { taskUniqueId: 0, earliestStart: 0 }, + assumptionTemplate: "task {taskUniqueId} starts no earlier than day {earliestStart}", + }, + { + code: STANDARD_CODES.concurrentResourceCost, + family: "scorer", + tier: "estimator", + block: ConcurrentResourceCostBlock as never, + defaultShadow: { resourceUniqueId: 0, basePrice: 100, growthBase: 2, threshold: 0 }, + }, + { + code: STANDARD_CODES.openUnitPenalty, + family: "scorer", + tier: "estimator", + block: OpenUnitPenaltyBlock as never, + defaultShadow: { unitIds: [], softMax: 0, weight: 1 }, + }, + { + code: STANDARD_CODES.hiringLagPenalty, + family: "scorer", + tier: "estimator", + block: HiringLagPenaltyBlock as never, + defaultShadow: { resourceUniqueId: 0, trainingWeeks: 2, costPerCrewWeek: 1000 }, + }, + { + code: STANDARD_CODES.unimodalDeviation, + family: "scorer", + tier: "engineer", + block: UnimodalDeviationBlock as never, + defaultShadow: { resourceUniqueId: 0, allowSecondPeak: false, jitterTolerance: 1 }, + }, + { + code: STANDARD_CODES.makespan, + family: "scorer", + tier: "sales", + block: MakespanBlock as never, + defaultShadow: {}, + }, + { + code: STANDARD_CODES.serialSGS, + family: "search", + tier: "engineer", + block: SerialSGSBlock as never, + defaultShadow: {}, + }, + ]; +} + +/** A registry pre-loaded with the standard defs; pass extra defs to extend + * (plugins, app-specific blocks, `.v2` revisions). */ +export function standardRegistry(extraDefs?: ReadonlyArray): BlockRegistry { + return createRegistry([...standardBlockDefs(), ...(extraDefs ?? [])]); +} diff --git a/src/leveling.ts b/src/leveling.ts index f8dafda..9300671 100644 --- a/src/leveling.ts +++ b/src/leveling.ts @@ -68,6 +68,7 @@ export { serialSGS } from "./level-core/search/serialSGS.ts"; export * from "./level-blocks/registry.ts"; export * from "./level-blocks/graph.ts"; export * from "./level-blocks/compile.ts"; +export * from "./level-blocks/standard.ts"; // ── Blocks (configured constraints & scorers + MiniZinc fragments) ─── export * from "./level-blocks/types.ts"; diff --git a/test/level-blocks/standard.test.ts b/test/level-blocks/standard.test.ts new file mode 100644 index 0000000..32d2330 --- /dev/null +++ b/test/level-blocks/standard.test.ts @@ -0,0 +1,57 @@ +// The standard registry: versioned codes, valid shadows, family coverage. + +import { describe, expect, test } from "bun:test"; + +import { STANDARD_CODES, standardRegistry } from "../../src/level-blocks/standard.ts"; +import type { BlockDef } from "../../src/level-blocks/registry.ts"; + +describe("standardRegistry", () => { + test("registers every standard code under the levelset..v convention", () => { + const registry = standardRegistry(); + for (const code of Object.values(STANDARD_CODES)) { + expect(code).toMatch(/^levelset\.[A-Za-z]+\.v\d+$/); + expect(registry.get(code)).toBeDefined(); + } + expect(registry.list()).toHaveLength(Object.values(STANDARD_CODES).length); + }); + + test("covers all four families with exactly one search", () => { + const registry = standardRegistry(); + expect(registry.list({ family: "constraint" }).length).toBeGreaterThanOrEqual(5); + expect(registry.list({ family: "scorer" }).length).toBeGreaterThanOrEqual(5); + expect(registry.list({ family: "search" }).map((d) => d.code)).toEqual([ + STANDARD_CODES.serialSGS, + ]); + }); + + test("defaultShadows apply cleanly through each block", () => { + // register() already parse-validates shadows; also confirm apply() + // accepts them so shadow blocks are runnable, not just parseable. + for (const def of standardRegistry().list()) { + const parsed = def.block.schema.input.parse(def.defaultShadow); + expect(() => (def.block as { apply(input: unknown): unknown }).apply(parsed)).not.toThrow(); + } + }); + + test("extends with extra defs without disturbing the standard set", () => { + const extra: BlockDef = { + code: "myapp.Probe.v1", + family: "collector", + tier: "engineer", + block: { + id: "Probe", + schema: { + input: standardRegistry().get(STANDARD_CODES.makespan)!.block.schema.input, + output: standardRegistry().get(STANDARD_CODES.makespan)!.block.schema.output, + }, + apply: () => ({ tag: "probe" }), + toMiniZinc: () => ({ text: "% probe" }), + doc: { nl: "probe", pseudocode: "probe" }, + } as never, + defaultShadow: {}, + }; + const registry = standardRegistry([extra]); + expect(registry.get("myapp.Probe.v1")).toBeDefined(); + expect(registry.get(STANDARD_CODES.serialSGS)).toBeDefined(); + }); +});