mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
co-authored by
Sinabina
spinsirr
Claude Fable 5
parent
2166545849
commit
26d2f8abfc
+12
-2
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -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 () => [
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user