GitNexus/gitnexus/bench/incremental-write-integrity/measure.cjs

194 lines
7.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Native identity reconciliation overhead and scaling.
* node --import tsx bench/incremental-write-integrity/measure.cjs [--check]
*
* COPY, graph construction, fingerprints and correctness controls are untimed.
* Each timed sample includes all single-label scans and exact tuple checks.
* Counts/fingerprints reject empty output; timing gates use normalized scaling.
*/
const assert = require('node:assert/strict');
const { mkdtemp, rm, readFile } = require('node:fs/promises');
const { tmpdir, cpus } = require('node:os');
const { join, resolve } = require('node:path');
const { pathToFileURL } = require('node:url');
const { createHash } = require('node:crypto');
const ROOT = resolve(__dirname, '../..');
const source = (file) => import(pathToFileURL(join(ROOT, file)).href);
(async () => {
const { createKnowledgeGraph } = await source('src/core/graph/graph.ts');
const { reconcileGraphNodeIdentities } = await source(
'src/core/incremental/write-reconciliation.ts',
);
const adapter = await source('src/core/lbug/lbug-adapter.ts');
const baseline = JSON.parse(await readFile(join(__dirname, 'baseline.json'), 'utf8'));
assert.equal(baseline.version, 1);
assert.ok(Number.isFinite(baseline.linear_scaling_budget) && baseline.linear_scaling_budget > 0);
const results = [];
for (const size of baseline.sizes) {
assert.ok(Number.isSafeInteger(size.nodes) && size.nodes > 0);
assert.match(size.fingerprint, /^[a-f0-9]{64}$/);
const graph = createKnowledgeGraph();
const hash = createHash('sha256');
for (let i = 0; i < size.nodes; i++) {
const id = `Function:src/owner${Math.floor(i / 32)}.ts:fn${i}`;
const properties = {
name: `fn${i}`,
filePath: `src/owner${Math.floor(i / 32)}.ts`,
startLine: (i % 32) * 4,
endLine: (i % 32) * 4 + 2,
};
graph.addNode({ id, label: 'Function', properties });
hash.update(
JSON.stringify([
id,
properties.name,
properties.filePath,
properties.startLine,
properties.endLine,
]) + '\n',
);
}
assert.equal(hash.digest('hex'), size.fingerprint);
const directory = await mkdtemp(join(tmpdir(), 'gnx-reconciliation-bench-'));
try {
await adapter.initLbug(join(directory, 'lbug'), { skipFts: true });
await adapter.loadGraphToLbug(
graph,
directory,
directory,
undefined,
undefined,
undefined,
'none',
);
await adapter.tryFlushWAL();
const samples = [];
let calls = 0;
const query = async (sql) => {
calls++;
return adapter.executeQuery(sql);
};
for (let run = 0; run < 9; run++) {
calls = 0;
const start = performance.now();
const receipt = await reconcileGraphNodeIdentities(graph, query, 'benchmark');
const elapsed = performance.now() - start;
assert.equal(receipt.nodes, size.nodes);
assert.equal(calls, receipt.tables);
if (run >= 2) samples.push(elapsed);
}
const first = graph.iterNodes().next().value;
const alteredQuery = async (sql) => {
const rows = await adapter.executeQuery(sql);
if (sql.includes('(n:`Function`)')) rows.find((r) => r.id === first.id).startLine++;
return rows;
};
await assert.rejects(
reconcileGraphNodeIdentities(graph, alteredQuery, 'negative-control'),
/startLine/,
);
await assert.rejects(
reconcileGraphNodeIdentities(graph, async () => [], 'negative-control'),
/missing ID/,
);
// Native 0.18.3 can corrupt scan projections after this real write, even
// without FTS. Use an independent tuple-set oracle to require rejection
// whenever the scan is wrong; a future native fix can pass normally.
const { extractChangedSubgraph } = await source('src/core/incremental/subgraph-extract.ts');
const files = new Set(['src/owner0.ts', `src/owner${Math.floor((size.nodes - 1) / 32)}.ts`]);
const wanted = new Set(
[...graph.iterNodes()].map((node) =>
JSON.stringify([
node.id,
node.properties.name,
node.properties.filePath,
node.properties.startLine,
node.properties.endLine,
]),
),
);
const nativePhases = [];
const audit = async (phase) => {
const rows = await adapter.executeQuery(
'MATCH (n:Function) RETURN n.id AS id, n.name AS name, ' +
'n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine',
);
const actual = new Set(
rows.map((r) => JSON.stringify([r.id, r.name, r.filePath, r.startLine, r.endLine])),
);
const mismatched = [...wanted].filter((tuple) => !actual.has(tuple)).length;
const rejected =
rows.length !== size.nodes || mismatched > 0 || actual.size !== wanted.size;
if (rejected) {
await assert.rejects(
reconcileGraphNodeIdentities(graph, adapter.executeQuery, phase),
/Graph identity reconciliation/,
);
} else {
assert.equal(
(await reconcileGraphNodeIdentities(graph, adapter.executeQuery, phase)).nodes,
size.nodes,
);
}
nativePhases.push({
phase,
rows: rows.length,
missing_tuples: mismatched,
verdict: rejected ? 'rejected' : 'certified',
});
};
await adapter.deleteNodesForFiles([...files]);
await adapter.loadGraphToLbug(
extractChangedSubgraph(graph, files),
directory,
directory,
undefined,
undefined,
undefined,
'none',
);
await audit('post-COPY');
await adapter.tryFlushWAL();
await audit('post-checkpoint');
await adapter.closeLbug();
await adapter.initLbug(join(directory, 'lbug'), { readOnly: true, skipFts: true });
await audit('reopened');
samples.sort((a, b) => a - b);
results.push({
nodes: size.nodes,
queries: calls,
min_ms: +samples[0].toFixed(3),
median_ms: +samples[Math.floor(samples.length / 2)].toFixed(3),
fingerprint: size.fingerprint,
native_phases: nativePhases,
});
} finally {
await adapter.closeLbug();
await rm(directory, { recursive: true, force: true });
}
}
const scaling =
results[1].median_ms / results[0].median_ms / (results[1].nodes / results[0].nodes);
const report = {
node: process.version,
platform: process.platform,
cpu: cpus()[0].model,
results,
normalized_scaling: +scaling.toFixed(3),
negative_controls: 'missing/swapped fields rejected',
native_sequences:
'selective delete/COPY, checkpoint and read-only reopen checked against independent tuple oracle',
};
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
if (process.argv.includes('--check'))
assert.ok(
scaling <= baseline.linear_scaling_budget,
`normalized scaling ${scaling} exceeds ${baseline.linear_scaling_budget}`,
);
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});