From 254439750ab81510ca7656e1b1858fe4fa8604fa Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:13:54 +0800 Subject: [PATCH] fix(process): attribute trace omissions without changing shared successors --- docs/process-capture.md | 6 +- src/domain/process/processComparison.ts | 16 +- src/domain/process/processTraceEvaluation.ts | 35 +++- .../processTraceLifecycleFollowup.test.ts | 174 ++++++++++++++++++ 4 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 src/domain/process/processTraceLifecycleFollowup.test.ts diff --git a/docs/process-capture.md b/docs/process-capture.md index 375cc37def..1583585949 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -484,7 +484,11 @@ Trace assertions use coverage for the sources they select, so an assertion about complete raw terminal output need not fail because rendered snapshots were omitted. A trace difference applies to the dimensions identified by its diagnostic; unrelated dimensions retain their own comparison, including unknown -coverage. Captures missing `truncation_details` are rejected on import; +coverage. A finite-variant omission stays nonconforming to that variant, but does +not mark a shared successor changed merely because its journal position shifted. +Observed ordering violations remain changes. When a requested trace differs or +its journal is unavailable and per-source values cannot locate the difference, +`first_divergence` is unknown rather than evidence that no divergence occurred. Captures missing `truncation_details` are rejected on import; preserve them as historical files and recapture for current comparisons. `limits.file_bytes` is already the cumulative whole-file hashing budget, independent of `limits.output_bytes`, which bounds retained terminal content. diff --git a/src/domain/process/processComparison.ts b/src/domain/process/processComparison.ts index dc41c2c2b4..b39b37b66b 100644 --- a/src/domain/process/processComparison.ts +++ b/src/domain/process/processComparison.ts @@ -503,15 +503,21 @@ export const compareProcessCaptures = ( ? ({ status: "none" } as const) : observedFirstDivergence.status === "found" ? observedFirstDivergence - : !sameNormalization || - left.residual_unknowns.length > 0 || - right.residual_unknowns.length > 0 + : trace?.verdict === "different" || trace?.verdict === "unknown" ? ({ status: "unknown", reason: - "Residual unknowns prevent proving that no divergence occurred.", + "The declared trace differs or has insufficient journal evidence; per-source observations cannot locate its first cross-source divergence.", } as const) - : observedFirstDivergence; + : !sameNormalization || + left.residual_unknowns.length > 0 || + right.residual_unknowns.length > 0 + ? ({ + status: "unknown", + reason: + "Residual unknowns prevent proving that no divergence occurred.", + } as const) + : observedFirstDivergence; const observedStatus = deriveProcessComparisonStatus( PROCESS_COMPARISON_DIMENSIONS.map((dimension) => dimensions[dimension]), ); diff --git a/src/domain/process/processTraceEvaluation.ts b/src/domain/process/processTraceEvaluation.ts index cf80a791df..9b1713a191 100644 --- a/src/domain/process/processTraceEvaluation.ts +++ b/src/domain/process/processTraceEvaluation.ts @@ -131,6 +131,34 @@ type FiniteTraceEvaluation = readonly locationIndexes: readonly number[]; }; +/** Distinguish pure omissions from displaced successors, without relaxing the language. */ +const finiteTraceOmissions = ( + ids: readonly string[], + tokens: Extract< + ProcessTraceSpecification["language"], + { kind: "finite_traces" } + >["variants"][number]["trace"], +): readonly string[] | undefined => { + let observed = 0; + const missing: string[] = []; + for (const token of tokens) { + const remaining = new Map(); + for (const id of typeof token === "string" ? [token] : token.unordered) + remaining.set(id, (remaining.get(id) ?? 0) + 1); + while (observed < ids.length) { + const id = ids[observed]; + const count = id === undefined ? 0 : (remaining.get(id) ?? 0); + if (id === undefined || count === 0) break; + if (count === 1) remaining.delete(id); + else remaining.set(id, count - 1); + observed++; + } + for (const [id, count] of remaining) + for (let index = 0; index < count; index++) missing.push(id); + } + return observed === ids.length && missing.length > 0 ? missing : undefined; +}; + /** Match variants once, retaining the best-prefix mismatch for attribution. */ const evaluateFiniteTrace = ( ids: readonly string[], @@ -187,7 +215,12 @@ const evaluateFiniteTrace = ( unexpected.length > 0 || actual.length < expected.length ) { - recordMismatch(offset, missing, unexpected); + const omitted = finiteTraceOmissions(ids, variant.trace); + recordMismatch( + offset, + omitted ?? missing, + omitted === undefined ? unexpected : [], + ); mismatched = true; break; } diff --git a/src/domain/process/processTraceLifecycleFollowup.test.ts b/src/domain/process/processTraceLifecycleFollowup.test.ts new file mode 100644 index 0000000000..91b11e97f6 --- /dev/null +++ b/src/domain/process/processTraceLifecycleFollowup.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { compareProcessCaptures } from "./processComparison.js"; +import { + capture, + processStarted, + terminal, +} from "./processTraceComparison.fixture.js"; +import { parseProcessCapture } from "./processCaptureParsing.js"; +import { processTraceSpecificationSchema } from "./processTraceSpecification.js"; + +const typed = { + sequence: 0, + scheduled_at_ms: 20, + dispatched_at_ms: 21, + type: "input", + data: "go", + outcome: "dispatched", +}; +type Step = "ready" | "worker" | "typed"; +const observation = (order: readonly Step[], data = typed.data) => { + const base = capture({ + frames: [terminal], + process_samples: order.includes("worker") ? [processStarted] : [], + event_journal: [ + { collection: "frames", index: 0 }, + ...(order.includes("worker") + ? [{ collection: "process_samples" as const, index: 0 }] + : []), + ], + }); + return parseProcessCapture({ + ...base, + interaction_events: [{ ...typed, data }], + event_journal: [ + { collection: "filesystem_checkpoints", index: 0 }, + ...order.map((id) => ({ + collection: + id === "ready" + ? "frames" + : id === "worker" + ? "process_samples" + : "interaction_events", + index: 0, + })), + { collection: "lifecycle", index: 0 }, + { collection: "lifecycle", index: 1 }, + { collection: "filesystem_checkpoints", index: 1 }, + ].map((entry, capture_order) => ({ ...entry, capture_order })), + }); +}; +const specification = (required = false, tail = false, unordered = false) => + processTraceSpecificationSchema.parse({ + events: [ + { + id: "ready", + source: "terminal_raw", + exact: terminal, + cardinality: { kind: "required" }, + }, + { + id: "worker", + source: "process", + exact: processStarted, + cardinality: { kind: required ? "required" : "optional" }, + }, + { + id: "typed", + source: "interaction", + exact: typed, + cardinality: { kind: "required" }, + }, + ], + language: { + kind: "finite_traces", + variants: [ + { + id: "declared", + trace: unordered + ? [{ unordered: ["ready", "worker"] }, "typed"] + : tail + ? ["ready", "typed", "worker"] + : ["ready", "worker", "typed"], + }, + ], + }, + }); + +describe("process trace omission and localization boundaries", () => { + it.each([ + { reverse: false, required: false, tail: false, unordered: false }, + { reverse: true, required: false, tail: false, unordered: false }, + { reverse: false, required: true, tail: false, unordered: false }, + { reverse: false, required: false, tail: true, unordered: false }, + { reverse: false, required: false, tail: false, unordered: true }, + ])( + "attributes only the omitted process observation ($reverse/$required/$tail/$unordered)", + ({ reverse, required, tail, unordered }) => { + const full = observation( + tail ? ["ready", "typed", "worker"] : ["ready", "worker", "typed"], + ); + const missing = observation(["ready", "typed"]); + expect(full.interaction_events).toEqual(missing.interaction_events); + const result = compareProcessCaptures( + reverse ? missing : full, + reverse ? full : missing, + { traceSpecification: specification(required, tail, unordered) }, + ); + expect(result).toMatchObject({ + status: "changed", + interaction: "unchanged", + process: "changed", + terminal: "unchanged", + first_divergence: { status: "found", dimension: "process", index: 0 }, + trace: { + verdict: "different", + diagnostic: { event_ids: ["worker"], locations: [] }, + }, + }); + }, + ); + + it("keeps a genuine cross-source reorder changed and localization unknown", () => { + const result = compareProcessCaptures( + observation(["ready", "worker", "typed"]), + observation(["ready", "typed", "worker"]), + { traceSpecification: specification() }, + ); + expect(result).toMatchObject({ + status: "changed", + interaction: "changed", + process: "changed", + first_divergence: { status: "unknown" }, + trace: { verdict: "different" }, + }); + }); + + it("retains a real interaction payload difference and its location", () => { + const result = compareProcessCaptures( + observation(["ready", "worker", "typed"]), + observation(["ready", "worker", "typed"], "stop"), + { traceSpecification: specification() }, + ); + expect(result).toMatchObject({ + interaction: "changed", + first_divergence: { status: "found", dimension: "interaction", index: 0 }, + }); + }); + + it("does not claim no divergence when the requested journal is unavailable", () => { + const full = observation(["ready", "worker", "typed"]); + const historical = parseProcessCapture({ ...full, event_journal: [] }); + expect( + compareProcessCaptures(full, historical, { + traceSpecification: specification(), + }), + ).toMatchObject({ + first_divergence: { status: "unknown" }, + trace: { verdict: "unknown" }, + }); + expect( + compareProcessCaptures(full, full, { + traceSpecification: specification(), + }), + ).toMatchObject({ + status: "unchanged", + first_divergence: { status: "none" }, + trace: { verdict: "equivalent" }, + }); + expect(compareProcessCaptures(full, historical)).toMatchObject({ + status: "unchanged", + first_divergence: { status: "none" }, + }); + }); +});