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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-25 05:21:09 -07:00
committed by GitHub
parent 58a5275aa2
commit cc2656be8d
5 changed files with 211 additions and 13 deletions
+14 -2
View File
@@ -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
@@ -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<string, unknown>[],
) => {
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)}`,
@@ -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<void> {
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<void> {
const pending: Iterator<unknown>[] = [[root][Symbol.iterator]()];
while (pending.length) {
@@ -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<string, Evidence>();
/**
* 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<Evidence> => {
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<Evidence> => {
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,
+25 -3
View File
@@ -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<MatchingFunction[]> => {
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),
);