diff --git a/src/domain/process/processCapture.comparison.test.ts b/src/domain/process/processCapture.comparison.test.ts index 4d238da2d..0bc69335d 100644 --- a/src/domain/process/processCapture.comparison.test.ts +++ b/src/domain/process/processCapture.comparison.test.ts @@ -192,3 +192,75 @@ it("keeps filesystem comparison unknown without coverage and reports observed ch }), ).toMatchObject({ filesystem: "changed", status: "changed" }); }); + +it.each([ + [500, "interaction", 6], + [4, "process", 4], +] as const)( + "orders an interaction difference by its dispatch time (process sample at %i ms)", + (processTime, dimension, at) => { + const capture = (side: "left" | "right") => { + const base = emptyCapture(); + const journal = [ + { collection: "filesystem_checkpoints" as const, index: 0 }, + ...(processTime < 6 + ? [{ collection: "process_samples" as const, index: 0 }] + : []), + { collection: "interaction_events" as const, index: 0 }, + ...(processTime >= 6 + ? [{ collection: "process_samples" as const, index: 0 }] + : []), + { collection: "lifecycle" as const, index: 0 }, + { collection: "lifecycle" as const, index: 1 }, + { collection: "filesystem_checkpoints" as const, index: 1 }, + ].map((entry, capture_order) => ({ ...entry, capture_order })); + return { + ...base, + manifest: { + ...base.manifest, + completed_at: "2026-01-01T00:00:01.000Z", + }, + settlement: { ...base.settlement, elapsed_ms: 1000 }, + filesystem_checkpoints: base.filesystem_checkpoints.map( + (checkpoint, index) => ({ + ...checkpoint, + at_ms: index === 0 ? 0 : 1000, + }), + ), + interaction_events: [ + { + sequence: 0, + scheduled_at_ms: 5, + dispatched_at_ms: 6, + type: "input" as const, + data: "hello", + outcome: + side === "left" ? ("dispatched" as const) : ("failed" as const), + }, + ], + process_samples: [ + { + at_ms: processTime, + pid: 2, + parent_pid: 1, + command: side === "left" ? "worker-a" : "worker-b", + process_group_id: 1, + session_id: 1, + }, + ], + event_journal: journal, + }; + }; + const comparison = compareFullyObserved(capture("left"), capture("right")); + expect(comparison).toMatchObject({ + interaction: "changed", + process: "changed", + first_divergence: { + status: "found", + dimension, + left_at_ms: at, + right_at_ms: at, + }, + }); + }, +); diff --git a/src/domain/process/processCapture.validation.test.ts b/src/domain/process/processCapture.validation.test.ts index df0344b7c..db36b2c64 100644 --- a/src/domain/process/processCapture.validation.test.ts +++ b/src/domain/process/processCapture.validation.test.ts @@ -839,3 +839,27 @@ it("requires compatible contracts and enforces capture age through a clock seam" }), ); }); + +it("requires interaction dispatch times to follow their sequence", () => { + const capture = emptyCapture(); + const interaction = (sequence: number, dispatched: number) => ({ + sequence, + scheduled_at_ms: 0, + dispatched_at_ms: dispatched, + type: "input" as const, + data: "x", + outcome: "dispatched" as const, + }); + const issues = (dispatched: readonly number[]) => + processCaptureIssues({ + ...capture, + interaction_events: dispatched.map((at, index) => interaction(index, at)), + }).filter(({ message }) => message.includes("dispatch timestamps")); + expect(issues([5, 5, 10])).toEqual([]); + expect(issues([10, 5])).toEqual([ + { + path: "interaction_events", + message: expect.stringContaining("dispatch timestamps must be ordered"), + }, + ]); +}); diff --git a/src/domain/process/processCaptureValidation.ts b/src/domain/process/processCaptureValidation.ts index 33c6d5483..537f6f92f 100644 --- a/src/domain/process/processCaptureValidation.ts +++ b/src/domain/process/processCaptureValidation.ts @@ -17,12 +17,10 @@ type RequireInvariant = ( message: string, ) => void; -const orderedTimestamps = ( - values: readonly { readonly at_ms: number }[], -): boolean => +const orderedTimestamps = (values: readonly number[]): boolean => values.every((value, index) => { const previous = values[index - 1]; - return previous === undefined || value.at_ms >= previous.at_ms; + return previous === undefined || value >= previous; }); const validateCommitments = ( @@ -66,7 +64,14 @@ const validateOrdering = ( ["rendered_frames", capture.rendered_frames], ["process_samples", capture.process_samples], ] as const) - require(orderedTimestamps(values), name, "timestamps must be ordered"); + require(orderedTimestamps( + values.map(({ at_ms }) => at_ms), + ), name, "timestamps must be ordered"); + // Interactions are dispatched in sequence, so their recorded dispatch + // times cannot decrease; comparison ranks a changed interaction by it. + require(orderedTimestamps( + capture.interaction_events.map(({ dispatched_at_ms }) => dispatched_at_ms), + ), "interaction_events", "dispatch timestamps must be ordered"); }; const validateEventJournal = ( @@ -242,7 +247,7 @@ const validateLifecycle = ( require(capture.filesystem_checkpoints.map(({ name }) => name).join(",") === "before,after_settlement", "filesystem_checkpoints", "capture must include initial and final filesystem snapshots"); require(orderedTimestamps( - capture.filesystem_checkpoints, + capture.filesystem_checkpoints.map(({ at_ms }) => at_ms), ), "filesystem_checkpoints", "filesystem snapshot times must be ordered"); require(!capture.filesystem_checkpoints.some(({ truncated }) => truncated) || capture.truncated, "truncated", "filesystem snapshot truncation must propagate to the capture"); diff --git a/src/domain/process/processComparison.ts b/src/domain/process/processComparison.ts index 21c7bd80b..eafcd4fd8 100644 --- a/src/domain/process/processComparison.ts +++ b/src/domain/process/processComparison.ts @@ -165,17 +165,25 @@ type DivergenceDimension = Exclude< { 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; +// Interactions record when their outcome was observed as the dispatch time; +// every other timed observation uses at_ms. +const EVENT_TIME_FIELD: Readonly> = { + terminal: "at_ms", + interaction: "dispatched_at_ms", + exit: "at_ms", + filesystem: "at_ms", + process: "at_ms", +}; + +const eventTime = ( + dimension: DivergenceDimension, + value: unknown, +): number | null => { + if (typeof value !== "object" || value === null) return null; + const time: unknown = Reflect.get(value, EVENT_TIME_FIELD[dimension]); + return typeof time === "number" && Number.isSafeInteger(time) && time >= 0 + ? time + : null; }; const firstCollectionDivergence = ( @@ -195,8 +203,8 @@ const firstCollectionDivergence = ( status: "found", dimension, index, - left_at_ms: eventTime(leftValue), - right_at_ms: eventTime(rightValue), + left_at_ms: eventTime(dimension, leftValue), + right_at_ms: eventTime(dimension, rightValue), left: jsonValueSchema.parse(leftValue), right: jsonValueSchema.parse(rightValue), };