diff --git a/engine/v5/run-v5.mjs b/engine/v5/run-v5.mjs index d41141de..f7a44472 100755 --- a/engine/v5/run-v5.mjs +++ b/engine/v5/run-v5.mjs @@ -30,6 +30,7 @@ import { evaluateEnforcement, PLAN_STATES, } from "./dispatch-plan.mjs"; +import { compareDispatchPlanToTopology } from "./plan-diff.mjs"; import { classifyTopologyParticipation, DISPATCH_OBSERVABILITY, @@ -217,7 +218,27 @@ async function main() { dispatch_plan: dispatchPlan.dispatches, dispatch_plan_error: dispatchPlan.error, }); - writeMeta(matchId, { dispatchPlan }); + // B's dependent variable is the deviation between the voluntary plan and the + // declared topology. plan-diff.mjs computed it, nothing ever called it, and + // nothing wrote it down — the same shape as biasReport, which judge() produced + // and the manifest dropped. An E2 calibration cell came back with the plan + // recorded and the comparison absent, which is a run that measures A and + // silently collects nothing for B. + let planDiff; + try { + const topo = JSON.parse(fs.readFileSync(path.join(regimeDir, "topology.json"), "utf8")); + planDiff = compareDispatchPlanToTopology(dispatchPlan, topo); + } catch (err) { + // A regime without topology.json cannot be compared; say so rather than + // omitting the field, so an absent comparison is distinguishable from one + // that was never attempted. + planDiff = { + plan_status: dispatchPlan.status, + comparison_available: false, + reason: `topology unavailable: ${err.message}`, + }; + } + writeMeta(matchId, { dispatchPlan, planDiff }); const enforcementSetup = enforceDispatch ? loadRequiredOffices(regimeDir) diff --git a/engine/v5/tournament.mjs b/engine/v5/tournament.mjs index e420fadc..f091e4e1 100644 --- a/engine/v5/tournament.mjs +++ b/engine/v5/tournament.mjs @@ -458,6 +458,7 @@ export async function runTournament({ } catch { /* child may have failed before creating meta.json */ } + result.planDiff = meta.planDiff ?? null; result.dispatchPlan = meta.dispatchPlan ?? { status: "parse_failed", dispatches: null, @@ -479,6 +480,7 @@ export async function runTournament({ result.dispatchEnforcement.status === "enforcement_passed"; writeMeta(result.matchId, { dispatchPlan: result.dispatchPlan, + ...(result.planDiff ? { planDiff: result.planDiff } : {}), dispatchEnforcement: result.dispatchEnforcement, topologyParticipation: result.topologyParticipation, }); diff --git a/test/r11-instrumentation.test.mjs b/test/r11-instrumentation.test.mjs index c9dc6c4a..a528a0dd 100644 --- a/test/r11-instrumentation.test.mjs +++ b/test/r11-instrumentation.test.mjs @@ -481,3 +481,37 @@ test("R11 plan events remain observable but cannot contaminate judge or skill tr const runtime = reconstructRuntimeGraph([plan, execution]); assert.equal(runtime.observed.dispatch_sequence.length, 0); }); + +// ── B's dependent variable has to be written down, not merely computable ───── +// +// plan-diff.mjs was built with two exported functions and zero callers: the +// comparison between the voluntary plan and the declared topology was never +// computed and never persisted. An E2 calibration cell came back with +// dispatchPlan recorded for all ten arms and planDiff absent from every one — +// a run that measures A and silently collects nothing for B. This is the same +// shape as biasReport, which judge() computed and the manifest dropped. +test("R11 run-v5 persists the plan-vs-topology comparison next to the plan", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "civagent-r11-plandiff-")); + const fake = makeFakeEnvironment(home); + const matchId = "r11-plandiff"; + try { + const result = await spawnCollect(process.execPath, [ + path.join(ROOT, "engine/v5/run-v5.mjs"), + "--backend", "native", "--no-skill", "china/tang", "test task", + ], { ...fake.env, CIVAGENT_MATCH_ID: matchId }); + assert.equal(result.code, 0, result.stderr); + + const meta = JSON.parse( + fs.readFileSync(path.join(home, ".civagent", "matches", matchId, "meta.json"), "utf8"), + ); + assert.ok(meta.dispatchPlan, "precondition: the plan is recorded"); + assert.ok(meta.planDiff, "the comparison must be recorded too, not left computable"); + assert.equal(meta.planDiff.plan_status, meta.dispatchPlan.status, + "the comparison must describe the plan actually recorded"); + assert.ok(meta.planDiff.unsupported_dimensions, + "and must keep naming what it cannot compare, rather than implying full coverage"); + } finally { + fs.rmSync(fake.bin, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + } +});