mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
Rebase-port of #2452 (spinsirr:fix/calibration-profile-scope-and-cli) onto current master after tonight's merges made the fork branch conflict. - cli: add 'calibration' to CLI_ONLY so dispatch reaches its existing handler instead of falling through to "Unknown command" (#2035); honor --source / GBRAIN_SOURCE in the calibration CLI. - takes: route takes_list / takes_search / takes_scorecard / takes_calibration through sourceScopeOpts(ctx) (federated array > scalar > nothing) and scope engine reads via the take's page.source_id — JOIN filter for list/search, EXISTS for scorecard/curve — on both engines (#2200-class). - bigint: shared takeHitRowToHit coercion in searchTakes / searchTakesVector (both engines) + bigintToStringReplacer on the cli.ts output normalizer and the `gbrain call` exit, so int8/BIGSERIAL columns no longer crash JSON.stringify (#2450); calibration profile id BIGSERIAL → string. - calibration: default model ids route through TIER_DEFAULTS (provider-prefixed) instead of bare model strings; admin calibration chart endpoints fixed (takes has no page_slug column; month-precision since_date; Date generated_at; bigint id in drill-down). The think/gather source-scope slice of the original PR was dropped: it already landed on master via #2739. Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local> Co-authored-by: spinsirr <ID+spinsirr@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
/**
|
|
* v0.36.1.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.1.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);
|
|
});
|
|
});
|