refactor(process): split trace comparison helpers
This commit is contained in:
parent
75d5a87054
commit
6d8ecd2b88
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue