Files
gbrain/test/takes-source-scope.test.ts
T
26d2f8abfc fix(calibration,takes,cli): calibration CLI routing, source-scoped takes reads, BigInt-safe outputs (takeover of #2452) (#2892)
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>
2026-07-16 21:20:12 -07:00

99 lines
4.3 KiB
TypeScript

/**
* Regression: takes read ops honor source / federated_read scope via the
* take's page.source_id, while preserving the holder allow-list. (#2200-class;
* see garrytan/gbrain#2200 comment.)
*
* takes has no source_id column of its own — it's scoped through
* JOIN pages.source_id (list/search) or EXISTS pages (scorecard/curve).
* PGLite, in-memory, no DATABASE_URL required.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
let engine: PGLiteEngine;
async function addSource(id: string): Promise<void> {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, created_at)
VALUES ($1, $1, NULL, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
[id],
);
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
await addSource('tenant-a');
await addSource('tenant-b');
const pageA = await engine.putPage('people/a-ex', { title: 'A', type: 'person', compiled_truth: '## Takes\n' }, { sourceId: 'tenant-a' });
const pageB = await engine.putPage('people/b-ex', { title: 'B', type: 'person', compiled_truth: '## Takes\n' }, { sourceId: 'tenant-b' });
await engine.addTakesBatch([
{ page_id: pageA.id, row_num: 1, claim: 'Acme founder will raise a Series A', kind: 'bet', holder: 'garry', weight: 0.7 },
{ page_id: pageA.id, row_num: 2, claim: 'Acme founder went public', kind: 'bet', holder: 'world', weight: 0.6 },
{ page_id: pageB.id, row_num: 1, claim: 'Beta founder will exit big', kind: 'bet', holder: 'garry', weight: 0.8 },
]);
// Resolve so the scorecard/curve aggregates have correct/incorrect rows.
await engine.resolveTake(pageA.id, 1, { quality: 'correct', resolvedBy: 'garry' });
await engine.resolveTake(pageA.id, 2, { quality: 'incorrect', resolvedBy: 'world' });
await engine.resolveTake(pageB.id, 1, { quality: 'correct', resolvedBy: 'garry' });
});
afterAll(async () => {
await engine.disconnect();
});
describe('listTakes / searchTakes — JOIN pages.source_id scope', () => {
test('sourceIds (federated) filters to the listed source', async () => {
const a = await engine.listTakes({ sourceIds: ['tenant-a'] });
expect(a).toHaveLength(2);
expect(a.every(t => t.page_slug === 'people/a-ex')).toBe(true);
});
test('scalar sourceId filters to that source', async () => {
const b = await engine.listTakes({ sourceId: 'tenant-b' });
expect(b).toHaveLength(1);
expect(b[0].page_slug).toBe('people/b-ex');
});
test('no source scope → all sources (local CLI behavior unchanged)', async () => {
const all = await engine.listTakes({});
expect(all).toHaveLength(3);
});
test('source scope AND holder allow-list compose (intersection)', async () => {
const r = await engine.listTakes({ sourceIds: ['tenant-a'], takesHoldersAllowList: ['world'] });
expect(r).toHaveLength(1);
expect(r[0].holder).toBe('world');
expect(r[0].page_slug).toBe('people/a-ex');
});
test('searchTakes honors source scope', async () => {
const a = await engine.searchTakes('founder', { sourceIds: ['tenant-a'] });
expect(a.length).toBeGreaterThan(0);
expect(a.every(h => h.page_slug === 'people/a-ex')).toBe(true);
});
});
describe('getScorecard / getCalibrationCurve — EXISTS pages.source_id scope', () => {
test('getScorecard counts only the scoped source', async () => {
expect((await engine.getScorecard({ sourceIds: ['tenant-a'] }, undefined)).total_bets).toBe(2);
expect((await engine.getScorecard({ sourceId: 'tenant-b' }, undefined)).total_bets).toBe(1);
expect((await engine.getScorecard({}, undefined)).total_bets).toBe(3);
});
test('getScorecard source scope AND holder allow-list compose', async () => {
// tenant-a bets: garry(correct) + world(incorrect); allow-list world → 1 bet
expect((await engine.getScorecard({ sourceIds: ['tenant-a'] }, ['world'])).total_bets).toBe(1);
});
test('getCalibrationCurve buckets only the scoped source', async () => {
const a = await engine.getCalibrationCurve({ sourceIds: ['tenant-a'] }, undefined);
expect(a.reduce((s, b) => s + b.n, 0)).toBe(2);
const b = await engine.getCalibrationCurve({ sourceId: 'tenant-b' }, undefined);
expect(b.reduce((s, x) => s + x.n, 0)).toBe(1);
});
});