mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +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>
244 lines
9.3 KiB
TypeScript
244 lines
9.3 KiB
TypeScript
/**
|
|
* v0.31 Hot Memory — entity slug canonicalization.
|
|
*
|
|
* Per /plan-eng-review D4: at extract time, resolve a free-form entity name
|
|
* (e.g. "Alice") against `pages.slug` so that hot memory + the existing graph
|
|
* see the same canonical id. Falls back to a slugified form when no page
|
|
* matches.
|
|
*
|
|
* Pure helper; the engine layer is the data dependency injected by callers.
|
|
* Lives under `src/core/entities/` so signal-detector can reuse it for the
|
|
* Sonnet pass too without circular import through facts/.
|
|
*
|
|
* Prefix-expansion step lives between fuzzy match and slugify fallback.
|
|
* Bare first names like "Alice" score too low on pg_trgm (short strings
|
|
* have terrible trigram overlap), so without this step they fall through
|
|
* to slugify("Alice") → "alice", which spawns a phantom `people/alice.md`
|
|
* stub at brain root instead of resolving to the existing
|
|
* `people/alice-example` page. The fix queries `slug LIKE 'people/X-%'`
|
|
* (then `companies/X-%`) when fuzzy fails on a single-word bare name, and
|
|
* uses connection count (links + chunks) as the tiebreaker when multiple
|
|
* candidates match.
|
|
*/
|
|
|
|
import type { BrainEngine } from '../engine.ts';
|
|
|
|
/**
|
|
* Canonicalize a free-form entity reference to a page slug.
|
|
*
|
|
* Resolution order:
|
|
* 1. If `raw` is already a page slug shape (contains a "/" or matches an
|
|
* exact pages.slug row in this source), return it untouched.
|
|
* 2. Try fuzzy match against pages.slug + pages.title within the source
|
|
* (case-insensitive). Pick the highest-trgm-score match if any.
|
|
* 3. Fall back to a deterministic slugify: lowercase-no-spaces with
|
|
* hyphen-collapse. NOT prefixed with a directory — caller decides
|
|
* whether to prefix `people/`, `companies/`, etc.
|
|
*
|
|
* Returns null when raw is empty or whitespace-only. Non-empty input always
|
|
* produces a non-null slug — the fallback path is the floor.
|
|
*/
|
|
export async function resolveEntitySlug(
|
|
engine: BrainEngine,
|
|
source_id: string,
|
|
raw: string,
|
|
): Promise<string | null> {
|
|
if (!raw) return null;
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return null;
|
|
|
|
// 1. Exact match on slug. If raw already looks like a slug (or matches
|
|
// a row exactly), use it.
|
|
if (looksLikeSlug(trimmed)) {
|
|
const exact = await tryExactSlug(engine, source_id, trimmed);
|
|
if (exact) return exact;
|
|
}
|
|
|
|
// 2. Fuzzy match against existing pages within the source. Match either
|
|
// on slug fragment or on title.
|
|
const fuzzy = await tryFuzzyMatch(engine, source_id, trimmed);
|
|
if (fuzzy) return fuzzy;
|
|
|
|
// 3. Prefix-expansion match: when the input looks like a bare first name
|
|
// (no slash, no prefix, slugifies to a single short token), try
|
|
// `people/<token>-%` then `companies/<token>-%`. Short bare names
|
|
// score terribly on pg_trgm — similarity('alice', 'alice-example')
|
|
// is below the 0.4 threshold — so this is the layer that catches
|
|
// `"Alice"` → `people/alice-example` before we phantom-stub a bare
|
|
// `people/alice.md`.
|
|
if (isBareName(trimmed)) {
|
|
const expanded = await tryPrefixExpansion(engine, source_id, slugify(trimmed));
|
|
if (expanded) return expanded;
|
|
}
|
|
|
|
// 4. Fallback: deterministic slugify.
|
|
return slugify(trimmed);
|
|
}
|
|
|
|
/**
|
|
* "Bare name" detector — true when the input is a single word with no
|
|
* slash, no embedded prefix marker, and slugifies to a non-empty token.
|
|
* Multi-word inputs (e.g. "Alice Example") are handled by fuzzy match;
|
|
* this gate only fires for short first-name-shaped tokens.
|
|
*/
|
|
function isBareName(raw: string): boolean {
|
|
if (raw.includes('/')) return false;
|
|
// One-token input. Whitespace-tokenize: "Alice" → 1, "Alice Example" → 2.
|
|
const tokens = raw.trim().split(/\s+/).filter(Boolean);
|
|
if (tokens.length !== 1) return false;
|
|
const slug = slugify(raw);
|
|
if (!slug) return false;
|
|
// Reject hyphenated multi-token slugs like "alice-example" — those
|
|
// should hit the exact-slug or fuzzy path, not prefix expansion.
|
|
if (slug.includes('-')) return false;
|
|
return true;
|
|
}
|
|
|
|
const PREFIX_EXPANSION_DIRS = ['people', 'companies'] as const;
|
|
|
|
/**
|
|
* Look up pages whose slug starts with `<dir>/<token>-` for each known
|
|
* entity directory. When multiple candidates match within a directory,
|
|
* pick the one with the highest connection count (links_in + links_out +
|
|
* chunk count) — the most-mentioned entity is the most likely canonical
|
|
* target for a bare-name reference. When no candidates match in any
|
|
* directory, returns null and the caller falls through to slugify.
|
|
*/
|
|
async function tryPrefixExpansion(
|
|
engine: BrainEngine,
|
|
source_id: string,
|
|
token: string,
|
|
): Promise<string | null> {
|
|
for (const dir of PREFIX_EXPANSION_DIRS) {
|
|
const pattern = `${dir}/${token}-%`;
|
|
try {
|
|
const rows = await engine.executeRaw<{
|
|
slug: string;
|
|
connection_count: number;
|
|
}>(
|
|
// Connection count is a simple proxy for canonicality:
|
|
// (incoming links) + (outgoing links) + (content chunks).
|
|
//
|
|
// SQL shape: correlated subqueries scoped to the slug-LIKE
|
|
// candidates. The pre-v0.34.5 version used derived-table JOINs
|
|
// (SELECT FROM links GROUP BY to_page_id, etc.) which forced the
|
|
// planner to aggregate the FULL 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.
|
|
//
|
|
// The slug LIKE filter is already selective in practice (typical
|
|
// brain has 0-5 pages per prefix), so the correlated subqueries
|
|
// run N=3 times per matched row, hitting the indexes on
|
|
// links.to_page_id, links.from_page_id, and content_chunks.page_id
|
|
// directly. Even on a pathological prefix matching 1000+ pages,
|
|
// work is bounded per-candidate, not whole-table.
|
|
`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`,
|
|
[source_id, pattern],
|
|
);
|
|
if (rows.length === 0) continue;
|
|
// Single unambiguous match: return it.
|
|
if (rows.length === 1) return rows[0].slug;
|
|
// Multiple matches: the top row (sorted by connection_count desc)
|
|
// wins. The slug-ASC secondary key makes ties deterministic when
|
|
// connection counts collide — important for test pinning.
|
|
return rows[0].slug;
|
|
} catch {
|
|
// Defensive: a missing table or index shouldn't crash extraction.
|
|
// Try the next directory (or fall through to slugify).
|
|
continue;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function looksLikeSlug(s: string): boolean {
|
|
// Slug shape: lowercase letters/digits with at least one slash OR matches
|
|
// [a-z0-9-]+ exactly. Anything with whitespace or capital letters fails.
|
|
if (/\s/.test(s)) return false;
|
|
if (s !== s.toLowerCase()) return false;
|
|
return /^[a-z0-9/_-]+$/.test(s);
|
|
}
|
|
|
|
async function tryExactSlug(
|
|
engine: BrainEngine,
|
|
source_id: string,
|
|
candidate: string,
|
|
): Promise<string | null> {
|
|
try {
|
|
const rows = await engine.executeRaw<{ slug: string }>(
|
|
`SELECT slug FROM pages WHERE source_id = $1 AND slug = $2 AND deleted_at IS NULL LIMIT 1`,
|
|
[source_id, candidate],
|
|
);
|
|
if (rows.length > 0) return rows[0].slug;
|
|
} catch {
|
|
// Defensive: fail open. Caller still gets a slug from the fallback.
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function tryFuzzyMatch(
|
|
engine: BrainEngine,
|
|
source_id: string,
|
|
raw: string,
|
|
): Promise<string | null> {
|
|
const lc = raw.toLowerCase();
|
|
const fragment = slugify(raw);
|
|
// Prefer titles (display names) over slug fragments since user input
|
|
// tends to be display-name-shaped ("Alice Example" vs "alice-example"). Cap at
|
|
// 3 candidates; pick the first deterministic one.
|
|
try {
|
|
const rows = await engine.executeRaw<{ slug: string; title: string; score: number }>(
|
|
`SELECT slug, title,
|
|
GREATEST(
|
|
similarity(lower(title), $2),
|
|
similarity(slug, $3)
|
|
) AS score
|
|
FROM pages
|
|
WHERE source_id = $1
|
|
AND deleted_at IS NULL
|
|
AND (
|
|
lower(title) % $2
|
|
OR slug ILIKE '%' || $3 || '%'
|
|
)
|
|
ORDER BY score DESC, slug ASC
|
|
LIMIT 3`,
|
|
[source_id, lc, fragment],
|
|
);
|
|
if (rows.length > 0 && rows[0].score >= 0.4) return rows[0].slug;
|
|
} catch {
|
|
// pg_trgm functions might not be available on every engine config;
|
|
// fall through to slugify.
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Deterministic slugify: lowercase, replace non-alphanumerics with hyphens,
|
|
* collapse repeated hyphens, trim leading/trailing hyphens.
|
|
*
|
|
* Exported for tests + callers who want the same fallback shape independently.
|
|
*/
|
|
export function slugify(raw: string): string {
|
|
return raw
|
|
.toLowerCase()
|
|
.normalize('NFKD')
|
|
// NFKD decomposes accents into combining marks (U+0300..U+036F);
|
|
// strip them before replacing the rest with hyphens so "è" → "e",
|
|
// not "e" + "-".
|
|
.replace(/[̀-ͯ]/g, '')
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
}
|