From 26d2f8abfc0e7c6fead5ea89b6494ce8c3cf737f Mon Sep 17 00:00:00 2001 From: Time Attakc <89218912+time-attack@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:20:12 -0700 Subject: [PATCH] fix(calibration,takes,cli): calibration CLI routing, source-scoped takes reads, BigInt-safe outputs (takeover of #2452) (#2892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: spinsirr Co-authored-by: Claude Fable 5 --- src/cli.ts | 14 ++- src/commands/calibration.ts | 19 +++- src/commands/call.ts | 6 +- src/commands/serve-http.ts | 34 ++++--- src/core/calibration/voice-gate.ts | 3 +- src/core/cycle/calibration-profile.ts | 3 +- src/core/engine.ts | 14 ++- src/core/operations.ts | 5 + src/core/pglite-engine.ts | 23 ++++- src/core/postgres-engine.ts | 43 ++++++-- src/core/utils.ts | 23 ++++- test/calibration-cli.test.ts | 19 +++- test/calibration-profile.test.ts | 29 ++++++ test/cli-bigint-normalize.test.ts | 97 ++++++++++++++++++ test/cross-brain-calibration.test.ts | 2 +- test/e2e/takes-postgres.test.ts | 25 +++++ ...al-contradictions-calibration-join.test.ts | 2 +- test/nudge.test.ts | 2 +- test/recall-footer.test.ts | 2 +- test/takes-source-scope.test.ts | 98 +++++++++++++++++++ 20 files changed, 425 insertions(+), 38 deletions(-) create mode 100644 test/cli-bigint-normalize.test.ts create mode 100644 test/takes-source-scope.test.ts diff --git a/src/cli.ts b/src/cli.ts index a73f23839..80a324552 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,8 +43,18 @@ for (const op of operations) { } } +/** + * JSON replacer: `bigint` → string, matching the postgres.js wire shape (int8 + * comes back as a string on the routed path). Lets the local-engine output + * normalizer round-trip bigint columns (e.g. a `BIGSERIAL` `id`) instead of + * throwing `TypeError: Do not know how to serialize a BigInt`. + */ +export function bigintToStringReplacer(_key: string, value: unknown): unknown { + return typeof value === 'bigint' ? value.toString() : value; +} + // CLI-only commands that bypass the operation layer -const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch']); +export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch']); // CLI-only commands whose handlers print their own --help text. These are // excluded from the generic short-circuit so detailed per-command and // per-subcommand usage stays reachable. @@ -453,7 +463,7 @@ async function main() { // path's return value so renderers see the same shape they'd see on the // routed path. Date → ISO string; bigint → string (postgres.js shape); // Buffer → object. Microsecond-cost; eliminates a whole drift bug class. - const result = JSON.parse(JSON.stringify(rawResult)); + const result = JSON.parse(JSON.stringify(rawResult, bigintToStringReplacer)); const output = formatResult(op.name, result); if (output) process.stdout.write(output); } catch (e: unknown) { diff --git a/src/commands/calibration.ts b/src/commands/calibration.ts index fcf7ecba0..c67db9048 100644 --- a/src/commands/calibration.ts +++ b/src/commands/calibration.ts @@ -25,7 +25,9 @@ import type { GBrainConfig } from '../core/config.ts'; import { GBrainError } from '../core/types.ts'; export interface CalibrationProfileRow { - id: number; + /** BIGSERIAL → string (postgres.js int8 wire shape; never Number() — int8 + * exceeds 2^53). No consumer does arithmetic on it; it's audit/serialize only. */ + id: string; source_id: string; holder: string; wave_version: string; @@ -67,7 +69,12 @@ export async function getLatestProfile( sql += ` ORDER BY generated_at DESC LIMIT 1`; const rows = await engine.executeRaw(sql, params); - return rows[0] ?? null; + if (!rows[0]) return null; + // `id` is BIGSERIAL → the pg driver returns it as a JS bigint, which crashes + // JSON.stringify on the --json / MCP output paths once a row exists. Coerce to + // string — matches the postgres.js int8 wire shape (and cli.ts's ENG-2 + // "bigint → string" contract); String() has no 2^53 ceiling, unlike Number(). + return { ...rows[0], id: String(rows[0].id) }; } /** Human format the profile for terminal output. */ @@ -125,6 +132,7 @@ export interface RunCalibrationArgs { regenerate?: boolean; undoWave?: string; abReport?: boolean; + source?: string; } function parseArgs(args: string[]): { sub?: string; opts: RunCalibrationArgs } { @@ -144,6 +152,7 @@ function parseArgs(args: string[]): { sub?: string; opts: RunCalibrationArgs } { else if (a === '--json') opts.json = true; else if (a === '--regenerate') opts.regenerate = true; else if (a === '--undo-wave') opts.undoWave = args[++i]; + else if (a === '--source') opts.source = args[++i]; } return { sub, opts }; } @@ -159,7 +168,11 @@ export async function runCalibration( ): Promise { const { opts } = parseArgs(args); const holder = opts.holder ?? 'garry'; - const sourceId = 'default'; + // Resolve --source / GBRAIN_SOURCE / .gbrain-source so the (now reachable, #2035) + // calibration command targets the right source in a multi-source brain instead + // of always reading `default`. No signal → 'default' (prior behavior). + const { resolveSourceId } = await import('../core/source-resolver.ts'); + const sourceId = await resolveSourceId(engine, opts.source ?? null); if (opts.undoWave) { // T17 / D18 CDX-3 — reverse the wave's mutations on canonical state. diff --git a/src/commands/call.ts b/src/commands/call.ts index ce9566be1..e0009457d 100644 --- a/src/commands/call.ts +++ b/src/commands/call.ts @@ -1,6 +1,7 @@ import type { BrainEngine } from '../core/engine.ts'; import { handleToolCall } from '../mcp/server.ts'; import { resolveSourceId } from '../core/source-resolver.ts'; +import { bigintToStringReplacer } from '../cli.ts'; /** * `gbrain call ` — trusted local op-dispatch surface. @@ -49,5 +50,8 @@ export async function runCall(engine: BrainEngine, args: string[]) { // an explicit/env/dotfile id refers to a non-registered source. const sourceId = await resolveSourceId(engine, explicitSource); const result = await handleToolCall(engine, tool, params, { sourceId }); - console.log(JSON.stringify(result, null, 2)); + // `gbrain call` bypasses cli.ts's op-output normalizer entirely, so this + // exit needs its own bigint-safe replacer — any op returning an int8 column + // (BIGSERIAL id) would otherwise crash plain JSON.stringify (#2450). + console.log(JSON.stringify(result, bigintToStringReplacer, 2)); } diff --git a/src/commands/serve-http.ts b/src/commands/serve-http.ts index 20a165a50..ee8da69ba 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -1108,7 +1108,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption // v0.36.1.0 ship state: surface the top resolved takes for the // holder as drill-down evidence. Per-pattern provenance is v0.37. const takes = await engine.executeRaw<{ - id: number; + id: string; page_slug: string; row_num: number; claim: string; @@ -1116,10 +1116,13 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption resolved_quality: string | null; since_date: string | null; }>( - `SELECT id, page_slug, row_num, claim, weight, resolved_quality, since_date - FROM takes - WHERE holder = $1 AND active = true AND resolved_at IS NOT NULL - ORDER BY weight DESC, since_date DESC + // `takes` has no page_slug column — it comes from the joined page. + // id::text — it's a BIGSERIAL (bigint); res.json() below can't serialize a + // raw bigint ("cannot serialize BigInt"), so project it as a string. + `SELECT t.id::text AS id, p.slug AS page_slug, t.row_num, t.claim, t.weight, t.resolved_quality, t.since_date + FROM takes t JOIN pages p ON p.id = t.page_id + WHERE t.holder = $1 AND t.active = true AND t.resolved_at IS NOT NULL + ORDER BY t.weight DESC, t.since_date DESC LIMIT 25`, [holder], ); @@ -1167,7 +1170,9 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption // proper 90-day time series will read from calibration_profiles // generated_at history in v0.37 once we have multiple snapshots. const series = profile?.brier !== null && profile?.brier !== undefined - ? [{ date: profile.generated_at.slice(0, 10), brier: profile.brier }] + // generated_at comes back from the engine as a Date (TIMESTAMPTZ), not + // a string — `.slice` would throw. Normalize to a YYYY-MM-DD string. + ? [{ date: new Date(profile.generated_at).toISOString().slice(0, 10), brier: profile.brier }] : []; return res.send(renderBrierTrend({ series })); } @@ -1194,12 +1199,17 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption weight: number; since_date: string; }>( - `SELECT id, page_slug, claim, weight, since_date - FROM takes - WHERE active = true AND resolved_at IS NULL AND superseded_by IS NULL - AND weight >= 0.7 - AND since_date::date < (now() - INTERVAL '12 months') - ORDER BY since_date ASC + // `takes` has no page_slug column — it comes from the joined page. + // since_date is TEXT and may be month-precision ('YYYY-MM'); '2026-06'::date + // throws "invalid input syntax for type date", so normalize to the 1st + // before casting. + `SELECT t.id, p.slug AS page_slug, t.claim, t.weight, t.since_date + FROM takes t JOIN pages p ON p.id = t.page_id + WHERE t.active = true AND t.resolved_at IS NULL AND t.superseded_by IS NULL + AND t.weight >= 0.7 + AND (t.since_date || CASE WHEN length(t.since_date) = 7 THEN '-01' ELSE '' END)::date + < (now() - INTERVAL '12 months') + ORDER BY t.since_date ASC LIMIT 5`, ); const now = new Date(); diff --git a/src/core/calibration/voice-gate.ts b/src/core/calibration/voice-gate.ts index c1fe7f6b8..d9dd38bba 100644 --- a/src/core/calibration/voice-gate.ts +++ b/src/core/calibration/voice-gate.ts @@ -27,6 +27,7 @@ import { chat as gatewayChat } from '../ai/gateway.ts'; import type { VoiceGateMode } from './templates.ts'; +import { TIER_DEFAULTS } from '../model-config.ts'; /** * Verdict the Haiku judge returns for a candidate string. Pass-through @@ -159,7 +160,7 @@ export async function defaultJudge(input: { .replace('{CANDIDATE}', input.candidate); const result = await gatewayChat({ messages: [{ role: 'user', content: prompt }], - model: 'claude-haiku-4-5', + model: TIER_DEFAULTS.utility, maxTokens: 100, }); return parseJudgeOutput(result.text); diff --git a/src/core/cycle/calibration-profile.ts b/src/core/cycle/calibration-profile.ts index f07724f48..2555cb03e 100644 --- a/src/core/cycle/calibration-profile.ts +++ b/src/core/cycle/calibration-profile.ts @@ -27,6 +27,7 @@ import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts'; import { chat as gatewayChat } from '../ai/gateway.ts'; +import { TIER_DEFAULTS } from '../model-config.ts'; import { gateVoice, type VoiceGateGenerator, type VoiceGateJudge } from '../calibration/voice-gate.ts'; import { patternStatementTemplate, type PatternStatementSlots } from '../calibration/templates.ts'; // v0.41 T10 — domain widening. The aggregator module resolves the active @@ -228,7 +229,7 @@ class CalibrationProfilePhase extends BaseCyclePhase { ): Promise<{ summary: string; details: Record; status?: PhaseStatus }> { const holder = opts.holder ?? 'garry'; const promptVersion = opts.promptVersion ?? CALIBRATION_PROFILE_PROMPT_VERSION; - const modelId = opts.model ?? 'claude-sonnet-4-6'; + const modelId = opts.model ?? TIER_DEFAULTS.reasoning; const gradeCompletion = opts.gradeCompletion ?? 1.0; const patternsGenerator = opts.patternsGenerator ?? defaultPatternsGenerator; const biasTagsGenerator = opts.biasTagsGenerator ?? defaultBiasTagsGenerator; diff --git a/src/core/engine.ts b/src/core/engine.ts index 822fa2098..4a8d27046 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -307,6 +307,10 @@ export interface TakesListOpts { sortBy?: 'weight' | 'since_date' | 'created_at'; limit?: number; offset?: number; + /** Federated/source scope via the take's page.source_id. Array wins over + * scalar, matching sourceScopeOpts. Omitted (local CLI) = no source filter. */ + sourceId?: string; + sourceIds?: string[]; } /** Search result row from searchTakes / searchTakesVector. */ @@ -405,6 +409,9 @@ export interface TakesScorecardOpts { domainPrefix?: string; // e.g. 'companies/' to scope the scorecard since?: string; // ISO date 'YYYY-MM-DD' until?: string; // ISO date 'YYYY-MM-DD' + /** Federated/source scope via the take's page.source_id (array wins over scalar). */ + sourceId?: string; + sourceIds?: string[]; } /** v0.30.0: calibration curve bucket. */ @@ -424,6 +431,9 @@ export interface CalibrationBucket { export interface CalibrationCurveOpts { holder?: string; bucketSize?: number; // default 0.1 + /** Federated/source scope via the take's page.source_id (array wins over scalar). */ + sourceId?: string; + sourceIds?: string[]; } /** Synthesis evidence row input (provenance from think synthesis pages). */ @@ -1473,7 +1483,7 @@ export interface BrainEngine { * Honors `takesHoldersAllowList` via WHERE filter so MCP-bound calls cannot * retrieve holders outside the token's allow-list. */ - searchTakes(query: string, opts?: SearchOpts & { takesHoldersAllowList?: string[] }): Promise; + searchTakes(query: string, opts?: SearchOpts & { takesHoldersAllowList?: string[]; sourceId?: string; sourceIds?: string[] }): Promise; /** * Vector search across active takes. Cosine distance against `embedding`. @@ -1481,7 +1491,7 @@ export interface BrainEngine { */ searchTakesVector( embedding: Float32Array, - opts?: SearchOpts & { takesHoldersAllowList?: string[] }, + opts?: SearchOpts & { takesHoldersAllowList?: string[]; sourceId?: string; sourceIds?: string[] }, ): Promise; /** Look up embeddings by take id (mirrors getEmbeddingsByChunkIds). */ diff --git a/src/core/operations.ts b/src/core/operations.ts index d95f045fd..82f1d8e06 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -1741,6 +1741,8 @@ const takes_list: Operation = { }, handler: async (ctx, p) => { return ctx.engine.listTakes({ + // #2200-class: honor federated/source scope (via the take's page.source_id). + ...sourceScopeOpts(ctx), page_slug: p.page_slug as string | undefined, holder: p.holder as string | undefined, kind: p.kind as never, @@ -1767,6 +1769,7 @@ const takes_search: Operation = { }, handler: async (ctx, p) => { return ctx.engine.searchTakes(p.query as string, { + ...sourceScopeOpts(ctx), limit: p.limit as number | undefined, takesHoldersAllowList: ctx.takesHoldersAllowList, }); @@ -1795,6 +1798,7 @@ const takes_scorecard: Operation = { handler: async (ctx, p) => { return ctx.engine.getScorecard( { + ...sourceScopeOpts(ctx), holder: p.holder as string | undefined, domainPrefix: p.domain_prefix as string | undefined, since: p.since as string | undefined, @@ -1821,6 +1825,7 @@ const takes_calibration: Operation = { handler: async (ctx, p) => { return ctx.engine.getCalibrationCurve( { + ...sourceScopeOpts(ctx), holder: p.holder as string | undefined, bucketSize: p.bucket_size as number | undefined, }, diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index 2f20766b3..7dab6707d 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -45,7 +45,7 @@ import type { DomainBankSampleOpts, CorpusSampleOpts, DomainBankRow, EnrichCandidatesOpts, EnrichCandidate, } from './types.ts'; -import { validateSlug, contentHash, rowToPage, rowToStalePage, rowToChunk, rowToSearchResult, takeRowToTake, isUndefinedTableError, warnOncePerProcess } from './utils.ts'; +import { validateSlug, contentHash, rowToPage, rowToStalePage, rowToChunk, rowToSearchResult, takeRowToTake, takeHitRowToHit, isUndefinedTableError, warnOncePerProcess } from './utils.ts'; import { deriveResolutionTuple, finalizeScorecard } from './takes-resolution.ts'; import { normalizeWeightForStorage } from './takes-fence.ts'; import { executeRawJsonb } from './sql-query.ts'; @@ -4595,6 +4595,8 @@ export class PGLiteEngine implements BrainEngine { OR ($6::boolean = false AND t.resolved_at IS NULL) ) AND ($7::text[] IS NULL OR t.holder = ANY($7::text[])) + AND ($11::text[] IS NULL OR p.source_id = ANY($11::text[])) + AND ($12::text IS NULL OR p.source_id = $12::text) ORDER BY CASE WHEN $8 = 'weight' THEN t.weight END DESC NULLS LAST, CASE WHEN $8 = 'since_date' THEN t.since_date END DESC NULLS LAST, @@ -4611,6 +4613,9 @@ export class PGLiteEngine implements BrainEngine { sortBy, limit, offset, + // #2200-class: source scope via the take's page.source_id (array wins over scalar). + opts.sourceIds && opts.sourceIds.length > 0 ? opts.sourceIds : null, + opts.sourceIds && opts.sourceIds.length > 0 ? null : (opts.sourceId ?? null), ] ); return rows.map((r) => takeRowToTake(r as Record)); @@ -4642,7 +4647,9 @@ export class PGLiteEngine implements BrainEngine { opts.sourceIds && opts.sourceIds.length > 0 ? null : (opts.sourceId ?? null), ] ); - return rows as unknown as TakeHit[]; + // Engine parity with PostgresEngine: coerce hit rows through the shared + // helper so both engines return the same TakeHit runtime shape (#2450). + return rows.map((r) => takeHitRowToHit(r as Record)); } async searchTakesVector( @@ -4672,7 +4679,9 @@ export class PGLiteEngine implements BrainEngine { opts.sourceIds && opts.sourceIds.length > 0 ? null : (opts.sourceId ?? null), ] ); - return rows as unknown as TakeHit[]; + // Engine parity with PostgresEngine: coerce hit rows through the shared + // helper so both engines return the same TakeHit runtime shape (#2450). + return rows.map((r) => takeHitRowToHit(r as Record)); } async getTakeEmbeddings(ids: number[]): Promise> { @@ -4841,6 +4850,10 @@ export class PGLiteEngine implements BrainEngine { if (opts.since !== undefined) { params.push(opts.since); clauses.push(`AND since_date >= $${params.length}`); } if (opts.until !== undefined) { params.push(opts.until); clauses.push(`AND since_date <= $${params.length}`); } if (allowList !== undefined) { params.push(allowList); clauses.push(`AND holder = ANY($${params.length}::text[])`); } + // #2200-class: source scope via the take's page (EXISTS — no pages JOIN here). + const srcIds = opts.sourceIds && opts.sourceIds.length > 0 ? opts.sourceIds : null; + if (srcIds) { params.push(srcIds); clauses.push(`AND EXISTS (SELECT 1 FROM pages p WHERE p.id = takes.page_id AND p.source_id = ANY($${params.length}::text[]))`); } + else if (opts.sourceId) { params.push(opts.sourceId); clauses.push(`AND EXISTS (SELECT 1 FROM pages p WHERE p.id = takes.page_id AND p.source_id = $${params.length})`); } const where = clauses.join(' '); // v0.36.1.1 T1c: `resolved` deliberately filters to the 3-state subset // (correct|incorrect|partial) — NOT `resolved_quality IS NOT NULL` — so @@ -4877,6 +4890,10 @@ export class PGLiteEngine implements BrainEngine { const clauses: string[] = []; if (opts.holder !== undefined) { params.push(opts.holder); clauses.push(`AND holder = $${params.length}`); } if (allowList !== undefined) { params.push(allowList); clauses.push(`AND holder = ANY($${params.length}::text[])`); } + // #2200-class: source scope via the take's page (EXISTS — no pages JOIN here). + const srcIds = opts.sourceIds && opts.sourceIds.length > 0 ? opts.sourceIds : null; + if (srcIds) { params.push(srcIds); clauses.push(`AND EXISTS (SELECT 1 FROM pages p WHERE p.id = takes.page_id AND p.source_id = ANY($${params.length}::text[]))`); } + else if (opts.sourceId) { params.push(opts.sourceId); clauses.push(`AND EXISTS (SELECT 1 FROM pages p WHERE p.id = takes.page_id AND p.source_id = $${params.length})`); } const where = clauses.join(' '); // NUMERIC casts for exact decimal arithmetic — keeps PGLite + Postgres // bucket boundaries identical at FP-edge weights (e.g. 0.7/0.1). diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index 4ad16bab4..a55487ad3 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -60,7 +60,7 @@ import { computeAnomaliesFromBuckets } from './cycle/anomaly.ts'; import * as db from './db.ts'; import { ConnectionManager } from './connection-manager.ts'; import { logConnectionEvent } from './connection-audit.ts'; -import { validateSlug, contentHash, rowToPage, rowToStalePage, rowToChunk, rowToSearchResult, parseEmbedding, tryParseEmbedding, takeRowToTake, isUndefinedTableError, warnOncePerProcess } from './utils.ts'; +import { validateSlug, contentHash, rowToPage, rowToStalePage, rowToChunk, rowToSearchResult, parseEmbedding, tryParseEmbedding, takeRowToTake, takeHitRowToHit, isUndefinedTableError, warnOncePerProcess } from './utils.ts'; import { resolveBoostMap, resolveHardExcludes } from './search/source-boost.ts'; import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, buildRecencyComponentSql, buildBestPerPagePoolCte } from './search/sql-ranking.ts'; import { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } from './ai/defaults.ts'; @@ -4584,6 +4584,14 @@ export class PostgresEngine implements BrainEngine { const limit = clampSearchLimit(opts.limit, 100, 500); const offset = Math.max(0, Math.floor(opts.offset ?? 0)); const active = opts.active ?? true; + // #2200-class: takes have no source_id of their own; scope via the page's + // source_id (already JOINed). Array wins over scalar, matching sourceScopeOpts. + const sourceFilter = + opts.sourceIds && opts.sourceIds.length > 0 + ? sql`AND p.source_id = ANY(${opts.sourceIds}::text[])` + : opts.sourceId + ? sql`AND p.source_id = ${opts.sourceId}` + : sql``; const rows = await sql` SELECT t.*, p.slug AS page_slug FROM takes t @@ -4603,6 +4611,7 @@ export class PostgresEngine implements BrainEngine { ${opts.takesHoldersAllowList ?? null}::text[] IS NULL OR t.holder = ANY(${opts.takesHoldersAllowList ?? null}::text[]) ) + ${sourceFilter} ORDER BY CASE WHEN ${opts.sortBy ?? 'created_at'} = 'weight' THEN t.weight END DESC NULLS LAST, CASE WHEN ${opts.sortBy ?? 'created_at'} = 'since_date' THEN t.since_date END DESC NULLS LAST, @@ -4612,7 +4621,7 @@ export class PostgresEngine implements BrainEngine { return rows.map((r) => takeRowToTake(r as Record)); } - async searchTakes(query: string, opts: SearchOpts & { takesHoldersAllowList?: string[] } = {}): Promise { + async searchTakes(query: string, opts: SearchOpts & { takesHoldersAllowList?: string[]; sourceId?: string; sourceIds?: string[] } = {}): Promise { const sql = this.sql; const limit = clampSearchLimit(opts.limit, 30, 100); const sourceFilter = opts.sourceIds && opts.sourceIds.length > 0 @@ -4636,12 +4645,15 @@ export class PostgresEngine implements BrainEngine { ORDER BY score DESC, t.weight DESC LIMIT ${limit} `; - return rows as unknown as TakeHit[]; + // #2450-class: int8 columns arrive as native BigInt from the pg driver; + // coerce per-row (takeRowToTake precedent) so MCP/CLI JSON.stringify + // doesn't crash the moment a search actually matches. + return rows.map((r) => takeHitRowToHit(r as Record)); } async searchTakesVector( embedding: Float32Array, - opts: SearchOpts & { takesHoldersAllowList?: string[] } = {}, + opts: SearchOpts & { takesHoldersAllowList?: string[]; sourceId?: string; sourceIds?: string[] } = {}, ): Promise { const sql = this.sql; const limit = clampSearchLimit(opts.limit, 30, 100); @@ -4667,7 +4679,10 @@ export class PostgresEngine implements BrainEngine { ORDER BY t.embedding <=> ${vec}::vector LIMIT ${limit} `; - return rows as unknown as TakeHit[]; + // #2450-class: int8 columns arrive as native BigInt from the pg driver; + // coerce per-row (takeRowToTake precedent) so MCP/CLI JSON.stringify + // doesn't crash the moment a search actually matches. + return rows.map((r) => takeHitRowToHit(r as Record)); } async getTakeEmbeddings(ids: number[]): Promise> { @@ -4802,6 +4817,14 @@ export class PostgresEngine implements BrainEngine { : sql``; const sinceClause = opts.since ? sql`AND since_date >= ${opts.since}` : sql``; const untilClause = opts.until ? sql`AND since_date <= ${opts.until}` : sql``; + // #2200-class: takes carry no source_id; scope via the take's page via EXISTS + // (this query has no pages JOIN). Array wins over scalar (sourceScopeOpts shape). + const sourceFilter = + opts.sourceIds && opts.sourceIds.length > 0 + ? sql`AND EXISTS (SELECT 1 FROM pages p WHERE p.id = takes.page_id AND p.source_id = ANY(${opts.sourceIds}::text[]))` + : opts.sourceId + ? sql`AND EXISTS (SELECT 1 FROM pages p WHERE p.id = takes.page_id AND p.source_id = ${opts.sourceId})` + : sql``; // v0.36.1.1 T1c: `resolved` deliberately filters to the 3-state subset // (correct|incorrect|partial) — NOT `resolved_quality IS NOT NULL` — so // historical comparisons against pre-v74 scorecards stay valid. @@ -4820,7 +4843,7 @@ export class PostgresEngine implements BrainEngine { END )::float AS brier FROM takes - WHERE 1=1 ${holderClause} ${domainClause} ${sinceClause} ${untilClause} ${allowed} + WHERE 1=1 ${holderClause} ${domainClause} ${sinceClause} ${untilClause} ${allowed} ${sourceFilter} `; const r = rows[0] as { total_bets: number; resolved: number; correct: number; incorrect: number; partial: number; unresolvable_count: number; brier: number | null }; return finalizeScorecard(r); @@ -4842,6 +4865,12 @@ export class PostgresEngine implements BrainEngine { const maxIdx = Math.floor(1 / bucketSize) - 1; const allowed = allowList ? sql`AND holder = ANY(${allowList}::text[])` : sql``; const holderClause = opts.holder ? sql`AND holder = ${opts.holder}` : sql``; + const sourceFilter = + opts.sourceIds && opts.sourceIds.length > 0 + ? sql`AND EXISTS (SELECT 1 FROM pages p WHERE p.id = takes.page_id AND p.source_id = ANY(${opts.sourceIds}::text[]))` + : opts.sourceId + ? sql`AND EXISTS (SELECT 1 FROM pages p WHERE p.id = takes.page_id AND p.source_id = ${opts.sourceId})` + : sql``; // Bucketing uses NUMERIC for exact decimal arithmetic. Going through // FLOAT introduces IEEE 754 rounding (e.g. 0.7/0.1 = 6.9999..., FLOOR=6 // instead of the expected 7), which makes Postgres and PGLite diverge @@ -4855,7 +4884,7 @@ export class PostgresEngine implements BrainEngine { (resolved_quality = 'correct')::int AS hit FROM takes WHERE resolved_quality IN ('correct','incorrect') - ${holderClause} ${allowed} + ${holderClause} ${allowed} ${sourceFilter} ) SELECT (bucket_idx::numeric * ${bucketSize}::numeric)::float AS bucket_lo, diff --git a/src/core/utils.ts b/src/core/utils.ts index 0e885b2ee..04989263b 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes } from 'crypto'; import type { Page, PageInput, PageType, Chunk, SearchResult, StalePageRow } from './types.ts'; -import type { Take, TakeKind } from './engine.ts'; +import type { Take, TakeKind, TakeHit } from './engine.ts'; /** * SHA-256 hash a token/secret for storage. Never store plaintext tokens. @@ -427,3 +427,24 @@ export function takeRowToTake(row: Record): Take { updated_at: isoOrNull(row.updated_at) ?? '', }; } + +/** + * Convert a takes search-hit SQL row to the `TakeHit` shape. The Postgres + * driver returns int8 columns (`take_id`/`page_id` are BIGSERIAL-backed) as + * native BigInt, which crashes JSON.stringify at the MCP/CLI serialization + * boundary (#2450-class). Number() is the same 2^53 envelope takeRowToTake + * already accepts for these ids. + */ +export function takeHitRowToHit(row: Record): TakeHit { + return { + take_id: Number(row.take_id), + page_id: Number(row.page_id), + page_slug: String(row.page_slug ?? ''), + row_num: Number(row.row_num), + claim: String(row.claim), + kind: row.kind as TakeKind, + holder: String(row.holder), + weight: Number(row.weight), + score: Number(row.score), + }; +} diff --git a/test/calibration-cli.test.ts b/test/calibration-cli.test.ts index 1ddbd6d5e..c70ebe483 100644 --- a/test/calibration-cli.test.ts +++ b/test/calibration-cli.test.ts @@ -61,7 +61,7 @@ function buildCtx(engine: BrainEngine, opts: { sourceId?: string; allowedSources function buildProfile(opts: Partial & { holder: string }): CalibrationProfileRow { return { - id: 1, + id: '1', source_id: opts.source_id ?? 'default', holder: opts.holder, wave_version: 'v0.36.1.0', @@ -99,6 +99,10 @@ describe('parseArgs', () => { expect(parseArgs(['--regenerate']).opts.regenerate).toBe(true); }); + test('--source (so the reachable command can target a non-default source)', () => { + expect(parseArgs(['--source', 'canon']).opts.source).toBe('canon'); + }); + test('--undo-wave ', () => { expect(parseArgs(['--undo-wave', 'v0.36.1.0']).opts.undoWave).toBe('v0.36.1.0'); }); @@ -151,6 +155,19 @@ describe('getLatestProfile', () => { // SELECT clause names the column but WHERE clause omits source_id filter. expect(capturedSql[0]).not.toContain('AND source_id'); }); + + test('coerces BIGSERIAL bigint id to number so JSON.stringify is safe (#2450)', async () => { + const engine = { + kind: 'pglite', + async executeRaw(): Promise { + return [{ ...buildProfile({ holder: 'brain' }), id: 10n }] as unknown as T[]; + }, + } as unknown as BrainEngine; + const p = await getLatestProfile(engine, { holder: 'brain' }); + expect(typeof p!.id).toBe('string'); + expect(p!.id).toBe('10'); + expect(() => JSON.stringify(p)).not.toThrow(); + }); }); // ─── formatProfileText ────────────────────────────────────────────── diff --git a/test/calibration-profile.test.ts b/test/calibration-profile.test.ts index f5fd8583e..f92e516ed 100644 --- a/test/calibration-profile.test.ts +++ b/test/calibration-profile.test.ts @@ -22,6 +22,8 @@ import { type BiasTagsGenerator, } from '../src/core/cycle/calibration-profile.ts'; import type { VoiceGateJudge } from '../src/core/calibration/voice-gate.ts'; +import { TIER_DEFAULTS } from '../src/core/model-config.ts'; +import { parseModelId } from '../src/core/ai/model-resolver.ts'; import type { OperationContext } from '../src/core/operations.ts'; import type { BrainEngine, TakesScorecard } from '../src/core/engine.ts'; @@ -239,6 +241,33 @@ describe('runPhaseCalibrationProfile — phase integration', () => { expect(insert!.params[11]).toEqual(['over-confident-geography']); // active_bias_tags }); + test('default model is a provider-prefixed id, persisted to model_id (#2451)', async () => { + const { engine, captured } = buildMockEngine({ scorecard: ENOUGH_RESOLVED_SCORECARD }); + const patternsGenerator: PatternStatementsGenerator = async () => [ + 'You call early-stage tactics well — 8 of 10 held up.', + ]; + await runPhaseCalibrationProfile(buildCtx(engine), { + patternsGenerator, + biasTagsGenerator: async () => [], + voiceGateJudge: passJudge, + }); + const insert = captured.find(c => c.sql.includes('INSERT INTO calibration_profiles')); + expect(insert).toBeDefined(); + // Pre-fix this was a bare 'claude-sonnet-4-6' → gateway.chat() throws + // "missing a provider prefix". The fix routes the default through TIER_DEFAULTS. + expect(insert!.params).toContain(TIER_DEFAULTS.reasoning); + expect(parseModelId(TIER_DEFAULTS.reasoning).providerId).toBe('anthropic'); + }); + + test('calibration + voice-gate tier defaults are provider-prefixed (#2451)', () => { + // calibration default (reasoning) + voice-gate judge default (utility) both + // route through TIER_DEFAULTS; a bare id would throw "missing a provider prefix". + for (const m of [TIER_DEFAULTS.reasoning, TIER_DEFAULTS.utility]) { + expect(() => parseModelId(m)).not.toThrow(); + expect(parseModelId(m).providerId).toBe('anthropic'); + } + }); + test('voice gate rejects both attempts → template fallback written, voice_gate_passed=false', async () => { const { engine, captured } = buildMockEngine({ scorecard: ENOUGH_RESOLVED_SCORECARD }); const patternsGenerator: PatternStatementsGenerator = async () => [ diff --git a/test/cli-bigint-normalize.test.ts b/test/cli-bigint-normalize.test.ts new file mode 100644 index 000000000..8e58d3873 --- /dev/null +++ b/test/cli-bigint-normalize.test.ts @@ -0,0 +1,97 @@ +/** + * Regression tests for the local-op CLI output normalizer and CLI command + * reachability. + * + * - bigintToStringReplacer: cli.ts JSON-normalizes a local op's return value + * so a bigint column (e.g. a BIGSERIAL `id`) round-trips to a string instead + * of crashing `JSON.stringify`. (garrytan/gbrain#2450) + * - CLI_ONLY: `calibration` is reachable; it was missing from the set, so the + * dispatch fell through to "Unknown command" despite a `case 'calibration'` + * handler existing. (garrytan/gbrain#2035) + * - takeHitRowToHit: searchTakes/searchTakesVector coerce int8 driver rows + * (native BigInt) to the numeric TakeHit contract, so MCP `takes_search` + * doesn't crash the serializer the moment a query matches. (#2450 comments) + */ +import { describe, test, expect } from 'bun:test'; +import { bigintToStringReplacer, CLI_ONLY } from '../src/cli.ts'; +import { takeHitRowToHit } from '../src/core/utils.ts'; +import { runCall } from '../src/commands/call.ts'; +import type { BrainEngine } from '../src/core/engine.ts'; + +describe('bigintToStringReplacer (#2450)', () => { + test('serializes a bigint to its string form instead of throwing', () => { + const raw = { id: 9007199254740993n, total: 5, name: 'x' }; + const out = JSON.parse(JSON.stringify(raw, bigintToStringReplacer)); + expect(out).toEqual({ id: '9007199254740993', total: 5, name: 'x' }); + }); + + test('handles nested + array bigints', () => { + const raw = { row: { id: 1n }, ids: [2n, 3n], plain: true }; + const out = JSON.parse(JSON.stringify(raw, bigintToStringReplacer)); + expect(out).toEqual({ row: { id: '1' }, ids: ['2', '3'], plain: true }); + }); + + test('leaves non-bigint values untouched', () => { + expect(bigintToStringReplacer('k', 5)).toBe(5); + expect(bigintToStringReplacer('k', 's')).toBe('s'); + expect(bigintToStringReplacer('k', null)).toBeNull(); + }); + + test('a bare object with a bigint throws under plain stringify but not with the replacer', () => { + expect(() => JSON.stringify({ id: 1n })).toThrow(); + expect(() => JSON.stringify({ id: 1n }, bigintToStringReplacer)).not.toThrow(); + }); +}); + +describe('CLI_ONLY command reachability (#2035)', () => { + test('`calibration` is in CLI_ONLY so dispatch reaches its handler', () => { + expect(CLI_ONLY.has('calibration')).toBe(true); + }); +}); + +describe('takeHitRowToHit (#2450 — takes_search MCP path)', () => { + test('coerces BigInt int8 columns to numbers per the TakeHit contract', () => { + const hit = takeHitRowToHit({ + take_id: 42n, page_id: 7n, page_slug: 'people/alice-example', row_num: 3n, + claim: 'Strong DX intuition', kind: 'take', holder: 'garry', + weight: 0.8, score: 0.91, + }); + expect(hit).toEqual({ + take_id: 42, page_id: 7, page_slug: 'people/alice-example', row_num: 3, + claim: 'Strong DX intuition', kind: 'take', holder: 'garry', + weight: 0.8, score: 0.91, + }); + expect(() => JSON.stringify(hit)).not.toThrow(); + }); + + test('a raw driver row with BigInt ids crashes plain stringify; the coerced hit does not', () => { + const raw = { take_id: 1n, page_id: 2n, row_num: 0n }; + expect(() => JSON.stringify(raw)).toThrow(); + expect(() => JSON.stringify(takeHitRowToHit(raw))).not.toThrow(); + }); +}); + +describe('runCall output exit is bigint-safe (#2450)', () => { + test('prints an op result carrying a bigint instead of crashing', async () => { + // `gbrain call` bypasses cli.ts's normalizer, so its own stringify must + // carry the replacer. Stub just the surface runCall touches: --source + // resolution (assertSourceExists) + the get_stats handler pass-through. + const stub = { + executeRaw: async (sql: string) => + sql.includes('FROM sources') ? [{ id: 'default' }] : [], + getStats: async () => ({ pages: 42n, chunks: 7 }), + } as unknown as BrainEngine; + + const lines: string[] = []; + const orig = console.log; + console.log = (msg?: unknown) => { lines.push(String(msg)); }; + try { + // --source pins tier 1 of resolveSourceId so the test is hermetic + // against GBRAIN_SOURCE / .gbrain-source on the host machine. + await runCall(stub, ['--source', 'default', 'get_stats']); + } finally { + console.log = orig; + } + expect(JSON.parse(lines.join('\n'))).toEqual({ pages: '42', chunks: 7 }); + }); +}); diff --git a/test/cross-brain-calibration.test.ts b/test/cross-brain-calibration.test.ts index 5c30bb964..d4bb95c31 100644 --- a/test/cross-brain-calibration.test.ts +++ b/test/cross-brain-calibration.test.ts @@ -28,7 +28,7 @@ import type { CalibrationProfileRow } from '../src/commands/calibration.ts'; function buildProfile(opts: { published: boolean; source_id?: string; holder?: string } = { published: false }): CalibrationProfileRow { return { - id: 1, + id: '1', source_id: opts.source_id ?? 'default', holder: opts.holder ?? 'garry', wave_version: 'v0.36.1.0', diff --git a/test/e2e/takes-postgres.test.ts b/test/e2e/takes-postgres.test.ts index 6749ec338..e812195f6 100644 --- a/test/e2e/takes-postgres.test.ts +++ b/test/e2e/takes-postgres.test.ts @@ -87,10 +87,35 @@ d('v0.28 takes engine — Postgres', () => { expect(hits.length).toBeGreaterThan(0); expect(hits[0].claim.toLowerCase()).toContain('technical'); + // #2450: int8 columns (take_id/page_id) arrive as native BigInt from the + // pg driver; the raw-row cast crashed JSON.stringify at the MCP boundary + // the moment a query matched. Only this suite runs the real driver that + // produces BigInt, so only these assertions fail if the takeHitRowToHit + // call sites regress. + expect(typeof hits[0].take_id).toBe('number'); + expect(typeof hits[0].page_id).toBe('number'); + expect(() => JSON.stringify(hits)).not.toThrow(); + const worldHits = await engine.searchTakes('founder', { takesHoldersAllowList: ['world'] }); expect(worldHits.every(h => h.holder === 'world')).toBe(true); }); + test('searchTakesVector returns coerced, JSON-serializable hits (#2450)', async () => { + const engine = getEngine(); + // Fixture takes carry no embeddings; give one a vector directly so the + // vector path (embedding IS NOT NULL) returns a real row. + const vec = `[${new Array(1536).fill(0.001).join(',')}]`; + await engine.executeRaw( + `UPDATE takes SET embedding = $1::vector WHERE page_id = $2 AND row_num = 1`, + [vec, alicePageId], + ); + const hits = await engine.searchTakesVector(new Float32Array(1536).fill(0.001)); + expect(hits.length).toBeGreaterThan(0); + expect(typeof hits[0].take_id).toBe('number'); + expect(typeof hits[0].page_id).toBe('number'); + expect(() => JSON.stringify(hits)).not.toThrow(); + }); + test('supersedeTake is transactional on real Postgres', async () => { const engine = getEngine(); const { oldRow, newRow } = await engine.supersedeTake(alicePageId, 3, { diff --git a/test/eval-contradictions-calibration-join.test.ts b/test/eval-contradictions-calibration-join.test.ts index d088093b0..34c591a0f 100644 --- a/test/eval-contradictions-calibration-join.test.ts +++ b/test/eval-contradictions-calibration-join.test.ts @@ -52,7 +52,7 @@ function buildFinding(slugA: string, slugB: string): ContradictionFinding { function buildProfile(activeTags: string[], brier: number | null = 0.21): CalibrationProfileRow { return { - id: 1, + id: '1', source_id: 'default', holder: 'garry', wave_version: 'v0.36.1.0', diff --git a/test/nudge.test.ts b/test/nudge.test.ts index 402e3d114..2ba260c27 100644 --- a/test/nudge.test.ts +++ b/test/nudge.test.ts @@ -57,7 +57,7 @@ function buildTake(overrides: Partial = {}): Take { function buildProfile(activeBiasTags: string[], holder = 'garry'): CalibrationProfileRow { return { - id: 1, + id: '1', source_id: 'default', holder, wave_version: 'v0.36.1.0', diff --git a/test/recall-footer.test.ts b/test/recall-footer.test.ts index c5447cd57..fda612d5b 100644 --- a/test/recall-footer.test.ts +++ b/test/recall-footer.test.ts @@ -19,7 +19,7 @@ import type { CalibrationProfileRow } from '../src/commands/calibration.ts'; function buildProfile(opts: Partial = {}): CalibrationProfileRow { return { - id: 1, + id: '1', source_id: 'default', holder: 'garry', wave_version: 'v0.36.1.0', diff --git a/test/takes-source-scope.test.ts b/test/takes-source-scope.test.ts new file mode 100644 index 000000000..9825eb571 --- /dev/null +++ b/test/takes-source-scope.test.ts @@ -0,0 +1,98 @@ +/** + * 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 { + 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); + }); +});