fix(process): attribute trace omissions without changing shared successors

This commit is contained in:
morluto committed 2026-10-24 03:16:30 +08:00
1 parent 584791fedf
commit 254439750a
4 files changed
+224 -7

No files matched your search

+5 -1
View File
@@ -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.
+11 -5
View File
@@ -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]),
);
+34 -1
View File
@@ -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<string, number>();
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;
}
@@ -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" },
});
});
});