diff --git a/README.md b/README.md index 1ff6f96..eb7d2df 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,10 @@ const run = runFromConfig(project, { ], }, weights: { makespan: 1, wip: 0.3 }, // null = compute and report, don't optimize - // or tiered selection: objective: { lexicographic: ["makespan", "wip"] } + // Each tier is one scorer or a weighted scorer group: + objective: { + lexicographic: ["makespan", { weightedSum: { wip: 0.3, makespan: 1 } }], + }, }); const best = await run.best(); @@ -269,7 +272,7 @@ The moving parts, each usable on its own: - **`standardRegistry()`** — every shipping block under a versioned code; semantic changes ship as `.v2` while `.v1` keeps resolving, so saved configs stay reproducible. Extend with your own defs via `standardRegistry(extraDefs)`. - **`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. +- **`weightedScorer` / `bestByLex`** — multi-objective combination as data: a weight map (with `null` = reported-but-unweighted) or ordered lexicographic tiers. A tier may name one scorer or combine commensurable scorers as `{ weightedSum: { scorerId: weight } }`; only later tiers break ties. - **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. @@ -323,7 +326,7 @@ makespan = "report" # the string sentinel "report" ⇄ null (compute, don't op lateness = 1 [objective] -lexicographic = ["lateness", "makespan"] # ordered tiers; weights stay report-only +lexicographic = ["lateness", { weightedSum = { labor = 1, makespan = 0.25 } }] [sweep] maxCombinations = 64 @@ -346,7 +349,7 @@ Grammar, at a glance: | `[pipeline]` | Optional. `systemCode` only (an app label); the block list is `[blocks.*]`. | | `[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. | +| `[objective]` | Optional ordered tiers. Each entry is a scorer id or `{ weightedSum = { scorerId = weight } }`; top-level `[weights]` remains report-only when this section is present. | | `[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. diff --git a/generated/block-meta.json b/generated/block-meta.json index 3734cee..1193246 100644 --- a/generated/block-meta.json +++ b/generated/block-meta.json @@ -198,6 +198,34 @@ } } } + }, + "objective": { + "lexicographicTierSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "weightedSum": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "number" + } + } + }, + "required": [ + "weightedSum" + ], + "additionalProperties": false + } + ] + } } }, "blocks": [ diff --git a/src/level-blocks/meta.ts b/src/level-blocks/meta.ts index 97f9dde..0899765 100644 --- a/src/level-blocks/meta.ts +++ b/src/level-blocks/meta.ts @@ -21,6 +21,7 @@ import { } from "./composition.ts"; import { blockInstanceSchema } from "./graph.ts"; import type { BlockDef, BlockFamily, BlockTier, PanelBinding } from "./registry.ts"; +import { lexicographicTierSchema } from "./runConfig.ts"; import { DEFAULT_MAX_COMBINATIONS, HARD_MAX_COMBINATIONS, @@ -155,6 +156,10 @@ export interface BlockMetaDump { hardMaxCombinations: number; schema: unknown; }; + objective: { + /** One entry in objective.lexicographic: a scorer id or weighted tier. */ + lexicographicTierSchema: unknown; + }; }; blocks: DerivedBlockMeta[]; } @@ -176,6 +181,9 @@ function authoringMeta(): BlockMetaDump["authoring"] { hardMaxCombinations: HARD_MAX_COMBINATIONS, schema: z.toJSONSchema(sweepSchema, { io: "input" }), }, + objective: { + lexicographicTierSchema: z.toJSONSchema(lexicographicTierSchema, { io: "input" }), + }, }; } diff --git a/src/level-blocks/runConfig.ts b/src/level-blocks/runConfig.ts index a00531b..6f6419b 100644 --- a/src/level-blocks/runConfig.ts +++ b/src/level-blocks/runConfig.ts @@ -65,6 +65,21 @@ export const workUnitSchema = z.object({ taskUniqueIds: z.array(z.number().int()), }); +export const weightedSumTierSchema = z.strictObject({ + weightedSum: z + .record(z.string(), z.number()) + .refine( + (weights) => Object.keys(weights).length > 0, + "weightedSum must name at least one scorer", + ) + .refine( + (weights) => Object.values(weights).some((weight) => weight !== 0), + "weightedSum must contain at least one non-zero weight", + ), +}); +export const lexicographicTierSchema = z.union([z.string(), weightedSumTierSchema]); +export type LexicographicTier = z.infer; + // 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. @@ -89,13 +104,13 @@ export const runConfigSchema = z.strictObject({ /** 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(), - /** Tiered selection: ordered scorer instanceIds compared - * lexicographically — the second tier only breaks ties on the first. - * When present, `best()` selects by these tiers and `weights` remain - * report-only. */ + /** Tiered selection: each tier is either one scorer instanceId or a + * weighted sum of scorer instanceIds. Tiers compare lexicographically — + * the second tier only breaks ties on the first. When present, `best()` + * selects by these tiers and top-level `weights` remain report-only. */ objective: z .strictObject({ - lexicographic: z.array(z.string()).min(1), + lexicographic: z.array(lexicographicTierSchema).min(1), }) .optional(), /** Bounded parameter grid evaluated above the unchanged flat pipeline. */ @@ -210,10 +225,12 @@ export function checkRunConfigRefs( const badRefs = [ ...Object.keys(config.weights ?? {}).map((k) => ({ k, where: "weights key" })), - ...(config.objective?.lexicographic ?? []).map((k) => ({ - k, - where: "objective.lexicographic entry", - })), + ...(config.objective?.lexicographic ?? []).flatMap((tier) => + lexicographicTierRefs(tier).map((k) => ({ + k, + where: "objective.lexicographic entry", + })), + ), ...collectorRefs, ].filter(({ k }) => !scorerIds.has(k)); return badRefs.map(({ k, where }) => ({ @@ -509,9 +526,9 @@ export function runFromConfig( // fillRunConfig checked lexicographic ids reference scorer blocks; they // must also be *active* (a disabled scorer can't drive selection). const activeScorerIds = new Set(scorers.map((s) => s.instanceId)); - const inactiveLex = (filled.objective?.lexicographic ?? []).filter( - (id) => !activeScorerIds.has(id), - ); + const inactiveLex = (filled.objective?.lexicographic ?? []) + .flatMap(lexicographicTierRefs) + .filter((id) => !activeScorerIds.has(id)); if (inactiveLex.length > 0) { throw new GraphValidationError( inactiveLex.map((id) => ({ @@ -630,7 +647,7 @@ export function runFromConfig( const lex = filled.objective?.lexicographic; if (lex) { const byId = new Map(runScorers.map((s) => [s.instanceId, s.scorer])); - return run.stream().bestByLex(lex.map((id) => byId.get(id)!)); + return run.stream().bestByLex(lex.map((tier) => compileLexicographicTier(tier, byId))); } return run.stream().bestBy(objective); }, @@ -723,6 +740,24 @@ function collectorScorerRefs(collector: Collector): ReadonlyArray { } } +function lexicographicTierRefs(tier: LexicographicTier): string[] { + return typeof tier === "string" ? [tier] : Object.keys(tier.weightedSum); +} + +function compileLexicographicTier( + tier: LexicographicTier, + scorersById: ReadonlyMap, +): Scorer { + if (typeof tier === "string") return scorersById.get(tier)!; + return weightedScorer( + Object.entries(tier.weightedSum).map(([instanceId, weight]) => ({ + scorer: scorersById.get(instanceId)!, + weight, + })), + `weightedSum(${Object.keys(tier.weightedSum).join(",")})`, + ); +} + // Search.run returns a Failure as the generator's return value; // streamFromFactory expects a plain AsyncGenerator. Re-yield to // drop the return value (the stream just ends, matching ScheduleStream's diff --git a/test/conf/serialize.test.ts b/test/conf/serialize.test.ts index 9b5d541..2ee784d 100644 --- a/test/conf/serialize.test.ts +++ b/test/conf/serialize.test.ts @@ -123,6 +123,20 @@ describe("serializeConf — round-trip guarantee", () => { roundTrips(config); }); + test("weighted lexicographic tiers preserve their inline weight maps", () => { + const config = runConfigSchema.parse({ + formatVersion: 1, + pipeline: { + formatVersion: 1, + blocks: [{ instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }], + }, + objective: { + lexicographic: ["lateness", { weightedSum: { labor: 2, earliness: 0.25 } }], + }, + }); + 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; diff --git a/test/level-blocks/meta.test.ts b/test/level-blocks/meta.test.ts index b49fc80..6913200 100644 --- a/test/level-blocks/meta.test.ts +++ b/test/level-blocks/meta.test.ts @@ -222,5 +222,15 @@ describe("dumpBlockMeta", () => { hardMaxCombinations: HARD_MAX_COMBINATIONS, schema: { type: "object", required: ["axes"] }, }); + expect(dump.authoring.objective.lexicographicTierSchema).toMatchObject({ + anyOf: [ + { type: "string" }, + { + type: "object", + required: ["weightedSum"], + properties: { weightedSum: { type: "object" } }, + }, + ], + }); }); }); diff --git a/test/level-blocks/runConfig.test.ts b/test/level-blocks/runConfig.test.ts index 890dc4e..33ba0a4 100644 --- a/test/level-blocks/runConfig.test.ts +++ b/test/level-blocks/runConfig.test.ts @@ -176,6 +176,47 @@ describe("runFromConfig", () => { ).toThrow(/disabled scorer/); }); + test("a lexicographic tier may be a weighted sum of scorers", async () => { + const run = runFromConfig(crewProject(), { + ...baseConfig(), + objective: { + lexicographic: [{ weightedSum: { makespan: 2, "wip-cost": 0.5 } }], + }, + }); + expect(await run.best()).not.toBeNull(); + + expect(() => + runFromConfig(crewProject(), { + ...baseConfig(), + objective: { lexicographic: [{ weightedSum: { "crew-cap": 1 } }] }, + }), + ).toThrow(GraphValidationError); + + const disabled = baseConfig(); + disabled.pipeline.blocks[1] = { ...disabled.pipeline.blocks[1]!, enabled: false }; + expect(() => + runFromConfig(crewProject(), { + ...disabled, + objective: { lexicographic: [{ weightedSum: { "wip-cost": 1 } }] }, + }), + ).toThrow(/disabled scorer/); + }); + + test("weighted lexicographic tiers reject empty and all-zero sums", () => { + expect(() => + fillRunConfig({ + ...baseConfig(), + objective: { lexicographic: [{ weightedSum: {} }] }, + }), + ).toThrow(/at least one scorer/); + expect(() => + fillRunConfig({ + ...baseConfig(), + objective: { lexicographic: [{ weightedSum: { makespan: 0 } }] }, + }), + ).toThrow(/non-zero weight/); + }); + test("collectors run against the search stream, labeled by instanceId", async () => { const config = baseConfig(); config.pipeline.blocks.push(