mirror of
https://github.com/morluto/rea.git
synced 2026-11-01 04:16:28 +00:00
fix(environment): count writes in flight when a value changes between reads
The volatility check compared write start positions with the two reads, so a write already in flight when the earlier read started was missed and its effect was named volatile. A write now counts when its interval overlaps the span from the earlier read's start to the later read's end. The check also ran once per changed field and scanned every write. It now runs once per pair of observations, answered by a binary search over the writes in timeline order and the latest end among each prefix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e93547c84
commit
aba9cb15c8
3 files changed
+82
-20
No files matched your search
@@ -25,6 +25,7 @@ import {
|
||||
scalarText,
|
||||
segmentsOf,
|
||||
apiBaseLengths,
|
||||
writeSpanCheck,
|
||||
type ExtractedState,
|
||||
type JsonObject,
|
||||
type RecordedViewReason,
|
||||
@@ -68,6 +69,7 @@ export class EnvironmentStateExtraction {
|
||||
readonly #position: ReadonlyMap<number, number>;
|
||||
readonly #byOrdinal: ReadonlyMap<number, StateRecordFacts>;
|
||||
readonly #writes: readonly StateRecordFacts[];
|
||||
readonly #writeWithin: ReturnType<typeof writeSpanCheck>;
|
||||
readonly #bases: ReadonlyMap<string, number>;
|
||||
readonly #identity = new Map<
|
||||
string,
|
||||
@@ -99,6 +101,7 @@ export class EnvironmentStateExtraction {
|
||||
input.records.map((record) => [record.ordinal, record]),
|
||||
);
|
||||
this.#writes = input.records.filter(isStateWrite);
|
||||
this.#writeWithin = writeSpanCheck(this.#writes);
|
||||
this.#bases = apiBaseLengths(input.tools.map((tool) => tool.path_template));
|
||||
}
|
||||
|
||||
@@ -380,20 +383,22 @@ export class EnvironmentStateExtraction {
|
||||
observation.tool === plan.tool.name &&
|
||||
requestLine(this.#byOrdinal.get(observation.ordinal)!) === line,
|
||||
);
|
||||
for (const name of Object.keys(object))
|
||||
if (
|
||||
const changed = Object.keys(object).filter(
|
||||
(name) =>
|
||||
earlier !== undefined &&
|
||||
Object.hasOwn(earlier.object, name) &&
|
||||
!same(earlier.object[name], object[name])
|
||||
) {
|
||||
// A change across any recorded write, even under another root, is not evidence of volatility.
|
||||
if (this.#writeBetween(earlier.position, position)) {
|
||||
this.#rewritten.add(plan.type);
|
||||
continue;
|
||||
}
|
||||
const fields = this.#volatile.get(plan.type) ?? new Set<string>();
|
||||
this.#volatile.set(plan.type, fields.add(name));
|
||||
}
|
||||
!same(earlier.object[name], object[name]),
|
||||
);
|
||||
if (earlier !== undefined && changed.length > 0) {
|
||||
// A change across any recorded write, even under another root, is not evidence of volatility.
|
||||
if (this.#writeWithin(this.#byOrdinal.get(earlier.ordinal)!, record))
|
||||
this.#rewritten.add(plan.type);
|
||||
else
|
||||
this.#volatile.set(
|
||||
plan.type,
|
||||
new Set([...(this.#volatile.get(plan.type) ?? []), ...changed]),
|
||||
);
|
||||
}
|
||||
this.#retain(ENTRY_BYTES + jsonBytes(object));
|
||||
entity.observations.push({
|
||||
position,
|
||||
@@ -404,14 +409,6 @@ export class EnvironmentStateExtraction {
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether a recorded write lies strictly between two timeline positions. */
|
||||
#writeBetween(from: number, to: number): boolean {
|
||||
return this.#writes.some((write) => {
|
||||
const position = this.#position.get(write.ordinal)!;
|
||||
return position > from && position < to;
|
||||
});
|
||||
}
|
||||
|
||||
#merge(): void {
|
||||
for (const entity of [...this.#entities.values()].sort(
|
||||
compareObservedEntities,
|
||||
|
||||
@@ -207,6 +207,26 @@ export const isStateWrite = (record: StateRecordFacts): boolean =>
|
||||
!isQueryPost(record.method, record.path) &&
|
||||
(record.status === null || (record.status >= 200 && record.status < 400));
|
||||
|
||||
/** Whether any write overlaps the span from one record's start to a later record's end; writes come in timeline order. */
|
||||
export const writeSpanCheck = (
|
||||
writes: readonly StateRecordFacts[],
|
||||
): ((earlier: StateRecordFacts, later: StateRecordFacts) => boolean) => {
|
||||
// The latest end among each prefix of writes, so one search answers a span.
|
||||
const reach: number[] = [];
|
||||
for (const write of writes)
|
||||
reach.push(Math.max(reach.at(-1) ?? Number.NEGATIVE_INFINITY, write.end));
|
||||
return (earlier, later) => {
|
||||
let low = 0;
|
||||
let high = writes.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (writes[middle]!.start < later.end) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return low > 0 && reach[low - 1]! > earlier.start;
|
||||
};
|
||||
};
|
||||
|
||||
/** Literal leading segments shared by every template with the same first segment: each path's API base. */
|
||||
export const apiBaseLengths = (
|
||||
templates: readonly string[],
|
||||
|
||||
@@ -215,6 +215,51 @@ it("leaves a type recorded when a value changed across a write under another roo
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a type recorded when a value changed across a write already in flight", () => {
|
||||
const exchange = ([method, path, seconds, time, title]: readonly [
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
string,
|
||||
]) => ({
|
||||
startedDateTime: new Date(
|
||||
Date.parse("2026-10-17T18:00:00.000Z") + seconds * 1000,
|
||||
).toISOString(),
|
||||
time,
|
||||
request: { method, url: `http://app.example${path}` },
|
||||
response: {
|
||||
status: 200,
|
||||
content: {
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify([{ id: 1, title }]),
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = stateOf(
|
||||
parentOf(
|
||||
analysisViewCaptureEvidence(
|
||||
environmentManifestCaptureFixture([
|
||||
exchange([
|
||||
"POST",
|
||||
"/api/dashboards/db",
|
||||
0,
|
||||
30_000,
|
||||
"Saltmarsh Depot",
|
||||
]),
|
||||
exchange(["GET", "/api/search", 10, 5, "Granite Ledger"]),
|
||||
exchange(["GET", "/api/search", 40, 5, "Saltmarsh Depot"]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(viewOf(state, "get_search")).toEqual({
|
||||
tool: "get_search",
|
||||
shape: "recorded",
|
||||
reason: "fields-differ",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not list the whole collection when a counter exceeds the number listed", () => {
|
||||
const state = stateOf(
|
||||
captureOf([
|
||||
|
||||
Reference in new issue
Block a user