diff --git a/src/core/calibration/recall-footer.ts b/src/core/calibration/recall-footer.ts new file mode 100644 index 000000000..be2075b3f --- /dev/null +++ b/src/core/calibration/recall-footer.ts @@ -0,0 +1,81 @@ +/** + * v0.36.0.0 (T16) — calibration footer for `gbrain recall` morning pulse. + * + * Pure formatter. Given an active calibration profile, returns the + * conversational block to prepend or append to the recall output: + * + * Calibration this quarter: + * Brier 0.18 (was 0.22 90d ago — improving). + * Right on early-stage tactics, late on macro by 18 months. + * Over-confident on team execution; under-calibrated on regulatory risk. + * + * Threads you opened and never came back to: + * · AI search platform differentiation (17 months silent) + * · International expansion playbook (12 months silent) + * + * Cold-brain branch: returns empty string when no profile or + * insufficient resolved takes. The caller decides whether to prepend + * the block; cold-brain absence is the cleanest non-event. + * + * v0.36.0.0 ship state: opt-in via `gbrain recall --show-calibration` + * to keep R3 regression posture (existing recall text shape unchanged + * for users who don't pass the flag). v0.37 defaults to on. + * + * Trend computation: v0.36.0.0 has only ONE profile snapshot (the most + * recent generation). Trend ("was X 90d ago — improving/declining") + * arrives when we accumulate generated_at history. + */ + +import type { CalibrationProfileRow } from '../../commands/calibration.ts'; + +export interface AbandonedThreadSummary { + claim: string; + monthsSilent: number; +} + +export interface RecallFooterOpts { + profile: CalibrationProfileRow | null; + abandonedThreads?: AbandonedThreadSummary[]; + /** Width hint for column alignment on threads. Default 50. */ + threadColumnWidth?: number; +} + +export function buildRecallCalibrationFooter(opts: RecallFooterOpts): string { + const { profile, abandonedThreads } = opts; + if (!profile) return ''; + if (profile.total_resolved < 5) return ''; + + const lines: string[] = []; + lines.push('Calibration this quarter:'); + + // Brier line. v0.36.0.0 has only the current snapshot — no 90d comparison. + if (profile.brier !== null) { + lines.push(` Brier ${profile.brier.toFixed(2)} ${trendNote(profile.brier)}`); + } + // Up to 4 pattern statements. Indent for readability. + for (const p of profile.pattern_statements.slice(0, 4)) { + lines.push(` ${p}`); + } + + if (abandonedThreads && abandonedThreads.length > 0) { + lines.push(''); + lines.push('Threads you opened and never came back to:'); + const colWidth = opts.threadColumnWidth ?? 50; + for (const t of abandonedThreads.slice(0, 5)) { + const claim = t.claim.length > colWidth ? t.claim.slice(0, colWidth - 1) + '…' : t.claim; + const padded = claim.padEnd(colWidth, ' '); + lines.push(` · ${padded}(${t.monthsSilent} months silent)`); + } + } + + return lines.join('\n'); +} + +function trendNote(brier: number): string { + // Map Brier to a conversational anchor. No history yet so we describe + // the absolute value rather than trend. + if (brier <= 0.1) return '(strong calibration).'; + if (brier <= 0.2) return '(solid).'; + if (brier <= 0.25) return '(near baseline).'; + return '(worse than always-50% baseline — review your high-conviction calls).'; +} diff --git a/test/recall-footer.test.ts b/test/recall-footer.test.ts new file mode 100644 index 000000000..3a79b0844 --- /dev/null +++ b/test/recall-footer.test.ts @@ -0,0 +1,154 @@ +/** + * v0.36.0.0 (T16) — recall morning pulse calibration footer tests. + * + * Pure formatter tests. No engine, no LLM. + * + * Tests cover: + * - cold-brain branch: null profile → empty string + * - insufficient resolved (< 5) → empty string + * - happy path: section header + Brier + patterns + * - abandoned threads section: optional, formatted, capped at 5 + * - trend note maps Brier ranges to conversational copy + * - patterns capped at 4 + * - column alignment on abandoned threads + */ + +import { describe, test, expect } from 'bun:test'; +import { buildRecallCalibrationFooter } from '../src/core/calibration/recall-footer.ts'; +import type { CalibrationProfileRow } from '../src/commands/calibration.ts'; + +function buildProfile(opts: Partial = {}): CalibrationProfileRow { + return { + id: 1, + source_id: 'default', + holder: 'garry', + wave_version: 'v0.36.0.0', + generated_at: '2026-05-17T00:00:00Z', + published: false, + total_resolved: 12, + brier: 0.18, + accuracy: 0.6, + partial_rate: 0.1, + grade_completion: 1.0, + pattern_statements: ['Right on early-stage tactics, late on macro by 18 months.'], + active_bias_tags: ['over-confident-geography'], + voice_gate_passed: true, + voice_gate_attempts: 1, + model_id: 'claude-sonnet-4-6', + ...opts, + }; +} + +describe('buildRecallCalibrationFooter — cold-brain branch', () => { + test('null profile → empty string', () => { + expect(buildRecallCalibrationFooter({ profile: null })).toBe(''); + }); + + test('insufficient resolved (< 5) → empty string', () => { + expect(buildRecallCalibrationFooter({ profile: buildProfile({ total_resolved: 3 }) })).toBe( + '', + ); + }); + + test('zero resolved → empty string', () => { + expect(buildRecallCalibrationFooter({ profile: buildProfile({ total_resolved: 0 }) })).toBe( + '', + ); + }); +}); + +describe('buildRecallCalibrationFooter — happy path', () => { + test('emits header + Brier + pattern block', () => { + const out = buildRecallCalibrationFooter({ profile: buildProfile() }); + expect(out).toContain('Calibration this quarter:'); + expect(out).toContain('Brier 0.18'); + expect(out).toContain('Right on early-stage tactics'); + }); + + test('Brier line includes trend note ("solid")', () => { + expect(buildRecallCalibrationFooter({ profile: buildProfile({ brier: 0.18 }) })).toContain( + '(solid)', + ); + }); + + test('Brier near-baseline range', () => { + expect( + buildRecallCalibrationFooter({ profile: buildProfile({ brier: 0.24 }) }), + ).toContain('(near baseline)'); + }); + + test('Brier worse than baseline → review hint', () => { + expect(buildRecallCalibrationFooter({ profile: buildProfile({ brier: 0.32 }) })).toContain( + 'worse than always-50%', + ); + }); + + test('Brier strong calibration', () => { + expect( + buildRecallCalibrationFooter({ profile: buildProfile({ brier: 0.08 }) }), + ).toContain('(strong calibration)'); + }); + + test('omits Brier line when brier=null (resolved correct+incorrect = 0)', () => { + const out = buildRecallCalibrationFooter({ profile: buildProfile({ brier: null }) }); + expect(out).not.toContain('Brier null'); + expect(out).toContain('Calibration this quarter:'); // header still emitted + }); + + test('caps at 4 pattern statements', () => { + const out = buildRecallCalibrationFooter({ + profile: buildProfile({ pattern_statements: ['a', 'b', 'c', 'd', 'e', 'f'] }), + }); + const aIdx = out.indexOf(' a\n'); + expect(out).toContain(' a'); + expect(out).toContain(' d'); + // 'e' and 'f' should NOT appear as standalone bullets. + const lines = out.split('\n'); + const bullets = lines.filter(l => /^ {2}[abcdef]$/.test(l)); + expect(bullets.length).toBe(4); + void aIdx; + }); +}); + +describe('buildRecallCalibrationFooter — abandoned threads', () => { + test('omits the abandoned-threads section when no threads passed', () => { + const out = buildRecallCalibrationFooter({ profile: buildProfile() }); + expect(out).not.toContain('Threads you opened'); + }); + + test('emits section + rows when threads provided', () => { + const out = buildRecallCalibrationFooter({ + profile: buildProfile(), + abandonedThreads: [ + { claim: 'AI search platform differentiation', monthsSilent: 17 }, + { claim: 'International expansion playbook', monthsSilent: 12 }, + ], + }); + expect(out).toContain('Threads you opened and never came back to:'); + expect(out).toContain('AI search platform differentiation'); + expect(out).toContain('17 months silent'); + expect(out).toContain('International expansion playbook'); + expect(out).toContain('12 months silent'); + }); + + test('caps at 5 abandoned threads', () => { + const threads = Array.from({ length: 8 }, (_, i) => ({ + claim: `thread ${i}`, + monthsSilent: 12 + i, + })); + const out = buildRecallCalibrationFooter({ profile: buildProfile(), abandonedThreads: threads }); + expect(out).toContain('thread 0'); + expect(out).toContain('thread 4'); + expect(out).not.toContain('thread 5'); + }); + + test('truncates long claim text with ellipsis', () => { + const longClaim = 'x'.repeat(100); + const out = buildRecallCalibrationFooter({ + profile: buildProfile(), + abandonedThreads: [{ claim: longClaim, monthsSilent: 12 }], + threadColumnWidth: 30, + }); + expect(out).toContain('x'.repeat(29) + '…'); + }); +});