import canonicalize from "canonicalize"; import { z } from "zod"; import type { ProcessCapture } from "./processCapture.js"; import { jsonValueSchema } from "./jsonValue.js"; import { compareProcessTraces, processTraceOutcomesDiffer, processTraceComparisonResultSchema, type ProcessTraceSpecification, } from "./processTraceComparison.js"; import { dimensionsForTraceSources, traceCoversObservedDimension, } from "./processTraceDimensionProjection.js"; /** Comparison classification that never equates incomplete evidence. */ export const comparisonStatusSchema = z.enum([ "unchanged", "added", "removed", "changed", "truncated", "unknown", "contradiction", ]); type ComparisonStatus = z.infer; /** Canonical ordered dimensions that contribute to an overall process status. */ export const PROCESS_COMPARISON_DIMENSIONS = [ "terminal", "interaction", "exit", "filesystem", "process", ] as const; /** Derive the overall comparison status from every process dimension. */ export const deriveProcessComparisonStatus = ( dimensions: readonly ComparisonStatus[], ): ComparisonStatus => { if (dimensions.includes("truncated")) return "truncated"; if ( dimensions.some((status) => ["changed", "added", "removed"].includes(status), ) ) return "changed"; return dimensions.includes("unknown") ? "unknown" : "unchanged"; }; /** Pure normalized comparison between two captures. */ export const processCaptureComparisonSchema = z .object({ status: comparisonStatusSchema, terminal: comparisonStatusSchema, interaction: comparisonStatusSchema, exit: comparisonStatusSchema, filesystem: comparisonStatusSchema, process: comparisonStatusSchema, first_divergence: z.discriminatedUnion("status", [ z.object({ status: z.literal("none") }), z.object({ status: z.literal("unknown"), reason: z.string() }), z.object({ status: z.literal("found"), dimension: z.enum([ "terminal", "interaction", "exit", "filesystem", "process", ]), index: z.number().int().nonnegative(), left_at_ms: z.number().int().nonnegative().nullable(), right_at_ms: z.number().int().nonnegative().nullable(), left: jsonValueSchema.nullable(), right: jsonValueSchema.nullable(), }), ]), trace: processTraceComparisonResultSchema.optional(), limitations: z.array(z.string()), }) .superRefine((comparison, context) => { const expected = deriveProcessComparisonStatus( PROCESS_COMPARISON_DIMENSIONS.map((dimension) => comparison[dimension]), ); if (comparison.status !== expected) context.addIssue({ code: "custom", message: "Process comparison status contradicts its dimensions", path: ["status"], }); }); export type ProcessCaptureComparison = z.infer< typeof processCaptureComparisonSchema >; const terminalObservations = (capture: ProcessCapture): readonly unknown[] => [ ...capture.frames.map((frame) => ({ kind: "raw" as const, ...frame })), ...capture.rendered_frames.map((frame) => ({ kind: "rendered" as const, ...frame, })), ].sort((left, right) => { const time = left.at_ms - right.at_ms; if (time !== 0) return time; if (left.kind !== right.kind) return left.kind === "raw" ? -1 : 1; return left.sequence - right.sequence; }); const filesystemObservations = (capture: ProcessCapture): readonly unknown[] => capture.filesystem_checkpoints; const sameJsonValue = (left: unknown, right: unknown): boolean => { const encodedLeft = canonicalize(left); return encodedLeft !== undefined && encodedLeft === canonicalize(right); }; /** Canonical observations for one process-comparison dimension. */ export const processDimensionObservations = ( capture: ProcessCapture, dimension: (typeof PROCESS_COMPARISON_DIMENSIONS)[number], ): readonly unknown[] => { switch (dimension) { case "terminal": return terminalObservations(capture); case "interaction": return capture.interaction_events; case "exit": return [{ ...capture.exit, settlement: capture.settlement }]; case "filesystem": return filesystemObservations(capture); case "process": return capture.process_samples; } }; const classifyCollection = ( left: readonly unknown[], right: readonly unknown[], ): ComparisonStatus => { if (sameJsonValue(left, right)) return "unchanged"; if (left.length === 0) return "added"; if (right.length === 0) return "removed"; return "changed"; }; const hasUnknown = ( capture: ProcessCapture, scope: ProcessCapture["residual_unknowns"][number]["scope"], ): boolean => capture.residual_unknowns.some((item) => item.scope === scope); type DivergenceDimension = Exclude< ProcessCaptureComparison["first_divergence"], { readonly status: "none" | "unknown" } >["dimension"]; const eventTime = (value: unknown): number | null => { if ( typeof value === "object" && value !== null && "at_ms" in value && typeof value.at_ms === "number" && Number.isSafeInteger(value.at_ms) && value.at_ms >= 0 ) return value.at_ms; return null; }; const firstCollectionDivergence = ( dimension: DivergenceDimension, left: readonly unknown[], right: readonly unknown[], ): Extract< ProcessCaptureComparison["first_divergence"], { readonly status: "found" } > | null => { const length = Math.max(left.length, right.length); for (let index = 0; index < length; index += 1) { const leftValue = left[index] ?? null; const rightValue = right[index] ?? null; if (sameJsonValue(leftValue, rightValue)) continue; return { status: "found", dimension, index, left_at_ms: eventTime(leftValue), right_at_ms: eventTime(rightValue), left: jsonValueSchema.parse(leftValue), right: jsonValueSchema.parse(rightValue), }; } return null; }; const chooseFirstDivergence = ( candidates: ReadonlyArray | null>, ): ProcessCaptureComparison["first_divergence"] => { const found = candidates.filter( (candidate): candidate is NonNullable => candidate !== null, ); return ( found.sort((left, right) => { const leftTime = Math.min( left.left_at_ms ?? Number.MAX_SAFE_INTEGER, left.right_at_ms ?? Number.MAX_SAFE_INTEGER, ); const rightTime = Math.min( right.left_at_ms ?? Number.MAX_SAFE_INTEGER, right.right_at_ms ?? Number.MAX_SAFE_INTEGER, ); return leftTime - rightTime; })[0] ?? { status: "none" } ); }; const assertComparable = ( left: ProcessCapture, right: ProcessCapture, options: { readonly maxCaptureAgeMs?: number; readonly now?: () => number }, ): void => { if ( left.manifest.comparison_contract_sha256 !== right.manifest.comparison_contract_sha256 ) throw new TypeError( "Process captures have incompatible comparison contracts", ); if (options.maxCaptureAgeMs === undefined) return; const maxCaptureAgeMs = options.maxCaptureAgeMs; const now = (options.now ?? Date.now)(); if ( [left, right].some( ({ manifest }) => now - Date.parse(manifest.completed_at) > maxCaptureAgeMs, ) ) throw new TypeError("Process capture exceeds max_capture_age_ms"); }; const truncatedComparison = (): ProcessCaptureComparison => ({ status: "truncated", terminal: "truncated", interaction: "truncated", exit: "truncated", filesystem: "truncated", process: "truncated", first_divergence: { status: "unknown", reason: "At least one capture is truncated.", }, limitations: ["At least one capture is truncated."], }); const applyTraceVerdict = ( dimensions: ComparisonDimensions, specification: ProcessTraceSpecification, trace: ReturnType, captures: readonly [left: ProcessCapture, right: ProcessCapture], ): ComparisonDimensions => { const traceShowsDifference = processTraceOutcomesDiffer(trace); const traceStatus = trace.verdict === "unknown" ? "unknown" : trace.verdict === "equivalent" || !traceShowsDifference ? "unchanged" : "changed"; 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 === "nonconforming") return exact; if (trace.verdict === "different") return traceShowsDifference ? "changed" : exact; 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"), process: statusFor("process"), }; }; type ComparisonDimensions = Pick< ProcessCaptureComparison, "terminal" | "interaction" | "exit" | "filesystem" | "process" >; const compareDimensions = ( left: ProcessCapture, right: ProcessCapture, sameNormalization: boolean, ): ComparisonDimensions => { const classify = ( scope: ProcessCapture["residual_unknowns"][number]["scope"], leftValues: readonly unknown[], rightValues: readonly unknown[], ): ComparisonStatus => !sameNormalization || hasUnknown(left, scope) || hasUnknown(right, scope) ? "unknown" : classifyCollection(leftValues, rightValues); return { terminal: classify( "terminal", terminalObservations(left), terminalObservations(right), ), interaction: classify( "interaction", left.interaction_events, right.interaction_events, ), exit: hasUnknown(left, "exit") || hasUnknown(right, "exit") ? "unknown" : sameJsonValue( { exit: left.exit, settlement: left.settlement }, { exit: right.exit, settlement: right.settlement }, ) ? "unchanged" : "changed", filesystem: classify( "filesystem", filesystemObservations(left), filesystemObservations(right), ), process: classify("process", left.process_samples, right.process_samples), }; }; /** * Compare two process capture observations without treating absence as proof. * * Observed differences outrank residual unknowns. A capture may therefore be * definitively changed in one dimension while completeness remains unknown. */ export const compareProcessCaptures = ( left: ProcessCapture, right: ProcessCapture, options: { readonly maxCaptureAgeMs?: number; readonly now?: () => number; readonly traceSpecification?: ProcessTraceSpecification; } = {}, ): ProcessCaptureComparison => { assertComparable(left, right, options); if (left.truncated || right.truncated) { const truncated = truncatedComparison(); return options.traceSpecification === undefined ? truncated : { ...truncated, trace: compareProcessTraces(left, right, options.traceSpecification), }; } const sameNormalization = sameJsonValue( left.normalization, right.normalization, ); 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( dimension, processDimensionObservations(left, dimension), processDimensionObservations(right, dimension), ), ); const observedFirstDivergence = chooseFirstDivergence( divergenceCandidates.map((candidate) => candidate !== null && trace?.verdict === "equivalent" && dimensions[candidate.dimension] === "unchanged" ? null : candidate, ), ); const firstDivergence = 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; const observedStatus = deriveProcessComparisonStatus( PROCESS_COMPARISON_DIMENSIONS.map((dimension) => dimensions[dimension]), ); return { status: observedStatus, ...dimensions, first_divergence: firstDivergence, ...(trace === undefined ? {} : { trace }), limitations: [ ...left.limitations, ...right.limitations, ...left.residual_unknowns.map(({ reason }) => reason), ...right.residual_unknowns.map(({ reason }) => reason), ...(sameNormalization ? [] : ["Capture normalization rules differ."]), ], }; };