Compare commits

..
Author SHA1 Message Date
SinabinaandClaude Fable 5 48a6d21acf fix(embed): support hosted Perplexity embeddings (pplx-embed-v1-*) (#1046)
Adds a `perplexity` embedding recipe (OpenAI-compatible at
https://api.perplexity.ai/v1, auth via PERPLEXITY_API_KEY only — never an
OPENAI_API_KEY fallback) covering pplx-embed-v1-0.6b and pplx-embed-v1-4b.

Perplexity's /embeddings endpoint diverges from OpenAI's wire shape in two
places that break the AI SDK adapter, handled by a new perplexityCompatFetch
shim (mirrors the Voyage/ZeroEntropy pattern incl. the two-layer OOM caps):
- encoding_format only accepts base64_int8/base64_binary; the SDK's 'float'
  default is forced to 'base64_int8' outbound.
- The response embedding is base64-encoded signed int8 components (natively
  quantized); decoded to number[] inbound so the SDK's Zod schema validates.
  Cosine similarity is scale-invariant, so raw int8 components rank correctly.

Flexible dims (Matryoshka-style 128..native max: 1024 for 0.6b, 2560 for 4b)
validate fail-loud in dims.ts + the init preflight; `dimensions` is
Perplexity's native field so no wire translation is needed. default_dims is
1024 (works on a plain vector column for both models); the 4b model's full
2560 width rides the existing halfvec (>2000 dims) storage/ANN path. Pricing
entries land in embedding-pricing.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:26:32 -07:00
10 changed files with 370 additions and 162 deletions
+1 -4
View File
@@ -935,10 +935,7 @@ export function formatResult(opName: string, result: unknown): string {
lines.push(`Link coverage (entities): ${(h.link_coverage * 100).toFixed(1)}%`);
}
if (h.timeline_coverage !== undefined) {
lines.push(`Timeline coverage (entity pages): ${(h.timeline_coverage * 100).toFixed(1)}%`);
}
if (h.timeline_coverage_score !== undefined) {
lines.push(`Timeline density (all pages): ${h.timeline_coverage_score}/15 (whole-brain brain-score component)`);
lines.push(`Timeline coverage (entities): ${(h.timeline_coverage * 100).toFixed(1)}%`);
}
if (Array.isArray(h.most_connected) && h.most_connected.length > 0) {
lines.push('Most connected entities:');
+3 -3
View File
@@ -5868,12 +5868,12 @@ export async function buildChecks(
message: `Only code/test fixture entity pages found (${entityCount}); graph_coverage not applicable`,
});
} else if (linkCoverage >= 0.5 && timelineCoverage >= 0.5) {
checks.push({ name: 'graph_coverage', status: 'ok', message: `Entity link coverage ${linkPct}%, entity timeline coverage ${timelinePct}%` });
checks.push({ name: 'graph_coverage', status: 'ok', message: `Entity link coverage ${linkPct}%, timeline ${timelinePct}%` });
} else {
checks.push({
name: 'graph_coverage',
status: 'warn',
message: `Entity link coverage ${linkPct}%, entity timeline coverage ${timelinePct}% (${eligibleEntityCount} entity pages). Run: gbrain extract all`,
message: `Entity link coverage ${linkPct}%, timeline ${timelinePct}% (${eligibleEntityCount} entity pages). Run: gbrain extract all`,
});
}
@@ -5885,7 +5885,7 @@ export async function buildChecks(
const parts = [
`embed ${health.embed_coverage_score}/35`,
`links ${health.link_density_score}/25`,
`timeline density (all pages) ${health.timeline_coverage_score}/15`,
`timeline ${health.timeline_coverage_score}/15`,
`orphans ${health.no_orphans_score}/15`,
`dead-links ${health.no_dead_links_score}/10`,
];
+41
View File
@@ -90,6 +90,30 @@ export function isValidOpenAITextEmbedding3Dim(modelId: string, dims: number): b
return Number.isInteger(dims) && dims >= 1 && dims <= max;
}
// Perplexity hosted embeddings (#1046): Matryoshka-style flexible dims,
// any integer from 128 up to the model's native size. `dimensions` is the
// native wire field (no translation needed); output encoding divergence
// (base64 int8) is handled by perplexityCompatFetch in gateway.ts.
const PERPLEXITY_EMBEDDING_MAX_DIMS: Record<string, number> = {
'pplx-embed-v1-0.6b': 1024,
'pplx-embed-v1-4b': 2560,
};
export const PERPLEXITY_MIN_DIMS = 128;
export function isPerplexityEmbeddingModel(modelId: string): boolean {
return modelId in PERPLEXITY_EMBEDDING_MAX_DIMS;
}
export function maxPerplexityEmbeddingDim(modelId: string): number | undefined {
return PERPLEXITY_EMBEDDING_MAX_DIMS[modelId];
}
export function isValidPerplexityDim(modelId: string, dims: number): boolean {
const max = PERPLEXITY_EMBEDDING_MAX_DIMS[modelId];
if (max === undefined) return false;
return Number.isInteger(dims) && dims >= PERPLEXITY_MIN_DIMS && dims <= max;
}
// NVIDIA NIM hosted embedding models use asymmetric input_type values. Most
// emit fixed natural dimensions, but llama-nemotron-embed-1b-v2 accepts
// Matryoshka-style dimension overrides (e.g. matching an existing 1280d
@@ -226,6 +250,23 @@ export function dimsProviderOptions(
},
};
}
// Perplexity pplx-embed-v1-* — flexible dims via the native
// `dimensions` field. Fail-loud when the configured dim is outside
// the model's range (same rationale as the Voyage/ZE guards: the
// upstream HTTP 400 misroutes as a transient network error).
// Symmetric retrieval — inputType is never emitted.
if (isPerplexityEmbeddingModel(modelId)) {
if (!isValidPerplexityDim(modelId, dims)) {
const max = maxPerplexityEmbeddingDim(modelId)!;
throw new AIConfigError(
`Perplexity model "${modelId}" supports embedding_dimensions in ` +
`${PERPLEXITY_MIN_DIMS}..${max}, got ${dims}.`,
`Set \`embedding_dimensions\` to a value between ${PERPLEXITY_MIN_DIMS} and ${max} ` +
`in your gbrain config.`,
);
}
return { openaiCompatible: { dimensions: dims } };
}
// NVIDIA NIM hosted embeddings are OpenAI-compatible but require
// asymmetric input_type. Use passage for indexing/document-side vectors
// and query for search-side vectors. Only llama-nemotron-embed-1b-v2
+111
View File
@@ -263,6 +263,18 @@ export class ZeroEntropyResponseTooLargeError extends Error {
}
}
/** Perplexity twin of the Voyage/ZE OOM caps (#1046). Int8 components are
* 1 byte each, so a real response (512 texts × 2560 dims) is ~1.3 MB —
* anything near this cap is unambiguously not legitimate. */
const MAX_PERPLEXITY_RESPONSE_BYTES = 256 * 1024 * 1024;
export class PerplexityResponseTooLargeError extends Error {
constructor(message: string) {
super(message);
this.name = 'PerplexityResponseTooLargeError';
}
}
// ---- Unified auth resolution (D12=A) ----
//
// Pre-v0.32, openai-compatible auth was duplicated across instantiateEmbedding,
@@ -1292,6 +1304,103 @@ const openAICompatAsymmetricFetch = (async (input: RequestInfo | URL, init?: Req
return fetch(typeof input === 'string' ? input : input.toString(), baseInit);
}) as unknown as typeof fetch;
/**
* Perplexity compatibility shim (#1046). Perplexity's `/v1/embeddings`
* endpoint is OpenAI-shaped but diverges on two points that break the AI
* SDK's openai-compatible adapter:
* - `encoding_format` only accepts 'base64_int8' (default) or
* 'base64_binary'; the SDK sends 'float', which Perplexity rejects.
* Force 'base64_int8' on the wire.
* - The response `embedding` is a base64 string encoding SIGNED INT8
* components (natively quantized output). The SDK schema expects
* `number[]` — decode Int8Array → number[] here. Cosine similarity is
* scale-invariant, so the raw int8 components rank correctly.
* `dimensions` is Perplexity's native field name — no translation needed
* (dims.ts emits it directly). Layer 1/Layer 2 OOM caps mirror the Voyage
* pattern.
*
* Exported for tests (behavioral coverage of the int8 decode); not part of
* the public gateway API.
*/
export const perplexityCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
// OUTBOUND: force the encoding Perplexity actually accepts.
if (init?.body && typeof init.body === 'string') {
try {
const parsed = JSON.parse(init.body);
if (parsed && typeof parsed === 'object' && parsed.encoding_format !== 'base64_int8') {
parsed.encoding_format = 'base64_int8';
// Drop Content-Length so fetch recomputes from the new body.
const headers = new Headers(init.headers ?? {});
headers.delete('content-length');
init = { ...init, body: JSON.stringify(parsed), headers };
}
} catch {
// Body wasn't JSON — pass through untouched.
}
}
const resp = await fetch(input as any, init);
if (!resp.ok) return resp;
const ct = resp.headers.get('content-type') ?? '';
if (!ct.toLowerCase().includes('application/json')) return resp;
// Layer 1: Content-Length pre-check BEFORE the body is parsed.
const contentLengthHeader = resp.headers.get('content-length');
if (contentLengthHeader) {
const len = parseInt(contentLengthHeader, 10);
if (Number.isFinite(len) && len > MAX_PERPLEXITY_RESPONSE_BYTES) {
throw new PerplexityResponseTooLargeError(
`Perplexity response Content-Length=${len} exceeds ${MAX_PERPLEXITY_RESPONSE_BYTES} bytes — ` +
`likely compromised endpoint or misconfiguration`,
);
}
}
// INBOUND: decode base64 int8 embeddings to number[] so the SDK's Zod
// schema validates.
try {
const json: any = await resp.clone().json();
if (!json || typeof json !== 'object') return resp;
let modified = false;
if (Array.isArray(json.data)) {
for (const item of json.data) {
if (item && typeof item.embedding === 'string') {
// Layer 2: per-embedding cap for chunked responses that skipped
// Layer 1. base64 → bytes is the canonical 0.75 ratio.
const estDecoded = Math.ceil(item.embedding.length * 0.75);
if (estDecoded > MAX_PERPLEXITY_RESPONSE_BYTES) {
throw new PerplexityResponseTooLargeError(
`Perplexity embedding base64 exceeds ${MAX_PERPLEXITY_RESPONSE_BYTES} bytes ` +
`(estimated ${estDecoded} bytes from ${item.embedding.length} base64 chars)`,
);
}
// base64_int8: one signed int8 per component.
const bytes = Buffer.from(item.embedding, 'base64');
item.embedding = Array.from(new Int8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength));
modified = true;
}
}
}
if (json.usage && typeof json.usage === 'object' && json.usage.prompt_tokens === undefined) {
json.usage.prompt_tokens = typeof json.usage.total_tokens === 'number'
? json.usage.total_tokens
: 0;
modified = true;
}
if (!modified) return resp;
return new Response(JSON.stringify(json), {
status: resp.status,
statusText: resp.statusText,
headers: resp.headers,
});
} catch (err) {
// OOM-cap throws MUST propagate; anything else falls back to the
// original response (same contract as voyageCompatFetch).
if (err instanceof PerplexityResponseTooLargeError) throw err;
return resp;
}
}) as unknown as typeof fetch;
async function resolveEmbeddingProvider(modelStr: string): Promise<{ model: any; recipe: Recipe; modelId: string }> {
const { parsed, recipe } = resolveRecipe(modelStr);
assertTouchpoint(recipe, 'embedding', parsed.modelId, getExtendedModelsForProvider(parsed.providerId));
@@ -1360,6 +1469,8 @@ function instantiateEmbedding(recipe: Recipe, modelId: string, cfg: AIGatewayCon
? zeroEntropyCompatFetch
: recipe.id === 'nvidia'
? nvidiaCompatFetch
: recipe.id === 'perplexity'
? perplexityCompatFetch
: openAICompatAsymmetricFetch);
const client = createOpenAICompatible({
name: recipe.id,
+2
View File
@@ -26,6 +26,7 @@ import { llamaServerReranker } from './llama-server-reranker.ts';
import { moonshot } from './moonshot.ts';
import { mistral } from './mistral.ts';
import { nvidia } from './nvidia.ts';
import { perplexity } from './perplexity.ts';
const ALL: Recipe[] = [
openai,
@@ -48,6 +49,7 @@ const ALL: Recipe[] = [
moonshot,
mistral,
nvidia,
perplexity,
];
/** Map from `provider:id` key to recipe. */
+54
View File
@@ -0,0 +1,54 @@
import type { Recipe } from '../types.ts';
/**
* Perplexity's hosted embeddings API (#1046). OpenAI-shaped at
* `POST {base}/embeddings` but diverges on the wire:
* - `encoding_format` only accepts 'base64_int8' (default) or
* 'base64_binary' — the AI SDK's 'float' default is rejected.
* - The response `embedding` is a base64 string encoding SIGNED INT8
* components (natively quantized output), not a float array.
* Both divergences are handled by perplexityCompatFetch in gateway.ts
* (force 'base64_int8' outbound; decode Int8Array → number[] inbound).
* Cosine similarity is scale-invariant, so the raw int8 components store
* and rank correctly as floats.
*
* Models (per docs.perplexity.ai/api-reference/embeddings-post, 2026-07):
* - pplx-embed-v1-0.6b: dims 128..1024 (default 1024)
* - pplx-embed-v1-4b: dims 128..2560 (default 2560)
* The flexible-dim range validation lives in src/core/ai/dims.ts
* (PERPLEXITY_EMBEDDING_MAX_DIMS). default_dims is pinned at 1024 so both
* models work out of the box on a plain vector(N) column; users who want
* the 4b model's full 2560 width set `embedding_dimensions: 2560` and the
* existing halfvec path (dims > 2000) covers storage + ANN.
*
* Auth is PERPLEXITY_API_KEY only — deliberately NO OPENAI_API_KEY
* fallback (a Perplexity brain must never silently bill/route through
* OpenAI). If your key lives in PPLX_API_KEY, re-export it.
*/
export const perplexity: Recipe = {
id: 'perplexity',
name: 'Perplexity',
tier: 'openai-compat',
implementation: 'openai-compatible',
base_url_default: 'https://api.perplexity.ai/v1',
auth_env: {
required: ['PERPLEXITY_API_KEY'],
setup_url: 'https://www.perplexity.ai/settings/api',
},
touchpoints: {
embedding: {
models: ['pplx-embed-v1-0.6b', 'pplx-embed-v1-4b'],
default_dims: 1024,
cost_per_1m_tokens_usd: 0.03, // pplx-embed-v1-4b; 0.6b is $0.004/M
price_last_verified: '2026-07-21',
// Perplexity enforces 120K combined tokens (and 512 texts) per
// request. Same pre-split posture as Voyage: assume a dense
// tokenizer (1 char ≈ 1 token) at 0.5 utilization; the gateway's
// recursive halving is the runtime safety net.
max_batch_tokens: 120_000,
chars_per_token: 1,
safety_factor: 0.5,
},
},
setup_hint: 'Get an API key at https://www.perplexity.ai/settings/api, then `export PERPLEXITY_API_KEY=...` (re-export PPLX_API_KEY if that is where your key lives).',
};
+13
View File
@@ -32,6 +32,10 @@ import {
nvidiaEmbeddingDim,
nvidiaEmbeddingDimOptions,
supportsNvidiaEmbeddingDimension,
isPerplexityEmbeddingModel,
isValidPerplexityDim,
maxPerplexityEmbeddingDim,
PERPLEXITY_MIN_DIMS,
} from './ai/dims.ts';
/**
@@ -462,6 +466,15 @@ function isCustomDimValidForProvider(
`(allowed: ${ZEROENTROPY_VALID_DIMS.join(', ')}).`,
};
}
if (recipe.id === 'perplexity' && isPerplexityEmbeddingModel(modelId)) {
if (isValidPerplexityDim(modelId, requestedDims)) return { valid: true, error: '' };
return {
valid: false,
error:
`Perplexity ${modelId} accepts dimensions ${PERPLEXITY_MIN_DIMS}..${maxPerplexityEmbeddingDim(modelId)}, ` +
`got ${requestedDims}.`,
};
}
if (recipe.id === 'openai' && isOpenAITextEmbedding3Model(modelId)) {
if (isValidOpenAITextEmbedding3Dim(modelId, requestedDims)) return { valid: true, error: '' };
const maxDim = maxOpenAITextEmbedding3Dim(modelId);
+3
View File
@@ -40,6 +40,9 @@ export const EMBEDDING_PRICING: Record<string, EmbeddingPricing> = {
// Mistral (https://mistral.ai/pricing/api/, verified 2026-07-19)
'mistral:mistral-embed': { pricePerMTok: 0.10 },
'mistral:mistral-embed-2312': { pricePerMTok: 0.10 },
// Perplexity (https://docs.perplexity.ai/getting-started/pricing, verified 2026-07-21)
'perplexity:pplx-embed-v1-0.6b': { pricePerMTok: 0.004 },
'perplexity:pplx-embed-v1-4b': { pricePerMTok: 0.03 },
};
export type PriceLookupResult =
+142
View File
@@ -0,0 +1,142 @@
/**
* #1046 — Perplexity hosted embeddings (pplx-embed-v1-*).
*
* Covers the three seams the recipe touches:
* - recipe registration + auth (PERPLEXITY_API_KEY only, never OPENAI_API_KEY)
* - flexible-dim validation (128..native max) in dims.ts + the init
* preflight (resolveSchemaEmbeddingDim), incl. the >2000-dim 4b case
* - perplexityCompatFetch: forces encoding_format=base64_int8 outbound and
* decodes the base64 int8 embedding payload to number[] inbound
*/
import { afterEach, describe, expect, test } from 'bun:test';
import {
dimsProviderOptions,
isPerplexityEmbeddingModel,
isValidPerplexityDim,
maxPerplexityEmbeddingDim,
} from '../../src/core/ai/dims.ts';
import { getRecipe, RECIPES } from '../../src/core/ai/recipes/index.ts';
import { perplexity } from '../../src/core/ai/recipes/perplexity.ts';
import { defaultResolveAuth, perplexityCompatFetch } from '../../src/core/ai/gateway.ts';
import { AIConfigError } from '../../src/core/ai/errors.ts';
import { resolveSchemaEmbeddingDim } from '../../src/core/embedding-dim-check.ts';
import { lookupEmbeddingPrice } from '../../src/core/embedding-pricing.ts';
describe('recipe: perplexity', () => {
test('registered as an OpenAI-compatible embedding provider', () => {
expect(RECIPES.has('perplexity')).toBe(true);
expect(getRecipe('perplexity')).toBe(perplexity);
expect(perplexity.tier).toBe('openai-compat');
expect(perplexity.implementation).toBe('openai-compatible');
expect(perplexity.base_url_default).toBe('https://api.perplexity.ai/v1');
const e = perplexity.touchpoints.embedding!;
expect(e.models).toEqual(['pplx-embed-v1-0.6b', 'pplx-embed-v1-4b']);
expect(e.default_dims).toBe(1024);
expect(e.max_batch_tokens).toBe(120_000);
});
test('auth is PERPLEXITY_API_KEY bearer — no OPENAI_API_KEY fallback', () => {
expect(perplexity.resolveAuth).toBeUndefined();
expect(perplexity.auth_env?.required).toEqual(['PERPLEXITY_API_KEY']);
expect(defaultResolveAuth(perplexity, { PERPLEXITY_API_KEY: 'fake-pplx' }, 'embedding')).toEqual({
headerName: 'Authorization',
token: 'Bearer fake-pplx',
});
// An OPENAI_API_KEY in the env must NOT satisfy Perplexity auth.
expect(() => defaultResolveAuth(perplexity, { OPENAI_API_KEY: 'sk-test' }, 'embedding')).toThrow(AIConfigError);
});
test('dims: 128..native-max range per model', () => {
expect(isPerplexityEmbeddingModel('pplx-embed-v1-4b')).toBe(true);
expect(maxPerplexityEmbeddingDim('pplx-embed-v1-4b')).toBe(2560);
expect(maxPerplexityEmbeddingDim('pplx-embed-v1-0.6b')).toBe(1024);
expect(isValidPerplexityDim('pplx-embed-v1-4b', 2560)).toBe(true);
expect(isValidPerplexityDim('pplx-embed-v1-4b', 128)).toBe(true);
expect(isValidPerplexityDim('pplx-embed-v1-4b', 64)).toBe(false);
expect(isValidPerplexityDim('pplx-embed-v1-0.6b', 2560)).toBe(false);
});
test('dimsProviderOptions emits native `dimensions`, fails loud out of range', () => {
expect(dimsProviderOptions('openai-compatible', 'pplx-embed-v1-4b', 2560)).toEqual({
openaiCompatible: { dimensions: 2560 },
});
// Symmetric provider — inputType never emitted.
expect(dimsProviderOptions('openai-compatible', 'pplx-embed-v1-4b', 1024, 'query')).toEqual({
openaiCompatible: { dimensions: 1024 },
});
expect(() => dimsProviderOptions('openai-compatible', 'pplx-embed-v1-0.6b', 2560)).toThrow(AIConfigError);
});
test('init preflight accepts the 4b model at its native 2560 dims (halfvec territory)', () => {
const res = resolveSchemaEmbeddingDim({
embedding_model: 'perplexity:pplx-embed-v1-4b',
embedding_dimensions: 2560,
});
expect(res).toEqual({
ok: true,
dim: 2560,
model: 'perplexity:pplx-embed-v1-4b',
provider: 'perplexity',
recipeDefault: 1024,
});
const bad = resolveSchemaEmbeddingDim({
embedding_model: 'perplexity:pplx-embed-v1-4b',
embedding_dimensions: 4096,
});
expect(bad.ok).toBe(false);
});
test('embedding pricing table knows both models', () => {
expect(lookupEmbeddingPrice('perplexity:pplx-embed-v1-4b')).toMatchObject({ kind: 'known', pricePerMTok: 0.03 });
expect(lookupEmbeddingPrice('perplexity:pplx-embed-v1-0.6b')).toMatchObject({ kind: 'known', pricePerMTok: 0.004 });
});
});
describe('perplexityCompatFetch — int8 wire shim', () => {
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
test('forces encoding_format=base64_int8 outbound and decodes int8 base64 inbound', async () => {
const int8 = new Int8Array([3, -7, 127, -128]);
const b64 = Buffer.from(int8.buffer).toString('base64');
let sentBody: any;
globalThis.fetch = (async (_input: any, init?: RequestInit) => {
sentBody = JSON.parse(init!.body as string);
return new Response(
JSON.stringify({
object: 'list',
model: 'pplx-embed-v1-4b',
data: [{ object: 'embedding', index: 0, embedding: b64 }],
usage: { prompt_tokens: 4, total_tokens: 4 },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}) as any;
const resp = await (perplexityCompatFetch as any)('https://api.perplexity.ai/v1/embeddings', {
method: 'POST',
headers: { 'content-type': 'application/json' },
// The AI SDK sends encoding_format:'float' — Perplexity rejects it.
body: JSON.stringify({ model: 'pplx-embed-v1-4b', input: ['hi'], encoding_format: 'float', dimensions: 4 }),
});
expect(sentBody.encoding_format).toBe('base64_int8');
expect(sentBody.dimensions).toBe(4); // native field, untouched
const json = await resp.json();
expect(json.data[0].embedding).toEqual([3, -7, 127, -128]);
expect(json.usage.prompt_tokens).toBe(4);
});
test('non-JSON and error responses pass through untouched', async () => {
globalThis.fetch = (async () =>
new Response('nope', { status: 401, headers: { 'content-type': 'text/plain' } })) as any;
const resp = await (perplexityCompatFetch as any)('https://api.perplexity.ai/v1/embeddings', {
method: 'POST',
body: JSON.stringify({ model: 'pplx-embed-v1-4b', input: ['hi'] }),
});
expect(resp.status).toBe(401);
expect(await resp.text()).toBe('nope');
});
});
@@ -1,155 +0,0 @@
/**
* Issue #2298 — timeline metric presentation contract.
*
* Authoritative upstream semantics (src/core/types.ts):
* - Metric A `timeline_coverage` (entity-scoped, fraction 01):
* eligible entity pages WITH a timeline entry / eligible entity pages
* -> surfaced by `graph_coverage` check AND `get_health` CLI entity line.
* - Metric B `timeline_coverage_score` (whole-brain, 015 brain-score component):
* all pages WITH a timeline entry / all pages
* -> surfaced by `brain_score` component breakdown AND (separately) CLI.
*
* The two have DIFFERENT numerators/denominators. This PR labels each
* explicitly and keeps BOTH the entity CLI line and the whole-brain line.
*
* Tests (no private EriadorMu data, no production/home DB, no network):
* - numeric denominator assertions (Metric A = 50%, Metric B = 4/15)
* - doctor rendered-message assertions (exact labels, no ambiguous old label)
* - CLI rendered-output assertions (exact lines, guard matrix)
* - red/green: same assertions FAIL on origin/master, PASS on this branch
*
* Scoring formula UNCHANGED. Canonical PGLite fixture via resetPgliteState.
*/
import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { sqlQueryForEngine } from '../src/core/sql-query.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { buildChecks } from '../src/commands/doctor.ts';
import { formatResult } from '../src/cli.ts';
let engine: PGLiteEngine;
async function seedFourPages(eng: PGLiteEngine): Promise<void> {
const sql = sqlQueryForEngine(eng);
// 2 eligible entity pages, 2 technical/non-entity pages.
// Only ONE entity page has a timeline entry; only ONE total page does.
await sql`
INSERT INTO pages (slug, source_id, type, title, compiled_truth, frontmatter, content_hash, created_at, updated_at)
VALUES
('acme-example', 'default', 'company', 'Acme', '', '{}', 'h1', now(), now()),
('alice-example', 'default', 'person', 'Alice', '', '{}', 'h2', now(), now()),
('technical-a', 'default', 'note', 'Tech A', '', '{}', 'h3', now(), now()),
('technical-b', 'default', 'note', 'Tech B', '', '{}', 'h4', now(), now())
`;
const companyId = (await sql`SELECT id FROM pages WHERE slug='acme-example'`)[0].id as number;
await sql`INSERT INTO timeline_entries (page_id, date, source, summary, detail)
VALUES (${companyId}, CURRENT_DATE, 'test', 'milestone', '{}')`;
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
describe('issue #2298 — numeric denominator semantics', () => {
test('entity timeline coverage = 1/2 = 50% (2 eligible entities, 1 with timeline)', async () => {
await seedFourPages(engine);
const health = await engine.getHealth();
expect(health.timeline_coverage).toBeDefined();
expect(Math.round((health.timeline_coverage ?? 0) * 100)).toBe(50);
});
test('whole-brain timeline density = 1/4 -> score 4/15 (4 total pages, 1 with timeline)', async () => {
await seedFourPages(engine);
const health = await engine.getHealth();
expect(health.timeline_coverage_score).toBeDefined();
expect(health.timeline_coverage_score).toBe(4);
});
test('the two metrics use independent denominators', async () => {
await seedFourPages(engine);
const health = await engine.getHealth();
expect(Math.round((health.timeline_coverage ?? 0) * 100)).toBe(50);
expect(health.timeline_coverage_score ?? 0).toBe(4);
// 50% (entity, /2) != 26.7% (whole-brain, /4). Provably distinct.
expect(Math.round(((health.timeline_coverage_score ?? 0) / 15) * 100)).not.toBe(50);
});
});
describe('issue #2298 — doctor rendered-message contract', () => {
test('graph_coverage renders entity-scoped label with 50%', async () => {
await seedFourPages(engine);
const checks = await buildChecks(engine, [], null);
const graph = checks.find((c) => c.name === 'graph_coverage');
expect(graph, 'graph_coverage check must be present').toBeDefined();
expect(graph!.message).toContain('entity timeline coverage 50%');
// ambiguous old label must NOT be present
expect(graph!.message).not.toMatch(/timeline 50%/);
expect(graph!.message).not.toMatch(/timeline \(entity, brain score\)/);
});
test('brain_score renders whole-brain density label 4/15', async () => {
await seedFourPages(engine);
const checks = await buildChecks(engine, [], null);
const brain = checks.find((c) => c.name === 'brain_score');
expect(brain, 'brain_score check must be present').toBeDefined();
expect(brain!.message).toContain('timeline density (all pages) 4/15');
// wrong labels must NOT be present
expect(brain!.message).not.toMatch(/timeline 4\/15/);
expect(brain!.message).not.toMatch(/timeline \(entity, brain score\)/);
// brain-score component must NOT carry the word "entity" (it is whole-brain)
const timelinePart = brain!.message.split('timeline density (all pages) 4/15')[0] + 'timeline density (all pages) 4/15';
expect(timelinePart).not.toMatch(/entity/);
});
});
describe('issue #2298 — CLI get_health rendered-output contract', () => {
function fakeHealth(overrides: Record<string, unknown>): any {
return {
embed_coverage: 1, missing_embeddings: 0, stale_pages: 0, orphan_pages: 0,
link_coverage: 1, timeline_coverage: 0.5, timeline_coverage_score: 4,
most_connected: [], ...overrides,
};
}
test('both entity and whole-brain lines render, no undefined/15', () => {
const out = formatResult('get_health', fakeHealth({}));
expect(out).toContain('Timeline coverage (entity pages): 50.0%');
expect(out).toContain('Timeline density (all pages): 4/15');
expect(out).not.toContain('undefined/15');
expect(out).not.toContain('Timeline coverage (entities)');
expect(out).not.toMatch(/timeline \(entity, brain score\)/);
expect(out).not.toMatch(/bare "timeline 4\/15"/);
});
test('guard matrix: entity present, whole-brain absent -> only entity line', () => {
const out = formatResult('get_health', fakeHealth({ timeline_coverage_score: undefined }));
expect(out).toContain('Timeline coverage (entity pages): 50.0%');
expect(out).not.toContain('Timeline density (all pages)');
expect(out).not.toContain('undefined/15');
});
test('guard matrix: whole-brain present, entity absent -> only whole-brain line', () => {
const out = formatResult('get_health', fakeHealth({ timeline_coverage: undefined }));
expect(out).toContain('Timeline density (all pages): 4/15');
expect(out).not.toContain('Timeline coverage (entity pages)');
expect(out).not.toContain('undefined/15');
});
test('guard matrix: both absent -> neither timeline line, never undefined/15', () => {
const out = formatResult('get_health', fakeHealth({ timeline_coverage: undefined, timeline_coverage_score: undefined }));
expect(out).not.toContain('Timeline coverage (entity pages)');
expect(out).not.toContain('Timeline density (all pages)');
expect(out).not.toContain('undefined/15');
});
});