refactor(process): split trace comparison helpers

This commit is contained in:
morluto 2026-07-22 20:39:03 +00:00
parent 75d5a87054
commit 6d8ecd2b88
2 changed files with 283 additions and 90 deletions

View File

@ -3,6 +3,12 @@ import { z } from "zod";
import type { ProcessCapture } from "./processCapture.js";
import { validateProcessCapture } from "./processCapture.js";
import { jsonValueSchema } from "./jsonValue.js";
import {
compareProcessTraces,
processTraceComparisonResultSchema,
type ProcessTraceSource,
type ProcessTraceSpecification,
} from "./processTraceComparison.js";
/** Comparison classification that never equates incomplete evidence. */
export const comparisonStatusSchema = z.enum([
@ -73,6 +79,7 @@ export const processCaptureComparisonSchema = z
right: jsonValueSchema.nullable(),
}),
]),
trace: processTraceComparisonResultSchema.optional(),
limitations: z.array(z.string()),
})
.superRefine((comparison, context) => {
@ -269,6 +276,115 @@ const truncatedComparison = (): ProcessCaptureComparison => ({
limitations: ["At least one capture is truncated."],
});
const dimensionsForTraceSources = (
sources: ReadonlySet<ProcessTraceSource>,
): ReadonlySet<(typeof PROCESS_COMPARISON_DIMENSIONS)[number]> => {
const dimensions = new Set<(typeof PROCESS_COMPARISON_DIMENSIONS)[number]>();
for (const source of sources) {
if (source.startsWith("terminal_")) dimensions.add("terminal");
else if (source === "interaction") dimensions.add("interaction");
else if (source === "lifecycle") dimensions.add("exit");
else if (source === "filesystem") dimensions.add("filesystem");
else if (
source === "http" ||
source === "websocket" ||
source === "replay_transition"
)
dimensions.add("protocol");
else if (source === "process") dimensions.add("process");
else if (source === "shim") dimensions.add("shim");
}
return dimensions;
};
const traceCoversObservedDimension = (
dimension: (typeof PROCESS_COMPARISON_DIMENSIONS)[number],
sources: ReadonlySet<ProcessTraceSource>,
left: ProcessCapture,
right: ProcessCapture,
): boolean => {
const any = <Value>(
leftValues: readonly Value[],
rightValues: readonly Value[],
): boolean => leftValues.length > 0 || rightValues.length > 0;
switch (dimension) {
case "terminal":
return (
(!any(left.frames, right.frames) || sources.has("terminal_raw")) &&
(!any(left.rendered_frames, right.rendered_frames) ||
sources.has("terminal_rendered"))
);
case "interaction":
return sources.has("interaction");
case "exit":
return sources.has("lifecycle");
case "filesystem":
return false;
case "protocol":
return (
(![...left.protocol_events, ...right.protocol_events].some(
({ protocol }) => protocol === "http",
) ||
sources.has("http")) &&
(![...left.protocol_events, ...right.protocol_events].some(
({ protocol }) => protocol === "websocket",
) ||
sources.has("websocket")) &&
(!any(left.replay_transitions, right.replay_transitions) ||
sources.has("replay_transition"))
);
case "process":
return sources.has("process");
case "shim":
return sources.has("shim");
}
};
const applyTraceVerdict = (
dimensions: ComparisonDimensions,
specification: ProcessTraceSpecification,
trace: ReturnType<typeof compareProcessTraces>,
captures: readonly [left: ProcessCapture, right: ProcessCapture],
): ComparisonDimensions => {
const traceStatus: ComparisonStatus =
trace.verdict === "equivalent"
? "unchanged"
: trace.verdict === "different"
? "changed"
: "unknown";
const sources = new Set(specification.events.map(({ source }) => source));
const covered = dimensionsForTraceSources(sources);
const statusFor = (
dimension: (typeof PROCESS_COMPARISON_DIMENSIONS)[number],
): ComparisonStatus => {
const exact = dimensions[dimension];
if (!covered.has(dimension)) return exact;
if (trace.verdict === "different") return "changed";
if (
trace.verdict === "unknown" &&
["changed", "added", "removed"].includes(exact)
)
return exact;
return traceCoversObservedDimension(
dimension,
sources,
captures[0],
captures[1],
)
? traceStatus
: exact;
};
return {
terminal: statusFor("terminal"),
interaction: statusFor("interaction"),
exit: statusFor("exit"),
filesystem: statusFor("filesystem"),
protocol: statusFor("protocol"),
process: statusFor("process"),
shim: statusFor("shim"),
};
};
type ComparisonDimensions = Pick<
ProcessCaptureComparison,
| "terminal"
@ -326,7 +442,6 @@ const compareDimensions = (
};
};
/** Compare bounded captures without equating missing or incompatible evidence. */
/**
* Compare two Process Capture v4 observations without treating absence as proof.
*
@ -339,64 +454,73 @@ export const compareProcessCaptures = (
options: {
readonly maxCaptureAgeMs?: number;
readonly now?: () => number;
readonly traceSpecification?: ProcessTraceSpecification;
} = {},
): ProcessCaptureComparison => {
assertComparable(left, right, options);
if (left.truncated || right.truncated) return truncatedComparison();
if (left.truncated || right.truncated) {
const truncated = truncatedComparison();
return options.traceSpecification === undefined
? truncated
: {
...truncated,
trace: compareProcessTraces(left, right, options.traceSpecification),
};
}
const sameNormalization =
JSON.stringify(left.normalization) === JSON.stringify(right.normalization);
const dimensions = compareDimensions(left, right, sameNormalization);
const observedFirstDivergence = chooseFirstDivergence([
const exactDimensions = compareDimensions(left, right, sameNormalization);
const trace =
options.traceSpecification === undefined
? undefined
: compareProcessTraces(left, right, options.traceSpecification);
const dimensions =
trace === undefined || options.traceSpecification === undefined
? exactDimensions
: applyTraceVerdict(exactDimensions, options.traceSpecification, trace, [
left,
right,
]);
const divergenceCandidates = PROCESS_COMPARISON_DIMENSIONS.map((dimension) =>
firstCollectionDivergence(
"terminal",
terminalObservations(left),
terminalObservations(right),
dimension,
processDimensionObservations(left, dimension),
processDimensionObservations(right, dimension),
),
firstCollectionDivergence(
"interaction",
left.interaction_events,
right.interaction_events,
);
const observedFirstDivergence = chooseFirstDivergence(
divergenceCandidates.map((candidate) =>
candidate !== null &&
trace?.verdict === "equivalent" &&
dimensions[candidate.dimension] === "unchanged"
? null
: candidate,
),
firstCollectionDivergence(
"exit",
[{ ...left.exit, settlement: left.settlement }],
[{ ...right.exit, settlement: right.settlement }],
),
firstCollectionDivergence(
"filesystem",
filesystemObservations(left),
filesystemObservations(right),
),
firstCollectionDivergence(
"protocol",
[...left.protocol_events, ...left.replay_transitions],
[...right.protocol_events, ...right.replay_transitions],
),
firstCollectionDivergence(
"process",
left.process_samples,
right.process_samples,
),
firstCollectionDivergence("shim", left.shim_events, right.shim_events),
]);
);
const firstDivergence =
observedFirstDivergence.status === "found"
? 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;
trace?.verdict === "equivalent" &&
deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => dimensions[dimension]),
) === "unchanged"
? ({ status: "none" } as const)
: observedFirstDivergence.status === "found"
? 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;
return {
status: deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => dimensions[dimension]),
),
...dimensions,
first_divergence: firstDivergence,
...(trace === undefined ? {} : { trace }),
limitations: [
...left.limitations,
...right.limitations,

View File

@ -4,6 +4,7 @@ import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
import type { ProcessCapture } from "./processCapture.js";
import {
canonicalTraceJson,
comparableTracePayload,
processTraceCardinalityBounds,
type ProcessTraceSource,
type ProcessTraceSpecification,
@ -260,7 +261,12 @@ const matchRecords = (
const matches = specification.events.filter(
(event) =>
event.source === record.source &&
canonicalTraceJson(event.exact) === canonicalTraceJson(record.payload),
canonicalTraceJson(
comparableTracePayload(event.exact, event.ignore_fields),
) ===
canonicalTraceJson(
comparableTracePayload(record.payload, event.ignore_fields),
),
);
if (matches.length !== 1)
return failure({
@ -303,68 +309,131 @@ const cardinalityFailure = (
return null;
};
const evaluatePartialOrder = (
type PartialOrderLanguage = Extract<
ProcessTraceSpecification["language"],
{ readonly kind: "partial_order" }
>;
const evaluateOrderingConstraints = (
trace: MatchedTrace,
specification: ProcessTraceSpecification,
): EvaluatedSide => {
if (specification.language.kind !== "partial_order")
throw new TypeError("Expected a partial-order trace specification");
language: PartialOrderLanguage,
): { readonly failure: EvaluatedSide | null; readonly satisfied: string[] } => {
const satisfied: string[] = [];
for (const edge of specification.language.happens_before) {
for (const edge of language.happens_before) {
const before = trace.locationsById.get(edge.before) ?? [];
const after = trace.locationsById.get(edge.after) ?? [];
const lastBefore = before.at(-1);
const firstAfter = after[0];
if (lastBefore === undefined && firstAfter !== undefined)
return {
failure: failure({
kind: "edge",
message: `${edge.before} is required when ${edge.after} occurs.`,
eventIds: [edge.before, edge.after],
locations: [firstAfter],
rawTrace: trace.rawTrace,
}),
satisfied,
};
if (
lastBefore !== undefined &&
firstAfter !== undefined &&
lastBefore.capture_order >= firstAfter.capture_order
)
return failure({
kind: "edge",
message: `${edge.before} must happen before ${edge.after}.`,
eventIds: [edge.before, edge.after],
locations: [lastBefore, firstAfter],
rawTrace: trace.rawTrace,
});
return {
failure: failure({
kind: "edge",
message: `${edge.before} must happen before ${edge.after}.`,
eventIds: [edge.before, edge.after],
locations: [lastBefore, firstAfter],
rawTrace: trace.rawTrace,
}),
satisfied,
};
satisfied.push(`happens_before:${edge.before}:${edge.after}`);
}
for (const constraint of language.not_before) {
const events = trace.locationsById.get(constraint.event) ?? [];
const anchors = trace.locationsById.get(constraint.anchor) ?? [];
const firstEvent = events[0];
const firstAnchor = anchors[0];
if (firstEvent !== undefined && firstAnchor === undefined)
return {
failure: failure({
kind: "edge",
message: `${constraint.anchor} is required when ${constraint.event} occurs.`,
eventIds: [constraint.event, constraint.anchor],
locations: [firstEvent],
rawTrace: trace.rawTrace,
}),
satisfied,
};
if (
firstEvent !== undefined &&
firstAnchor !== undefined &&
firstEvent.capture_order < firstAnchor.capture_order
)
return {
failure: failure({
kind: "edge",
message: `${constraint.event} must not occur before ${constraint.anchor}.`,
eventIds: [constraint.event, constraint.anchor],
locations: [firstEvent, firstAnchor],
rawTrace: trace.rawTrace,
}),
satisfied,
};
satisfied.push(`not_before:${constraint.event}:${constraint.anchor}`);
}
return { failure: null, satisfied };
};
const boundaryFailure = (
trace: MatchedTrace,
kind: "prefix" | "suffix",
expected: readonly string[],
): EvaluatedSide | null => {
const ids = trace.rawTrace.map(({ event_id }) => event_id);
const prefix = specification.language.prefix;
if (
canonicalTraceJson(ids.slice(0, prefix.length)) !==
canonicalTraceJson(prefix)
)
return failure({
kind: "prefix",
message: "Observed trace does not satisfy the declared prefix.",
eventIds: prefix,
locations: trace.rawTrace
.slice(0, prefix.length)
.map(({ location }) => location),
rawTrace: trace.rawTrace,
});
const suffix = specification.language.suffix;
if (
canonicalTraceJson(suffix.length === 0 ? [] : ids.slice(-suffix.length)) !==
canonicalTraceJson(suffix)
)
return failure({
kind: "suffix",
message: "Observed trace does not satisfy the declared suffix.",
eventIds: suffix,
locations: trace.rawTrace
.slice(-suffix.length)
.map(({ location }) => location),
rawTrace: trace.rawTrace,
});
const actual =
kind === "prefix"
? ids.slice(0, expected.length)
: expected.length === 0
? []
: ids.slice(-expected.length);
if (canonicalTraceJson(actual) === canonicalTraceJson(expected)) return null;
const locations =
kind === "prefix"
? trace.rawTrace.slice(0, expected.length)
: trace.rawTrace.slice(-expected.length);
return failure({
kind,
message: `Observed trace does not satisfy the declared ${kind}.`,
eventIds: expected,
locations: locations.map(({ location }) => location),
rawTrace: trace.rawTrace,
});
};
const evaluatePartialOrder = (
trace: MatchedTrace,
language: PartialOrderLanguage,
): EvaluatedSide => {
const ordering = evaluateOrderingConstraints(trace, language);
if (ordering.failure !== null) return ordering.failure;
const prefixFailure = boundaryFailure(trace, "prefix", language.prefix);
if (prefixFailure !== null) return prefixFailure;
const suffixFailure = boundaryFailure(trace, "suffix", language.suffix);
if (suffixFailure !== null) return suffixFailure;
const satisfied = ordering.satisfied;
satisfied.push(
...specification.language.unordered_groups.map(
...language.unordered_groups.map(
({ events }) => `unordered:${events.join(":")}`,
),
);
if (prefix.length > 0) satisfied.push(`prefix:${prefix.join(":")}`);
if (suffix.length > 0) satisfied.push(`suffix:${suffix.join(":")}`);
if (language.prefix.length > 0)
satisfied.push(`prefix:${language.prefix.join(":")}`);
if (language.suffix.length > 0)
satisfied.push(`suffix:${language.suffix.join(":")}`);
return {
result: {
status: "pass",
@ -383,7 +452,7 @@ const evaluateMatchedTrace = (
const invalidCardinality = cardinalityFailure(trace, specification);
if (invalidCardinality !== null) return invalidCardinality;
if (specification.language.kind === "partial_order")
return evaluatePartialOrder(trace, specification);
return evaluatePartialOrder(trace, specification.language);
const ids = trace.rawTrace.map(({ event_id }) => event_id);
const variant = evaluateFiniteTrace(ids, specification);
return variant === null