diff --git a/src/domain/analysisView/EnvironmentStateExtraction.ts b/src/domain/analysisView/EnvironmentStateExtraction.ts index 162bff9040..c4976adcea 100644 --- a/src/domain/analysisView/EnvironmentStateExtraction.ts +++ b/src/domain/analysisView/EnvironmentStateExtraction.ts @@ -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; readonly #byOrdinal: ReadonlyMap; readonly #writes: readonly StateRecordFacts[]; + readonly #writeWithin: ReturnType; readonly #bases: ReadonlyMap; 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(); - 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, diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index 76d7d6d1e3..5565fa2c09 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -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[], diff --git a/src/domain/analysisView/environmentStateView.test.ts b/src/domain/analysisView/environmentStateView.test.ts index d4ef9be9c5..c54a0074eb 100644 --- a/src/domain/analysisView/environmentStateView.test.ts +++ b/src/domain/analysisView/environmentStateView.test.ts @@ -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([