diff --git a/src/json/JsonReader.ts b/src/json/JsonReader.ts index 1743cf0..f5da2fc 100644 --- a/src/json/JsonReader.ts +++ b/src/json/JsonReader.ts @@ -15,17 +15,23 @@ import { type ProjectProperties, } from "../model/types.ts"; import { parseProjectDate } from "../dateTime.ts"; +import { RunProvenanceSchema } from "../schema/provenance.ts"; export class JsonReader { read(json: string): ProjectFile { const raw = JSON.parse(json) as RawProject; + // Provenance embedded by a leveling run round-trips; anything that + // doesn't parse as provenance is dropped rather than failing the read. + const levelset = RunProvenanceSchema.safeParse(raw.levelset); + return { properties: parseProperties(raw.properties), tasks: arrayify(raw.tasks).map(parseTask), resources: arrayify(raw.resources).map(parseResource), assignments: arrayify(raw.assignments).map(parseAssignment), calendars: arrayify(raw.calendars).map(parseCalendar), + ...(levelset.success ? { levelset: levelset.data } : {}), }; } } @@ -38,6 +44,7 @@ interface RawProject { resources?: RawResource[]; assignments?: RawAssignment[]; calendars?: RawCalendar[]; + levelset?: unknown; } interface RawProperties { diff --git a/src/level-blocks/runConfig.ts b/src/level-blocks/runConfig.ts index c9ae408..cfd5169 100644 --- a/src/level-blocks/runConfig.ts +++ b/src/level-blocks/runConfig.ts @@ -30,6 +30,7 @@ import type { Search, } from "../level-core/types.ts"; import { weightedScorer } from "../level-core/weightedScorer.ts"; +import type { RunProvenance } from "../schema/provenance.ts"; import { LEVELSET_VERSION } from "../version.ts"; import { compileGraph, GraphValidationError, type CompileTarget } from "./compile.ts"; @@ -98,7 +99,9 @@ export interface LevelingRun { 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. */ + /** Write the schedule back onto the source project, embedding run + * provenance (the filled config, engine version, achieved scores, + * warnings, and search annotations) as the file's `levelset` block. */ materialize(schedule: Schedule): Project; } @@ -258,7 +261,19 @@ export function runFromConfig( return report; }, materialize(schedule: Schedule): Project { - return materialize(schedule); + const provenance: RunProvenance = { + levelsetVersion: LEVELSET_VERSION, + generatedAt: new Date().toISOString(), + search: search.name, + runConfig: filled as unknown as Record, + ...(filled.seed !== undefined ? { seed: filled.seed } : {}), + scores: run.scores(schedule), + ...(analysis.issues.length > 0 ? { warnings: [...analysis.issues] } : {}), + ...(schedule.annotations.size > 0 + ? { annotations: Object.fromEntries(schedule.annotations) } + : {}), + }; + return { ...materialize(schedule), levelset: provenance }; }, }; return run; diff --git a/src/schema/index.ts b/src/schema/index.ts index 160299d..aed30de 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -1,5 +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 { TaskSchema } from "./task.ts"; export type { Task } from "./task.ts"; export { ResourceSchema } from "./resource.ts"; diff --git a/src/schema/project.ts b/src/schema/project.ts index 3d03e2b..9fcc2c7 100644 --- a/src/schema/project.ts +++ b/src/schema/project.ts @@ -5,6 +5,7 @@ import { TaskSchema } from "./task.ts"; import { ResourceSchema } from "./resource.ts"; import { AssignmentSchema } from "./assignment.ts"; import { CalendarSchema } from "./calendar.ts"; +import { RunProvenanceSchema } from "./provenance.ts"; export const ProjectPropertiesSchema = z.object({ title: z.nullable(z.string()), @@ -25,6 +26,8 @@ export const ProjectFileSchema = z.object({ resources: z.array(ResourceSchema), assignments: z.array(AssignmentSchema), calendars: z.array(CalendarSchema), + /** Run provenance embedded by a leveling run (absent on plain reads). */ + levelset: RunProvenanceSchema.optional(), }); export type ProjectProperties = z.infer; diff --git a/src/schema/provenance.ts b/src/schema/provenance.ts new file mode 100644 index 0000000..cfec9ba --- /dev/null +++ b/src/schema/provenance.ts @@ -0,0 +1,42 @@ +// Run provenance — the `levelset` metadata block a leveling run embeds in +// every materialized ProjectFile (spaCy ships config.cfg + meta.json inside +// every model directory; this is the leveling equivalent). Six months +// later, "why does floor 3 start in June?" is answerable from the file +// itself: which constraints, which weights, which engine version. +// +// Everything here is plain JSON: configs and results serialize, code never +// does. `runConfig` is kept loosely typed at this layer so the schema +// package doesn't depend on level-blocks; `runFromConfig` writes a filled +// RunConfig into it. + +import { z } from "zod"; + +export const RunIssueJsonSchema = z.object({ + code: z.string(), + severity: z.enum(["error", "warning"]), + source: z.string(), + message: z.string(), +}); + +export const RunProvenanceSchema = z.object({ + /** levelset version that produced the schedule. */ + levelsetVersion: z.string(), + /** ISO timestamp of materialization. */ + generatedAt: z.string().optional(), + /** Name of the search backend that produced the schedule. */ + search: z.string().optional(), + /** The *filled* run config (every default expanded) — the reproducible + * artifact: rerunning it against the same source project must produce + * the same schedule. */ + runConfig: z.record(z.string(), z.unknown()).optional(), + seed: z.number().int().optional(), + /** Raw scorer values achieved, keyed by scorer instanceId. */ + scores: z.record(z.string(), z.number()).optional(), + /** Static-analysis warnings the run proceeded under. */ + warnings: z.array(RunIssueJsonSchema).optional(), + /** Schedule annotations (unsupportedConstraints, approximatedRelations, + * disabledConstraints, …) carried out of the search. */ + annotations: z.record(z.string(), z.unknown()).optional(), +}); + +export type RunProvenance = z.infer; diff --git a/test/level-blocks/provenance.test.ts b/test/level-blocks/provenance.test.ts new file mode 100644 index 0000000..9a4e7ca --- /dev/null +++ b/test/level-blocks/provenance.test.ts @@ -0,0 +1,85 @@ +// Run provenance: every materialized project is self-describing — filled +// config, engine version, achieved scores — and survives a JSON round trip. + +import { describe, expect, test } from "bun:test"; + +import { JsonReader } from "../../src/json/JsonReader.ts"; +import { JsonWriter } from "../../src/json/JsonWriter.ts"; +import { runFromConfig } from "../../src/level-blocks/runConfig.ts"; +import { STANDARD_CODES } from "../../src/level-blocks/standard.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 project() { + 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)], + }); +} + +function config() { + return { + formatVersion: 1, + seed: 42, + pipeline: { + formatVersion: 1, + blocks: [ + { + instanceId: "crew-cap", + code: STANDARD_CODES.maxConcurrentResource, + params: { resourceUniqueId: 100, max: 1 }, + }, + { instanceId: "makespan", code: STANDARD_CODES.makespan, params: {} }, + { instanceId: "search", code: STANDARD_CODES.serialSGS, params: {} }, + ], + }, + }; +} + +describe("run provenance", () => { + test("materialize embeds version, filled config, seed, and scores", async () => { + const run = runFromConfig(project(), config()); + const best = (await run.best())!; + const leveled = run.materialize(best); + + const provenance = leveled.levelset!; + expect(provenance.levelsetVersion).toBe(LEVELSET_VERSION); + expect(provenance.search).toBe("serial-SGS"); + expect(provenance.seed).toBe(42); + expect(provenance.scores).toEqual({ makespan: best.makespan }); + expect(provenance.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + // The embedded config is the FILLED one — rerunning it reproduces the run. + expect(provenance.runConfig).toEqual(run.config as never); + const rerun = runFromConfig(project(), provenance.runConfig); + const rebest = (await rerun.best())!; + expect(rebest.tasks).toEqual(best.tasks); + }); + + test("provenance survives a JSON write → read round trip", async () => { + const run = runFromConfig(project(), config()); + const leveled = run.materialize((await run.best())!); + + const json = new JsonWriter().write(leveled); + const reread = new JsonReader().read(json); + expect(reread.levelset).toEqual(leveled.levelset!); + }); + + test("plain projects have no levelset block and reads tolerate garbage", () => { + const json = new JsonWriter().write(project()); + const reread = new JsonReader().read(json); + expect(reread.levelset).toBeUndefined(); + + const garbage = new JsonReader().read('{"levelset": {"nonsense": true}}'); + expect(garbage.levelset).toBeUndefined(); + }); +});