diff --git a/src/domain/processComparison.ts b/src/domain/processComparison.ts index 86fd3291c..ce4e42c4d 100644 --- a/src/domain/processComparison.ts +++ b/src/domain/processComparison.ts @@ -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, +): 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, + left: ProcessCapture, + right: ProcessCapture, +): boolean => { + const any = ( + 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, + 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, diff --git a/src/domain/processTraceEvaluation.ts b/src/domain/processTraceEvaluation.ts index 56843dbdc..b181b722a 100644 --- a/src/domain/processTraceEvaluation.ts +++ b/src/domain/processTraceEvaluation.ts @@ -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