fix(process): order interaction differences by their dispatch time (#2739)

* fix(process): order interaction differences by their dispatch time

first_divergence ranks candidate differences by the earliest recorded
time of the differing observations, but eventTime read only `at_ms`.
Interaction events record their time as `dispatched_at_ms`
(and `scheduled_at_ms`), so a changed interaction always ranked as
untimed. An interaction outcome that differed at 6 ms therefore lost to
a process sample that differed at 500 ms.

Give each comparison dimension its recorded time field. Interactions use
the dispatch time, when their outcome was observed; the other
dimensions keep `at_ms`. An earlier process difference still wins.

Fixes #2645

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(process): require ordered interaction dispatch times in captures

Comparison ranks a changed interaction by its dispatch time, while
firstCollectionDivergence returns the first differing interaction by
sequence. The capture parser checked only that interaction sequences are
contiguous, so an imported capture whose dispatch times decrease could
make that first-by-sequence difference later than another differing
interaction.

Interactions are dispatched in sequence and their times come from the
monotonic capture clock, so require non-decreasing dispatch times at
parsing, as frames, rendered frames, process samples and filesystem
checkpoints already require for their timestamps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-13 07:02:28 -07:00 committed by GitHub
parent 173da755cd
commit ce748be2f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 128 additions and 19 deletions

View File

@ -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,
},
});
},
);

View File

@ -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"),
},
]);
});

View File

@ -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");

View File

@ -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<Record<DivergenceDimension, string>> = {
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),
};