From cc2656be8d8ff335283cc3d43910ca85ce519d29 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Sun, 25 Oct 2026 05:21:09 -0700 Subject: [PATCH] perf(native): charge function matching for the copies it makes (#7108) * perf(native): charge matching for what it copies, not borrowed retained inventories Native function matching deep-walked every retained list_procedures record at about 2.8 KB per Ghidra-shaped row, although the session ledger lends those immutable records without a copy, and charged each inventory's one Evidence ID once per function. The report and Evidence copies of every emitted member were not charged at all. Charge each inventory row before parsing for the member it becomes, including its report and Evidence copies, charge the shared inventory ID once, and skip the walk only for borrowed immutable inventories and closure records that matching never parses. Inline Evidence, bundle records and selected observations keep their existing charges. Retained references now fit about 22,000 rows per side instead of 10,000, and an oversized side is refused before its rows are parsed. Refs #6406 Co-Authored-By: Claude Opus 5.5 * fix(native): charge the envelope of borrowed matching inputs Borrowed inventories skipped admission entirely, but matching copies their limitations into the report and Evidence and digests their profile, so a retained inventory with few rows and large limitations escaped the allowance. Charge every envelope field and leave only the result payloads, which row charging covers or matching never parses, uncharged. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/native-function-matching.md | 16 ++- .../NativeFunctionMatchingService.test.ts | 130 +++++++++++++++++- .../native/NativeMatchingAdmission.ts | 12 ++ .../native/NativeMatchingSources.ts | 38 ++++- src/domain/native/functionInventory.ts | 28 +++- 5 files changed, 211 insertions(+), 13 deletions(-) diff --git a/docs/native-function-matching.md b/docs/native-function-matching.md index 5e38b48464..2e2dd3cb1c 100644 --- a/docs/native-function-matching.md +++ b/docs/native-function-matching.md @@ -118,8 +118,20 @@ extract its matching Evidence record to a JSON file for the existing CLI `inspect-analysis-view` `evidence-file` source. Input bundles are unchanged. Representation admission is bounded at 96 MiB before cloning selected observations -and expanding matching indexes and repeated per-function source links. Work yields to cancellation. No worker/provider -concurrency is introduced; this budget is not a bound on total process RSS. +and expanding matching indexes and repeated per-function source links. Each +inventory row is charged before parsing for the member it becomes, including the +report and Evidence copies that member is serialized into, so an oversized side +is refused before its rows are parsed. Inline Evidence and bundle records are +charged for the copies their authentication or loading creates. An exact +session reference to retained Evidence is borrowed without a copy, so only +what matching derives from it is charged; the inventory's own Evidence ID is +charged once, not per function. Selected observations are charged in full. +Measured with Ghidra-shaped rows, about 20-character names and no +observations, about 22,000 rows per side fit through retained +`list_procedures` references, and about 10,000 through inline Evidence or CLI +`source_bundle` inputs. Work yields to +cancellation. No worker/provider concurrency is introduced; this budget is not +a bound on total process RSS. Summary delivery happens after complete matching Evidence is constructed and retained. It reduces the initial reply, but does not avoid that construction or raise the allowance. There is no runtime override. Repeated provenance remains diff --git a/src/application/native/NativeFunctionMatchingService.test.ts b/src/application/native/NativeFunctionMatchingService.test.ts index b231184aee..c8fc4ef5f1 100644 --- a/src/application/native/NativeFunctionMatchingService.test.ts +++ b/src/application/native/NativeFunctionMatchingService.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "vitest"; import { createHash } from "node:crypto"; import { matchNativeFunctions } from "./NativeFunctionMatchingService.js"; import { nativeFunctionMatchingSchema } from "../../domain/native/functionMatching.js"; -import { createEvidence } from "../../domain/evidence.js"; +import { createEvidence, immutableEvidence } from "../../domain/evidence.js"; import { createAnalysisProfile } from "../../domain/analysisProfile.js"; import { functionDossierSchema } from "../../domain/hopperValues.js"; import { procedureInfoSchema } from "../../domain/native/procedureInfo.js"; @@ -791,6 +791,134 @@ it("cooperatively preserves the exact cancellation reason and rejects expansion expect(large.error._tag).toBe("AnalysisResourceConstraintError"); }); +const retainedInventories = ( + rows: (side: "1" | "2") => readonly Record[], +) => { + const [left, right] = (["1", "2"] as const).map((side) => + immutableEvidence( + createEvidence(nativeMatchingTarget(side), nativeMatchingProvider, { + operation: "list_procedures", + parameters: {}, + result: rows(side), + }), + ), + ); + const ledger = new Map([left!, right!].map((x) => [x.evidence_id, x])); + const reference = (record: { evidence_id: string }) => ({ + kind: "retained-evidence" as const, + evidence_id: record.evidence_id, + }); + return { + left: left!, + right: right!, + references: { left: reference(left!), right: reference(right!) }, + lookup: (id: string) => ledger.get(id), + }; +}; +const ghidraRow = (side: "1" | "2", index: number) => ({ + address: `0x${(0x400000 + index * 16 + (side === "2" ? 8 : 0)).toString(16)}`, + value: `subsystem_function_${index}`, + symbol: { + primary: true, + dynamic: false, + external: false, + type: "Function", + source: "imported", + }, + procedure: { external: false, thunk: false, thunk_target: null }, +}); + +it("charges a borrowed retained inventory for its emitted members, not its resident payload", async () => { + // One shared 40 KB string per row: ~40 KB resident, but a per-occurrence + // walk would charge 1000 copies of it. + const resident = "r".repeat(40_000); + const { left, right, references, lookup } = retainedInventories((side) => + Array.from({ length: 1000 }, (_, i) => ({ + ...ghidraRow(side, i), + provider_note: resident, + })), + ); + const borrowed = await matchNativeFunctions(references, undefined, lookup); + if (!borrowed.ok) throw borrowed.error; + expect( + nativeFunctionMatchingSchema.parse( + borrowed.value.evidence.normalized_result, + ).summary.matched, + ).toBe(1000); + // Inline Evidence is authenticated by cloning, so its whole payload is charged. + const inline = await matchNativeFunctions({ + left: structuredClone(left), + right: structuredClone(right), + }); + expect(inline.ok).toBe(false); + if (!inline.ok) + expect(inline.error._tag).toBe("AnalysisResourceConstraintError"); +}); + +it("still charges a borrowed inventory's envelope, which matching copies into its result", async () => { + // Limitations are prefixed into the report and Evidence; 25 distinct + // 1 MiB entries are charged above the allowance although rows are few. + const record = (side: "1" | "2", limitations: readonly string[]) => + immutableEvidence( + createEvidence(nativeMatchingTarget(side), nativeMatchingProvider, { + operation: "list_procedures", + parameters: {}, + result: [ghidraRow(side, 0)], + limitations: [...limitations], + }), + ); + const left = record( + "1", + Array.from({ length: 25 }, (_, i) => `${"l".repeat(1024 * 1024)}${i}`), + ), + right = record("2", []); + const ledger = new Map([left, right].map((x) => [x.evidence_id, x])); + const refused = await matchNativeFunctions( + { + left: { kind: "retained-evidence", evidence_id: left.evidence_id }, + right: { kind: "retained-evidence", evidence_id: right.evidence_id }, + }, + undefined, + (id) => ledger.get(id), + ); + expect(refused.ok).toBe(false); + if (!refused.ok) + expect(refused.error._tag).toBe("AnalysisResourceConstraintError"); +}); + +it("matches 20,000 retained Ghidra-shaped rows per side and refuses an oversized side before parsing its rows", async () => { + const large = retainedInventories((side) => + Array.from({ length: 20_000 }, (_, i) => ghidraRow(side, i)), + ); + const matched = await matchNativeFunctions( + large.references, + undefined, + large.lookup, + ); + if (!matched.ok) throw matched.error; + expect( + nativeFunctionMatchingSchema.parse(matched.value.evidence.normalized_result) + .summary, + ).toEqual({ matched: 20_000, only_left: 0, only_right: 0, unresolved: 0 }); + // A malformed final row would be an integrity failure if parsing began. + const oversized = retainedInventories((side) => [ + ...Array.from({ length: 50_000 }, (_, i) => ghidraRow(side, i)), + { address: "not an address", value: 1 }, + ]); + const refused = await matchNativeFunctions( + oversized.references, + undefined, + oversized.lookup, + ); + expect(refused.ok).toBe(false); + if (!refused.ok) + expect(refused.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + reportedLimits: { maximum_bytes: 96 * 1024 * 1024 }, + retryable: false, + }); +}); + it("keeps duplicate candidate storage linear and emits every input occurrence exactly once", async () => { const entries = Array.from({ length: 1000 }, (_, i) => ({ address: `0x${i.toString(16)}`, diff --git a/src/application/native/NativeMatchingAdmission.ts b/src/application/native/NativeMatchingAdmission.ts index 9867697071..dda8687273 100644 --- a/src/application/native/NativeMatchingAdmission.ts +++ b/src/application/native/NativeMatchingAdmission.ts @@ -1,6 +1,7 @@ import { setImmediate } from "node:timers/promises"; import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; import { fixedNativeAllowance } from "../../domain/native/nativeDeadlines.js"; +import type { Evidence } from "../../domain/evidence.js"; const NATIVE_MATCHING_REPRESENTATION_BYTES = 96 * 1024 * 1024; @@ -34,6 +35,17 @@ export class NativeMatchingAdmission { this.signal === undefined ? {} : { signal: this.signal }, ); }; + /** + * Charge a borrowed record's envelope, which matching copies into its + * limitations, digests and links. Its result payloads stay resident: the + * caller charges what matching derives from them, or never parses them. + */ + async admitBorrowed(record: Evidence): Promise { + await this.checkpoint(128); + for (const [key, value] of Object.entries(record)) + if (key !== "raw_result" && key !== "normalized_result") + await this.admit(value); + } async admit(root: unknown): Promise { const pending: Iterator[] = [[root][Symbol.iterator]()]; while (pending.length) { diff --git a/src/application/native/NativeMatchingSources.ts b/src/application/native/NativeMatchingSources.ts index f632a6075f..3ad34d8bbd 100644 --- a/src/application/native/NativeMatchingSources.ts +++ b/src/application/native/NativeMatchingSources.ts @@ -1,7 +1,7 @@ import type { MatchNativeFunctionsInput } from "../../contracts/native/functionMatchingContracts.js"; import { digestCanonicalValue } from "../../domain/canonicalDigest.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; -import type { Evidence } from "../../domain/evidence.js"; +import { isImmutableEvidence, type Evidence } from "../../domain/evidence.js"; import { readEvidence } from "../../domain/evidenceValidation.js"; import { retainedEvidenceReferenceSchema } from "../../domain/evidenceReference.js"; import { @@ -34,18 +34,30 @@ export const resolveNativeMatchingSources = async ( readonly sources: readonly Evidence[]; }> => { const sources = new Map(); + /** + * Charge only what matching copies. A retained immutable record is borrowed + * without cloning. When matching never parses its result, or charges the + * representation it derives from it (inventory rows), only its envelope is + * charged; walking the result would charge resident memory as if matching + * had allocated it. + */ const resolve = async ( input: unknown, path?: readonly (string | number)[], + borrowable = false, ): Promise => { const reference = retainedEvidenceReferenceSchema.safeParse(input); - let raw = input; + let raw = input, + borrowed: Evidence | undefined; if (reference.success) { const found = resolveEvidenceInput(reference.data, lookup); if (!found.ok) throw found.error; raw = found.value; + if (borrowable && isImmutableEvidence(found.value)) + borrowed = found.value; } - await admission.admit(raw); + if (borrowed !== undefined) await admission.admitBorrowed(borrowed); + else await admission.admit(raw); const checked = readEvidence(raw); if (!checked.ok) throw path !== undefined && !reference.success @@ -66,8 +78,9 @@ export const resolveNativeMatchingSources = async ( raw: unknown, path: readonly (string | number)[], operations: readonly string[], + borrowable = false, ): Promise => { - const evidence = await resolve(raw, path); + const evidence = await resolve(raw, path, borrowable); if ( evidence.predicate_type !== "rea.analysis" || !operations.includes(evidence.operation) || @@ -82,8 +95,14 @@ export const resolveNativeMatchingSources = async ( } return evidence; }; - const left = await select(input.left, ["left"], INVENTORY_OPERATIONS); - const right = await select(input.right, ["right"], INVENTORY_OPERATIONS); + // readFunctionInventory charges each inventory row before parsing it. + const left = await select(input.left, ["left"], INVENTORY_OPERATIONS, true); + const right = await select( + input.right, + ["right"], + INVENTORY_OPERATIONS, + true, + ); const observations = async ( inputs: readonly unknown[], field: "left_observations" | "right_observations", @@ -136,7 +155,12 @@ export const resolveNativeMatchingSources = async ( for (const source of sources.values()) for (const id of source.evidence_links) if (!sources.has(id)) - await resolve({ kind: "retained-evidence", evidence_id: id }); + // Closure records are only returned for retention, never parsed. + await resolve( + { kind: "retained-evidence", evidence_id: id }, + undefined, + true, + ); return { left, right, diff --git a/src/domain/native/functionInventory.ts b/src/domain/native/functionInventory.ts index a5192a8522..668eb8a5b0 100644 --- a/src/domain/native/functionInventory.ts +++ b/src/domain/native/functionInventory.ts @@ -239,7 +239,6 @@ const functionFact = async ( await checkpoint(256 + 8 * link.length); for (const link of symbols?.links ?? []) await checkpoint(256 + 8 * link.length); - await checkpoint(256 + 8 * inventory.evidence_id.length); const links = new Set([inventory.evidence_id]); for (const link of detail?.links ?? []) links.add(link); for (const link of symbols?.links ?? []) links.add(link); @@ -290,13 +289,36 @@ const functionFact = async ( }; }; +const textLength = (value: unknown): number => + typeof value === "string" ? value.length : 0; +/** + * Every row becomes exactly one emitted member: its parsed row and member + * (768 bytes), then the report validation and Evidence normalization copies + * that serialization digests (1280 bytes). Names and addresses are shared + * strings, charged for UTF-16 storage and serialized copies. + */ +const rowBytes = (row: unknown): number => + typeof row === "object" && row !== null + ? 2048 + + 4 * + (textLength((row as { address?: unknown }).address) + + textLength((row as { value?: unknown }).value)) + : 2048; + /** Consume application-admitted normalized facts; never infer body extents from neighbor entries. */ export const readFunctionInventory = async ( inventory: Evidence, observations: readonly Evidence[], checkpoint: NativeMatchingCheckpoint, ): Promise => { - const rows = sourcePayload(matchingRowsSchema, inventory.normalized_result); + // Charge each row's member before parsing clones it, so a borrowed retained + // inventory is bounded without walking the resident record and an oversized + // side is refused before allocation. Members share one inventory ID string. + const source = inventory.normalized_result; + if (Array.isArray(source)) + for (const row of source as unknown[]) await checkpoint(rowBytes(row)); + await checkpoint(256 + 8 * inventory.evidence_id.length); + const rows = sourcePayload(matchingRowsSchema, source); const facts: Facts = { details: new Map(), symbols: new Map(), @@ -320,7 +342,7 @@ export const readFunctionInventory = async ( const functions: MatchingFunction[] = []; for (let sourceIndex = 0; sourceIndex < rows.length; sourceIndex++) { const row = rows[sourceIndex]!; - await checkpoint(1024 + 4 * (row.address.length + row.value.length)); + await checkpoint(); functions.push( await functionFact({ inventory, row, sourceIndex }, facts, checkpoint), );