From ac1c2ebbc0f6975f3ba2835c40d6cb85c50eff71 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 11 Jun 2026 21:37:33 -0700 Subject: [PATCH] feat(context): multi-turn window extraction + confidence-scored volunteer core (#2095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entity-salience.ts: extractCandidatesFromWindow(turns) — runs the existing per-turn extractor across the last N turns (oldest→newest), merges by the normalizeAlias form with occurrence/newest-turn/user-mention metadata, and orders by salience (recency > frequency > user-role) so the MAX_CANDIDATES cap drops stale assistant chatter, not the entity the user just named. Closes the filed assistant-introduced-entities recall TODO; true pronoun coreference (never-named antecedents) stays out of scope. - retrieval-reflex.ts: ReflexPointer gains source_id + arm + confidence + matchedNorm. ARM_CONFIDENCE (alias 0.9 / title 0.8 / slug-suffix 0.6) lives next to the arm definitions so identity and score can't drift. Arm-2 provenance is classified in JS (codex D8 — the combined OR can't report which predicate matched). Federated sourceIds[] scope (alias arm loops per source; arm 2 uses source_id = ANY — no engine-interface change). Suppression gains 'slug-only' mode (codex D7, REQUIRED for windowing): the legacy title-whole-word rule would suppress every entity merely MENTIONED in a prior window turn, breaking the feature by construction — slugs only enter context when a pointer/page was actually surfaced. Default stays 'slug-and-title' for the window=1 legacy path. - volunteer.ts (new): parseWindow (lenient user:/assistant: prefixes, CRLF, unprefixed → one user turn), volunteerContext (zero-LLM: extract → resolve → +0.05 multi-turn/newest-turn boost → min_confidence 0.7 gate → cap 3/5; deterministic rationale strings, never raw conversation text), and volunteerUsageStats (per-arm/channel precision from the last_retrieved_at join, labeled approximate — 5-min throttle false negatives, unrelated-read false positives; codex D9). Tests: 35 green across volunteer-context (window parsing, pronoun follow-up via assistant-introduced entity, confidence gating, slug-only suppression, takes-fence privacy, multi-source scope, caps, stats join math) + retrieval-reflex back-compat + resolve-ipc. Co-Authored-By: Claude Fable 5 --- src/core/context/entity-salience.ts | 91 ++++++++- src/core/context/retrieval-reflex.ts | 160 +++++++++++---- src/core/context/volunteer.ts | 246 +++++++++++++++++++++++ test/context/resolve-ipc.test.ts | 5 +- test/volunteer-context.test.ts | 283 +++++++++++++++++++++++++++ 5 files changed, 746 insertions(+), 39 deletions(-) create mode 100644 src/core/context/volunteer.ts create mode 100644 test/volunteer-context.test.ts diff --git a/src/core/context/entity-salience.ts b/src/core/context/entity-salience.ts index a51d20ef2..91082a0dc 100644 --- a/src/core/context/entity-salience.ts +++ b/src/core/context/entity-salience.ts @@ -7,12 +7,15 @@ * touching the brain, so it must be fast (one regex pass) and precision-biased: * a false candidate costs a wasted resolve and, worse, a misleading pointer. * - * DELIBERATE v1 limits (documented, not bugs — see issue #1981 / eng-review): + * DELIBERATE limits (documented, not bugs — see issue #1981 / eng-review): * - Proper-case + ASCII biased. Misses lowercase names ("garry") and many * non-Latin scripts. - * - Current-user-message only. No pronoun follow-ups ("what about her?"), no - * entities the assistant introduced. - * These are TODOs, not v1 scope. Do NOT market this as full "human-like recall". + * - extractCandidates is single-turn. The v0.43 (#2095) window layer + * (extractCandidatesFromWindow) widens extraction across the last N turns + * — assistant-introduced entities and "what about her?" follow-ups whose + * antecedent was NAMED in the window now resolve; true pronoun + * coreference (never-named antecedents) remains out of scope. + * Do NOT market this as full "human-like recall". * * Resolution (alias/slug lookup) lives in retrieval-reflex.ts; this module only * decides WHAT to look up. @@ -172,3 +175,83 @@ export function extractCandidates(text: string): EntityCandidate[] { } return out; } + +// ── Rolling-window extraction (v0.43, #2095 push-based context) ────────── + +export interface WindowTurn { + role: 'user' | 'assistant'; + text: string; +} + +/** + * A window candidate with the salience metadata the volunteer layer's + * confidence boost reads: how many turns mentioned it, whether the NEWEST + * turn did, and whether the USER (vs only the assistant) ever said it. + */ +export interface WindowEntityCandidate extends EntityCandidate { + /** Number of distinct turns that mentioned this candidate. */ + occurrences: number; + /** Mentioned in the newest (last) turn of the window. */ + inNewestTurn: boolean; + /** Mentioned in at least one USER turn (assistant-only mentions rank lower). */ + userMention: boolean; +} + +/** + * Extract candidates across the last N turns (oldest → newest). Pure, + * zero-LLM: runs the per-turn extractor on each turn and merges by the + * normalizeAlias form. Ordering is salience-aware — recency of last mention, + * cross-turn frequency, and a user-role boost — so when the merged set + * exceeds MAX_CANDIDATES, the dropped tail is the stalest assistant-only + * chatter, not the entity the user just named. + */ +export function extractCandidatesFromWindow(turns: WindowTurn[]): WindowEntityCandidate[] { + if (!turns?.length) return []; + interface WAcc extends WindowEntityCandidate { + lastTurnIdx: number; + order: number; + } + const acc = new Map(); + let order = 0; + const lastIdx = turns.length - 1; + + for (let i = 0; i < turns.length; i++) { + const turn = turns[i]; + if (!turn?.text) continue; + for (const c of extractCandidates(turn.text)) { + const norm = normalizeAlias(c.query); + if (!norm) continue; + const existing = acc.get(norm); + if (existing) { + existing.occurrences += 1; + existing.lastTurnIdx = i; + existing.inNewestTurn = existing.inNewestTurn || i === lastIdx; + if (turn.role === 'user' && !existing.userMention) { + // First USER-said surface form beats an assistant-introduced one + // for the display label. + existing.display = c.display; + existing.userMention = true; + } + } else { + acc.set(norm, { + display: c.display, + query: c.query, + occurrences: 1, + lastTurnIdx: i, + inNewestTurn: i === lastIdx, + userMention: turn.role === 'user', + order: order++, + }); + } + } + } + + // Salience weight: recency dominates, then frequency, then user-role. + // Deterministic tie-break on first-seen order. + const weight = (c: WAcc) => + (c.lastTurnIdx + 1) / turns.length + Math.min(c.occurrences, 4) * 0.1 + (c.userMention ? 0.15 : 0); + return Array.from(acc.values()) + .sort((a, b) => weight(b) - weight(a) || a.order - b.order) + .slice(0, MAX_CANDIDATES) + .map(({ lastTurnIdx: _l, order: _o, ...rest }) => rest); +} diff --git a/src/core/context/retrieval-reflex.ts b/src/core/context/retrieval-reflex.ts index d1cb8e3af..70a327e95 100644 --- a/src/core/context/retrieval-reflex.ts +++ b/src/core/context/retrieval-reflex.ts @@ -34,10 +34,40 @@ import type { EntityCandidate } from './entity-salience.ts'; export const DEFAULT_MAX_POINTERS = 3; const SYNOPSIS_MAX = 160; +/** Which resolution arm produced a pointer (provenance → honest confidence). */ +export type ResolveArm = 'alias' | 'title' | 'slug-suffix'; + +/** + * v0.43 (#2095) — arm → confidence. Lives HERE, next to the arm definitions, + * so arm identity and its score can't drift apart (eng-review note). The + * volunteer layer imports these; small deterministic boosts (multi-turn / + * newest-turn mention) are added on top there. + */ +export const ARM_CONFIDENCE: Record = { + alias: 0.9, + title: 0.8, + 'slug-suffix': 0.6, +}; + export interface ReflexPointer { display: string; slug: string; + /** Which brain source the page lives in (federated callers need it for dedup). */ + source_id: string; synopsis: string; + /** Resolution provenance (v0.43 #2095). */ + arm: ResolveArm; + /** Base arm confidence (ARM_CONFIDENCE[arm]); callers may boost. */ + confidence: number; + /** + * normalizeAlias form of the CANDIDATE that resolved this pointer (v0.43 + * #2095) — lets the volunteer layer join pointers back to window-salience + * metadata without guessing from the display label (which falls back to the + * page title when the candidate surface differs, e.g. alias "Swami" → + * title "Swami X"). Absent only when suffix classification couldn't + * recover the source candidate. + */ + matchedNorm?: string; } export interface PointerBlock { @@ -55,10 +85,30 @@ export interface ResolvePointersOpts { * message's own mention would suppress every pointer (eng-review/Codex fix). */ priorContextText?: string; + /** + * v0.43 (#2095, codex D7) — suppression mode. + * 'slug-and-title' (default, window=1 legacy): suppress when the slug OR + * the page title appears whole-word in prior context. + * 'slug-only' (REQUIRED under multi-turn windowing): suppress on slug + * presence only. Slugs only enter context when a pointer block or page + * body was actually injected; a bare mention of "Alice Example" in a + * prior turn never contains `people/alice-example`. The title rule + * would suppress every entity merely MENTIONED in a prior window turn + * — breaking window extraction by construction. + */ + suppression?: 'slug-and-title' | 'slug-only'; + /** + * v0.43 (#2095) — federated scope: resolve across these sources instead of + * the single positional sourceId. Precedence mirrors sourceScopeOpts + * (federated array > scalar). Alias arm loops per source; the title/slug + * arm uses source_id = ANY(...) in one query. + */ + sourceIds?: string[]; } interface PageRow { slug: string; + source_id: string; title: string; type: string | null; frontmatter: Record | null; @@ -102,26 +152,49 @@ export async function resolveEntitiesToPointers( } if (!aliasNorms.length) return null; - // Ordered set of resolved slugs (alias hits first → higher confidence). - const resolvedSlugs: string[] = []; + // Federated scope (v0.43 #2095): explicit sourceIds win over the scalar. + const sourceIds = opts.sourceIds?.length ? opts.sourceIds : [sourceId]; + + // Ordered set of resolved (source, slug) pairs with arm provenance — + // alias hits pushed first → higher confidence. + const resolved: Array<{ slug: string; source_id: string; arm: ResolveArm; matchedNorm?: string }> = []; const seen = new Set(); - const pushSlug = (slug: string) => { - if (slug && !seen.has(slug)) { - seen.add(slug); - resolvedSlugs.push(slug); + // Neither source ids nor slugs contain spaces, so a space separator is safe. + const keyOf = (src: string, slug: string) => `${src} ${slug}`; + const push = (slug: string, src: string, arm: ResolveArm, matchedNorm?: string) => { + if (!slug) return; + const k = keyOf(src, slug); + if (!seen.has(k)) { + seen.add(k); + resolved.push({ slug, source_id: src, arm, matchedNorm }); } }; + // Reverse maps for arm-2 provenance: which CANDIDATE produced this row + // (titles/slugs were built per candidate above, in the same pass). + const titleToNorm = new Map(); + const slugToNorm = new Map(); + for (const c of candidates) { + const norm = normalizeAlias(c.query); + if (!norm) continue; + const tl = c.query.toLowerCase(); + if (!titleToNorm.has(tl)) titleToNorm.set(tl, norm); + const s = slugify(c.query); + if (s && !slugToNorm.has(s)) slugToNorm.set(s, norm); + } - // Arm 1 — alias-first. Unambiguous single-slug hits only. Guarded: pre-v110 - // brains throw "relation page_aliases does not exist" — swallow and continue. - try { - const aliasMap = await engine.resolveAliases(aliasNorms, { sourceId }); - for (const norm of aliasNorms) { - const hits = aliasMap.get(norm); - if (hits && hits.length === 1) pushSlug(hits[0].slug); + // Arm 1 — alias-first. Unambiguous single-slug hits only, per source (no + // engine-interface change for federation). Guarded: pre-v110 brains throw + // "relation page_aliases does not exist" — swallow and continue. + for (const src of sourceIds) { + try { + const aliasMap = await engine.resolveAliases(aliasNorms, { sourceId: src }); + for (const norm of aliasNorms) { + const hits = aliasMap.get(norm); + if (hits && hits.length === 1) push(hits[0].slug, src, 'alias', norm); + } + } catch { + /* no page_aliases table (pre-v110) — degrade to the title/slug arm */ } - } catch { - /* no page_aliases table (pre-v110) — degrade to the title/slug arm */ } // Arm 2 — exact title OR slug-suffix. This is the recall fix: a bare "Alice @@ -130,53 +203,72 @@ export async function resolveEntitiesToPointers( let rows: PageRow[] = []; try { rows = await engine.executeRaw( - `SELECT slug, title, type, frontmatter, compiled_truth + `SELECT slug, source_id, title, type, frontmatter, compiled_truth FROM pages WHERE deleted_at IS NULL - AND source_id = $1 + AND source_id = ANY($1::text[]) AND ( lower(title) = ANY($2::text[]) OR slug = ANY($3::text[]) OR slug LIKE ANY($4::text[]) )`, - [sourceId, titlesLc, exactSlugs, slugSuffixes], + [sourceIds, titlesLc, exactSlugs, slugSuffixes], ); } catch { rows = []; } - // Hydrate alias-resolved slugs too (their bodies for the synopsis) if not in rows. - const rowBySlug = new Map(); - for (const r of rows) rowBySlug.set(r.slug, r); - const aliasOnly = resolvedSlugs.filter((s) => !rowBySlug.has(s)); + // Hydrate alias-resolved pages too (their bodies for the synopsis) if not in rows. + const rowByKey = new Map(); + for (const r of rows) rowByKey.set(keyOf(r.source_id, r.slug), r); + const aliasOnly = resolved.filter((p) => !rowByKey.has(keyOf(p.source_id, p.slug))); if (aliasOnly.length) { try { const extra = await engine.executeRaw( - `SELECT slug, title, type, frontmatter, compiled_truth + `SELECT slug, source_id, title, type, frontmatter, compiled_truth FROM pages - WHERE deleted_at IS NULL AND source_id = $1 AND slug = ANY($2::text[])`, - [sourceId, aliasOnly], + WHERE deleted_at IS NULL AND source_id = ANY($1::text[]) AND slug = ANY($2::text[])`, + [sourceIds, aliasOnly.map((p) => p.slug)], ); - for (const r of extra) rowBySlug.set(r.slug, r); + for (const r of extra) rowByKey.set(keyOf(r.source_id, r.slug), r); } catch { /* ignore — alias slug may be stale */ } } // Title/slug matches that weren't alias hits, appended after alias hits. - for (const r of rows) pushSlug(r.slug); + // Arm provenance per row is classified in JS (codex D8) — the combined OR + // can't report which predicate matched: an exact lower(title) hit is the + // 'title' arm; anything else got in via slug / slug-suffix. + const titleSet = new Set(titlesLc); + for (const r of rows) { + const titleLc = (r.title ?? '').toLowerCase(); + if (titleSet.has(titleLc)) { + push(r.slug, r.source_id, 'title', titleToNorm.get(titleLc)); + } else { + // Slug arm: exact slugified-candidate match, else suffix scan. + const tail = r.slug.includes('/') ? r.slug.slice(r.slug.lastIndexOf('/') + 1) : r.slug; + push(r.slug, r.source_id, 'slug-suffix', slugToNorm.get(r.slug) ?? slugToNorm.get(tail)); + } + } // Build pointers in confidence order, applying suppression + cap. + const suppression = opts.suppression ?? 'slug-and-title'; const pointers: ReflexPointer[] = []; - for (const slug of resolvedSlugs) { - const row = rowBySlug.get(slug); + for (const { slug, source_id, arm, matchedNorm } of resolved) { + const row = rowByKey.get(keyOf(source_id, slug)); if (!row) continue; - // Suppression: already present in PRIOR context (slug or title). The current - // turn is deliberately excluded from priorContextText. + // Suppression: already present in PRIOR context. The current turn is + // deliberately excluded from priorContextText. Under windowing + // ('slug-only', codex D7) only the slug counts — a slug appears in prior + // context only when a pointer/page was actually surfaced there, while a + // title appears on any bare mention. if (priorLc) { - const titleLc = (row.title ?? '').toLowerCase(); if (priorLc.includes(slug.toLowerCase())) continue; - if (titleLc && wholeWordIncludes(priorLc, titleLc)) continue; + if (suppression === 'slug-and-title') { + const titleLc = (row.title ?? '').toLowerCase(); + if (titleLc && wholeWordIncludes(priorLc, titleLc)) continue; + } } const display = displayForRow(row, displayByNorm); const synopsis = safeSynopsis(row); - pointers.push({ display, slug, synopsis }); + pointers.push({ display, slug, source_id, synopsis, arm, confidence: ARM_CONFIDENCE[arm], matchedNorm }); if (pointers.length >= maxPointers) break; } diff --git a/src/core/context/volunteer.ts b/src/core/context/volunteer.ts new file mode 100644 index 000000000..59fa98b0f --- /dev/null +++ b/src/core/context/volunteer.ts @@ -0,0 +1,246 @@ +/** + * v0.43 (#2095) — push-based context: the brain VOLUNTEERS relevant pages + * from a rolling conversation window instead of waiting to be asked. + * + * window text ─ parseWindow() ─→ turns[] ─ extractCandidatesFromWindow() + * │ (recency + frequency + + * │ user-role weights) + * ▼ + * resolveEntitiesToPointers(sourceIds[]) alias 0.9 / title 0.8 / + * │ slug-suffix 0.6 (+0.05 boost + * ▼ for ≥2-turn or newest-turn + * gate min_confidence (default 0.7) → mentions) + * suppression (slug-only — windowing) → + * cap (3 default / 5 max) + * + * Zero-LLM, deterministic, precision-biased: push noise is worse than pull + * silence (#2095). At the default gate, slug-suffix matches (0.6+0.05 < 0.7) + * never volunteer — they need an explicit lower min_confidence. + * + * Consumed by three channels: the volunteer_context op, the retrieval-reflex + * window path, and `gbrain watch`. Event logging lives in + * volunteer-events.ts; usage stats here derive "used" from + * pages.last_retrieved_at — APPROXIMATE by design (the 5-min last-retrieved + * throttle causes false negatives; unrelated reads cause false positives). + */ + +import type { BrainEngine } from '../engine.ts'; +import { normalizeAlias } from '../search/alias-normalize.ts'; +import { + extractCandidatesFromWindow, + type WindowTurn, + type WindowEntityCandidate, +} from './entity-salience.ts'; +import { + resolveEntitiesToPointers, + ARM_CONFIDENCE, + type ResolveArm, +} from './retrieval-reflex.ts'; + +export const VOLUNTEER_DEFAULT_MAX_PAGES = 3; +export const VOLUNTEER_MAX_PAGES_CAP = 5; +export const VOLUNTEER_DEFAULT_MIN_CONFIDENCE = 0.7; +/** Deterministic boost for ≥2-turn or newest-turn mentions. */ +export const VOLUNTEER_SALIENCE_BOOST = 0.05; + +export interface VolunteeredPage { + slug: string; + source_id: string; + display: string; + confidence: number; + arm: ResolveArm; + /** Deterministic template string — never raw conversation text. */ + rationale: string; + synopsis: string; +} + +export interface VolunteerOpts { + /** Resolved source scope (federated array > scalar — sourceScopeOpts shape). */ + sourceIds: string[]; + /** Prior context (already-surfaced pointers/pages) for slug-only suppression. */ + priorContext?: string; + maxPages?: number; + minConfidence?: number; +} + +const TURN_PREFIX_RE = /^(user|assistant)\s*:\s?(.*)$/i; + +/** + * Lenient window parser: `user:` / `assistant:` line prefixes start a new + * turn (oldest → newest); unprefixed lines continue the current turn; input + * with no prefixes at all is ONE user turn (so `echo "..." | volunteer-context` + * just works). CRLF-tolerant. Empty/whitespace input → []. + */ +export function parseWindow(text: string): WindowTurn[] { + if (!text || !text.trim()) return []; + const turns: WindowTurn[] = []; + let current: WindowTurn | null = null; + for (const rawLine of text.split(/\r?\n/)) { + const m = TURN_PREFIX_RE.exec(rawLine); + if (m) { + if (current) turns.push(current); + current = { role: m[1].toLowerCase() as WindowTurn['role'], text: m[2] ?? '' }; + } else if (current) { + current.text += (current.text ? '\n' : '') + rawLine; + } else if (rawLine.trim()) { + // No prefix seen yet — accumulate into an implicit user turn. + current = { role: 'user', text: rawLine }; + } + } + if (current) turns.push(current); + // Trim trailing whitespace-only turn bodies. + return turns + .map((t) => ({ role: t.role, text: t.text.trim() })) + .filter((t) => t.text.length > 0); +} + +function clampMaxPages(n: number | undefined): number { + if (typeof n !== 'number' || !Number.isFinite(n) || n < 1) return VOLUNTEER_DEFAULT_MAX_PAGES; + return Math.min(Math.floor(n), VOLUNTEER_MAX_PAGES_CAP); +} + +function rationaleFor(arm: ResolveArm, display: string, c: WindowEntityCandidate | undefined, windowSize: number): string { + const armText = + arm === 'alias' ? `alias match "${display}"` + : arm === 'title' ? `exact title match "${display}"` + : `slug match "${display}"`; + if (!c) return armText; + const parts = [armText]; + if (c.occurrences >= 2) parts.push(`mentioned in ${c.occurrences} of last ${windowSize} turns`); + else if (c.inNewestTurn) parts.push('mentioned in the newest turn'); + if (!c.userMention) parts.push('assistant-introduced'); + return parts.join('; '); +} + +/** + * Volunteer confidence-gated pages for a conversation window. Pure read — + * event logging is the CALLER's job (through the volunteer-events sink). + * Non-relational, zero-LLM; returns [] when nothing clears the gate. + */ +export async function volunteerContext( + engine: BrainEngine, + turns: WindowTurn[], + opts: VolunteerOpts, +): Promise { + if (!turns.length || !opts.sourceIds?.length) return []; + const candidates = extractCandidatesFromWindow(turns); + if (!candidates.length) return []; + const byNorm = new Map(); + for (const c of candidates) { + const norm = normalizeAlias(c.query); + if (norm && !byNorm.has(norm)) byNorm.set(norm, c); + } + + const maxPages = clampMaxPages(opts.maxPages); + const minConfidence = + typeof opts.minConfidence === 'number' && opts.minConfidence >= 0 && opts.minConfidence <= 1 + ? opts.minConfidence + : VOLUNTEER_DEFAULT_MIN_CONFIDENCE; + + // Resolve up to the hard cap so the confidence gate sees the full pool — + // a gated-out alias hit must not shadow a passing title hit behind it. + const block = await resolveEntitiesToPointers(engine, opts.sourceIds[0], candidates, { + sourceIds: opts.sourceIds, + priorContextText: opts.priorContext, + suppression: 'slug-only', + maxPointers: VOLUNTEER_MAX_PAGES_CAP * 2, + }); + if (!block) return []; + + const out: VolunteeredPage[] = []; + for (const p of block.pointers) { + // matchedNorm is the resolver's provenance join-key (the candidate that + // resolved the pointer); display-based lookup is the fallback for the + // rare suffix rows where provenance couldn't be recovered. + const cand = (p.matchedNorm ? byNorm.get(p.matchedNorm) : undefined) ?? byNorm.get(normalizeAlias(p.display)); + const boost = cand && (cand.occurrences >= 2 || cand.inNewestTurn) ? VOLUNTEER_SALIENCE_BOOST : 0; + const confidence = Math.min(0.99, ARM_CONFIDENCE[p.arm] + boost); + if (confidence < minConfidence) continue; + out.push({ + slug: p.slug, + source_id: p.source_id, + display: p.display, + confidence, + arm: p.arm, + rationale: rationaleFor(p.arm, p.display, cand, turns.length), + synopsis: p.synopsis, + }); + if (out.length >= maxPages) break; + } + return out; +} + +// ── Usage stats (the feedback loop) ────────────────────────────────────── + +export interface VolunteerArmStats { + match_arm: string; + channel: string; + volunteered: number; + used: number; + /** used / volunteered, 0 when nothing volunteered. */ + precision: number; +} + +export interface VolunteerUsageStats { + days: number; + /** The join is approximate — see the note (false +/- documented in #2095 D9). */ + approximate: true; + note: string; + total_volunteered: number; + total_used: number; + by_arm: VolunteerArmStats[]; +} + +export const VOLUNTEER_STATS_NOTE = + 'approximate: "used" = pages.last_retrieved_at > volunteered_at. The 5-min ' + + 'last-retrieved throttle causes false negatives; unrelated reads of the same ' + + 'page cause false positives.'; + +/** + * Per-arm/channel precision over the last N days, source-scoped. Read-only; + * returns zeroed stats on pre-v116 brains (no table). + */ +export async function volunteerUsageStats( + engine: BrainEngine, + sourceIds: string[], + days = 30, +): Promise { + const safeDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : 30; + let rows: Array<{ match_arm: string; channel: string; volunteered: string | number; used: string | number }> = []; + try { + rows = await engine.executeRaw( + `SELECT e.match_arm, e.channel, + count(*)::text AS volunteered, + count(*) FILTER (WHERE p.last_retrieved_at > e.volunteered_at)::text AS used + FROM context_volunteer_events e + LEFT JOIN pages p + ON p.source_id = e.source_id AND p.slug = e.slug AND p.deleted_at IS NULL + WHERE e.source_id = ANY($1::text[]) + AND e.volunteered_at > now() - ($2 || ' days')::interval + GROUP BY e.match_arm, e.channel + ORDER BY e.match_arm, e.channel`, + [sourceIds, String(safeDays)], + ); + } catch { + rows = []; // pre-v116 brain — table doesn't exist yet + } + const by_arm: VolunteerArmStats[] = rows.map((r) => { + const volunteered = Number(r.volunteered); + const used = Number(r.used); + return { + match_arm: r.match_arm, + channel: r.channel, + volunteered, + used, + precision: volunteered > 0 ? Number((used / volunteered).toFixed(3)) : 0, + }; + }); + return { + days: safeDays, + approximate: true, + note: VOLUNTEER_STATS_NOTE, + total_volunteered: by_arm.reduce((s, a) => s + a.volunteered, 0), + total_used: by_arm.reduce((s, a) => s + a.used, 0), + by_arm, + }; +} diff --git a/test/context/resolve-ipc.test.ts b/test/context/resolve-ipc.test.ts index c9e83f8f0..a9687b88d 100644 --- a/test/context/resolve-ipc.test.ts +++ b/test/context/resolve-ipc.test.ts @@ -26,7 +26,10 @@ describe('resolve IPC', () => { test('round-trip: client gets the pointer block the server returns', async () => { const dir = tmpDir(); const sock = resolveSocketPath(dir); - const block: PointerBlock = { pointers: [{ display: 'Alice', slug: 'people/alice', synopsis: 'x' }], text: 'BLOCK' }; + const block: PointerBlock = { + pointers: [{ display: 'Alice', slug: 'people/alice', source_id: 'default', synopsis: 'x', arm: 'alias', confidence: 0.9 }], + text: 'BLOCK', + }; const server = await startResolveIpcServer(sock, async (req) => { expect(req.candidates[0].query).toBe('Alice'); return block; diff --git a/test/volunteer-context.test.ts b/test/volunteer-context.test.ts new file mode 100644 index 000000000..28905f3d0 --- /dev/null +++ b/test/volunteer-context.test.ts @@ -0,0 +1,283 @@ +/** + * v0.43 (#2095) — push-based context core: window parsing, multi-turn + * extraction, confidence-gated volunteering, slug-only suppression, privacy, + * and the usage-stats join. Hermetic in-memory PGLite (no file lock), + * modeled on test/retrieval-reflex.test.ts. + */ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { normalizeAlias } from '../src/core/search/alias-normalize.ts'; +import { + extractCandidatesFromWindow, + MAX_CANDIDATES, + type WindowTurn, +} from '../src/core/context/entity-salience.ts'; +import { resolveEntitiesToPointers } from '../src/core/context/retrieval-reflex.ts'; +import { + parseWindow, + volunteerContext, + volunteerUsageStats, + VOLUNTEER_DEFAULT_MIN_CONFIDENCE, +} from '../src/core/context/volunteer.ts'; +import { insertVolunteerEvents } from '../src/core/context/volunteer-events.ts'; +import { TAKES_FENCE_BEGIN, TAKES_FENCE_END } from '../src/core/takes-fence.ts'; + +let engine: PGLiteEngine; + +async function seed(slug: string, title: string, body: string, source = 'default') { + if (source !== 'default') { + await engine.executeRaw( + `INSERT INTO sources (id, name, local_path) VALUES ($1, $1, $2) + ON CONFLICT (id) DO NOTHING`, + [source, `/tmp/${source}`], + ); + } + await engine.executeRaw( + `INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline) + VALUES ($1, $2, 'person', $3, $4, '')`, + [slug, source, title, body], + ); +} + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); +}, 60_000); + +afterAll(async () => { + await engine.disconnect(); +}); + +beforeEach(async () => { + await engine.executeRaw('DELETE FROM page_aliases').catch(() => {}); + await engine.executeRaw('DELETE FROM context_volunteer_events').catch(() => {}); + await engine.executeRaw('DELETE FROM pages'); +}); + +describe('parseWindow', () => { + test('role prefixes split turns oldest → newest', () => { + const turns = parseWindow('user: ask Alice about the deal\nassistant: noted\nuser: what did she say?'); + expect(turns).toEqual([ + { role: 'user', text: 'ask Alice about the deal' }, + { role: 'assistant', text: 'noted' }, + { role: 'user', text: 'what did she say?' }, + ]); + }); + + test('CRLF input parses identically', () => { + const turns = parseWindow('user: hello\r\nassistant: hi\r\n'); + expect(turns).toEqual([ + { role: 'user', text: 'hello' }, + { role: 'assistant', text: 'hi' }, + ]); + }); + + test('unprefixed text is ONE user turn (echo | volunteer-context just works)', () => { + const turns = parseWindow('met with Alice Example today\nshe asked about acme'); + expect(turns).toEqual([{ role: 'user', text: 'met with Alice Example today\nshe asked about acme' }]); + }); + + test('continuation lines append to the open turn', () => { + const turns = parseWindow('user: first line\nsecond line\nassistant: ok'); + expect(turns[0]).toEqual({ role: 'user', text: 'first line\nsecond line' }); + expect(turns[1]).toEqual({ role: 'assistant', text: 'ok' }); + }); + + test('empty / whitespace input → []', () => { + expect(parseWindow('')).toEqual([]); + expect(parseWindow(' \n \n')).toEqual([]); + }); +}); + +describe('extractCandidatesFromWindow', () => { + test('merges across turns with occurrence + newest-turn metadata', () => { + const turns: WindowTurn[] = [ + { role: 'user', text: 'ask Alice Example about the deal' }, + { role: 'assistant', text: 'Alice Example said she will follow up' }, + { role: 'user', text: 'and ping Bob Sample too' }, + ]; + const cands = extractCandidatesFromWindow(turns); + const alice = cands.find((c) => normalizeAlias(c.query) === normalizeAlias('Alice Example')); + const bob = cands.find((c) => normalizeAlias(c.query) === normalizeAlias('Bob Sample')); + expect(alice).toBeDefined(); + expect(alice!.occurrences).toBe(2); + expect(alice!.inNewestTurn).toBe(false); + expect(alice!.userMention).toBe(true); + expect(bob).toBeDefined(); + expect(bob!.inNewestTurn).toBe(true); + }); + + test('assistant-only mention is flagged (userMention=false)', () => { + const cands = extractCandidatesFromWindow([ + { role: 'assistant', text: 'You should talk to Charlie Demo about this' }, + { role: 'user', text: 'good idea' }, + ]); + const charlie = cands.find((c) => normalizeAlias(c.query) === normalizeAlias('Charlie Demo')); + expect(charlie).toBeDefined(); + expect(charlie!.userMention).toBe(false); + }); + + test('cap holds across a noisy window', () => { + const noisy = Array.from({ length: 30 }, (_, i) => `Entity Number${i} did something.`).join(' '); + const cands = extractCandidatesFromWindow([{ role: 'user', text: noisy }]); + expect(cands.length).toBeLessThanOrEqual(MAX_CANDIDATES); + }); +}); + +describe('volunteerContext', () => { + test('assistant-introduced entity two turns back resolves on a pronoun follow-up', async () => { + await seed('people/alice-example', 'Alice Example', 'Alice is an early founder.'); + const turns = parseWindow( + 'user: who should I talk to about the seed round?\n' + + 'assistant: Alice Example led a similar round last year.\n' + + 'user: what did she invest in?', + ); + const pages = await volunteerContext(engine, turns, { sourceIds: ['default'] }); + expect(pages.length).toBe(1); + expect(pages[0].slug).toBe('people/alice-example'); + expect(pages[0].arm).toBe('title'); + expect(pages[0].rationale).toContain('assistant-introduced'); + }); + + test('confidence gate drops slug-suffix matches at the default threshold', async () => { + // Page resolvable ONLY via slug-suffix: title differs from the mention. + await seed('projects/widget-co', 'The Widget Company Project', 'A project page.'); + const turns = parseWindow('user: any updates on Widget-Co this week?'); + const gated = await volunteerContext(engine, turns, { sourceIds: ['default'] }); + expect(gated).toEqual([]); + // Lowering min_confidence lets it through with honest provenance. + const loose = await volunteerContext(engine, turns, { sourceIds: ['default'], minConfidence: 0.5 }); + expect(loose.length).toBe(1); + expect(loose[0].arm).toBe('slug-suffix'); + expect(loose[0].confidence).toBeLessThan(VOLUNTEER_DEFAULT_MIN_CONFIDENCE); + }); + + test('alias arm volunteers with boost when mentioned in the newest turn', async () => { + await seed('people/swami-x', 'Swami X', 'A close friend.'); + await engine.setPageAliases('people/swami-x', 'default', [normalizeAlias('Swami')]); + const pages = await volunteerContext( + engine, + parseWindow('user: Spoke with Swami today'), + { sourceIds: ['default'] }, + ); + expect(pages.length).toBe(1); + expect(pages[0].arm).toBe('alias'); + expect(pages[0].confidence).toBeCloseTo(0.95, 5); // 0.9 + newest-turn boost + expect(pages[0].rationale).toContain('alias match'); + }); + + test('suppression is slug-only under windowing: a prior-turn MENTION does not suppress', async () => { + await seed('people/alice-example', 'Alice Example', 'A founder.'); + // Prior context contains the TITLE (a bare mention from an earlier turn) + // but NOT the slug — the page was never actually surfaced. + const pages = await volunteerContext( + engine, + parseWindow('user: ping Alice Example again'), + { sourceIds: ['default'], priorContext: 'earlier the user said: tell Alice Example the news' }, + ); + expect(pages.length).toBe(1); + // A slug in prior context (the page WAS surfaced) does suppress. + const suppressed = await volunteerContext( + engine, + parseWindow('user: ping Alice Example again'), + { sourceIds: ['default'], priorContext: 'pointer: people/alice-example was injected last turn' }, + ); + expect(suppressed).toEqual([]); + }); + + test('privacy: takes-fence content never leaks into the synopsis', async () => { + const body = `${TAKES_FENCE_BEGIN}\nSECRET_HUNCH_DO_NOT_LEAK\n${TAKES_FENCE_END}\nAlice is a founder.`; + await seed('people/alice-example', 'Alice Example', body); + const pages = await volunteerContext(engine, parseWindow('user: about Alice Example'), { sourceIds: ['default'] }); + expect(pages.length).toBe(1); + expect(JSON.stringify(pages)).not.toContain('SECRET_HUNCH_DO_NOT_LEAK'); + }); + + test('multi-source scope: resolves from both sources, never outside the scope', async () => { + await seed('people/alice-example', 'Alice Example', 'Founder.', 'default'); + await seed('people/bob-sample', 'Bob Sample', 'Engineer.', 'team-brain'); + await seed('people/eve-other', 'Eve Other', 'Out of scope.', 'private-brain'); + const turns = parseWindow('user: intro Alice Example to Bob Sample and Eve Other'); + const pages = await volunteerContext(engine, turns, { sourceIds: ['default', 'team-brain'] }); + const keys = pages.map((p) => `${p.source_id}:${p.slug}`).sort(); + expect(keys).toEqual(['default:people/alice-example', 'team-brain:people/bob-sample']); + }); + + test('zero-candidate fast path: no entities → [] without touching resolution', async () => { + const pages = await volunteerContext(engine, parseWindow('user: ok thanks, sounds good'), { + sourceIds: ['default'], + }); + expect(pages).toEqual([]); + }); + + test('max_pages caps at 5 even when asked for more', async () => { + for (let i = 0; i < 8; i++) { + await seed(`people/person-${i}`, `Person Alpha${i}`, 'A person.'); + } + const text = Array.from({ length: 8 }, (_, i) => `Person Alpha${i}`).join(' and '); + const pages = await volunteerContext(engine, parseWindow(`user: intro ${text}`), { + sourceIds: ['default'], + maxPages: 50, + }); + expect(pages.length).toBeLessThanOrEqual(5); + }); +}); + +describe('volunteerUsageStats', () => { + test('join math: used = last_retrieved_at > volunteered_at, labeled approximate', async () => { + await seed('people/alice-example', 'Alice Example', 'Founder.'); + await seed('people/bob-sample', 'Bob Sample', 'Engineer.'); + await insertVolunteerEvents(engine, [ + { source_id: 'default', slug: 'people/alice-example', confidence: 0.9, match_arm: 'alias', rationale: 'r', channel: 'op' }, + { source_id: 'default', slug: 'people/bob-sample', confidence: 0.8, match_arm: 'title', rationale: 'r', channel: 'op' }, + ]); + // Alice was opened AFTER being volunteered; Bob never was. + await engine.executeRaw( + `UPDATE pages SET last_retrieved_at = now() + interval '1 minute' WHERE slug = 'people/alice-example'`, [], + ); + const stats = await volunteerUsageStats(engine, ['default'], 30); + expect(stats.approximate).toBe(true); + expect(stats.note).toContain('approximate'); + expect(stats.total_volunteered).toBe(2); + expect(stats.total_used).toBe(1); + const alias = stats.by_arm.find((a) => a.match_arm === 'alias')!; + expect(alias.used).toBe(1); + expect(alias.precision).toBe(1); + const title = stats.by_arm.find((a) => a.match_arm === 'title')!; + expect(title.used).toBe(0); + }); + + test('zero events → zeroed stats, not an error', async () => { + const stats = await volunteerUsageStats(engine, ['default'], 7); + expect(stats.total_volunteered).toBe(0); + expect(stats.by_arm).toEqual([]); + }); +}); + +describe('resolveEntitiesToPointers — new provenance surface (back-compat)', () => { + test('pointers carry arm + confidence + source_id', async () => { + await seed('people/alice-example', 'Alice Example', 'Founder.'); + const block = await resolveEntitiesToPointers( + engine, + 'default', + [{ display: 'Alice Example', query: 'Alice Example' }], + {}, + ); + expect(block).not.toBeNull(); + expect(block!.pointers[0].arm).toBe('title'); + expect(block!.pointers[0].confidence).toBe(0.8); + expect(block!.pointers[0].source_id).toBe('default'); + }); + + test('legacy suppression (slug-and-title) still drops a title mention in prior context', async () => { + await seed('people/alice-example', 'Alice Example', 'Founder.'); + const block = await resolveEntitiesToPointers( + engine, + 'default', + [{ display: 'Alice Example', query: 'Alice Example' }], + { priorContextText: 'we discussed Alice Example earlier' }, + ); + expect(block).toBeNull(); + }); +});