Files
gbrain/test/nudge.test.ts
T
Garry TanandClaude Opus 4.7 8087a1f1f5 calibration: E7 nudge + 14-day cooldown (T13 / D16 F3)
Real-time pattern surfacing when a newly-committed high-conviction take
matches an active bias pattern. Conversational nudge text via the
templates module; 14-day cooldown per (take_id, nudge_pattern) via
take_nudge_log to prevent the feedback loop where each cycle re-fires
the same nudge on the same take.

Threshold gates (D16 F3):
  - holder match (profile.holder === take.holder)
  - conviction-weight > 0.7 (strict greater than)
  - take's slug-derived domain hint matches an active bias tag
    (takeDomainHint — same heuristic as eval-contradictions/calibration-join.ts
    for cross-surface consistency)

Cooldown gate:
  Before firing, probe take_nudge_log for (take_id, nudge_pattern) rows
  with fired_at >= now() - 14 days. Any hit → silently skip. After firing,
  insert a new row with channel='stderr' so the next 14 days are gated.

Feedback-loop prevention:
  User hedges a take in response to a nudge (e.g. weight 0.85 → 0.65).
  Even though the take's `weight` field changed, the cooldown row for
  the over-confident-geography pattern is still there from the original
  fire — so the next cycle's evaluateAndFireNudge() silently skips. The
  user reset path (gbrain takes nudge --reset N) clears the cooldown to
  re-arm.

Output channel (v0.36.0.0 ship state):
  STDERR only. Schema's `channel` column already supports multi-channel
  (webhook, admin SPA toast); routing those is a v0.37+ follow-up.

Architecture:
  evaluateNudgeRule(take, profile) — pure rule check. Returns
  { matched, reason, matchedTag }. No engine call.
  checkCooldown(engine, takeId, pattern) — engine probe, returns boolean.
  recordNudgeFire(engine, opts) — INSERT into take_nudge_log.
  evaluateAndFireNudge(opts) — full pipeline. Returns NudgeDecision.
  resetNudgeCooldown(engine, takeId) — DELETE...RETURNING for the CLI.

  buildNudgeText delegates to templates.ts nudgeTemplate (D24 mode='nudge'
  voice). v0.36.0.0 ship state uses the template directly; LLM-generated
  nudge text via the voice gate lands in v0.37+ when we have production
  examples to tune from.

Tests: 22 cases.
  takeDomainHint (5): companies/people/macro/geography/unrecognized.
  evaluateNudgeRule (6): no_profile, wrong_holder, conviction-at-threshold-
  is-NOT-eligible (strict >), no matching tag, happy match,
  first-match-wins for multiple candidate tags.
  checkCooldown (3): true on row hit, false on no row, cutoff date param
  verifies the 14-day boundary.
  evaluateAndFireNudge (4): happy fire (text contains hush command +
  matched tag), cooldown silent skip (no INSERT, no stderr), no_profile
  short-circuit, below-conviction short-circuit (no cooldown query fired).
  buildNudgeText (2): hush command shape, conviction value embedded.
  resetNudgeCooldown (2): returns count, idempotent on zero rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:42:47 -07:00

296 lines
11 KiB
TypeScript

/**
* v0.36.0.0 (T13 / E7) — nudge cooldown + threshold tests.
*
* Hermetic. Mock engine + injected stderr stream. No production stderr writes.
*
* Tests cover:
* - threshold gates: no profile, wrong holder, below conviction, no domain match
* - happy match path: above conviction + bias tag matches domain hint
* - cooldown: same pattern fired in last 14 days → silently skip
* - cooldown: same pattern fired > 14 days ago → fire (cooldown expired)
* - takeDomainHint: companies → hiring, macro/geography/tactics keywords match
* - resetNudgeCooldown: deletes rows for the take
* - log insertion captures (source_id, take_id, pattern, channel='stderr')
*/
import { describe, test, expect } from 'bun:test';
import {
evaluateAndFireNudge,
evaluateNudgeRule,
takeDomainHint,
checkCooldown,
resetNudgeCooldown,
buildNudgeText,
NUDGE_COOLDOWN_DAYS,
NUDGE_CONVICTION_THRESHOLD,
} from '../src/core/calibration/nudge.ts';
import type { CalibrationProfileRow } from '../src/commands/calibration.ts';
import type { BrainEngine, Take } from '../src/core/engine.ts';
function buildTake(overrides: Partial<Take> = {}): Take {
return {
id: 1,
page_id: 100,
page_slug: 'wiki/companies/acme-example',
row_num: 1,
claim: 'Marketplaces with cold-start liquidity always win.',
kind: 'bet',
holder: 'garry',
weight: 0.85,
since_date: '2026-05-17',
until_date: null,
source: null,
superseded_by: null,
active: true,
resolved_at: null,
resolved_outcome: null,
resolved_quality: null,
resolved_value: null,
resolved_unit: null,
resolved_source: null,
resolved_by: null,
created_at: '2026-05-17T00:00:00Z',
updated_at: '2026-05-17T00:00:00Z',
...overrides,
} as Take;
}
function buildProfile(activeBiasTags: string[], holder = 'garry'): CalibrationProfileRow {
return {
id: 1,
source_id: 'default',
holder,
wave_version: 'v0.36.0.0',
generated_at: '2026-05-17T00:00:00Z',
published: false,
total_resolved: 20,
brier: 0.21,
accuracy: 0.6,
partial_rate: 0.1,
grade_completion: 1.0,
pattern_statements: ['some pattern'],
active_bias_tags: activeBiasTags,
voice_gate_passed: true,
voice_gate_attempts: 1,
model_id: 'claude-sonnet-4-6',
};
}
interface SqlCall {
sql: string;
params: unknown[];
}
function buildMockEngine(opts: {
cooldownRows?: number; // 1 = active cooldown, 0 = no cooldown
deleteReturning?: number; // count of rows DELETE...RETURNING simulates
}): { engine: BrainEngine; sqls: SqlCall[] } {
const sqls: SqlCall[] = [];
const engine = {
kind: 'pglite',
async executeRaw<T>(sql: string, params?: unknown[]): Promise<T[]> {
sqls.push({ sql, params: params ?? [] });
if (sql.includes('SELECT id FROM take_nudge_log')) {
return new Array(opts.cooldownRows ?? 0).fill({ id: 1 }) as unknown as T[];
}
if (sql.includes('DELETE FROM take_nudge_log')) {
return new Array(opts.deleteReturning ?? 0).fill({ id: 1 }) as unknown as T[];
}
return [];
},
} as unknown as BrainEngine;
return { engine, sqls };
}
// ─── takeDomainHint ─────────────────────────────────────────────────
describe('takeDomainHint', () => {
test('companies/ slug → hiring', () => {
expect(takeDomainHint(buildTake({ page_slug: 'wiki/companies/acme' }))).toBe('hiring');
});
test('people/ slug → founder-behavior', () => {
expect(takeDomainHint(buildTake({ page_slug: 'wiki/people/alice' }))).toBe('founder-behavior');
});
test('macro keyword → macro', () => {
expect(takeDomainHint(buildTake({ page_slug: 'wiki/macro/forecast' }))).toBe('macro');
});
test('geography keyword → geography', () => {
expect(takeDomainHint(buildTake({ page_slug: 'wiki/geography/ny' }))).toBe('geography');
});
test('unrecognized slug → empty hint', () => {
expect(takeDomainHint(buildTake({ page_slug: 'wiki/random/x' }))).toBe('');
});
});
// ─── evaluateNudgeRule (pure) ───────────────────────────────────────
describe('evaluateNudgeRule', () => {
test('no profile → matched=false with reason=no_profile', () => {
expect(evaluateNudgeRule(buildTake(), null)).toEqual({ matched: false, reason: 'no_profile' });
});
test('wrong holder → matched=false with reason=wrong_holder', () => {
const profile = buildProfile(['over-confident-hiring'], 'alice');
expect(evaluateNudgeRule(buildTake({ holder: 'garry' }), profile).reason).toBe('wrong_holder');
});
test('conviction at threshold → matched=false (strict >)', () => {
const profile = buildProfile(['over-confident-hiring']);
expect(
evaluateNudgeRule(buildTake({ weight: NUDGE_CONVICTION_THRESHOLD }), profile).reason,
).toBe('below_conviction_threshold');
});
test('no matching bias tag → matched=false with reason=no_matching_bias_tag', () => {
const profile = buildProfile(['late-on-macro-tech']);
expect(
evaluateNudgeRule(buildTake({ page_slug: 'wiki/companies/acme' }), profile).reason,
).toBe('no_matching_bias_tag');
});
test('happy match: companies slug + hiring tag', () => {
const profile = buildProfile(['over-confident-hiring']);
const out = evaluateNudgeRule(buildTake({ page_slug: 'wiki/companies/acme' }), profile);
expect(out.matched).toBe(true);
expect(out.matchedTag).toBe('over-confident-hiring');
});
test('first-match-wins when multiple tags could match the hint', () => {
const profile = buildProfile([
'over-confident-hiring',
'late-on-hiring-cycles',
]);
const out = evaluateNudgeRule(buildTake({ page_slug: 'wiki/companies/acme' }), profile);
expect(out.matchedTag).toBe('over-confident-hiring');
});
});
// ─── checkCooldown ──────────────────────────────────────────────────
describe('checkCooldown', () => {
test('returns true when a recent row exists', async () => {
const { engine } = buildMockEngine({ cooldownRows: 1 });
expect(await checkCooldown(engine, 1, 'over-confident-hiring')).toBe(true);
});
test('returns false when no recent row', async () => {
const { engine } = buildMockEngine({ cooldownRows: 0 });
expect(await checkCooldown(engine, 1, 'over-confident-hiring')).toBe(false);
});
test('cutoff date param is NUDGE_COOLDOWN_DAYS ago', async () => {
const { engine, sqls } = buildMockEngine({});
await checkCooldown(engine, 1, 'tag');
const cutoffISO = sqls[0]!.params[2] as string;
const cutoff = new Date(cutoffISO).getTime();
const expected = Date.now() - NUDGE_COOLDOWN_DAYS * 24 * 60 * 60 * 1000;
expect(Math.abs(cutoff - expected)).toBeLessThan(1000); // within 1s
});
});
// ─── evaluateAndFireNudge ───────────────────────────────────────────
describe('evaluateAndFireNudge', () => {
test('happy path: matches + no cooldown → fires + writes log + returns text', async () => {
const { engine, sqls } = buildMockEngine({ cooldownRows: 0 });
const profile = buildProfile(['over-confident-hiring']);
let stderrWrites = '';
const stderr = { write: (s: string) => { stderrWrites += s; } };
const out = await evaluateAndFireNudge({
engine,
take: buildTake({ page_slug: 'wiki/companies/acme' }),
profile,
sourceId: 'default',
stderr,
});
expect(out.shouldFire).toBe(true);
expect(out.matchedTag).toBe('over-confident-hiring');
expect(stderrWrites).toContain('[gbrain]');
expect(stderrWrites).toContain('over-confident-hiring');
const insertCall = sqls.find(s => s.sql.includes('INSERT INTO take_nudge_log'));
expect(insertCall).toBeDefined();
expect(insertCall!.params).toEqual(['default', 1, 'over-confident-hiring', 'stderr']);
});
test('cooldown active → silently skips, no insert, no stderr', async () => {
const { engine, sqls } = buildMockEngine({ cooldownRows: 1 });
const profile = buildProfile(['over-confident-hiring']);
let stderrWrites = '';
const stderr = { write: (s: string) => { stderrWrites += s; } };
const out = await evaluateAndFireNudge({
engine,
take: buildTake({ page_slug: 'wiki/companies/acme' }),
profile,
sourceId: 'default',
stderr,
});
expect(out.shouldFire).toBe(false);
expect(out.reason).toBe('cooldown_active');
expect(stderrWrites).toBe('');
expect(sqls.find(s => s.sql.includes('INSERT'))).toBeUndefined();
});
test('no profile → silently skips with reason=no_profile', async () => {
const { engine } = buildMockEngine({});
const out = await evaluateAndFireNudge({
engine,
take: buildTake(),
profile: null,
sourceId: 'default',
});
expect(out.shouldFire).toBe(false);
expect(out.reason).toBe('no_profile');
});
test('below conviction threshold → silently skips', async () => {
const { engine, sqls } = buildMockEngine({});
const profile = buildProfile(['over-confident-hiring']);
const out = await evaluateAndFireNudge({
engine,
take: buildTake({ weight: 0.6, page_slug: 'wiki/companies/acme' }),
profile,
sourceId: 'default',
});
expect(out.shouldFire).toBe(false);
expect(out.reason).toBe('below_conviction_threshold');
// No cooldown query, no INSERT — both gated above the cooldown probe.
expect(sqls.find(s => s.sql.includes('SELECT id FROM take_nudge_log'))).toBeUndefined();
});
});
// ─── buildNudgeText ─────────────────────────────────────────────────
describe('buildNudgeText', () => {
test('contains the matched tag for hush command', () => {
const out = buildNudgeText({ matchedTag: 'over-confident-geography', conviction: 0.85 });
expect(out).toContain('over-confident-geography');
expect(out).toContain('gbrain takes nudge --hush over-confident-geography');
});
test('contains the conviction value', () => {
const out = buildNudgeText({ matchedTag: 'over-confident-hiring', conviction: 0.92 });
expect(out).toContain('0.92');
});
});
// ─── resetNudgeCooldown ─────────────────────────────────────────────
describe('resetNudgeCooldown', () => {
test('deletes rows for the take; returns count', async () => {
const { engine, sqls } = buildMockEngine({ deleteReturning: 3 });
const out = await resetNudgeCooldown(engine, 42);
expect(out.deleted).toBe(3);
expect(sqls[0]!.sql).toContain('DELETE FROM take_nudge_log');
expect(sqls[0]!.params).toEqual([42]);
});
test('returns 0 when no rows to delete (idempotent)', async () => {
const { engine } = buildMockEngine({ deleteReturning: 0 });
expect((await resetNudgeCooldown(engine, 99)).deleted).toBe(0);
});
});