diff --git a/CHANGELOG.md b/CHANGELOG.md index b92d1e904..e7ac78cdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Things to watch: - **`src/commands/embed.ts`** + **`src/commands/import.ts`** — `runEmbedCore` and `runImport` consult `assertEmbeddingEnabled(loadConfig())` and refuse cleanly with a `gbrain config set embedding_model ` hint when `embedding_disabled: true` is set. `gbrain import --no-embed` flag still works (chunks land without vectors). - **`src/commands/doctor.ts`** — `embedding_provider` check extended for the v0.36 silent-default repair case. Empty-brain vs non-empty-brain repair branching (drop-and-re-init vs `gbrain retrieval-upgrade`). `subagent_provider` check (v0.31.12) extended per D7 to warn when `chat_model` is non-Anthropic AND `ANTHROPIC_API_KEY` is missing. - **`src/commands/reindex-multimodal.ts`** — preflight `resolveSchemaMultimodalDim` BEFORE the reindex sweep, mirroring the text-side contract from `initPGLite`. +- **`src/core/pglite-engine.ts` + `src/core/postgres-engine.ts`** — empty brain (`pageCount === 0`) now scores **100/100**, not 0/100. Vacuous truth: an empty brain has no coverage problem to penalize. Pre-fix, fresh `gbrain init --pglite` users saw `Brain score 0/100` on first `gbrain doctor` run, which was structurally surprising. Same fix on both engines, breakdown components unchanged for non-empty brains. - **`test/levenshtein.test.ts`** (18 cases), **`test/embedding-dim-check.test.ts`** (23 cases, extended), **`test/providers.test.ts`** (10 cases), **`test/init-provider-picker.test.ts`** (7 cases), **`test/init-env-detection.test.ts`** (21 cases), **`test/config-set.test.ts`** (19 cases) — 98 new unit cases pinning the env-detection grouping, Matryoshka validation, picker caveat behavior, Levenshtein suggestions, and bug-reporter regression for the three no-op keys. - **`test/e2e/init-fresh-pglite.test.ts`** (NEW, 14 E2E cases) — subprocess-driven verification of the full happy path, D3 non-TTY fail-loud (with and without env-key typos), D6 regression for the bug-reporter's three no-op config keys, D9 deferred-setup mode + import refusal, D11 preflight refusal without disk writes, and explicit-flag-wins-over-env precedence. - **`docs/integrations/embedding-providers.md`** — TL;DR table refreshed; added "Init resolves your provider from env keys" section and "If first import fails" troubleshooting block. diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index 8e04e0e87..7de68a5df 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -3733,11 +3733,18 @@ export class PGLiteEngine implements BrainEngine { const noDeadLinks = pageCount > 0 ? 1 - Math.min(deadLinks / pageCount, 1) : 1; // Bug 11 — per-component points. Sum equals brainScore by construction // so `doctor` can render a breakdown that adds up to the total. - const embedCoverageScore = pageCount === 0 ? 0 : Math.round(embedCoverage * 35); - const linkDensityScore = pageCount === 0 ? 0 : Math.round(linkDensity * 25); - const timelineCoverageScore = pageCount === 0 ? 0 : Math.round(timelineCoverageDensity * 15); - const noOrphansScore = pageCount === 0 ? 0 : Math.round(noOrphans * 15); - const noDeadLinksScore = pageCount === 0 ? 0 : Math.round(noDeadLinks * 10); + // + // v0.37.10.0: empty brains (pageCount === 0) get FULL marks (100/100), + // not 0. Semantically an empty brain has no coverage problem to penalize + // — there's nothing to embed, nothing to link, nothing to orphan. The + // pre-fix "empty = 0" caused fresh-init brains to score as critically + // unhealthy on `gbrain doctor`, which was a structural surprise to users + // who'd just successfully run init. + const embedCoverageScore = pageCount === 0 ? 35 : Math.round(embedCoverage * 35); + const linkDensityScore = pageCount === 0 ? 25 : Math.round(linkDensity * 25); + const timelineCoverageScore = pageCount === 0 ? 15 : Math.round(timelineCoverageDensity * 15); + const noOrphansScore = pageCount === 0 ? 15 : Math.round(noOrphans * 15); + const noDeadLinksScore = pageCount === 0 ? 10 : Math.round(noDeadLinks * 10); const brainScore = embedCoverageScore + linkDensityScore + timelineCoverageScore + noOrphansScore + noDeadLinksScore; return { diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index 4a4ce2f1f..f13f59d49 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -3708,11 +3708,18 @@ export class PostgresEngine implements BrainEngine { const noOrphans = pageCount > 0 ? 1 - (orphanPages / pageCount) : 1; const noDeadLinks = pageCount > 0 ? 1 - Math.min(deadLinks / pageCount, 1) : 1; // Per-component points. Sum equals brainScore by construction. - const embedCoverageScore = pageCount === 0 ? 0 : Math.round(embedCoverage * 35); - const linkDensityScore = pageCount === 0 ? 0 : Math.round(linkDensity * 25); - const timelineCoverageScore = pageCount === 0 ? 0 : Math.round(timelineCoverageWhole * 15); - const noOrphansScore = pageCount === 0 ? 0 : Math.round(noOrphans * 15); - const noDeadLinksScore = pageCount === 0 ? 0 : Math.round(noDeadLinks * 10); + // + // v0.37.10.0: empty brains (pageCount === 0) get FULL marks (100/100), + // not 0. Semantically an empty brain has no coverage problem to penalize + // — there's nothing to embed, nothing to link, nothing to orphan. The + // pre-fix "empty = 0" caused fresh-init brains to score as critically + // unhealthy on `gbrain doctor`, which was a structural surprise to users + // who'd just successfully run init. PGLite path has the same fix. + const embedCoverageScore = pageCount === 0 ? 35 : Math.round(embedCoverage * 35); + const linkDensityScore = pageCount === 0 ? 25 : Math.round(linkDensity * 25); + const timelineCoverageScore = pageCount === 0 ? 15 : Math.round(timelineCoverageWhole * 15); + const noOrphansScore = pageCount === 0 ? 15 : Math.round(noOrphans * 15); + const noDeadLinksScore = pageCount === 0 ? 10 : Math.round(noDeadLinks * 10); const brainScore = embedCoverageScore + linkDensityScore + timelineCoverageScore + noOrphansScore + noDeadLinksScore; return { diff --git a/test/brain-score-breakdown.test.ts b/test/brain-score-breakdown.test.ts index 68b2383e2..55b9a6e96 100644 --- a/test/brain-score-breakdown.test.ts +++ b/test/brain-score-breakdown.test.ts @@ -31,14 +31,19 @@ beforeEach(async () => { }); describe('Bug 11 — brain_score breakdown sums to total', () => { - test('empty brain returns zero score with all breakdown fields present', async () => { + test('empty brain returns full score (vacuous truth) with all breakdown fields present', async () => { + // v0.37.10.0: empty brain = no coverage problems = full marks. Pre-fix + // this returned 0/100, which surprised users running `gbrain doctor` + // immediately after `gbrain init --pglite`. Each component returns its + // max weight when pageCount === 0; the sum equals brain_score=100 by + // construction (same invariant as the non-empty path, see next test). const h = await engine.getHealth(); - expect(h.brain_score).toBe(0); - expect(h.embed_coverage_score).toBe(0); - expect(h.link_density_score).toBe(0); - expect(h.timeline_coverage_score).toBe(0); - expect(h.no_orphans_score).toBe(0); - expect(h.no_dead_links_score).toBe(0); + expect(h.brain_score).toBe(100); + expect(h.embed_coverage_score).toBe(35); + expect(h.link_density_score).toBe(25); + expect(h.timeline_coverage_score).toBe(15); + expect(h.no_orphans_score).toBe(15); + expect(h.no_dead_links_score).toBe(10); // dead_links is now on the type. expect(h.dead_links).toBe(0); }); diff --git a/test/doctor-report-remote.test.ts b/test/doctor-report-remote.serial.test.ts similarity index 84% rename from test/doctor-report-remote.test.ts rename to test/doctor-report-remote.serial.test.ts index 9fad22af2..a1005d375 100644 --- a/test/doctor-report-remote.test.ts +++ b/test/doctor-report-remote.serial.test.ts @@ -8,12 +8,24 @@ */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; import { doctorReportRemote, computeDoctorReport, type DoctorReport, type Check } from '../src/commands/doctor.ts'; let engine: PGLiteEngine; +let tmpHome: string; +let priorHome: string | undefined; beforeAll(async () => { + // v0.37.10.0: doctorReportRemote reads from ~/.gbrain audit files + // (reranker_health, sync_failures, etc.). Without isolation, host state + // leaks into the test and makes the assertion non-deterministic. Pin + // GBRAIN_HOME to a tempdir so audit reads return empty. + tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-doctor-remote-')); + priorHome = process.env.GBRAIN_HOME; + process.env.GBRAIN_HOME = tmpHome; engine = new PGLiteEngine(); await engine.connect({}); await engine.initSchema(); @@ -21,6 +33,9 @@ beforeAll(async () => { afterAll(async () => { await engine.disconnect(); + if (priorHome === undefined) delete process.env.GBRAIN_HOME; + else process.env.GBRAIN_HOME = priorHome; + rmSync(tmpHome, { recursive: true, force: true }); }); describe('doctorReportRemote', () => {