mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(process): attribute trace omissions without changing shared successors
This commit is contained in:
1 parent
584791fedf
commit
254439750a
4 files changed
+224
-7
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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]),
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user