diff-index-drift.mjs 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. #!/usr/bin/env node
  2. /**
  3. * Diff two CodeGraph indexes of the SAME tree — typically a live,
  4. * incrementally-synced `.codegraph/codegraph.db` against a clean full rebuild
  5. * of the identical working tree (CG-33).
  6. *
  7. * Non-destructive: it only reads. Rebuilding is the caller's job, so the live
  8. * index is never clobbered by the tool measuring it — the mistake that cost the
  9. * original CG-33 artifact.
  10. *
  11. * # snapshot the live index BEFORE touching it
  12. * cp .codegraph/codegraph.db /tmp/live.db
  13. * node dist/bin/codegraph.js index .
  14. * node scripts/agent-eval/diff-index-drift.mjs /tmp/live.db .codegraph/codegraph.db
  15. *
  16. * Edges are compared as distinct `(source, target, kind)` triples. Raw row
  17. * counts are NOT a drift signal: a bidirectional divergence nets out. On the
  18. * codegraph repo the raw counts differed by +0.7% while 4.3% of distinct edges
  19. * were actually wrong.
  20. */
  21. import { DatabaseSync } from 'node:sqlite';
  22. import { existsSync } from 'node:fs';
  23. const [livePath, rebuiltPath] = process.argv.slice(2);
  24. if (!livePath || !rebuiltPath) {
  25. console.error('usage: diff-index-drift.mjs <live.db> <rebuilt.db>');
  26. process.exit(2);
  27. }
  28. for (const p of [livePath, rebuiltPath]) {
  29. if (!existsSync(p)) {
  30. // node:sqlite CREATES a missing file rather than failing, which silently
  31. // yields an empty schema and a confident, wrong conclusion. Refuse first.
  32. console.error(`not found: ${p}`);
  33. process.exit(2);
  34. }
  35. }
  36. const open = (p) => new DatabaseSync(p, { readOnly: true });
  37. const live = open(livePath);
  38. const rebuilt = open(rebuiltPath);
  39. const scalar = (db, q) => db.prepare(q).get().n;
  40. const edgeKey = (r) => `${r.source}\u0000${r.target}\u0000${r.kind}`;
  41. const liveEdges = live.prepare('select source, target, kind from edges').all();
  42. const rebuiltEdges = rebuilt.prepare('select source, target, kind from edges').all();
  43. const liveSet = new Set(liveEdges.map(edgeKey));
  44. const rebuiltSet = new Set(rebuiltEdges.map(edgeKey));
  45. const missing = rebuiltEdges.filter((r) => !liveSet.has(edgeKey(r))); // should exist, doesn't
  46. const stale = liveEdges.filter((r) => !rebuiltSet.has(edgeKey(r))); // exists, shouldn't
  47. const divergent = missing.length + stale.length;
  48. const byKind = (rows) => {
  49. const m = new Map();
  50. for (const r of rows) m.set(r.kind, (m.get(r.kind) ?? 0) + 1);
  51. return [...m].sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}=${v}`).join(', ') || '(none)';
  52. };
  53. const pct = (n, d) => (d ? ((n / d) * 100).toFixed(1) : '0.0');
  54. console.log(`live ${livePath}`);
  55. console.log(`rebuilt ${rebuiltPath}`);
  56. console.log('');
  57. console.log('counts live rebuilt');
  58. for (const [label, q] of [
  59. ['files', 'select count(*) n from files'],
  60. ['nodes', 'select count(*) n from nodes'],
  61. ['edges (rows)', 'select count(*) n from edges'],
  62. // Grouped rather than `count(distinct a || b || c)`: bare concatenation has no
  63. // separator, so `(ab, c)` and `(a, bc)` would collapse into one.
  64. ['edges (distinct)', 'select count(*) n from (select distinct source, target, kind from edges)'],
  65. ['heuristic edges', "select count(*) n from edges where provenance='heuristic'"],
  66. ]) {
  67. console.log(` ${label.padEnd(20)} ${String(scalar(live, q)).padEnd(9)} ${scalar(rebuilt, q)}`);
  68. }
  69. console.log('');
  70. console.log('edge divergence (distinct triples)');
  71. console.log(` missing from live: ${missing.length} — ${byKind(missing)}`);
  72. console.log(` stale in live: ${stale.length} — ${byKind(stale)}`);
  73. console.log(` TOTAL divergent: ${divergent} (${pct(divergent, rebuiltSet.size)}% of ${rebuiltSet.size})`);
  74. // Integrity checks — these separate "resolution went stale" (edges wrong, nodes
  75. // identical) from "residue accumulated" (duplicate/orphan rows). CG-33 is the
  76. // former: on the codegraph repo every check below was 0 on BOTH indexes.
  77. console.log('');
  78. console.log('integrity live rebuilt');
  79. for (const [label, q] of [
  80. ['duplicate nodes', 'select count(*) n from (select file_path,name,kind,start_line from nodes group by 1,2,3,4 having count(*)>1)'],
  81. ['orphan edges', 'select count(*) n from edges e where not exists(select 1 from nodes where id=e.source) or not exists(select 1 from nodes where id=e.target)'],
  82. ['nodes w/ missing file row', 'select count(*) n from nodes nd where not exists(select 1 from files f where f.path=nd.file_path)'],
  83. ]) {
  84. console.log(` ${label.padEnd(30)} ${String(scalar(live, q)).padEnd(6)} ${scalar(rebuilt, q)}`);
  85. }
  86. console.log('');
  87. console.log(divergent === 0
  88. ? 'CONVERGED — the synced index matches a full rebuild.'
  89. : `DRIFTED — ${divergent} edges differ. Rebuild-vs-rebuild is 0 (the indexer is deterministic), so this is sync divergence, not noise.`);
  90. process.exitCode = divergent === 0 ? 0 : 1;