mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* perf(extract_atoms): batch idempotency check via atomsExistingForHashes Replaces the per-hash transcript loop (7K SQL roundtrips on big brains) with one batch query using `frontmatter->>'source_hash' = ANY($2::text[])`. Migration v104 adds the partial expression index that keeps the new query O(log n) at scale (mirrors v97 pattern: CONCURRENTLY + invalid-remnant pre-drop on Postgres, plain CREATE INDEX on PGLite). Helper exported so test/cycle/extract-atoms-batch.test.ts can drive it directly without orchestrating the full phase. Fail-open posture preserved from the prior per-hash helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cycle): shorter lock TTL + active in-phase refresh + progress wiring Issue 3 + Issue 2 of the v0.41.20.0 ops-fix-wave. Codex caught during plan review that yieldBetweenPhases (the existing external hook) does NOT refresh the cycle DB lock — it's just a setImmediate() from jobs.ts:1405 / autopilot.ts:632, and lock.refresh() was never called from inside runCycle. Combined with the 30min TTL, crashed cycles wedged the lock for the full window before another worker could take over. Three coordinated changes: 1. LOCK_TTL_MINUTES 30 → 5 (src/core/cycle.ts). Crash recovers in ≤5 min instead of ≤30 min. 2. buildYieldDuringPhase(lock, outer) — exported closure that calls lock.refresh() AND the existing yieldBetweenPhases hook on every fire. Passed to both long phases (extract_atoms, synthesize_concepts) as their yieldDuringPhase opt. 3. maybeYield helper inside both phases — 30s throttle, fires inside the main work loop AND immediately after every `await chat()` LLM call (codex hardening: a single long LLM await could otherwise sit past TTL). Progress reporter wired through to both phases too (Issue 2): extract_atoms emits `[cycle.extract_atoms] N atoms / M skipped` ticks every ~1s; synthesize_concepts ticks per concept group. Cycle.ts owns start()/finish(); phases only call tick() and heartbeat() on the same reporter (NOT a child — that would produce path collision `cycle.extract_atoms.extract_atoms.work`). LockHandle interface exported for tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(extract): by-mention resumes from where it died Issue 4 of the v0.41.20.0 ops-fix-wave. On a 322K-page brain the sweep takes 10+ hours; if it died at 87% the user redid 87% on restart. Wires the existing `op_checkpoints` framework into extractMentionsFromDb with a flushAndCheckpoint ordering that closes the four codex-flagged correctness bugs at once: 1. Lost-links-on-crash — flush batch links to DB FIRST, commit page keys to checkpoint SECOND, persist THIRD. A crash between batch.push() and flushBatch() leaves the page un-checkpointed so resume re-scans it (no silently lost mention links). 2. Dry-run resume contradiction — dry-run does NOT load or persist the checkpoint. Verification path uses non-dry-run kill-and-resume. 3. Gazetteer hash in fingerprint — entity pages added mid-pause shift the gazetteer hash → new fingerprint → fresh scan against the new gazetteer. Without this, resumed runs would silently skip pages against a new entity set. 4. Filtered pages get checkpointed too — pages skipped by `--type` / `--since` / empty body / no-mentions all get marked completed so resume doesn't re-fetch them. Persist cadence: every 1000 items OR every 30s, whichever first (~322 persists / ~24s total overhead on the 322K-page brain). Crash window capped at 1000 pages (<0.3% loss). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(doctor): surface sync --all consolidation nudge to operators Issue 5 of the v0.41.20.0 ops-fix-wave. Multi-source brains see a paste-ready `gbrain sync --all --parallel 4 --workers 4 --skip-failed` in `gbrain doctor` output instead of maintaining two staggered per-source cron entries with manual deconfliction. New checkSyncConsolidation surfaces the recommendation when 2+ active sources exist; "not applicable" for single-source brains. Own try/catch returns warn on SQL failure — outer doctor catch wasn't a safe assumption. `skills/cron-scheduler/SKILL.md` gains a "Multi-source brains" recipe block documenting the pattern + connection-budget math (parallel × workers × 2 ≈ 32 connections at default 4/4). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): isolate GBRAIN_HOME in cycle-LFCA + schema-cli tests Two pre-existing tests assumed a clean ~/.gbrain/config.json and a free ~/.gbrain/cycle.lock — both shared across all gbrain processes on the machine. Sibling Conductor worktrees running their own gbrain tests poisoned the shared state, causing flakes: - test/cycle-last-full-cycle-at.test.ts test 5 timed out at 5s because runCycle returned 'skipped' (file lock held by a parallel test process), and last_full_cycle_at exit hook silently no-oped. Fix: each test wraps its body in `withEnv({GBRAIN_HOME: tmpdir})` so the file lock path becomes per-test. - test/schema-cli.test.ts `schema active reports default resolution` failed exit 1 because another worktree had set `schema_pack: gbrain-base-v2` in the shared config (a pack that doesn't exist in the bundle). Fix: gbrain() helper defaults GBRAIN_HOME to a per-file tempdir (beforeAll-owned), so subprocess invocations get an isolated config dir unless tests explicitly override. Both fixes confirmed via deliberate pollution + retest: 12/12 schema-cli tests pass under simulated `schema_pack: gbrain-base-v2` contamination; cycle-LFCA test 5 completes <2s with isolated home. Discovered during v0.41.20.0 ship while investigating parallel-worktree flake. Not caused by the ops-fix-wave but found via it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.41.20.0) Five daily-driver ops pains fixed in one wave: 1. extract_atoms 7K-roundtrip overhead → 1 batch query + index 2. silent long-running phases → progress ticks every ~1s 3. 30-min crashed-cycle lock TTL → 5 min + active in-phase refresh 4. by-mention restarts from page 0 → resumes via op_checkpoints 5. multi-source cron → doctor surfaces `sync --all --parallel` nudge Two follow-up TODOs filed under v0.41.19.0 ops-fix-wave block (will be renamed at follow-up time): - `gbrain sync print-cron` subcommand (P2 ergonomics) - Lock-loss detection in DbLockHandle.refresh() (P2 contract change) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update CLAUDE.md key files for v0.41.20.0 ops-fix-wave Folds the v0.41.20.0 wave annotations into the cycle/extract/op-checkpoint key-files block: batch idempotency via atomsExistingForHashes, shorter cycle lock TTL with buildYieldDuringPhase active refresh, progress wiring through extract_atoms + synthesize_concepts, by-mention resume via mentionsFingerprint with flushAndCheckpoint ordering, sync_consolidation doctor check, and the 44-case test suite pinning every contract. Regenerated llms-full.txt to match (CLAUDE.md edit invariant). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(ci): doctor categorization + facts-engine cosine ordering hardening Two CI-only failures caught on PR #1545 (v0.41.21.0 ops-fix-wave): 1. doctor-categories drift guard — new `sync_consolidation` check from T6 wasn't categorized in src/core/doctor-categories.ts. Added under OPS_CHECK_NAMES (it surfaces an operator-cron recommendation, not a brain-data quality signal). 2. facts-engine `embedding cosine ordering when both sides have embeddings` — passed locally, failed under CI's parallel shard. Bun's truncated assertion output didn't surface which expect() fired; hardened the test against unknown leak vectors by: - per-run unique entity_slug (`embed-test-<random8>`) instead of the static `embed-test`, so any future cross-test pollution is structurally impossible - `findIndex` + `aIdx < bIdx` assertion that pins the cosine RELATIONSHIP (A closer than B because cos(A,Q)=1.0 vs cos(B,Q)=0.0) instead of the brittle `result[0].fact === 'A'` position check. The new shape matches the test name's contract verbatim ("ordering when both sides have embeddings"), so any unrelated row in the result set can no longer flip the test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
/**
|
|
* v0.31 Phase 6 — facts engine round-trip tests on PGLite (in-memory, no
|
|
* DATABASE_URL required).
|
|
*
|
|
* Pins every BrainEngine facts method end-to-end:
|
|
* - insertFact (insert, supersede)
|
|
* - expireFact (idempotent-as-false)
|
|
* - listFactsByEntity / Since / Session / Supersessions
|
|
* - findCandidateDuplicates (entity-prefiltered, k cap, cosine ordering)
|
|
* - consolidateFact
|
|
* - getFactsHealth
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
const vec = (...vals: number[]): Float32Array => {
|
|
const a = new Float32Array(1536);
|
|
for (let i = 0; i < vals.length; i++) a[i] = vals[i];
|
|
return a;
|
|
};
|
|
|
|
describe('insertFact + listFactsByEntity', () => {
|
|
test('inserts a fact and reads it back', async () => {
|
|
const r = await engine.insertFact(
|
|
{ fact: 'alice example fact', kind: 'fact', entity_slug: 'people/alice-example', source: 'test' },
|
|
{ source_id: 'default' },
|
|
);
|
|
expect(r.id).toBeGreaterThan(0);
|
|
expect(r.status).toBe('inserted');
|
|
const rows = await engine.listFactsByEntity('default', 'people/alice-example');
|
|
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
const ours = rows.find(x => x.id === r.id);
|
|
expect(ours).toBeDefined();
|
|
expect(ours!.fact).toBe('alice example fact');
|
|
expect(ours!.kind).toBe('fact');
|
|
expect(ours!.visibility).toBe('private');
|
|
// v0.31.2: row mapper exposes notability; default 'medium' when caller omits.
|
|
expect(ours!.notability).toBe('medium');
|
|
expect(ours!.confidence).toBe(1.0);
|
|
});
|
|
|
|
test('respects kind CHECK', async () => {
|
|
const r = await engine.insertFact(
|
|
{ fact: 'durable', kind: 'preference', entity_slug: 'alice-test', source: 'test' },
|
|
{ source_id: 'default' },
|
|
);
|
|
const rows = await engine.listFactsByEntity('default', 'alice-test');
|
|
const ours = rows.find(x => x.id === r.id);
|
|
expect(ours?.kind).toBe('preference');
|
|
});
|
|
|
|
test('v0.31.2: notability round-trips for each tier (PR1 commit 4 contract pin)', async () => {
|
|
const tiers: Array<'high' | 'medium' | 'low'> = ['high', 'medium', 'low'];
|
|
for (const tier of tiers) {
|
|
const r = await engine.insertFact(
|
|
{
|
|
fact: `notability ${tier} test`,
|
|
kind: 'fact',
|
|
entity_slug: `notability-${tier}-pin`,
|
|
source: 'test',
|
|
notability: tier,
|
|
},
|
|
{ source_id: 'default' },
|
|
);
|
|
const rows = await engine.listFactsByEntity('default', `notability-${tier}-pin`);
|
|
const ours = rows.find(x => x.id === r.id);
|
|
expect(ours).toBeDefined();
|
|
// The row mapper MUST expose notability; without this assertion, the
|
|
// codex P1 #4 regression (FactRow drops the column) reappears silently.
|
|
expect(ours!.notability).toBe(tier);
|
|
}
|
|
});
|
|
|
|
test('supersede path: superseding row marks old as expired_at + superseded_by', async () => {
|
|
const old = await engine.insertFact(
|
|
{ fact: 'old fact', kind: 'fact', entity_slug: 'super-test', source: 'test' },
|
|
{ source_id: 'default' },
|
|
);
|
|
const newer = await engine.insertFact(
|
|
{ fact: 'new fact', kind: 'fact', entity_slug: 'super-test', source: 'test' },
|
|
{ source_id: 'default', supersedeId: old.id },
|
|
);
|
|
expect(newer.status).toBe('superseded');
|
|
expect(newer.id).toBeGreaterThan(old.id);
|
|
|
|
const supersessions = await engine.listSupersessions('default');
|
|
const oldRow = supersessions.find(r => r.id === old.id);
|
|
expect(oldRow).toBeDefined();
|
|
expect(oldRow!.expired_at).not.toBeNull();
|
|
expect(oldRow!.superseded_by).toBe(newer.id);
|
|
});
|
|
});
|
|
|
|
describe('expireFact', () => {
|
|
test('returns true on first call, false on idempotent re-call', async () => {
|
|
const r = await engine.insertFact(
|
|
{ fact: 'will expire', kind: 'fact', source: 'test' },
|
|
{ source_id: 'default' },
|
|
);
|
|
expect(await engine.expireFact(r.id)).toBe(true);
|
|
expect(await engine.expireFact(r.id)).toBe(false);
|
|
});
|
|
|
|
test('returns false on unknown id', async () => {
|
|
expect(await engine.expireFact(99999999)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('listFactsSince + listFactsBySession', () => {
|
|
test('listFactsSince filters by created_at', async () => {
|
|
const before = new Date();
|
|
await engine.insertFact(
|
|
{ fact: 'recent', kind: 'fact', source: 'test', source_session: 'topic-since' },
|
|
{ source_id: 'default' },
|
|
);
|
|
const rows = await engine.listFactsSince('default', before);
|
|
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
expect(rows.every(r => r.created_at.getTime() >= before.getTime())).toBe(true);
|
|
});
|
|
|
|
test('listFactsBySession filters by source_session', async () => {
|
|
await engine.insertFact(
|
|
{ fact: 'topic-A note', kind: 'fact', source: 'test', source_session: 'topic-A' },
|
|
{ source_id: 'default' },
|
|
);
|
|
await engine.insertFact(
|
|
{ fact: 'topic-B note', kind: 'fact', source: 'test', source_session: 'topic-B' },
|
|
{ source_id: 'default' },
|
|
);
|
|
const a = await engine.listFactsBySession('default', 'topic-A');
|
|
const b = await engine.listFactsBySession('default', 'topic-B');
|
|
expect(a.every(r => r.source_session === 'topic-A')).toBe(true);
|
|
expect(b.every(r => r.source_session === 'topic-B')).toBe(true);
|
|
expect(a.find(r => r.source_session === 'topic-B')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('findCandidateDuplicates', () => {
|
|
test('entity-prefiltered: rows from other entities never returned', async () => {
|
|
await engine.insertFact(
|
|
{ fact: 'alice fact', kind: 'fact', entity_slug: 'cand-alice', source: 'test' },
|
|
{ source_id: 'default' },
|
|
);
|
|
await engine.insertFact(
|
|
{ fact: 'tim fact', kind: 'fact', entity_slug: 'cand-tim', source: 'test' },
|
|
{ source_id: 'default' },
|
|
);
|
|
const candidates = await engine.findCandidateDuplicates('default', 'cand-alice', 'alice fact');
|
|
expect(candidates.every(c => c.entity_slug === 'cand-alice')).toBe(true);
|
|
expect(candidates.find(c => c.entity_slug === 'cand-tim')).toBeUndefined();
|
|
});
|
|
|
|
test('k cap honored', async () => {
|
|
for (let i = 0; i < 7; i++) {
|
|
await engine.insertFact(
|
|
{ fact: `cap-test ${i}`, kind: 'fact', entity_slug: 'cap-entity', source: 'test' },
|
|
{ source_id: 'default' },
|
|
);
|
|
}
|
|
const result = await engine.findCandidateDuplicates('default', 'cap-entity', 'x', { k: 3 });
|
|
expect(result.length).toBe(3);
|
|
});
|
|
|
|
test('embedding cosine ordering when both sides have embeddings', async () => {
|
|
// Use per-run unique entity_slug so the assertion is immune to any
|
|
// cross-test pollution (no other test in the file uses 'embed-test',
|
|
// but parallel CI shard runs have surfaced a flake where the
|
|
// position-0 assertion failed without a visible assertion-detail in
|
|
// the truncated log). The contract this test pins is "A ranks higher
|
|
// than B because cos(A,query)=1.0 vs cos(B,query)=0.0" — assert that
|
|
// RELATIONSHIP, not the absolute index, so any unrelated row in the
|
|
// result set can't flip the test.
|
|
const slug = `embed-test-${Math.random().toString(36).slice(2, 10)}`;
|
|
await engine.insertFact(
|
|
{ fact: 'A', kind: 'fact', entity_slug: slug, source: 'test', embedding: vec(1, 0, 0) },
|
|
{ source_id: 'default' },
|
|
);
|
|
await engine.insertFact(
|
|
{ fact: 'B', kind: 'fact', entity_slug: slug, source: 'test', embedding: vec(0, 1, 0) },
|
|
{ source_id: 'default' },
|
|
);
|
|
const result = await engine.findCandidateDuplicates(
|
|
'default', slug, 'q',
|
|
{ embedding: vec(1, 0, 0) },
|
|
);
|
|
const aIdx = result.findIndex(r => r.fact === 'A');
|
|
const bIdx = result.findIndex(r => r.fact === 'B');
|
|
expect(aIdx).toBeGreaterThanOrEqual(0); // A is in the result
|
|
expect(bIdx).toBeGreaterThanOrEqual(0); // B is in the result
|
|
// Closest by cosine MUST come first.
|
|
expect(aIdx).toBeLessThan(bIdx);
|
|
});
|
|
});
|
|
|
|
describe('consolidateFact', () => {
|
|
test('marks consolidated_at + consolidated_into; never DELETE', async () => {
|
|
// Need a take to point at — seed a page + take.
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, type, title) VALUES ('cons-test', 'concept', 'Cons Test') ON CONFLICT DO NOTHING`,
|
|
);
|
|
const pageRows = await engine.executeRaw<{ id: number }>(
|
|
`SELECT id FROM pages WHERE slug = 'cons-test' AND source_id = 'default'`,
|
|
);
|
|
const pageId = pageRows[0].id;
|
|
await engine.executeRaw(
|
|
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 99, 'cons claim', 'fact', 'self') ON CONFLICT DO NOTHING`,
|
|
[pageId],
|
|
);
|
|
const takeRows = await engine.executeRaw<{ id: number }>(
|
|
`SELECT id FROM takes WHERE page_id = $1 AND row_num = 99`,
|
|
[pageId],
|
|
);
|
|
const takeId = takeRows[0].id;
|
|
|
|
const fact = await engine.insertFact(
|
|
{ fact: 'will be consolidated', kind: 'fact', entity_slug: 'cons-test', source: 'test' },
|
|
{ source_id: 'default' },
|
|
);
|
|
|
|
await engine.consolidateFact(fact.id, takeId);
|
|
const rows = await engine.executeRaw<{ id: number; consolidated_at: Date | null; consolidated_into: number | null }>(
|
|
`SELECT id, consolidated_at, consolidated_into FROM facts WHERE id = $1`,
|
|
[fact.id],
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].consolidated_at).not.toBeNull();
|
|
expect(Number(rows[0].consolidated_into)).toBe(takeId);
|
|
});
|
|
});
|
|
|
|
describe('getFactsHealth', () => {
|
|
test('returns counters keyed by source_id', async () => {
|
|
const health = await engine.getFactsHealth('default');
|
|
expect(health.source_id).toBe('default');
|
|
expect(health.total_active).toBeGreaterThanOrEqual(0);
|
|
expect(health.total_today).toBeGreaterThanOrEqual(0);
|
|
expect(health.total_week).toBeGreaterThanOrEqual(0);
|
|
expect(Array.isArray(health.top_entities)).toBe(true);
|
|
});
|
|
|
|
test('total_today subset of total_week subset of total_active+expired', async () => {
|
|
const health = await engine.getFactsHealth('default');
|
|
expect(health.total_today).toBeLessThanOrEqual(health.total_week);
|
|
expect(health.total_active + health.total_expired).toBeGreaterThanOrEqual(health.total_week);
|
|
});
|
|
});
|