mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
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) => {
|
||||
|
||||
@@ -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]),
|
||||
);
|
||||
|
||||
@@ -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" }] },
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user