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:
parent
173da755cd
commit
ce748be2f3
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue