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>
This commit is contained in:
Time Attakc
2026-07-16 21:20:12 -07:00
committed by GitHub
co-authored by Sinabina spinsirr Claude Fable 5
parent 2166545849
commit 26d2f8abfc
20 changed files with 425 additions and 38 deletions
+12 -2
View File
@@ -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) {
+16 -3
View File
@@ -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<CalibrationProfileRow>(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<void> {
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.
+5 -1
View File
@@ -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 <tool> <json>` — 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));
}
+22 -12
View File
@@ -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();
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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<string, unknown>; 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;
+12 -2
View File
@@ -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<TakeHit[]>;
searchTakes(query: string, opts?: SearchOpts & { takesHoldersAllowList?: string[]; sourceId?: string; sourceIds?: string[] }): Promise<TakeHit[]>;
/**
* 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<TakeHit[]>;
/** Look up embeddings by take id (mirrors getEmbeddingsByChunkIds). */
+5
View File
@@ -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,
},
+20 -3
View File
@@ -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<string, unknown>));
@@ -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<string, unknown>));
}
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<string, unknown>));
}
async getTakeEmbeddings(ids: number[]): Promise<Map<number, Float32Array>> {
@@ -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).
+36 -7
View File
@@ -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<string, unknown>));
}
async searchTakes(query: string, opts: SearchOpts & { takesHoldersAllowList?: string[] } = {}): Promise<TakeHit[]> {
async searchTakes(query: string, opts: SearchOpts & { takesHoldersAllowList?: string[]; sourceId?: string; sourceIds?: string[] } = {}): Promise<TakeHit[]> {
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<string, unknown>));
}
async searchTakesVector(
embedding: Float32Array,
opts: SearchOpts & { takesHoldersAllowList?: string[] } = {},
opts: SearchOpts & { takesHoldersAllowList?: string[]; sourceId?: string; sourceIds?: string[] } = {},
): Promise<TakeHit[]> {
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<string, unknown>));
}
async getTakeEmbeddings(ids: number[]): Promise<Map<number, Float32Array>> {
@@ -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,
+22 -1
View File
@@ -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<string, unknown>): 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<string, unknown>): 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),
};
}
+18 -1
View File
@@ -61,7 +61,7 @@ function buildCtx(engine: BrainEngine, opts: { sourceId?: string; allowedSources
function buildProfile(opts: Partial<CalibrationProfileRow> & { 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 <id> (so the reachable command can target a non-default source)', () => {
expect(parseArgs(['--source', 'canon']).opts.source).toBe('canon');
});
test('--undo-wave <version>', () => {
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<T>(): Promise<T[]> {
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 ──────────────────────────────────────────────
+29
View File
@@ -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 () => [
+97
View File
@@ -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 });
});
});
+1 -1
View File
@@ -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',
+25
View File
@@ -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, {
@@ -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',
+1 -1
View File
@@ -57,7 +57,7 @@ function buildTake(overrides: Partial<Take> = {}): Take {
function buildProfile(activeBiasTags: string[], holder = 'garry'): CalibrationProfileRow {
return {
id: 1,
id: '1',
source_id: 'default',
holder,
wave_version: 'v0.36.1.0',
+1 -1
View File
@@ -19,7 +19,7 @@ import type { CalibrationProfileRow } from '../src/commands/calibration.ts';
function buildProfile(opts: Partial<CalibrationProfileRow> = {}): CalibrationProfileRow {
return {
id: 1,
id: '1',
source_id: 'default',
holder: 'garry',
wave_version: 'v0.36.1.0',
+98
View File
@@ -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<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);
});
});