mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(doctor,entities): supervisor crash classification + bare-name resolver + stub guard - doctor.ts/jobs.ts: classify worker exits with code !== 0 as real crashes vs code === 0 clean restarts (separate counter); fixes false-positive WARN on healthy supervisors - entities/resolve.ts: prefix-expansion step between fuzzy match and slugify fallback catches bare first names that score too low on pg_trgm; picks highest-connection candidate as tiebreaker - facts/fence-write.ts: stub-creation guard refuses to spawn unprefixed entity pages at brain root - facts/backstop.ts: routes stubGuardBlocked facts to engine.insertFact so the fact still persists even when no markdown file is created - docs/issues/doctor-auto-heal-and-scoring.md: spec for follow-up doctor health-score improvements - .gitignore: guard reports/network-intelligence/ (private brain exports) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(privacy): scrub real names from entity-resolve test fixtures and JSDoc Replace YC partner names with placeholders per CLAUDE.md privacy rule: alice-example, bob-example, charlie-example, dave-example. Stripe and Stripe Atlas retained (allowed household brands; exercises the two-word company-prefix case). Test semantics preserved: - Alice / Dave: single-match cases - Bob / Charlie: multi-match tiebreaker cases (winner has more chunks) All 13 entity-resolve cases pass with the scrubbed fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(supervisor): extract classifyWorkerExit() helper (DRY) Three call sites were inline-classifying worker exits: supervisor's restart policy (child-worker-supervisor.ts:291), doctor's supervisor check (doctor.ts:1016), and jobs supervisor status (jobs.ts:806). Same rule, three copies — drift risk if one is updated without the others. Extract to src/core/minions/exit-classification.ts as a pure function. Signature consumes audit-JSON shape ({ code: number | null }) so doctor and jobs (which read serialized events from JSONL) and supervisor (which reads Node's exit callback) call the same function. Helper's classification rule: code === 0 → clean_exit, everything else (non-zero, null, undefined, missing) → crash. Default-to-crash prevents corrupted rows from silently demoting into the clean-restart bucket. 5 hermetic unit tests (test/exit-classification.test.ts) pin all edge cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(facts): audit + sunset comment for stub-guard fires Wire telemetry into the v0.34.5 stub-guard at fence-write.ts:190. Every guard fire now appends a JSONL line to ~/.gbrain/audit/stub-guard-YYYY-Www.jsonl with {ts, slug, source_id, fact_count}. Operator visibility for the sunset criterion: when the new audit log reads <5 hits/week for 3 consecutive weeks on production brains, the prefix-expansion in resolveEntitySlug is sufficient and the guard can be removed in v0.36. Reader (readRecentStubGuardEvents) deliberately diverges from supervisor-audit.ts:readSupervisorEvents — it reads BOTH the current AND previous ISO-week file before filtering by ts. supervisor-audit's reader only reads the current week, which loses 24h-window correctness across Monday 00:00 UTC (a Sunday 23:55 event lives in last week's file). The 2-file read costs nothing and makes the window actually 24h. 9 hermetic unit tests pin filename math, the writer's swallows-errors contract, the cross-week-boundary read, sort order, missing-file behavior, and malformed-row tolerance. The cross-week test is the regression guard: if a future refactor copies the supervisor's single-file pattern, that test fails. Follow-up TODO (not in this PR): fix readSupervisorEvents to use the same 2-file pattern. The new stub-guard reader becomes the canonical template to copy back. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(doctor): stub_guard_24h check surfaces resolver gaps Adds a new doctor check that reads ~/.gbrain/audit/stub-guard-YYYY-Www.jsonl (via the dual-week-aware reader from T8) and surfaces the 24h fire count. WARN at >10 fires — at that rate the prefix-expansion in resolveEntitySlug is probably missing a case (typo prefix, alias, non-Latin script) and operators should grep the audit log for the offending slugs. Below the threshold but non-zero shows as OK with a count, so operators can watch the v0.36 sunset criterion (<5/week for 3 weeks → guard can be removed). Zero hits emits no check, keeping the doctor output clean on healthy brains. 5 source-grep regression tests pin the contract: check name, WARN threshold, fix hint mentions the audit log + the resolver function name, reader is the dual-week-aware variant (NOT the supervisor-audit single- week pattern), and zero-hits stays silent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(facts): pin stub-guard contract at writeFactsToFence + backstop layers - fence-write.test.ts: 3 new cases for the v0.34.5 stub guard. Bare slugs return {inserted: 0, stubGuardBlocked: true, ids: []} and create no file/.tmp at brain root. Prefixed slugs bypass the guard (regression guard against accidentally inverting the slug.includes('/') check). Empty facts array short-circuits before the guard fires. - facts-backstop.test.ts: 1 new case for the end-to-end routing. A bare-name LLM extraction resolves through to a bare slug, hits the guard, and lands in the facts table via engine.insertFact (DB-only). No phantom .md file; entity_slug stores the bare slug; source_markdown_slug is null. This is the routing contract Codex flagged as a "split-brain" data shape — the test pins the by-design behavior so a future refactor can't silently drop these facts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(supervisor): pin classifyWorkerExit consumer wire-up + regressions 12 new cases on top of the 5 helper unit tests: - doctor.ts / jobs.ts / child-worker-supervisor.ts each import the helper - All three call classifyWorkerExit at least once - doctor.ts and jobs.ts no longer carry the pre-T7 inline filter - supervisor uses the helper result to choose the clean_exit branch - audit-event shape round-trip: code=0 → clean_exit, code=1 → crash, code=null+SIGKILL → crash (catches future shape changes) The regression guards (3) and the wire-up checks (6) close the gap that motivated T7 in the first place: if a future change accidentally re-inlines the filter or shifts the audit event shape, the test fails before production sees the silent divergence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(entities): correlated subqueries scoped to slug-LIKE candidates Replace the derived-table JOIN shape in tryPrefixExpansion with correlated subqueries. The pre-fix SQL did LEFT JOIN (SELECT to_page_id, COUNT(*) FROM links GROUP BY to_page_id) li ON ... which forced the planner to aggregate the entire links + content_chunks tables on every prefix-expansion call — O(N) per call where N is total links/chunks in the brain. On a 100K-link / 50K-chunk brain that's slow enough to bottleneck fact-extraction. New shape uses correlated subqueries: (SELECT COUNT(*) FROM links WHERE to_page_id = p.id) + (SELECT COUNT(*) FROM links WHERE from_page_id = p.id) + (SELECT COUNT(*) FROM content_chunks WHERE page_id = p.id) The slug LIKE filter is already selective (typical brain has 0-5 pages per prefix), so the three subqueries run N≈3 times per matched row against the existing indexes on links.to_page_id, links.from_page_id, and content_chunks.page_id. Behavior preserved: 13/13 entity-resolve tests pass (single-match + multi-match tiebreaker + edge cases). Codex's outside-voice review caught the dead-end design that an earlier draft of this plan proposed (a CTE with `LIMIT 50` candidate cap — would have excluded correct high-connection candidates if their slug sorted late). Correlated subqueries without a candidate cap are the cleaner shape that lets the LIKE filter do the bounding work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(entities): perf regression guard for prefix-expansion (58x speedup) Hermetic PGLite benchmark with 5K pages + 50K links + 25K chunks. Runs the pre-T12 derived-table shape and the new correlated-subquery shape side-by-side against the same fixture, asserts NEW >= 5x faster than OLD. Baseline-ratio, not absolute wall-clock — different machines / Bun versions / CI load can shift absolute timings by 10x without indicating a real regression, but the SHAPE difference between "aggregate the full tables" and "correlated subquery per candidate" is what we care about. Measured: old_median=18.16ms, new_median=0.31ms, speedup=58.22x. The 5x assertion has plenty of headroom. The OLD SQL is embedded verbatim as the regression baseline. If a future refactor re-introduces full-table aggregation (LEFT JOIN against SELECT...GROUP BY over the whole links or content_chunks table), the test fails. PGLite-only — Postgres planner can shape derived-table JOINs differently enough that the 5x ratio could be noise on a 5K-page fixture. The structural correctness of the rewrite is the same on both; this is purely a planner-shape regression guard. .slow.test.ts suffix keeps it out of the fast loop (run via `bun run test:slow`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.35.2.0) Wave content: - Privacy scrub: PII rebuilt out of branch history; real names → placeholders - Bug fix: doctor + jobs no longer count clean worker exits as crashes - Bug fix: entity resolver prefix-expansion catches bare first names - DRY refactor: classifyWorkerExit() helper (one rule, 3 call sites) - Observability: stub_guard_24h doctor check + ISO-week audit log - Perf: 58x speedup on tryPrefixExpansion query shape Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: rebump v0.35.2.0 → v0.35.4.0 + scrub TODOS.md privacy violation VERSION/package.json/CHANGELOG header rebumped to v0.35.4.0 per user request (queue allocation). TODOS.md rephrased to not literally name the banned private-agent string — that was the CI failure root cause on the v0.35.2.0 push. CHANGELOG.md is on check-privacy.sh's allow-list (meta-documentation exception); TODOS.md is not. CI re-runs against this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
8.4 KiB
TypeScript
220 lines
8.4 KiB
TypeScript
/**
|
|
* Perf regression guard for tryPrefixExpansion (T12 of the kinshasa-v3 wave).
|
|
*
|
|
* Asserts that the new correlated-subquery shape is at least 5x faster than
|
|
* the pre-fix derived-table shape on the same seeded brain. Baseline-ratio,
|
|
* not absolute wall-clock — different machines / Bun versions / PGLite
|
|
* builds / CI load can shift absolute timings by 10x without indicating a
|
|
* real regression, but the SHAPE difference between "aggregate full tables"
|
|
* and "correlated subquery per candidate" is what we actually care about.
|
|
*
|
|
* The old SQL is embedded verbatim below as the regression baseline. If
|
|
* a future refactor accidentally re-introduces full-table aggregation
|
|
* (LEFT JOIN against a SELECT ... GROUP BY ... over the whole `links` or
|
|
* `content_chunks` table), this test fails.
|
|
*
|
|
* .slow.test.ts suffix keeps it out of the fast loop. Run via
|
|
* `bun run test:slow`.
|
|
*
|
|
* PGLite-only. Postgres E2E is intentionally skipped — PG's planner can
|
|
* shape the OLD query's derived tables differently enough that the 5x
|
|
* ratio could be noise on a 5K-page fixture. The structural correctness
|
|
* of the rewrite is the same on both engines; this is purely a planner-
|
|
* shape regression guard.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
// Seed sizes tuned to make the OLD shape visibly slow while keeping the
|
|
// cold-start fixture under ~10s. Numbers below match the kinshasa-v3 plan.
|
|
const PAGES = 5_000;
|
|
const LINKS = 50_000;
|
|
const CHUNKS = 25_000;
|
|
const RUNS = 5; // median of 5 runs per shape
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
|
|
// Seed pages. The Alice prefix has exactly 3 candidates (the case we
|
|
// want both shapes to actually evaluate); the rest are random fillers
|
|
// that contribute to the OLD shape's O(N) aggregation cost.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const db = (engine as any).db;
|
|
|
|
// Insert 3 target pages (people/alice-*).
|
|
for (const slug of ['people/alice-example', 'people/alice-research', 'people/alice-engineer']) {
|
|
await engine.putPage(slug, {
|
|
type: 'person',
|
|
title: slug.split('/').pop()!,
|
|
compiled_truth: `# ${slug}`,
|
|
frontmatter: { type: 'person', title: slug, slug },
|
|
}, { sourceId: 'default' });
|
|
}
|
|
|
|
// Bulk-insert filler pages. Use a single executeRaw with generate_series
|
|
// for speed; PGLite handles this fine.
|
|
await db.query(
|
|
`INSERT INTO pages (slug, type, title, compiled_truth, frontmatter, source_id, created_at, updated_at)
|
|
SELECT 'filler/page-' || gs::text,
|
|
'note',
|
|
'Filler ' || gs::text,
|
|
'# Filler',
|
|
'{}',
|
|
'default',
|
|
NOW(),
|
|
NOW()
|
|
FROM generate_series(1, ${PAGES}) gs`,
|
|
);
|
|
|
|
// Capture id range for link + chunk inserts.
|
|
const fillerIds = await db.query(`SELECT id FROM pages WHERE slug LIKE 'filler/%' LIMIT ${PAGES}`);
|
|
const aliceIds = await db.query(`SELECT id FROM pages WHERE slug LIKE 'people/alice-%'`);
|
|
const allIds = [...fillerIds.rows.map((r: { id: string }) => r.id), ...aliceIds.rows.map((r: { id: string }) => r.id)];
|
|
|
|
// Spread LINKS across filler pages (filler→filler). The OLD shape will
|
|
// aggregate ALL of these on every prefix-expansion call; the NEW shape
|
|
// touches only the alice rows via index.
|
|
const linkBatch = 5_000;
|
|
for (let i = 0; i < LINKS; i += linkBatch) {
|
|
const tuples: string[] = [];
|
|
const params: string[] = [];
|
|
let p = 1;
|
|
for (let j = 0; j < linkBatch && i + j < LINKS; j++) {
|
|
const from = allIds[Math.floor(Math.random() * allIds.length)];
|
|
const to = allIds[Math.floor(Math.random() * allIds.length)];
|
|
if (from === to) continue;
|
|
tuples.push(`($${p++}, $${p++}, 'mentions')`);
|
|
params.push(from, to);
|
|
}
|
|
if (tuples.length === 0) continue;
|
|
// ON CONFLICT DO NOTHING — the links table has a unique index across
|
|
// (from, to, type, source, origin); random pairs occasionally collide.
|
|
// Test seeding doesn't care if a few inserts are skipped; the order of
|
|
// magnitude is what matters for the perf comparison.
|
|
await db.query(
|
|
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ${tuples.join(',')}
|
|
ON CONFLICT DO NOTHING`,
|
|
params,
|
|
);
|
|
}
|
|
|
|
// Spread CHUNKS across all pages. Use a per-page counter so each
|
|
// (page_id, chunk_index) pair is unique — there's a unique index on
|
|
// content_chunks(page_id, chunk_index) so random chunk_index values
|
|
// would collide.
|
|
const chunkCounts = new Map<string, number>();
|
|
const chunkBatch = 5_000;
|
|
for (let i = 0; i < CHUNKS; i += chunkBatch) {
|
|
const tuples: string[] = [];
|
|
const params: (string | number)[] = [];
|
|
let p = 1;
|
|
for (let j = 0; j < chunkBatch && i + j < CHUNKS; j++) {
|
|
const pid = allIds[Math.floor(Math.random() * allIds.length)];
|
|
const idx = chunkCounts.get(pid) ?? 0;
|
|
chunkCounts.set(pid, idx + 1);
|
|
tuples.push(`($${p++}, $${p++}, $${p++})`);
|
|
params.push(pid, idx, `chunk ${i + j}`);
|
|
}
|
|
await db.query(
|
|
`INSERT INTO content_chunks (page_id, chunk_index, chunk_text) VALUES ${tuples.join(',')}`,
|
|
params,
|
|
);
|
|
}
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
// The pre-T12 query shape, embedded verbatim as the regression baseline.
|
|
// If a future refactor re-introduces this shape, the test fails.
|
|
const OLD_SQL = `
|
|
SELECT p.slug,
|
|
(COALESCE(li.in_count, 0) + COALESCE(lo.out_count, 0) + COALESCE(cc.chunk_count, 0))
|
|
AS connection_count
|
|
FROM pages p
|
|
LEFT JOIN (
|
|
SELECT to_page_id AS pid, COUNT(*)::int AS in_count
|
|
FROM links GROUP BY to_page_id
|
|
) li ON li.pid = p.id
|
|
LEFT JOIN (
|
|
SELECT from_page_id AS pid, COUNT(*)::int AS out_count
|
|
FROM links GROUP BY from_page_id
|
|
) lo ON lo.pid = p.id
|
|
LEFT JOIN (
|
|
SELECT page_id AS pid, COUNT(*)::int AS chunk_count
|
|
FROM content_chunks GROUP BY page_id
|
|
) cc ON cc.pid = p.id
|
|
WHERE p.source_id = $1
|
|
AND p.deleted_at IS NULL
|
|
AND p.slug LIKE $2
|
|
ORDER BY connection_count DESC, p.slug ASC
|
|
LIMIT 5
|
|
`;
|
|
|
|
// The T12 query shape — what tryPrefixExpansion now uses.
|
|
const NEW_SQL = `
|
|
SELECT p.slug,
|
|
((SELECT COUNT(*)::int FROM links WHERE to_page_id = p.id)
|
|
+ (SELECT COUNT(*)::int FROM links WHERE from_page_id = p.id)
|
|
+ (SELECT COUNT(*)::int FROM content_chunks WHERE page_id = p.id))
|
|
AS connection_count
|
|
FROM pages p
|
|
WHERE p.source_id = $1
|
|
AND p.deleted_at IS NULL
|
|
AND p.slug LIKE $2
|
|
ORDER BY connection_count DESC, p.slug ASC
|
|
LIMIT 5
|
|
`;
|
|
|
|
async function timeQuery(sql: string): Promise<number> {
|
|
const start = performance.now();
|
|
const rows = await engine.executeRaw<{ slug: string; connection_count: number }>(
|
|
sql,
|
|
['default', 'people/alice-%'],
|
|
);
|
|
const elapsed = performance.now() - start;
|
|
// Sanity: both shapes must return the same result set so the timing is
|
|
// comparing apples to apples.
|
|
if (rows.length === 0) throw new Error('Query returned no rows — fixture seeding broke');
|
|
return elapsed;
|
|
}
|
|
|
|
function median(xs: number[]): number {
|
|
const sorted = [...xs].sort((a, b) => a - b);
|
|
const mid = Math.floor(sorted.length / 2);
|
|
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
}
|
|
|
|
describe('tryPrefixExpansion perf regression — NEW shape >= 5x faster than OLD', () => {
|
|
it('correlated subqueries beat derived-table aggregation by 5x or more', async () => {
|
|
// Warm both shapes once so the planner caches its plan, then measure.
|
|
await timeQuery(OLD_SQL);
|
|
await timeQuery(NEW_SQL);
|
|
|
|
const oldTimes: number[] = [];
|
|
const newTimes: number[] = [];
|
|
for (let i = 0; i < RUNS; i++) oldTimes.push(await timeQuery(OLD_SQL));
|
|
for (let i = 0; i < RUNS; i++) newTimes.push(await timeQuery(NEW_SQL));
|
|
|
|
const oldMedian = median(oldTimes);
|
|
const newMedian = median(newTimes);
|
|
const speedup = oldMedian / newMedian;
|
|
|
|
// Emit timing data to stderr so a regression review can see the actual
|
|
// numbers, not just pass/fail.
|
|
process.stderr.write(
|
|
`[entity-resolve-perf] fixture=${PAGES}p+${LINKS}l+${CHUNKS}c ` +
|
|
`old_median=${oldMedian.toFixed(2)}ms new_median=${newMedian.toFixed(2)}ms ` +
|
|
`speedup=${speedup.toFixed(2)}x\n`,
|
|
);
|
|
|
|
expect(speedup).toBeGreaterThanOrEqual(5);
|
|
}, 60_000);
|
|
});
|