fix(process): preserve comparison uncertainty and attribution (#7853)

* fix(process): preserve comparison uncertainty and attribution

* fix(process): retain known file differences with unknown effects

* fix(process): preserve proven filesystem path differences

* fix(process): preserve trace and symlink differences
This commit is contained in:
morluto authored and GitHub committed 2026-10-29 01:01:16 +08:00
1 parent 251e27ef3b
commit dfa0689890
6 files changed
+898 -42

No files matched your search

@@ -1,4 +1,5 @@
import { expect, it } from "vitest";
import { jsonValueSchema } from "../jsonValue.js";
import {
processScenarioSchema,
processScenarioCommitment,
@@ -6,6 +7,7 @@ import {
digestProcessCommitment,
} from "./processScenario.js";
import { parseProcessCapture } from "./processCaptureParsing.js";
import type { FileState } from "./processCapture.js";
import {
deriveProcessComparisonStatus,
@@ -30,6 +32,354 @@ const compareFullyObserved = (
options,
);
it("does not turn unresolved filesystem effects into equality or a definite change", () => {
const capture = emptyCapture();
const file = {
path: "root_0:created.txt",
type: "file" as const,
mode: 0o644,
size: 4,
sha256: "a".repeat(64),
symlink_target: null,
};
const known = {
path: file.path,
status: "created" as const,
before: null,
after: file,
};
const unknown = {
...known,
status: "unknown" as const,
reason: "Prior scope was not observed.",
};
const withEffect = (effect: typeof known | typeof unknown) => ({
...capture,
files_after: [file],
filesystem_effects: [effect],
filesystem_checkpoints: capture.filesystem_checkpoints.map((checkpoint) =>
checkpoint.name === "after_settlement"
? { ...checkpoint, files: [file], effects: [effect] }
: checkpoint,
),
});
const unknownCapture = withEffect(unknown);
const knownCapture = withEffect(known);
for (const [left, right] of [
[unknownCapture, unknownCapture],
[unknownCapture, knownCapture],
[knownCapture, unknownCapture],
] as const)
expect(compareFullyObserved(left, right)).toMatchObject({
status: "unknown",
filesystem: "unknown",
first_divergence: { status: "unknown" },
});
expect(compareFullyObserved(knownCapture, knownCapture)).toMatchObject({
status: "unchanged",
filesystem: "unchanged",
first_divergence: { status: "none" },
});
});
it("preserves independently observed file changes alongside an unknown effect", () => {
const base = emptyCapture();
const link = {
path: "root_0:legacy-link",
type: "symlink" as const,
mode: 0o777,
size: 1,
sha256: null,
symlink_target: null,
symlink_target_hex: "ff",
};
const unknown = {
path: link.path,
status: "unknown" as const,
before: link,
after: link,
reason: "Target bytes were not retained.",
};
const make = (sha256: string) => {
const file = {
path: "root_0:known.txt",
type: "file" as const,
mode: 0o644,
size: 4,
sha256,
symlink_target: null,
};
const files = [link, file];
return {
...base,
files_before: files,
files_after: files,
filesystem_effects: [unknown],
filesystem_checkpoints: base.filesystem_checkpoints.map((checkpoint) => ({
...checkpoint,
files,
effects: checkpoint.name === "after_settlement" ? [unknown] : [],
})),
};
};
const left = make("a".repeat(64));
const right = make("b".repeat(64));
const traceSpecification = {
events: [
{
id: "before",
source: "filesystem" as const,
exact: jsonValueSchema.parse(left.filesystem_checkpoints[0]),
cardinality: { kind: "required" as const },
},
],
language: {
kind: "finite_traces" as const,
variants: [{ id: "v", trace: ["before"] }],
},
};
for (const [a, b] of [
[left, right],
[right, left],
] as const) {
for (const options of [undefined, { traceSpecification }])
expect(compareFullyObserved(a, b, options)).toMatchObject({
filesystem: "changed",
status: "changed",
first_divergence: { status: "unknown" },
});
}
});
it("compares recoverable symlink bytes across old and current captures", () => {
const base = emptyCapture();
const link = {
path: "root_0:link",
type: "symlink" as const,
mode: 0o777,
size: 1,
sha256: null,
symlink_target: null,
symlink_target_hex: "ff",
};
const make = (target: Extract<FileState, { type: "symlink" }>) => {
const effect = {
path: link.path,
status: "unknown" as const,
before: target,
after: target,
reason: "Target bytes were not retained.",
};
return {
...base,
filesystem_effects: [effect],
filesystem_checkpoints: base.filesystem_checkpoints.map((checkpoint) => ({
...checkpoint,
files: [target],
effects: checkpoint.name === "after_settlement" ? [effect] : [],
})),
};
};
const modern = make(link);
const { symlink_target_hex: _hex, ...legacyLink } = link;
const legacy = make({ ...legacyLink, symlink_target: "a" });
for (const [left, right] of [
[modern, legacy],
[legacy, modern],
] as const)
expect(compareFullyObserved(left, right)).toMatchObject({
filesystem: "changed",
first_divergence: { status: "unknown" },
});
const ambiguous = make({ ...legacyLink, symlink_target: "\ufffd" });
expect(compareFullyObserved(modern, ambiguous)).toMatchObject({
filesystem: "unknown",
status: "unknown",
});
});
it("preserves a definite attributed filesystem trace failure beside unknown effects", () => {
const base = emptyCapture();
const unknown = {
path: "root_0:link",
status: "unknown" as const,
before: null,
after: null,
reason: "Unobserved target.",
};
const make = (time: number) => ({
...base,
event_journal: [
{
capture_order: 0,
collection: "filesystem_checkpoints" as const,
index: 0,
},
{ capture_order: 1, collection: "lifecycle" as const, index: 0 },
{ capture_order: 2, collection: "lifecycle" as const, index: 1 },
{
capture_order: 3,
collection: "filesystem_checkpoints" as const,
index: 1,
},
],
filesystem_effects: [unknown],
filesystem_checkpoints: base.filesystem_checkpoints.map((checkpoint) =>
checkpoint.name === "after_settlement"
? { ...checkpoint, at_ms: time, effects: [unknown] }
: checkpoint,
),
});
const left = make(50);
const right = make(60);
const traceSpecification = {
events: [
{
id: "before",
source: "filesystem" as const,
exact: jsonValueSchema.parse(left.filesystem_checkpoints[0]),
cardinality: { kind: "required" as const },
},
{
id: "final",
source: "filesystem" as const,
exact: jsonValueSchema.parse(left.filesystem_checkpoints[1]),
cardinality: { kind: "required" as const },
},
],
language: {
kind: "finite_traces" as const,
variants: [{ id: "v", trace: ["before", "final"] }],
},
};
expect(
compareFullyObserved(left, right, { traceSpecification }),
).toMatchObject({
status: "changed",
filesystem: "changed",
first_divergence: { status: "unknown" },
trace: { verdict: "different" },
});
});
it("preserves proven file additions and removals beside an unrelated unknown effect", () => {
const base = emptyCapture();
const root = {
path: "root_0:.",
type: "directory" as const,
mode: 0o755,
size: 0,
sha256: null,
symlink_target: null,
};
const link = {
path: "root_0:legacy-link",
type: "symlink" as const,
mode: 0o777,
size: 1,
sha256: null,
symlink_target: null,
symlink_target_hex: "ff",
};
const file = {
path: "root_0:output.txt",
type: "file" as const,
mode: 0o644,
size: 4,
sha256: "a".repeat(64),
symlink_target: null,
};
const unknown = {
path: link.path,
status: "unknown" as const,
before: link,
after: link,
reason: "Target bytes were not retained.",
};
const created = {
path: file.path,
status: "created" as const,
before: null,
after: file,
};
const make = (withFile: boolean, unobserved: boolean) => {
const before = [root, link];
const after = withFile ? [...before, file] : before;
const effects = withFile ? [unknown, created] : [unknown];
return {
...base,
files_before: before,
files_after: after,
filesystem_effects: effects,
filesystem_checkpoints: base.filesystem_checkpoints.map((checkpoint) => ({
...checkpoint,
files: checkpoint.name === "before" ? before : after,
effects: checkpoint.name === "before" ? [] : effects,
})),
truncation_details: {
...base.truncation_details,
filesystem_before: {
...base.truncation_details.filesystem_before,
unobserved_subtrees: [],
},
filesystem_after: {
...base.truncation_details.filesystem_after,
unobserved_subtrees: unobserved
? [{ path: root.path, reason: "observer_owned" as const }]
: [],
},
},
};
};
const withFile = make(true, false);
const absent = make(false, false);
const unobserved = make(false, true);
const { unobserved_subtrees: _subtrees, ...legacyCoverage } =
absent.truncation_details.filesystem_after;
const legacy = {
...absent,
truncation_details: {
...absent.truncation_details,
filesystem_after: legacyCoverage,
},
};
const traceSpecification = {
events: [
{
id: "before",
source: "filesystem" as const,
exact: jsonValueSchema.parse(withFile.filesystem_checkpoints[0]),
cardinality: { kind: "required" as const },
},
],
language: {
kind: "finite_traces" as const,
variants: [{ id: "v", trace: ["before"] }],
},
};
for (const [left, right] of [
[withFile, absent],
[absent, withFile],
] as const) {
for (const options of [undefined, { traceSpecification }])
expect(compareFullyObserved(left, right, options)).toMatchObject({
filesystem: "changed",
status: "changed",
first_divergence: { status: "unknown" },
});
}
for (const [left, right] of [
[withFile, unobserved],
[unobserved, withFile],
[withFile, legacy],
[legacy, withFile],
] as const)
expect(compareFullyObserved(left, right)).toMatchObject({
filesystem: "unknown",
status: "unknown",
});
});
it.each(PROCESS_COMPARISON_DIMENSIONS)(
"requires a contradiction aggregate for a contradictory %s dimension",
(dimension) => {
+150 -37
View File
@@ -1,6 +1,7 @@
import canonicalize from "canonicalize";
import { z } from "zod";
import { processSourceTruncated } from "./processCaptureCoverage.js";
import { symlinkTargetIdentity } from "./symlinkTarget.js";
import type {
ProcessObservationLocation,
ProcessObservationSource,
@@ -18,7 +19,7 @@ import {
} from "./processTraceDimensionProjection.js";
import {
compareProcessTraces,
evaluateProcessTraceComparison,
processTraceOutcomesDiffer,
} from "./processTraceComparison.js";
const OPERATION = "compare_process_captures";
@@ -164,6 +165,97 @@ const hasUnknown = (
scope: ProcessCapture["residual_unknowns"][number]["scope"],
): boolean => capture.residual_unknowns.some((item) => item.scope === scope);
const hasUnknownFilesystemEffects = (capture: ProcessCapture): boolean =>
capture.filesystem_effects.some(({ status }) => status === "unknown") ||
capture.filesystem_checkpoints.some(({ effects }) =>
effects.some(({ status }) => status === "unknown"),
);
/** A missing descendant is evidence only under an observed, fully enumerated root. */
const checkpointProvesAbsence = (
capture: ProcessCapture,
checkpoint: ProcessCapture["filesystem_checkpoints"][number],
path: string,
files: ReadonlyMap<string, ProcessCapture["files_after"][number]>,
): boolean => {
const colon = path.indexOf(":");
if (colon < 1) return false;
const root = `${path.slice(0, colon)}:.`;
const coverage =
checkpoint.name === "before"
? capture.truncation_details.filesystem_before
: checkpoint.name === "after_settlement"
? capture.truncation_details.filesystem_after
: undefined;
if (
coverage === undefined ||
coverage.enumeration_truncated ||
coverage.unobserved_subtrees === undefined ||
files.get(root)?.type !== "directory"
)
return false;
return !coverage.unobserved_subtrees.some(({ path: subtree, reason }) =>
subtree === root
? path !== root
: path.startsWith(`${subtree}/`) ||
path.startsWith(`${subtree}\\`) ||
(reason === "observer_owned" && path === subtree),
);
};
/** Compare observed facts independently of unrelated unknown effects. */
const hasKnownFilesystemDifference = (
left: ProcessCapture,
right: ProcessCapture,
): boolean =>
left.filesystem_checkpoints.some((checkpoint) => {
const peer = right.filesystem_checkpoints.find(
({ name }) => name === checkpoint.name,
);
if (peer === undefined) return false;
const files = new Map(peer.files.map((file) => [file.path, file]));
return checkpoint.files.some((file) => {
const other = files.get(file.path);
if (other === undefined)
return checkpointProvesAbsence(right, peer, file.path, files);
if (
file.type !== other.type ||
file.mode !== other.mode ||
file.size !== other.size
)
return true;
if (file.type === "file" && other.type === "file")
return (
file.sha256 !== null &&
other.sha256 !== null &&
file.sha256 !== other.sha256
);
if (file.type === "symlink" && other.type === "symlink") {
const leftTarget = symlinkTargetIdentity(file);
const rightTarget = symlinkTargetIdentity(other);
if (leftTarget !== null && rightTarget !== null)
return leftTarget !== rightTarget;
return (
file.symlink_target !== null &&
other.symlink_target !== null &&
file.symlink_target !== other.symlink_target
);
}
return false;
});
}) ||
right.filesystem_checkpoints.some((checkpoint) => {
const peer = left.filesystem_checkpoints.find(
({ name }) => name === checkpoint.name,
);
if (peer === undefined) return false;
const files = new Map(peer.files.map((file) => [file.path, file]));
return checkpoint.files.some(
({ path }) =>
!files.has(path) && checkpointProvesAbsence(left, peer, path, files),
);
});
type DivergenceDimension = Exclude<
ProcessCaptureComparison["first_divergence"],
{ readonly status: "none" | "unknown" }
@@ -318,9 +410,10 @@ const LOCATION_SOURCES = {
const applyTraceVerdict = (
dimensions: ComparisonDimensions,
specification: ProcessTraceSpecification,
trace: ReturnType<typeof compareProcessTraces>,
evaluation: ReturnType<typeof evaluateProcessTraceComparison>,
captures: readonly [left: ProcessCapture, right: ProcessCapture],
): ComparisonDimensions => {
const { result: trace, diagnostics } = evaluation;
const traceShowsDifference = processTraceOutcomesDiffer(trace);
const traceStatus =
trace.verdict === "unknown"
@@ -333,14 +426,16 @@ const applyTraceVerdict = (
// A different verdict names the failing events and observations; attribute
// it only to their dimensions; unrelated comparisons keep their own status,
// including unknown coverage and independently observed additions/removals.
const diagnosticSources = new Set<ProcessObservationSource>([
...specification.events
.filter(({ id }) => trace.diagnostic?.event_ids.includes(id) === true)
.map(({ source }) => source),
...(trace.diagnostic?.locations ?? []).map(
({ collection }) => LOCATION_SOURCES[collection],
),
]);
const diagnosticSources = new Set<ProcessObservationSource>(
diagnostics.flatMap((diagnostic) => [
...specification.events
.filter(({ id }) => diagnostic.event_ids.includes(id))
.map(({ source }) => source),
...diagnostic.locations.map(
({ collection }) => LOCATION_SOURCES[collection],
),
]),
);
const attributed =
diagnosticSources.size > 0
? dimensionsForTraceSources(diagnosticSources)
@@ -355,6 +450,11 @@ const applyTraceVerdict = (
return traceShowsDifference && attributed.has(dimension)
? "changed"
: exact;
if (
dimension === "filesystem" &&
captures.some(hasUnknownFilesystemEffects)
)
return exact;
if (
trace.verdict === "unknown" &&
["changed", "added", "removed"].includes(exact)
@@ -395,6 +495,10 @@ const compareDimensions = (
!sameNormalization ||
hasUnknown(left, dimension) ||
hasUnknown(right, dimension) ||
(dimension === "filesystem" &&
(hasUnknownFilesystemEffects(left) ||
hasUnknownFilesystemEffects(right)) &&
!hasKnownFilesystemDifference(left, right)) ||
processSourceTruncated(left, source) ||
processSourceTruncated(right, source)
? "unknown"
@@ -436,17 +540,20 @@ export const compareProcessCaptures = (
right.normalization,
);
const exactDimensions = compareDimensions(left, right, sameNormalization);
const trace =
const traceEvaluation =
options.traceSpecification === undefined
? undefined
: compareProcessTraces(left, right, options.traceSpecification);
: evaluateProcessTraceComparison(left, right, options.traceSpecification);
const trace = traceEvaluation?.result;
const dimensions =
trace === undefined || options.traceSpecification === undefined
traceEvaluation === undefined || options.traceSpecification === undefined
? exactDimensions
: applyTraceVerdict(exactDimensions, options.traceSpecification, trace, [
left,
right,
]);
: applyTraceVerdict(
exactDimensions,
options.traceSpecification,
traceEvaluation,
[left, right],
);
const divergenceCandidates = PROCESS_COMPARISON_DIMENSIONS.map((dimension) =>
dimensions[dimension] === "unknown"
? null
@@ -472,30 +579,36 @@ export const compareProcessCaptures = (
reason:
"Incomplete observations prevent locating the first divergence; unaffected dimensions retain their comparisons.",
} as const)
: trace?.verdict === "equivalent" &&
deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map(
(dimension) => dimensions[dimension],
),
) === "unchanged"
? ({ status: "none" } as const)
: observedFirstDivergence.status === "found"
? observedFirstDivergence
: trace?.verdict === "different" || trace?.verdict === "unknown"
? ({
status: "unknown",
reason:
"The declared trace differs or has insufficient journal evidence; per-source observations cannot locate its first cross-source divergence.",
} as const)
: !sameNormalization ||
left.residual_unknowns.length > 0 ||
right.residual_unknowns.length > 0
: hasUnknownFilesystemEffects(left) || hasUnknownFilesystemEffects(right)
? ({
status: "unknown",
reason:
"Unresolved filesystem effects prevent locating the first divergence, even when another change is observed.",
} as const)
: trace?.verdict === "equivalent" &&
deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map(
(dimension) => dimensions[dimension],
),
) === "unchanged"
? ({ status: "none" } as const)
: observedFirstDivergence.status === "found"
? observedFirstDivergence
: 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]),
);
+22 -3
View File
@@ -3,6 +3,7 @@ import {
evaluateProcessTraceSide,
processTraceComparisonResultSchema,
type ProcessTraceComparisonResult,
type EvaluatedSide,
} from "./processTraceEvaluation.js";
import {
processTraceSpecificationSchema,
@@ -10,11 +11,14 @@ import {
} from "./processTraceSpecification.js";
/** Compare two captures against an explicit finite trace language. */
export const compareProcessTraces = (
export const evaluateProcessTraceComparison = (
left: ProcessCapture,
right: ProcessCapture,
specificationInput: ProcessTraceSpecification,
): ProcessTraceComparisonResult => {
): {
readonly result: ProcessTraceComparisonResult;
readonly diagnostics: readonly NonNullable<EvaluatedSide["diagnostic"]>[];
} => {
const specification =
processTraceSpecificationSchema.parse(specificationInput);
const leftEvaluation = evaluateProcessTraceSide(left, specification);
@@ -29,7 +33,7 @@ export const compareProcessTraces = (
left: leftEvaluation.result,
right: rightEvaluation.result,
});
return processTraceComparisonResultSchema.parse({
const result = processTraceComparisonResultSchema.parse({
verdict:
leftEvaluation.result.status === "unknown" ||
rightEvaluation.result.status === "unknown"
@@ -44,8 +48,23 @@ export const compareProcessTraces = (
right: rightEvaluation.result,
diagnostic,
});
return {
result,
diagnostics: [leftEvaluation.diagnostic, rightEvaluation.diagnostic].filter(
(value): value is NonNullable<EvaluatedSide["diagnostic"]> =>
value !== null,
),
};
};
/** Compare two captures against an explicit finite trace language. */
export const compareProcessTraces = (
left: ProcessCapture,
right: ProcessCapture,
specification: ProcessTraceSpecification,
): ProcessTraceComparisonResult =>
evaluateProcessTraceComparison(left, right, specification).result;
/** Whether trace conformance or raw declared order differs across captures. */
export const processTraceOutcomesDiffer = (
comparison: Pick<ProcessTraceComparisonResult, "left" | "right">,
@@ -0,0 +1,235 @@
import { expect, it } from "vitest";
import { jsonValueSchema } from "../jsonValue.js";
import { compareProcessCaptures } from "./processComparison.js";
import { parseProcessCapture } from "./processCaptureParsing.js";
import {
capture,
emptyCapture,
terminal,
} from "./processTraceComparison.fixture.js";
import type { ProcessTraceSpecification } from "./processTraceSpecification.js";
const rawSpec: ProcessTraceSpecification = {
events: [
{
id: "ready",
source: "terminal_raw",
exact: terminal,
cardinality: { kind: "required" },
},
],
language: {
kind: "finite_traces",
variants: [{ id: "only", trace: ["ready"] }],
},
};
it("does not let raw-only conformance hide omitted rendered terminal observations", () => {
const base = capture({
frames: [terminal],
process_samples: [],
event_journal: [{ collection: "frames", index: 0 }],
});
const omitted = parseProcessCapture({
...base,
truncated: true,
truncation_details: {
...base.truncation_details,
rendered_terminal: {
...base.truncation_details.rendered_terminal,
observed_frames: 1,
retained_frames: 0,
unknown_size_frames: 1,
},
},
});
expect(compareProcessCaptures(omitted, omitted)).toMatchObject({
terminal: "unknown",
first_divergence: { status: "unknown" },
});
expect(
compareProcessCaptures(omitted, omitted, { traceSpecification: rawSpec }),
).toMatchObject({
terminal: "unknown",
status: "unknown",
trace: { verdict: "equivalent" },
first_divergence: { status: "unknown" },
});
expect(
compareProcessCaptures(base, base, { traceSpecification: rawSpec }),
).toMatchObject({ terminal: "unchanged", trace: { verdict: "equivalent" } });
const rawOmitted = capture(
{
frames: [terminal],
process_samples: [],
event_journal: [{ collection: "frames", index: 0 }],
},
{ omittedTerminalFrame: true },
);
expect(
compareProcessCaptures(rawOmitted, rawOmitted, {
traceSpecification: rawSpec,
}),
).toMatchObject({ terminal: "unknown", trace: { verdict: "unknown" } });
});
const comparisonFixture = () => {
const input = {
sequence: 0,
scheduled_at_ms: 10,
dispatched_at_ms: 10,
type: "input" as const,
data: "go",
outcome: "dispatched" as const,
};
const definitions = [
{
id: "ready",
source: "terminal_raw" as const,
exact: terminal,
collection: "frames" as const,
index: 0,
},
{
id: "typed",
source: "interaction" as const,
exact: input,
collection: "interaction_events" as const,
index: 0,
},
{
id: "before",
source: "filesystem" as const,
exact: emptyCapture.filesystem_checkpoints[0]!,
collection: "filesystem_checkpoints" as const,
index: 0,
},
{
id: "settled",
source: "lifecycle" as const,
exact: { event: "settlement", ...emptyCapture.settlement },
collection: "lifecycle" as const,
index: 1,
},
{
id: "exited",
source: "lifecycle" as const,
exact: { event: "exit", ...emptyCapture.exit },
collection: "lifecycle" as const,
index: 0,
},
{
id: "after",
source: "filesystem" as const,
exact: emptyCapture.filesystem_checkpoints[1]!,
collection: "filesystem_checkpoints" as const,
index: 1,
},
];
const edges = [
{ before: "ready", after: "typed" },
{ before: "before", after: "settled" },
];
const specification: ProcessTraceSpecification = {
events: definitions.map(({ id, source, exact }) => ({
id,
source,
exact: jsonValueSchema.parse(exact),
cardinality: { kind: "required" },
})),
language: {
kind: "partial_order",
happens_before: edges,
not_before: [],
prefix: [],
suffix: [],
unordered_groups: definitions.flatMap(({ id }, i) =>
definitions
.slice(i + 1)
.filter(
({ id: other }) =>
!edges.some((e) => e.before === id && e.after === other),
)
.map(({ id: other }) => ({ events: [id, other] })),
),
},
};
const base = capture({
frames: [terminal],
process_samples: [],
event_journal: [{ collection: "frames", index: 0 }],
});
const make = (order: readonly string[]) =>
parseProcessCapture({
...base,
interaction_events: [input],
event_journal: order.map((id, capture_order) => {
const item = definitions.find((entry) => entry.id === id);
if (item === undefined) throw new Error(`Unknown event ${id}`);
return {
collection: item.collection,
index: item.index,
capture_order,
};
}),
});
const left = make(["before", "typed", "ready", "exited", "settled", "after"]);
const right = make([
"ready",
"typed",
"exited",
"settled",
"before",
"after",
]);
const passing = make([
"before",
"ready",
"typed",
"exited",
"settled",
"after",
]);
return { left, right, passing, specification };
};
it("attributes independent trace failures from both sides in either operand order", () => {
const { left, right, passing, specification } = comparisonFixture();
for (const [a, b] of [
[left, right],
[right, left],
] as const) {
expect(
compareProcessCaptures(a, b, { traceSpecification: specification }),
).toMatchObject({
terminal: "changed",
interaction: "changed",
filesystem: "changed",
exit: "changed",
trace: {
verdict: "different",
left: { status: "fail" },
right: { status: "fail" },
},
});
}
for (const [a, b] of [
[left, passing],
[passing, left],
] as const) {
expect(
compareProcessCaptures(a, b, { traceSpecification: specification }),
).toMatchObject({
terminal: "changed",
interaction: "changed",
filesystem: "unchanged",
exit: "unchanged",
});
}
expect(compareProcessCaptures(left, right)).toMatchObject({
terminal: "unchanged",
interaction: "unchanged",
filesystem: "unchanged",
exit: "unchanged",
});
});
@@ -1,6 +1,7 @@
import type { ProcessCapture } from "./processCaptureParsing.js";
import type { ProcessObservationSource } from "./processObservation.js";
import type { ProcessComparisonDimension } from "./processComparison.js";
import { processSourceTruncated } from "./processCaptureCoverage.js";
/** Map declared trace sources to process-comparison dimensions. */
export const dimensionsForTraceSources = (
@@ -31,8 +32,17 @@ export const traceCoversObservedDimension = (
switch (dimension) {
case "terminal":
return (
(!any(left.frames, right.frames) || sources.has("terminal_raw")) &&
(!any(left.rendered_frames, right.rendered_frames) ||
(!(
any(left.frames, right.frames) ||
processSourceTruncated(left, "terminal_raw") ||
processSourceTruncated(right, "terminal_raw")
) ||
sources.has("terminal_raw")) &&
(!(
any(left.rendered_frames, right.rendered_frames) ||
processSourceTruncated(left, "terminal_rendered") ||
processSourceTruncated(right, "terminal_rendered")
) ||
sources.has("terminal_rendered"))
);
case "interaction":
@@ -136,3 +136,132 @@ it("rejects an inconsistent imported process comparison while retaining its Evid
expect(providerLaunches).toBe(0);
await harness.client.ping();
});
it("keeps omitted rendered observations unknown through trace comparison and imported verification", async () => {
const frame = { sequence: 0, at_ms: 10, data: "Ready" };
const capture = parseProcessCapture({
...EMPTY_PROCESS_CAPTURE_EXAMPLE,
frames: [frame],
truncated: true,
truncation_details: {
...EMPTY_PROCESS_CAPTURE_EXAMPLE.truncation_details,
raw_terminal: {
budget_bytes: 5,
observed_bytes: 5,
retained_bytes: 5,
observed_frames: 1,
retained_frames: 1,
},
rendered_terminal: {
budget_bytes: 5,
observed_bytes: 0,
retained_bytes: 0,
observed_frames: 1,
retained_frames: 0,
unknown_size_frames: 1,
},
},
event_journal: [
{ collection: "filesystem_checkpoints", index: 0, capture_order: 0 },
{ collection: "frames", index: 0, capture_order: 1 },
{ collection: "lifecycle", index: 0, capture_order: 2 },
{ collection: "lifecycle", index: 1, capture_order: 3 },
{ collection: "filesystem_checkpoints", index: 1, capture_order: 4 },
],
});
const source = (side: string) =>
createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-capture",
operation: "capture_process_scenario",
parameters: { side },
result: capture,
confidence: "observed",
authority: "controlled-replay",
environment: {
id: "fixture",
platform: "fixture",
architecture: "fixture",
isolation: "process",
},
});
const left = source("left");
const right = source("right");
const harness = await createApplicationMcpHarness({
session: createTestBinarySession(() => {
throw new Error("Unexpected provider launch");
}),
});
onTestFinished(harness.close);
const compared = await harness.client.callTool({
name: "compare_process_captures",
arguments: {
left,
right,
trace_spec: {
events: [
{
id: "ready",
source: "terminal_raw",
exact: frame,
cardinality: { kind: "required" },
},
],
language: {
kind: "finite_traces",
variants: [{ id: "only", trace: ["ready"] }],
},
},
},
});
expect(compared.isError).not.toBe(true);
const comparison = parseEvidence(compared.structuredContent);
expect(comparison.normalized_result).toMatchObject({
terminal: "unknown",
status: "unknown",
trace: { verdict: "equivalent" },
first_divergence: { status: "unknown" },
});
const root = await createTestTempDirectory("rea-process-trace-unknown-");
const path = join(root, "bundle.json");
await writeFile(
path,
JSON.stringify(createEvidenceBundle([left, right, comparison])),
);
const restored = await createApplicationMcpHarness({
session: createTestBinarySession(() => {
throw new Error("Unexpected provider launch");
}),
});
onTestFinished(restored.close);
const imported = await restored.client.callTool({
name: "import_evidence_bundle",
arguments: { path },
});
expect(imported.isError).not.toBe(true);
const verification = await restored.client.callTool({
name: "verify_reconstruction",
arguments: {
specification: {
name: "Terminal coverage",
claims: [
{
kind: "behavioral",
claim_id: "terminal",
title: "Replay remains equivalent",
comparison_evidence_id: comparison.evidence_id,
dimension: "terminal",
},
],
},
},
});
expect(verification.isError).not.toBe(true);
expect(
reconstructionVerificationResultSchema.parse(
parseEvidence(verification.structuredContent).normalized_result,
),
).toMatchObject({
status: "unknown",
claims: { items: [{ observed_status: "unknown" }] },
});
});