From 8aaa800ef2853a8ef7cb985db49c267a3a835f0c Mon Sep 17 00:00:00 2001 From: "Russ T. Fugal" Date: Tue, 11 Aug 2026 00:21:59 -0600 Subject: [PATCH] feat(leveling): declarative GridSweep and canvas contracts Add a top-level GridSweep over the unchanged format-v1 pipeline: strict broadcast and keyed zip axes, constraint enable toggles, bounded deterministic expansion, per-cell scorer dispatch, lazy stream concatenation, skip-and-record infeasibility, winner annotations, and sweep provenance. Search/collector targets and scorer membership changes fail closed. Extend levelset/conf with a value-lossless [sweep] TOML projection and registry-aware target validation. Nested arrays and inline records round-trip, while invalid target paths and delivered values surface as attributed E308 issues. Complete the canvas boundary by publishing levelset/block-meta.json and deriving framework-owned instance and sweep authoring schemas, reserved names, enable semantics, and combination limits into the generated metadata. Regenerate the 17-block artifact, document the contracts, and add drift, export, execution, laziness, provenance, and round-trip coverage. --- README.md | 36 ++- generated/block-meta.json | 156 ++++++++++++ package.json | 6 +- src/conf/parse.ts | 20 +- src/conf/serialize.ts | 13 + src/level-blocks/meta.ts | 46 ++++ src/level-blocks/runConfig.ts | 295 ++++++++++++++++++++++- src/level-blocks/sweep.ts | 128 ++++++++++ src/level-core/scheduleStream.ts | 12 + src/leveling.ts | 1 + src/schema/index.ts | 4 +- src/schema/provenance.ts | 18 ++ test/conf/parse.test.ts | 38 +++ test/conf/serialize.test.ts | 46 +++- test/level-blocks/blockMeta.test.ts | 9 + test/level-blocks/meta.test.ts | 26 ++ test/level-blocks/provenance.test.ts | 23 ++ test/level-blocks/sweep.test.ts | 313 +++++++++++++++++++++++++ test/level-core/scheduleStream.test.ts | 26 +- 19 files changed, 1186 insertions(+), 30 deletions(-) create mode 100644 src/level-blocks/sweep.ts create mode 100644 test/level-blocks/sweep.test.ts diff --git a/README.md b/README.md index 39fe3a2..1ff6f96 100644 --- a/README.md +++ b/README.md @@ -116,13 +116,14 @@ Older MPP versions (8, 9, 12) are detected and produce a clear error message exp ### Subpath exports -| Path | Contents | -| ------------------- | ----------------------------------------------------------------------------- | -| `levelset` | Convenience functions (`readMpp`, `writeJson`, `writeCsv`, `writeXlsx`, etc.) | -| `levelset/leveling` | Resource-leveling toolkit (`level-core` + `level-blocks`) — no file-I/O deps | -| `levelset/conf` | `.conf` (TOML) run-config parse/serialize (`parseConf`, `serializeConf`) | -| `levelset/advanced` | Reader/writer classes, container utilities | -| `levelset/schema` | Zod validation schemas for all model types | +| Path | Contents | +| -------------------------- | ----------------------------------------------------------------------------- | +| `levelset` | Convenience functions (`readMpp`, `writeJson`, `writeCsv`, `writeXlsx`, etc.) | +| `levelset/leveling` | Resource-leveling toolkit (`level-core` + `level-blocks`) — no file-I/O deps | +| `levelset/conf` | `.conf` (TOML) run-config parse/serialize (`parseConf`, `serializeConf`) | +| `levelset/block-meta.json` | Generated, runtime-free registry and composition metadata | +| `levelset/advanced` | Reader/writer classes, container utilities | +| `levelset/schema` | Zod validation schemas for all model types | ## Resource leveling @@ -224,7 +225,7 @@ const wipPenalty = OpenUnitPenaltyBlock.apply({ unitIds: [10, 20], softMax: 2, w const best = await stream.bestBy(wipPenalty); ``` -> **Note:** `serialSGS` emits a single schedule, so a scorer currently _ranks_ output rather than _steering_ it — `bestBy` over a one-schedule stream returns that schedule. Scorers become an optimization lever once a multi-candidate search (restart / LDS / LNS transformer) consumes them. Hard constraints (the table above) are what shape `serialSGS` output today. +> **Note:** `serialSGS` emits one schedule per concrete config. A GridSweep widens that into one candidate per feasible cell, making scorers and collectors selection levers across the grid; without a sweep, `bestBy` over serialSGS still returns its single schedule. Hard constraints (the table above) are what shape each serialSGS result. Constraint blocks pair with the hard variants — `MaxConcurrentResourceBlock`, `PeakCapBlock`, `ConcurrentUnitsLimitBlock`, `DeadlineBlock`, `ReleaseBlock`, and `ModeSelectionBlock` build the corresponding `Constraint` and a MiniZinc fragment. `ModeSelectionBlock` lists alternative duration/resource-demand modes per task in preference order; the search picks a later mode only when it starts the task strictly earlier, and the chosen `modeId` and realized demand land on the emitted schedule. @@ -269,7 +270,8 @@ 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`, `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. +- **GridSweep (`sweep`)** — a bounded, deterministic cross-product over constraint/scorer parameters on one fixed resolved project. Broadcast axes apply one point to several targets; zip axes use `valueKey` to deliver coordinated per-target values. `paramPath: "$enabled"` toggles a constraint instance. Search/collector targets and scorer enablement changes are rejected so pipeline membership stays fixed. The default limit is 64 combinations (hard ceiling 4096); infeasible cells are skipped and recorded, and an entirely infeasible grid throws `SweepError`. Every candidate carries `sweep.point`, and materialized provenance records the winner and skipped cells. +- **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. A GridSweep supplies multiple candidates even when the configured search emits only one schedule per cell. - **`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. @@ -322,6 +324,17 @@ lateness = 1 [objective] lexicographic = ["lateness", "makespan"] # ordered tiers; weights stay report-only + +[sweep] +maxCombinations = 64 + +[[sweep.axes]] +id = "crew-cap" +values = [2, 3, 4] # broadcast points + +[[sweep.axes.targets]] +instanceId = "crew-cap" +paramPath = "max" # or "$enabled" for a constraint toggle ``` Grammar, at a glance: @@ -334,19 +347,20 @@ Grammar, at a glance: | `[blocks.]` | One table per block instance — the **TOML key is the instanceId** (quote it if it isn't bare-safe). Reserved keys `code` (required), `enabled` (omit = enabled), `provenance` (omit = `"curated"`), `shadow` (omit = `false`); every other key is a param. | | `[weights]` | Optional. `name = 1.0`, or `name = "report"` for report-only (⇄ `null`). | | `[objective]` | Optional. `lexicographic = ["a", "b"]` — ordered tiers. | +| `[sweep]` | Optional. `maxCombinations` plus `[[sweep.axes]]`; each axis has an `id`, `values`, and one or more `[[sweep.axes.targets]]`. Add `valueKey` to every target and use `[[sweep.axes.values]]` records for a zip axis. | Params map structurally: nested objects ⇄ dotted keys (`window.fromDay = 10`), object arrays ⇄ `[[blocks.x.param]]` array-of-tables, scalar arrays ⇄ inline arrays. Document order of `[blocks.*]` is preserved as `blocks[]` order; the serializer never reorders. **Value conventions** (each with a coded error): bare TOML datetime literals are rejected — quote dates as ISO strings (`E304`); non-finite numbers (`inf`/`nan`) are rejected (`E306`); every framework section is strict, so an unknown section or key is an error (`E302`). All conf issues carry a stable `LEVELSET_E3##` code (`E301` TOML syntax with line/column, `E302` config shape, `E303` missing/unsupported format, `E304` datetime literal, `E305` reserved-key collision, `E306` non-finite, `E308` registry validation attributed to its instanceId). The round trip is **value-lossless but comment/format-lossy**: `parseConf(serializeConf(c))` reproduces `c`, but hand-written comments and spacing are not preserved (JSON, not `.conf`, is the durable artifact). -There is deliberately **no `[search]` section** (search is just a block — add it under `[blocks.]`; `validateGraph` enforces exactly one) and **no `[profiles]` section** (schedule profiles are runtime memoization, not config state). A `[sweep]` section is a **reserved, not-yet-implemented** extension point (parameter-grid search, see [docs/gridsweep design](https://tangled.org/russ.fugl.dev/levelset)) — until it lands, `[sweep]` is an unknown-section error, fail-closed. +There is deliberately **no `[search]` section** (search is just a block — add it under `[blocks.]`; `validateGraph` enforces exactly one) and **no `[profiles]` section** (schedule profiles are runtime memoization, not config state). GridSweep remains above the flat graph: each cell compiles the same exactly-one-search pipeline with substituted values, so `formatVersion` remains `1`. **For block authors** — two field-validated rules keep a block conf-expressible (`lintRegistryForConf(registry)` checks them): 1. An `enabled`-shaped on/off policy belongs on the instance-level `enabled` lever, **never as a param name** — `code`/`enabled`/`provenance`/`shadow` are reserved block-table keys, so a param of that name is unreachable through `.conf` (`E305`). 2. Model "unbounded" as an **absent optional param**, never `Infinity` — non-finite numbers can't be written (`E306`), and an absent optional is the honest encoding. -**Block metadata.** `deriveBlockMeta(def)` / `dumpBlockMeta(registry)` (from `levelset/leveling`) project the registry into the field/variadic/complex-param taxonomy every downstream view needs (canvas, panel, LLM tool spec), alongside the composition grammar (`FAMILY_ORDER`, `canFollow`, `FAMILY_CARDINALITY`). A committed dump lives at `generated/block-meta.json` — regenerate it with `bun run generate:block-meta` (a drift test guards it). +**Block metadata.** `deriveBlockMeta(def)` / `dumpBlockMeta(registry)` (from `levelset/leveling`) project the registry into the field/variadic/complex-param taxonomy every downstream view needs (canvas, panel, LLM tool spec), alongside the composition grammar (`FAMILY_ORDER`, `canFollow`, `FAMILY_CARDINALITY`). The dump's `authoring` section also carries JSON Schemas for the framework-owned block-instance and GridSweep shapes, reserved instance-param names, the `$enabled` sweep target, and combination limits. Consumers that do not want to import the engine can load the committed artifact from `levelset/block-meta.json` using their runtime's JSON-module syntax. The source-tree copy lives at `generated/block-meta.json`; regenerate it with `bun run generate:block-meta` (a drift test guards it). ## Scripts diff --git a/generated/block-meta.json b/generated/block-meta.json index d3d57c6..3734cee 100644 --- a/generated/block-meta.json +++ b/generated/block-meta.json @@ -44,6 +44,162 @@ } } }, + "authoring": { + "instance": { + "reservedParamNames": [ + "code", + "provenance", + "shadow", + "enabled" + ], + "enabledWhenOmitted": true, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "code": { + "type": "string" + }, + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "provenance": { + "default": "curated", + "type": "string", + "enum": [ + "curated", + "candidate" + ] + }, + "shadow": { + "default": false, + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "instanceId", + "code", + "params" + ] + } + }, + "sweep": { + "enabledTarget": "$enabled", + "defaultMaxCombinations": 64, + "hardMaxCombinations": 4096, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "axes": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "targets": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "instanceId": { + "type": "string", + "minLength": 1 + }, + "paramPath": { + "type": "string", + "minLength": 1 + }, + "valueKey": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "instanceId", + "paramPath" + ], + "additionalProperties": false + } + }, + "values": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + } + }, + "required": [ + "id", + "targets", + "values" + ], + "additionalProperties": false + } + }, + "maxCombinations": { + "default": 64, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 4096 + } + }, + "required": [ + "axes" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/__schema0" + } + } + ] + } + } + } + } + }, "blocks": [ { "code": "levelset.MaxConcurrentResource.v1", diff --git a/package.json b/package.json index be18c20..fb31e96 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "./advanced": "./src/advanced.ts", "./schema": "./src/schema/index.ts", "./leveling": "./src/leveling.ts", - "./conf": "./src/conf/index.ts" + "./conf": "./src/conf/index.ts", + "./block-meta.json": "./generated/block-meta.json" }, "publishConfig": { "module": "./dist/index.js", @@ -58,7 +59,8 @@ "./conf": { "types": "./dist/conf/index.d.ts", "import": "./dist/conf/index.js" - } + }, + "./block-meta.json": "./generated/block-meta.json" } }, "files": [ diff --git a/src/conf/parse.ts b/src/conf/parse.ts index 04ba4bc..ae13e6e 100644 --- a/src/conf/parse.ts +++ b/src/conf/parse.ts @@ -15,7 +15,12 @@ import { parse as parseToml, TomlError } from "smol-toml"; import { validateGraph } from "../level-blocks/graph.ts"; import type { BlockDef, BlockRegistry } from "../level-blocks/registry.ts"; -import { checkRunConfigRefs, runConfigSchema, type RunConfig } from "../level-blocks/runConfig.ts"; +import { + checkRunConfigRefs, + runConfigSchema, + validateSweepConfig, + type RunConfig, +} from "../level-blocks/runConfig.ts"; import type { ConfIssue } from "./issues.ts"; @@ -39,6 +44,7 @@ const ALLOWED_SECTIONS = new Set([ "blocks", "weights", "objective", + "sweep", ]); const LEVELSET_KEYS = new Set(["format", "version", "seed"]); @@ -53,6 +59,7 @@ interface ConfDoc { blocks?: unknown; weights?: unknown; objective?: unknown; + sweep?: unknown; [key: string]: unknown; } interface LevelsetTbl { @@ -73,6 +80,7 @@ interface AssembledConfig { pipeline?: unknown; weights?: unknown; objective?: unknown; + sweep?: unknown; } interface BlockInstanceDraft { instanceId: string; @@ -109,16 +117,13 @@ export function reservedParamCollisions(def: BlockDef): string[] { } function unknownSectionMessage(key: string): string { - if (key === "sweep") { - return `[sweep] is a reserved, not-yet-implemented extension point (see the gridsweep design doc) — remove it`; - } if (key === "search") { return `[search] is not a conf section — search is a block; add it under [blocks.] with code = "levelset.SerialSGS.v1"`; } if (key === "profiles") { return `[profiles] is not a conf section — schedule profiles are runtime memoization, not config state`; } - return `Unknown top-level section [${key}] — the conf grammar allows only levelset, resolve, pipeline, blocks, weights, objective`; + return `Unknown top-level section [${key}] — the conf grammar allows only levelset, resolve, pipeline, blocks, weights, objective, sweep`; } /** Deep-scan for TOML datetime literals (E304) and non-finite numbers (E306), @@ -341,6 +346,7 @@ export function parseConf(text: string, opts?: ParseConfOptions): ParseConfResul } if (doc.objective !== undefined) assembled.objective = doc.objective; + if (doc.sweep !== undefined) assembled.sweep = doc.sweep; // Shape validation. formatVersion is pre-forced; everything else is checked // here (types, strict resolve/objective sections, required fields). @@ -370,6 +376,10 @@ export function parseConf(text: string, opts?: ParseConfOptions): ParseConfResul if (refIssues.length > 0) { return { ok: false, issues: refIssues.map((i) => graphIssueToConf(i)) }; } + const sweepIssues = validateSweepConfig(config, opts.registry, validated); + if (sweepIssues.length > 0) { + return { ok: false, issues: sweepIssues.map((i) => graphIssueToConf(i)) }; + } } return { ok: true, config, issues: [] }; diff --git a/src/conf/serialize.ts b/src/conf/serialize.ts index 4572c67..ea543f9 100644 --- a/src/conf/serialize.ts +++ b/src/conf/serialize.ts @@ -56,6 +56,13 @@ function tomlScalar(value: unknown): string { if (typeof value === "string") return tomlString(value); if (typeof value === "boolean") return String(value); if (typeof value === "number") return String(value); + if (Array.isArray(value)) return inlineArray(value); + if (isPlainObject(value)) { + const entries = Object.entries(value).map( + ([key, child]) => `${tomlKey(key)} = ${tomlScalar(child)}`, + ); + return `{ ${entries.join(", ")} }`; + } // Should not occur in a validated RunConfig; render defensively. return tomlString(String(value)); } @@ -203,6 +210,12 @@ export function serializeConf(config: RunConfig, opts?: SerializeConfOptions): s ); } + // [sweep] + [[sweep.axes]] — emitted through the generic table walker so + // targets and zip-axis record values retain their exact JSON structure. + if (config.sweep !== undefined) { + emitTable(["sweep"], [], config.sweep as Record, false, sections); + } + return sections.join("\n\n") + "\n"; } diff --git a/src/level-blocks/meta.ts b/src/level-blocks/meta.ts index f431fee..97f9dde 100644 --- a/src/level-blocks/meta.ts +++ b/src/level-blocks/meta.ts @@ -19,7 +19,14 @@ import { FAMILY_ORDER, type FamilyCardinality, } from "./composition.ts"; +import { blockInstanceSchema } from "./graph.ts"; import type { BlockDef, BlockFamily, BlockTier, PanelBinding } from "./registry.ts"; +import { + DEFAULT_MAX_COMBINATIONS, + HARD_MAX_COMBINATIONS, + SWEEP_ENABLED_TARGET, + sweepSchema, +} from "./sweep.ts"; import type { BlockDoc } from "./types.ts"; import { LEVELSET_VERSION } from "../version.ts"; @@ -131,9 +138,47 @@ export interface BlockMetaDump { follows: Record; cardinality: Record; }; + /** Framework-owned authoring shapes shared by every canvas. Block-specific + * params continue to come from each entry in `blocks`. */ + authoring: { + instance: { + /** Graph-instance keys that cannot also be block param names. */ + reservedParamNames: readonly string[]; + /** Runtime meaning of an omitted optional `enabled` property. */ + enabledWhenOmitted: true; + schema: unknown; + }; + sweep: { + /** Special target path that toggles the whole block instance. */ + enabledTarget: typeof SWEEP_ENABLED_TARGET; + defaultMaxCombinations: number; + hardMaxCombinations: number; + schema: unknown; + }; + }; blocks: DerivedBlockMeta[]; } +function authoringMeta(): BlockMetaDump["authoring"] { + const instanceSchema = z.toJSONSchema(blockInstanceSchema, { io: "input" }) as JsonSchemaNode; + const reservedParamNames = Object.keys(instanceSchema.properties ?? {}).filter( + (name) => name !== "instanceId" && name !== "params", + ); + return { + instance: { + reservedParamNames, + enabledWhenOmitted: true, + schema: instanceSchema, + }, + sweep: { + enabledTarget: SWEEP_ENABLED_TARGET, + defaultMaxCombinations: DEFAULT_MAX_COMBINATIONS, + hardMaxCombinations: HARD_MAX_COMBINATIONS, + schema: z.toJSONSchema(sweepSchema, { io: "input" }), + }, + }; +} + function resolveDotPath(value: unknown, path: string): unknown { let current = value; for (const segment of path.split(".")) { @@ -359,6 +404,7 @@ export function dumpBlockMeta(registry: { list(): BlockDef[] }): BlockMetaDump { follows: FAMILY_FOLLOWS, cardinality: FAMILY_CARDINALITY, }, + authoring: authoringMeta(), blocks: registry.list().map(deriveBlockMeta), }; } diff --git a/src/level-blocks/runConfig.ts b/src/level-blocks/runConfig.ts index 4d18d3f..a00531b 100644 --- a/src/level-blocks/runConfig.ts +++ b/src/level-blocks/runConfig.ts @@ -19,7 +19,7 @@ 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 { concatStreams, streamFromFactory } from "../level-core/scheduleStream.ts"; import type { Collector, Constraint, @@ -45,6 +45,16 @@ import { } from "./graph.ts"; import type { BlockRegistry } from "./registry.ts"; import { standardRegistry } from "./standard.ts"; +import { + expandSweepCells, + SWEEP_ENABLED_TARGET, + SWEEP_POINT_ANNOTATION, + SweepError, + sweepCombinationCount, + sweepSchema, + type SweepPoint, + type SweepStats, +} from "./sweep.ts"; // WorkUnit as JSON (mirrors the WorkUnit interface in level-core/types.ts). export const workUnitSchema = z.object({ @@ -88,6 +98,8 @@ export const runConfigSchema = z.strictObject({ lexicographic: z.array(z.string()).min(1), }) .optional(), + /** Bounded parameter grid evaluated above the unchanged flat pipeline. */ + sweep: sweepSchema.optional(), }); export type RunConfig = z.infer; @@ -222,7 +234,7 @@ export function fillRunConfig(config: unknown, registry?: BlockRegistry): RunCon const refIssues = checkRunConfigRefs(parsed, reg, validated); if (refIssues.length > 0) throw new GraphValidationError(refIssues); - return { + const filled: RunConfig = { ...parsed, levelset: LEVELSET_VERSION, pipeline: { @@ -233,6 +245,162 @@ export function fillRunConfig(config: unknown, registry?: BlockRegistry): RunCon })), }, }; + const sweepIssues = validateSweepConfig(filled, reg, validated); + if (sweepIssues.length > 0) throw new GraphValidationError(sweepIssues); + return filled; +} + +/** Cross-field sweep validation that depends on the filled graph and block schemas. */ +export function validateSweepConfig( + config: RunConfig, + registry: BlockRegistry, + validated: Extract, +): GraphIssue[] { + if (!config.sweep) return []; + const issues: GraphIssue[] = []; + const instanceById = new Map(config.pipeline.blocks.map((block) => [block.instanceId, block])); + const axisIds = new Set(); + + for (const axis of config.sweep.axes) { + if (axisIds.has(axis.id)) { + issues.push({ message: `sweep axis id "${axis.id}" is duplicated` }); + } + axisIds.add(axis.id); + + const keyedTargets = axis.targets.filter((target) => target.valueKey !== undefined); + const isZip = keyedTargets.length === axis.targets.length; + if (keyedTargets.length > 0 && !isZip) { + issues.push({ message: `sweep axis "${axis.id}" mixes keyed and unkeyed targets` }); + continue; + } + + const referencedKeys = new Set(keyedTargets.map((target) => target.valueKey!)); + if (isZip) { + axis.values.forEach((point, pointIndex) => { + if (point === null || Array.isArray(point) || typeof point !== "object") { + issues.push({ + message: `sweep axis "${axis.id}" zip point ${pointIndex} must be a record`, + }); + return; + } + const pointKeys = Object.keys(point); + for (const key of referencedKeys) { + if (!Object.hasOwn(point, key)) { + issues.push({ + message: `sweep axis "${axis.id}" zip point ${pointIndex} is missing valueKey "${key}"`, + }); + } + } + for (const key of pointKeys) { + if (!referencedKeys.has(key)) { + issues.push({ + message: `sweep axis "${axis.id}" zip point ${pointIndex} has unreferenced key "${key}"`, + }); + } + } + }); + } + + for (const target of axis.targets) { + const instance = instanceById.get(target.instanceId); + if (!instance) { + issues.push({ + instanceId: target.instanceId, + message: `sweep axis "${axis.id}" targets unknown instance "${target.instanceId}"`, + }); + continue; + } + + const parsedParams = validated.parsedParams.get(instance.instanceId) as + | Record + | undefined; + if ( + target.paramPath !== SWEEP_ENABLED_TARGET && + resolveSweepDotPath(parsedParams, target.paramPath) === undefined + ) { + issues.push({ + instanceId: target.instanceId, + code: instance.code, + message: `sweep axis "${axis.id}" paramPath "${target.paramPath}" does not resolve in instance "${target.instanceId}"`, + }); + continue; + } + + const def = registry.get(instance.code)!; + if (def.family !== "constraint" && def.family !== "scorer") { + issues.push({ + instanceId: target.instanceId, + code: instance.code, + message: `sweep axis "${axis.id}" targets ${def.family} instance "${target.instanceId}"; only constraint and scorer instances are sweepable`, + }); + continue; + } + if (def.family === "scorer" && target.paramPath === SWEEP_ENABLED_TARGET) { + issues.push({ + instanceId: target.instanceId, + code: instance.code, + message: `sweep axis "${axis.id}" cannot toggle scorer instance "${target.instanceId}"; scorer membership must stay fixed across cells`, + }); + continue; + } + axis.values.forEach((point, pointIndex) => { + if (isZip && (point === null || Array.isArray(point) || typeof point !== "object")) return; + const value = + target.valueKey === undefined + ? point + : (point as Record)[target.valueKey]; + const valueLabel = target.valueKey === undefined ? "" : ` valueKey "${target.valueKey}"`; + if (target.paramPath === SWEEP_ENABLED_TARGET) { + if (typeof value !== "boolean") { + issues.push({ + instanceId: target.instanceId, + code: instance.code, + message: `sweep axis "${axis.id}"${valueLabel} point ${pointIndex} must deliver a boolean to $enabled`, + }); + } + return; + } + + const candidate = structuredClone(parsedParams!); + setSweepDotPath(candidate, target.paramPath, structuredClone(value)); + const result = def.block.schema.input.safeParse(candidate); + if (!result.success) { + issues.push({ + instanceId: target.instanceId, + code: instance.code, + path: target.paramPath.split("."), + message: `sweep axis "${axis.id}"${valueLabel} point ${pointIndex} delivers an invalid value to "${target.instanceId}.${target.paramPath}": ${result.error.issues.map((issue) => issue.message).join("; ")}`, + }); + } + }); + } + } + + const combinations = sweepCombinationCount(config.sweep); + if (combinations > config.sweep.maxCombinations) { + issues.push({ + message: `sweep has ${combinations} combinations, exceeding maxCombinations ${config.sweep.maxCombinations}`, + }); + } + return issues; +} + +function resolveSweepDotPath(value: unknown, path: string): unknown { + let current = value; + for (const segment of path.split(".")) { + if (current === null || typeof current !== "object") return undefined; + current = (current as Record)[segment]; + } + return current; +} + +function setSweepDotPath(target: Record, path: string, value: unknown): void { + const segments = path.split("."); + let current = target; + for (let i = 0; i < segments.length - 1; i++) { + current = current[segments[i]!] as Record; + } + current[segments.at(-1)!] = value; } interface CompiledRun { @@ -385,8 +553,34 @@ export function runFromConfig( }); if (!analysis.ok) throw new RunAnalysisError(analysis.issues); - const objective = scorers.some((s) => s.weight !== null && s.weight !== 0) - ? weightedScorer(scorers, "objective") + // The base graph is analyzed once. Sweep cells only substitute schema-checked + // parameter values, then compile through the same exactly-one-search path. + const sweepCells = filled.sweep + ? expandSweepCells(filled).map((cell) => ({ + point: cell.point, + compiled: compileGraph(registry, cell.config.pipeline, runTarget(cell.config)), + })) + : []; + const sweepStatsBySchedule = new WeakMap(); + const sweepScorersBySchedule = new WeakMap>(); + const runScorers = filled.sweep + ? scorers.map( + (entry): WeightedScorerEntry => ({ + ...entry, + scorer: { + ...entry.scorer, + score(schedule: Schedule): number { + return ( + sweepScorersBySchedule.get(schedule)?.get(entry.instanceId) ?? entry.scorer + ).score(schedule); + }, + }, + }), + ) + : scorers; + + const objective = runScorers.some((s) => s.weight !== null && s.weight !== 0) + ? weightedScorer(runScorers, "objective") : { name: "Makespan", direction: "min" as const, score: (s: Schedule) => s.makespan }; const run: LevelingRun = { @@ -394,22 +588,54 @@ export function runFromConfig( resolved, analysis, constraints, - scorers, + scorers: runScorers, search, objective, stream(): ScheduleStream { + if (filled.sweep) { + const stats: SweepStats = { + combinations: sweepCells.length, + feasibleCells: 0, + skipped: [], + }; + const factories = sweepCells.map( + ({ point, compiled }) => + () => + streamFromFactory(() => + runSweepCell( + compiled.search, + resolved, + compiled.constraints, + point, + stats, + sweepStatsBySchedule, + compiled.scorers, + sweepScorersBySchedule, + ), + ), + ); + const merged = concatStreams(factories); + return streamFromFactory(async function* () { + for await (const schedule of merged) yield schedule; + if (stats.feasibleCells === 0) { + throw new SweepError( + `no feasible sweep cell among ${stats.combinations} combinations — loosen the grid or scheduling windows`, + ); + } + }); + } return streamFromFactory(() => stripReturn(search.run(resolved, constraints))); }, async best(): Promise { const lex = filled.objective?.lexicographic; if (lex) { - const byId = new Map(scorers.map((s) => [s.instanceId, s.scorer])); + const byId = new Map(runScorers.map((s) => [s.instanceId, s.scorer])); return run.stream().bestByLex(lex.map((id) => byId.get(id)!)); } return run.stream().bestBy(objective); }, async collectors(): Promise> { - const byId = new Map(scorers.map((s) => [s.instanceId, s.scorer])); + const byId = new Map(runScorers.map((s) => [s.instanceId, s.scorer])); // Each collector drains its own fresh stream (stream() rebuilds the // generator), so they run independently. return Promise.all( @@ -447,10 +673,12 @@ export function runFromConfig( }, scores(schedule: Schedule): Record { const report: Record = {}; - for (const entry of scorers) report[entry.instanceId] = entry.scorer.score(schedule); + for (const entry of runScorers) report[entry.instanceId] = entry.scorer.score(schedule); return report; }, materialize(schedule: Schedule): Project { + const sweepStats = sweepStatsBySchedule.get(schedule); + const winner = schedule.annotations.get(SWEEP_POINT_ANNOTATION); const provenance: RunProvenance = { levelsetVersion: LEVELSET_VERSION, generatedAt: new Date().toISOString(), @@ -464,6 +692,16 @@ export function runFromConfig( ...(schedule.annotations.size > 0 ? { annotations: Object.fromEntries(schedule.annotations) } : {}), + ...(filled.sweep && sweepStats && isSweepPoint(winner) + ? { + sweep: { + combinations: sweepStats.combinations, + feasibleCells: sweepStats.feasibleCells, + winner, + skipped: sweepStats.skipped.map(({ point, reason }) => ({ point, reason })), + }, + } + : {}), }; return { ...materialize(schedule), levelset: provenance }; }, @@ -492,3 +730,44 @@ function collectorScorerRefs(collector: Collector): ReadonlyArray { async function* stripReturn(gen: AsyncGenerator): AsyncGenerator { for await (const schedule of gen) yield schedule; } + +async function* runSweepCell( + search: Search, + resolved: ResolvedProject, + constraints: ReadonlyArray, + point: SweepPoint, + stats: SweepStats, + statsBySchedule: WeakMap, + cellScorers: ReadonlyArray, + scorersBySchedule: WeakMap>, +): AsyncGenerator { + let emitted = false; + try { + for await (const schedule of search.run(resolved, constraints)) { + if (!emitted) { + emitted = true; + stats.feasibleCells++; + } + const annotations = new Map(schedule.annotations); + annotations.set(SWEEP_POINT_ANNOTATION, point); + const tagged: Schedule = { ...schedule, annotations }; + statsBySchedule.set(tagged, stats); + scorersBySchedule.set( + tagged, + new Map(cellScorers.map((entry) => [entry.instanceId, entry.scorer])), + ); + yield tagged; + } + if (!emitted) stats.skipped.push({ point, reason: "infeasible" }); + } catch (error) { + if (emitted) throw error; + stats.skipped.push({ + point, + reason: `failed: ${error instanceof Error ? error.message : String(error)}`, + }); + } +} + +function isSweepPoint(value: unknown): value is SweepPoint { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/level-blocks/sweep.ts b/src/level-blocks/sweep.ts new file mode 100644 index 0000000..e8647df --- /dev/null +++ b/src/level-blocks/sweep.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; + +import type { RunConfig } from "./runConfig.ts"; + +export const DEFAULT_MAX_COMBINATIONS = 64; +export const HARD_MAX_COMBINATIONS = 4096; +export const SWEEP_POINT_ANNOTATION = "sweep.point"; +export const SWEEP_ENABLED_TARGET = "$enabled"; + +export const sweepTargetSchema = z.strictObject({ + instanceId: z.string().min(1), + paramPath: z.string().min(1), + valueKey: z.string().min(1).optional(), +}); + +export const sweepAxisSchema = z.strictObject({ + id: z.string().min(1), + targets: z.array(sweepTargetSchema).min(1), + values: z.array(z.json()).min(1), +}); + +export const sweepSchema = z.strictObject({ + axes: z.array(sweepAxisSchema).min(1), + maxCombinations: z + .number() + .int() + .positive() + .max(HARD_MAX_COMBINATIONS) + .default(DEFAULT_MAX_COMBINATIONS), +}); + +export type SweepTarget = z.infer; +export type SweepAxis = z.infer; +export type SweepConfig = z.infer; +export type SweepPoint = Record>>; + +export interface SweepCell { + readonly config: RunConfig; + readonly point: SweepPoint; +} + +export interface SweepSkippedCell { + readonly point: SweepPoint; + readonly reason: string; +} + +export interface SweepStats { + readonly combinations: number; + feasibleCells: number; + readonly skipped: SweepSkippedCell[]; +} + +export class SweepError extends Error { + constructor(message: string) { + super(message); + this.name = "SweepError"; + } +} + +export function sweepCombinationCount(sweep: SweepConfig): number { + return sweep.axes.reduce((count, axis) => count * axis.values.length, 1); +} + +/** Expand a filled config in declaration order, with the last axis varying fastest. */ +export function expandSweepCells(filled: RunConfig): SweepCell[] { + if (!filled.sweep) return [{ config: filled, point: {} }]; + + const axes = filled.sweep.axes; + const indexes = axes.map(() => 0); + const count = sweepCombinationCount(filled.sweep); + const cells: SweepCell[] = []; + + for (let combination = 0; combination < count; combination++) { + const point: SweepPoint = {}; + const blocks = structuredClone(filled.pipeline.blocks); + const byId = new Map(blocks.map((block) => [block.instanceId, block])); + + for (let axisIndex = 0; axisIndex < axes.length; axisIndex++) { + const axis = axes[axisIndex]!; + const axisPoint = structuredClone(axis.values[indexes[axisIndex]!]!); + point[axis.id] = axisPoint; + for (const target of axis.targets) { + const block = byId.get(target.instanceId)!; + const value = + target.valueKey === undefined + ? structuredClone(axisPoint) + : structuredClone( + (axisPoint as Record>>)[target.valueKey], + ); + if (target.paramPath === SWEEP_ENABLED_TARGET) { + block.enabled = value as boolean; + } else { + setDotPath(block.params, target.paramPath, value); + } + } + } + + const withoutSweep = { ...filled }; + delete withoutSweep.sweep; + cells.push({ + point, + config: { + ...withoutSweep, + pipeline: { ...filled.pipeline, blocks }, + }, + }); + + for (let axisIndex = axes.length - 1; axisIndex >= 0; axisIndex--) { + indexes[axisIndex] = indexes[axisIndex]! + 1; + if (indexes[axisIndex]! < axes[axisIndex]!.values.length) break; + indexes[axisIndex] = 0; + } + } + return cells; +} + +export function expandSweep(filled: RunConfig): RunConfig[] { + return expandSweepCells(filled).map((cell) => cell.config); +} + +function setDotPath(target: Record, path: string, value: unknown): void { + const segments = path.split("."); + let current = target; + for (let i = 0; i < segments.length - 1; i++) { + current = current[segments[i]!] as Record; + } + current[segments.at(-1)!] = value; +} diff --git a/src/level-core/scheduleStream.ts b/src/level-core/scheduleStream.ts index 239f4e5..1f07f7c 100644 --- a/src/level-core/scheduleStream.ts +++ b/src/level-core/scheduleStream.ts @@ -174,3 +174,15 @@ export function streamFromFactory(factory: () => AsyncGenerator): Sche [Symbol.asyncIterator]: factory, }); } + +/** Lazily concatenate streams in declaration order. A later factory is not + * invoked until every schedule from the preceding stream has been consumed. */ +export function concatStreams(factories: ReadonlyArray<() => ScheduleStream>): ScheduleStream { + return new ScheduleStreamImpl({ + async *[Symbol.asyncIterator]() { + for (const factory of factories) { + for await (const schedule of factory()) yield schedule; + } + }, + }); +} diff --git a/src/leveling.ts b/src/leveling.ts index cbe1c4f..6626e28 100644 --- a/src/leveling.ts +++ b/src/leveling.ts @@ -84,6 +84,7 @@ export * from "./level-blocks/graph.ts"; export * from "./level-blocks/compile.ts"; export * from "./level-blocks/standard.ts"; export * from "./level-blocks/runConfig.ts"; +export * from "./level-blocks/sweep.ts"; // ── Blocks (configured constraints & scorers + MiniZinc fragments) ─── export * from "./level-blocks/types.ts"; diff --git a/src/schema/index.ts b/src/schema/index.ts index aed30de..f269fa8 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -1,7 +1,7 @@ export { ProjectFileSchema, ProjectPropertiesSchema } from "./project.ts"; export type { ProjectFile, ProjectProperties } from "./project.ts"; -export { RunProvenanceSchema, RunIssueJsonSchema } from "./provenance.ts"; -export type { RunProvenance } from "./provenance.ts"; +export { RunProvenanceSchema, RunIssueJsonSchema, SweepProvenanceSchema } from "./provenance.ts"; +export type { RunProvenance, SweepProvenance } from "./provenance.ts"; export { TaskSchema } from "./task.ts"; export type { Task } from "./task.ts"; export { ResourceSchema } from "./resource.ts"; diff --git a/src/schema/provenance.ts b/src/schema/provenance.ts index 0b2194c..7ee26a6 100644 --- a/src/schema/provenance.ts +++ b/src/schema/provenance.ts @@ -21,6 +21,22 @@ export const RunIssueJsonSchema = z.looseObject({ message: z.string(), }); +// Loose for the same forward-compatibility reason as the containing +// provenance: a newer engine may add sweep diagnostics an older reader must +// preserve. +export const SweepProvenanceSchema = z.looseObject({ + combinations: z.number().int().nonnegative(), + feasibleCells: z.number().int().nonnegative(), + winner: z.record(z.string(), z.unknown()), + skipped: z.array( + z.looseObject({ + point: z.record(z.string(), z.unknown()), + reason: z.string(), + }), + ), +}); +export type SweepProvenance = z.infer; + export const RunProvenanceSchema = z.looseObject({ /** levelset version that produced the schedule. */ levelsetVersion: z.string(), @@ -40,6 +56,8 @@ export const RunProvenanceSchema = z.looseObject({ /** Schedule annotations (unsupportedConstraints, approximatedRelations, * disabledConstraints, …) carried out of the search. */ annotations: z.record(z.string(), z.unknown()).optional(), + /** Parameter-grid execution summary and the selected coordinate. */ + sweep: SweepProvenanceSchema.optional(), }); export type RunProvenance = z.infer; diff --git a/test/conf/parse.test.ts b/test/conf/parse.test.ts index 4166c38..a6c7328 100644 --- a/test/conf/parse.test.ts +++ b/test/conf/parse.test.ts @@ -58,6 +58,35 @@ describe("parseConf — grammar goldens", () => { }); describe("parseConf — grammar features", () => { + test("sweep axes preserve broadcast, zip, and $enabled targets", () => { + const result = parseConf( + `[levelset]\nformat = 1\n\n[blocks.cap-a]\ncode = "levelset.MaxConcurrentResource.v1"\nresourceUniqueId = 1\nmax = 2\n\n[blocks.cap-b]\ncode = "levelset.MaxConcurrentResource.v1"\nresourceUniqueId = 2\nmax = 2\n\n[blocks.search]\ncode = "levelset.SerialSGS.v1"\n\n[sweep]\nmaxCombinations = 8\n\n[[sweep.axes]]\nid = "caps"\n\n[[sweep.axes.targets]]\ninstanceId = "cap-a"\nparamPath = "max"\nvalueKey = "A"\n\n[[sweep.axes.targets]]\ninstanceId = "cap-b"\nparamPath = "max"\nvalueKey = "B"\n\n[[sweep.axes.values]]\nA = 2\nB = 3\n\n[[sweep.axes.values]]\nA = 3\nB = 4\n\n[[sweep.axes]]\nid = "enabled"\nvalues = [true, false]\n\n[[sweep.axes.targets]]\ninstanceId = "cap-a"\nparamPath = "$enabled"\n`, + ); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(JSON.stringify(result.issues)); + expect(result.config.sweep).toEqual({ + maxCombinations: 8, + axes: [ + { + id: "caps", + targets: [ + { instanceId: "cap-a", paramPath: "max", valueKey: "A" }, + { instanceId: "cap-b", paramPath: "max", valueKey: "B" }, + ], + values: [ + { A: 2, B: 3 }, + { A: 3, B: 4 }, + ], + }, + { + id: "enabled", + targets: [{ instanceId: "cap-a", paramPath: "$enabled" }], + values: [true, false], + }, + ], + }); + }); + test("block document order is preserved", () => { const config = parseOk("lexicographic"); expect(config.pipeline.blocks.map((b) => b.instanceId)).toEqual([ @@ -206,6 +235,15 @@ describe("parseConf — error fixtures (one per code)", () => { expect(issue.instanceId).toBe("mystery"); }); + test("E308 invalid sweep target is attributed to its instanceId", () => { + const issue = firstIssue( + `[levelset]\nformat = 1\n[blocks.s]\ncode = "levelset.SerialSGS.v1"\n[sweep]\n[[sweep.axes]]\nid = "missing-cap"\nvalues = [1, 2]\n[[sweep.axes.targets]]\ninstanceId = "missing"\nparamPath = "max"`, + ); + expect(issue.code).toBe("LEVELSET_E308"); + expect(issue.instanceId).toBe("missing"); + expect(issue.message).toContain('sweep axis "missing-cap"'); + }); + test("E308 zero searches", () => { const issue = firstIssue(`[levelset]\nformat = 1\n[blocks.m]\ncode = "levelset.Makespan.v1"`); expect(issue.code).toBe("LEVELSET_E308"); diff --git a/test/conf/serialize.test.ts b/test/conf/serialize.test.ts index 87a80b8..9b5d541 100644 --- a/test/conf/serialize.test.ts +++ b/test/conf/serialize.test.ts @@ -103,6 +103,26 @@ describe("serializeConf — round-trip guarantee", () => { } }); + test("sweep points preserve nested arrays and inline records", () => { + const config = runConfigSchema.parse({ + formatVersion: 1, + pipeline: { + formatVersion: 1, + blocks: [{ instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }], + }, + sweep: { + axes: [ + { + id: "structured", + targets: [{ instanceId: "search", paramPath: "options" }], + values: [[1, 2], { mode: "fast", limits: [3, 4] }], + }, + ], + }, + }); + roundTrips(config); + }); + test("a generated corpus of random subsets/weights/tiers round-trips", () => { // Deterministic PRNG (mulberry32) — no new dev deps, reproducible corpus. let state = 0x9e3779b9; @@ -159,7 +179,16 @@ describe("serializeConf — schema coverage", () => { // If a new top-level key lands on runConfigSchema (collectors, sweep, …), // this fails loudly until the conf parse/serialize mapping covers it. expect(Object.keys(runConfigSchema.shape).sort()).toEqual( - ["formatVersion", "levelset", "objective", "pipeline", "resolve", "seed", "weights"].sort(), + [ + "formatVersion", + "levelset", + "objective", + "pipeline", + "resolve", + "seed", + "sweep", + "weights", + ].sort(), ); }); @@ -183,6 +212,21 @@ describe("serializeConf — schema coverage", () => { }, weights: { make: null }, objective: { lexicographic: ["make"] }, + sweep: { + maxCombinations: 4, + axes: [ + { + id: "make-enabled", + targets: [{ instanceId: "make", paramPath: "$enabled" }], + values: [true, false], + }, + { + id: "zip", + targets: [{ instanceId: "make", paramPath: "$enabled", valueKey: "enabled" }], + values: [{ enabled: true }, { enabled: false }], + }, + ], + }, }); roundTrips(config); }); diff --git a/test/level-blocks/blockMeta.test.ts b/test/level-blocks/blockMeta.test.ts index af4f44a..3619d38 100644 --- a/test/level-blocks/blockMeta.test.ts +++ b/test/level-blocks/blockMeta.test.ts @@ -1,6 +1,7 @@ import { test, expect, describe } from "bun:test"; import committed from "../../generated/block-meta.json"; +import packageJson from "../../package.json"; import { dumpBlockMeta } from "../../src/level-blocks/meta.ts"; import { standardRegistry } from "../../src/level-blocks/standard.ts"; @@ -20,4 +21,12 @@ describe("generated/block-meta.json", () => { expect((committed as { levelsetVersion: string }).levelsetVersion).toBe(fresh.levelsetVersion); expect((committed as { formatVersion: number }).formatVersion).toBe(1); }); + + test("has a stable public JSON subpath in source and published exports", () => { + expect(packageJson.exports["./block-meta.json"]).toBe("./generated/block-meta.json"); + expect(packageJson.publishConfig.exports["./block-meta.json"]).toBe( + "./generated/block-meta.json", + ); + expect(packageJson.files).toContain("generated"); + }); }); diff --git a/test/level-blocks/meta.test.ts b/test/level-blocks/meta.test.ts index b3ab4bd..b49fc80 100644 --- a/test/level-blocks/meta.test.ts +++ b/test/level-blocks/meta.test.ts @@ -13,6 +13,11 @@ import { standardRegistry, STANDARD_CODES, } from "../../src/level-blocks/standard.ts"; +import { + DEFAULT_MAX_COMBINATIONS, + HARD_MAX_COMBINATIONS, + SWEEP_ENABLED_TARGET, +} from "../../src/level-blocks/sweep.ts"; const defsByCode = new Map(standardBlockDefs().map((d) => [d.code, d])); function meta(code: string) { @@ -197,4 +202,25 @@ describe("dumpBlockMeta", () => { expect(dump.composition.cardinality.search).toEqual({ min: 1, max: 1 }); expect(dump.levelsetVersion.length).toBeGreaterThan(0); }); + + test("derives canvas authoring contracts from the instance and sweep schemas", () => { + const dump = dumpBlockMeta(standardRegistry()); + expect(dump.authoring.instance.reservedParamNames).toEqual([ + "code", + "provenance", + "shadow", + "enabled", + ]); + expect(dump.authoring.instance.enabledWhenOmitted).toBe(true); + expect(dump.authoring.instance.schema).toMatchObject({ + type: "object", + required: ["instanceId", "code", "params"], + }); + expect(dump.authoring.sweep).toMatchObject({ + enabledTarget: SWEEP_ENABLED_TARGET, + defaultMaxCombinations: DEFAULT_MAX_COMBINATIONS, + hardMaxCombinations: HARD_MAX_COMBINATIONS, + schema: { type: "object", required: ["axes"] }, + }); + }); }); diff --git a/test/level-blocks/provenance.test.ts b/test/level-blocks/provenance.test.ts index 5361424..be0ef4c 100644 --- a/test/level-blocks/provenance.test.ts +++ b/test/level-blocks/provenance.test.ts @@ -89,6 +89,29 @@ describe("run provenance", () => { expect((reread.levelset as Record)["futureField"]).toEqual({ nested: true }); }); + test("unknown nested sweep provenance keys survive the round trip", async () => { + const run = runFromConfig(project(), config()); + const leveled = run.materialize((await run.best())!); + const annotated = { + ...leveled, + levelset: { + ...leveled.levelset!, + sweep: { + combinations: 2, + feasibleCells: 1, + winner: { cap: 2 }, + skipped: [], + futureDiagnostic: { retained: true }, + }, + }, + }; + + const reread = new JsonReader().read(new JsonWriter().write(annotated)); + expect((reread.levelset?.sweep as Record)["futureDiagnostic"]).toEqual({ + retained: true, + }); + }); + test("plain projects have no levelset block and reads tolerate garbage", () => { const json = new JsonWriter().write(project()); const reread = new JsonReader().read(json); diff --git a/test/level-blocks/sweep.test.ts b/test/level-blocks/sweep.test.ts new file mode 100644 index 0000000..6bb8a51 --- /dev/null +++ b/test/level-blocks/sweep.test.ts @@ -0,0 +1,313 @@ +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 { expandSweep, expandSweepCells, SweepError } from "../../src/level-blocks/sweep.ts"; + +import { + MON_JAN_5, + SAT_JAN_10, + makeAssignment, + makeProject, + makeResource, + makeTask, +} from "../level-core/fixtures.ts"; + +function project() { + return makeProject( + [ + makeTask({ uniqueId: 1, start: MON_JAN_5, finish: SAT_JAN_10 }), + makeTask({ uniqueId: 2, start: MON_JAN_5, finish: SAT_JAN_10 }), + ], + { + resources: [makeResource(100, "Crew"), makeResource(200, "Other")], + assignments: [makeAssignment(1, 100), makeAssignment(2, 100)], + }, + ); +} + +function configWithSweep() { + return { + formatVersion: 1 as const, + objective: { lexicographic: ["makespan"] }, + pipeline: { + formatVersion: 1 as const, + blocks: [ + { + instanceId: "cap-a", + code: STANDARD_CODES.maxConcurrentResource, + params: { resourceUniqueId: 100, max: 1 }, + }, + { + instanceId: "cap-b", + code: STANDARD_CODES.maxConcurrentResource, + params: { resourceUniqueId: 200, max: 1 }, + }, + { instanceId: "makespan", code: STANDARD_CODES.makespan, params: {} }, + { instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }, + ], + }, + sweep: { + maxCombinations: 8, + axes: [ + { + id: "caps", + targets: [ + { instanceId: "cap-a", paramPath: "max", valueKey: "A" }, + { instanceId: "cap-b", paramPath: "max", valueKey: "B" }, + ], + values: [ + { A: 1, B: 2 }, + { A: 2, B: 3 }, + ], + }, + { + id: "capBEnabled", + targets: [{ instanceId: "cap-b", paramPath: "$enabled" }], + values: [true, false], + }, + ], + }, + }; +} + +describe("GridSweep expansion and validation", () => { + test("expands zip and broadcast axes in odometer order without mutating the filled config", () => { + const filled = fillRunConfig(configWithSweep()); + const cells = expandSweepCells(filled); + expect(cells.map((cell) => cell.point)).toEqual([ + { caps: { A: 1, B: 2 }, capBEnabled: true }, + { caps: { A: 1, B: 2 }, capBEnabled: false }, + { caps: { A: 2, B: 3 }, capBEnabled: true }, + { caps: { A: 2, B: 3 }, capBEnabled: false }, + ]); + expect( + cells.map((cell) => + cell.config.pipeline.blocks.slice(0, 2).map((block) => ({ + max: block.params["max"], + enabled: block.enabled, + })), + ), + ).toEqual([ + [ + { max: 1, enabled: undefined }, + { max: 2, enabled: true }, + ], + [ + { max: 1, enabled: undefined }, + { max: 2, enabled: false }, + ], + [ + { max: 2, enabled: undefined }, + { max: 3, enabled: true }, + ], + [ + { max: 2, enabled: undefined }, + { max: 3, enabled: false }, + ], + ]); + expect(cells.every((cell) => cell.config.sweep === undefined)).toBe(true); + expect(filled.sweep?.axes).toHaveLength(2); + expect(expandSweep(filled)).toHaveLength(4); + }); + + test("rejects incoherent zip axes, ragged records, bad paths, bad delivered values, and oversized grids", () => { + const cases: Array<[unknown, RegExp]> = [ + [ + { + ...configWithSweep(), + sweep: { + axes: [ + { + id: "mixed", + targets: [ + { instanceId: "cap-a", paramPath: "max", valueKey: "A" }, + { instanceId: "cap-b", paramPath: "max" }, + ], + values: [{ A: 1 }], + }, + ], + }, + }, + /mixed.*mixes keyed and unkeyed/, + ], + [ + { + ...configWithSweep(), + sweep: { + axes: [ + { + id: "ragged", + targets: [{ instanceId: "cap-a", paramPath: "max", valueKey: "A" }], + values: [{ B: 1 }], + }, + ], + }, + }, + /ragged.*missing valueKey "A"/, + ], + [ + { + ...configWithSweep(), + sweep: { + axes: [ + { + id: "badPath", + targets: [{ instanceId: "cap-a", paramPath: "nope" }], + values: [1], + }, + ], + }, + }, + /badPath.*does not resolve/, + ], + [ + { + ...configWithSweep(), + sweep: { + axes: [ + { + id: "badValue", + targets: [{ instanceId: "cap-a", paramPath: "max" }], + values: [-1], + }, + ], + }, + }, + /badValue.*invalid value/, + ], + [ + { + ...configWithSweep(), + sweep: { + maxCombinations: 2, + axes: [ + { + id: "large", + targets: [{ instanceId: "cap-a", paramPath: "max" }], + values: [1, 2, 3], + }, + ], + }, + }, + /3 combinations.*maxCombinations 2/, + ], + ]; + + for (const [config, message] of cases) { + expect(() => fillRunConfig(config)).toThrow(message); + expect(() => fillRunConfig(config)).toThrow(GraphValidationError); + } + }); +}); + +describe("GridSweep execution", () => { + test("selects across cells and records the winning coordinate and sweep summary", async () => { + const run = runFromConfig(project(), configWithSweep()); + const best = (await run.best())!; + expect(best.annotations.get("sweep.point")).toEqual({ + caps: { A: 2, B: 3 }, + capBEnabled: true, + }); + + const provenance = run.materialize(best).levelset!; + expect(provenance.sweep).toEqual({ + combinations: 4, + feasibleCells: 4, + winner: { caps: { A: 2, B: 3 }, capBEnabled: true }, + skipped: [], + }); + expect(provenance.runConfig).toEqual(run.config as never); + }); + + test("uses each cell's compiled scorer parameters during cross-cell selection", async () => { + const config = configWithSweep(); + const blocks: Array<{ + instanceId: string; + code: string; + params: Record; + }> = config.pipeline.blocks; + blocks.splice(2, 0, { + instanceId: "cost", + code: STANDARD_CODES.concurrentResourceCost, + params: { resourceUniqueId: 100, basePrice: 10, growthBase: 2 }, + }); + const run = runFromConfig(project(), { + ...config, + objective: { lexicographic: ["cost"] }, + sweep: { + axes: [ + { + id: "price", + targets: [{ instanceId: "cost", paramPath: "basePrice" }], + values: [100, 1], + }, + ], + }, + }); + const best = (await run.best())!; + expect(best.annotations.get("sweep.point")).toEqual({ price: 1 }); + expect(run.scores(best)["cost"]).toBeLessThan(100); + }); + + test("throws SweepError after an all-infeasible grid", async () => { + const base = configWithSweep(); + const blocks: Array<{ + instanceId: string; + code: string; + params: Record; + }> = base.pipeline.blocks; + blocks.splice(2, 0, { + instanceId: "deadline", + code: STANDARD_CODES.deadline, + params: { taskUniqueId: 1, latestFinish: 20 }, + }); + const infeasible = { + ...base, + sweep: { + axes: [ + { + id: "deadline", + targets: [{ instanceId: "deadline", paramPath: "latestFinish" }], + values: [0, 1], + }, + ], + }, + }; + await expect(runFromConfig(project(), infeasible).best()).rejects.toBeInstanceOf(SweepError); + }); + + test("skips and records an infeasible cell while retaining feasible candidates", async () => { + const base = configWithSweep(); + const blocks: Array<{ + instanceId: string; + code: string; + params: Record; + }> = base.pipeline.blocks; + blocks.splice(2, 0, { + instanceId: "deadline", + code: STANDARD_CODES.deadline, + params: { taskUniqueId: 1, latestFinish: 20 }, + }); + const run = runFromConfig(project(), { + ...base, + sweep: { + axes: [ + { + id: "deadline", + targets: [{ instanceId: "deadline", paramPath: "latestFinish" }], + values: [1, 20], + }, + ], + }, + }); + const best = (await run.best())!; + expect(run.materialize(best).levelset!.sweep).toEqual({ + combinations: 2, + feasibleCells: 1, + winner: { deadline: 20 }, + skipped: [{ point: { deadline: 1 }, reason: "infeasible" }], + }); + }); +}); diff --git a/test/level-core/scheduleStream.test.ts b/test/level-core/scheduleStream.test.ts index e6d6e56..5c75e77 100644 --- a/test/level-core/scheduleStream.test.ts +++ b/test/level-core/scheduleStream.test.ts @@ -1,6 +1,10 @@ import { test, expect, describe } from "bun:test"; -import { ScheduleStreamImpl, streamFromFactory } from "../../src/level-core/scheduleStream.ts"; +import { + concatStreams, + ScheduleStreamImpl, + streamFromFactory, +} from "../../src/level-core/scheduleStream.ts"; import type { ResolvedProject, Schedule, @@ -92,6 +96,26 @@ describe("ScheduleStream — lazy transforms", () => { expect(out).toHaveLength(4); expect(out.map((s) => s.tasks[0]!.finishDay)).toEqual([3, 4, 5, 6]); }); + + test("concatStreams creates later streams only after earlier streams are exhausted", async () => { + const events: string[] = []; + const concatenated = concatStreams([ + () => { + events.push("create-a"); + return streamOf(fakeSchedule(1), fakeSchedule(2)); + }, + () => { + events.push("create-b"); + return streamOf(fakeSchedule(3)); + }, + ]); + + expect(events).toEqual([]); + expect((await concatenated.take(2).collect()).map((schedule) => schedule.makespan)).toEqual([ + 1, 2, + ]); + expect(events).toEqual(["create-a"]); + }); }); describe("ScheduleStream — materializing operations", () => { -- 2.54.0