From 080b64e0526f43206e3c3a4a0455429aedbd0d6f Mon Sep 17 00:00:00 2001 From: Time Attakc <89218912+time-attack@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:07:08 -0700 Subject: [PATCH] fix(doctor): brain_score orphan/timeline components use the orphans-audit linkable scope (#3155) Takeover of #2525, rebased onto current master. getHealth() in both engines now computes orphan_pages and the timeline component over a linkable_pages CTE driven by the same constants the orphans audit uses (src/core/linkable-scope.ts), so one doctor report can no longer show a 19% orphan_ratio next to a no-orphans score implying ~70%. Master's newer first-segment exclusions (raw, atoms, skills) are folded into the shared scope so the orphans audit loses nothing in the move. Co-authored-by: Garry Tan Co-authored-by: pabloglzg Co-authored-by: Claude Fable 5 --- src/core/orphan-policy.ts | 14 ++++++-- src/core/pglite-engine.ts | 35 ++++++++++++------ src/core/postgres-engine.ts | 33 ++++++++++++----- src/core/types.ts | 12 ++++++- test/advisor-ranking-eval.test.ts | 4 +-- test/brain-score-breakdown.test.ts | 46 ++++++++++++++++++++++++ test/brain-score-recommendations.test.ts | 1 + test/orphans-pure-fn.test.ts | 39 ++++++++++++++++++++ 8 files changed, 160 insertions(+), 24 deletions(-) diff --git a/src/core/orphan-policy.ts b/src/core/orphan-policy.ts index 321777ee8..739fed020 100644 --- a/src/core/orphan-policy.ts +++ b/src/core/orphan-policy.ts @@ -13,14 +13,20 @@ * gbrain config set orphans.exclude_slugs "some-one-off-page" */ -const AUTO_SUFFIX_PATTERNS = ['/_index', '/log']; +// '/readme' — a README is a folder descriptor, not a knowledge node; +// nothing is expected to wikilink to it. +const AUTO_SUFFIX_PATTERNS = ['/_index', '/log', '/readme']; -const PSEUDO_SLUGS = new Set(['_atlas', '_index', '_stats', '_orphans', '_scratch', 'claude']); +// 'readme' / 'index' — root-level folder descriptors, same rationale as the +// '/readme' suffix. 'schema' — written by the schema pack on init; 'log' — +// the root brain log. +const PSEUDO_SLUGS = new Set(['_atlas', '_index', '_stats', '_orphans', '_scratch', 'claude', 'readme', 'index', 'schema', 'log']); const RAW_SEGMENT = '/raw/'; const DENY_PREFIXES = [ 'output/', + 'outputs/', 'dashboards/', 'scripts/', 'templates/', @@ -39,6 +45,10 @@ const FIRST_SEGMENT_EXCLUSIONS = new Set([ 'skills', 'dreaming', 'daily', + // 'inbox' — GTD-style intake tray: dated collector records in transit + // (email digests, alerts) awaiting triage; nothing links INTO an inbox + // item, same rationale as 'daily'. + 'inbox', ]); const ROOT_DATE_SLUG = /^\d{4}-\d{2}-\d{2}(?:-.+)?$/; diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index 5ca8c0f24..3b6e422c9 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -5276,7 +5276,6 @@ export class PGLiteEngine implements BrainEngine { ) as dead_links, (SELECT count(*) FROM content_chunks WHERE embedded_at IS NULL) as missing_embeddings, (SELECT count(*) FROM links) as link_count, - (SELECT count(DISTINCT page_id) FROM timeline_entries) as pages_with_timeline, (SELECT count(*) FROM entity_pages e WHERE EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = e.id))::float / GREATEST((SELECT count(*) FROM entity_pages), 1)::float as link_coverage, @@ -5295,11 +5294,20 @@ export class PGLiteEngine implements BrainEngine { LIMIT 5 `); - const { rows: islandedRows } = await this.db.query(` - SELECT p.slug + // Per-page flags for the linkable scope: orphan_pages and the + // no-orphans / timeline-coverage DENOMINATORS are all computed over + // pages the shared orphan-reporting policy considers linkable (the same + // scope `gbrain orphans` and doctor's orphan_ratio use), so one doctor + // report cannot carry two contradictory orphan/coverage numbers. + // Archive (raw/), generated, and daily-log pages are not expected to + // participate in the curated graph. Filtered in TS because the policy + // includes per-brain config overrides. + const { rows: pageScopeRows } = await this.db.query(` + SELECT p.slug, + (NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id) + AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id)) as islanded, + EXISTS (SELECT 1 FROM timeline_entries te WHERE te.page_id = p.id) as has_timeline FROM pages p - WHERE NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id) - AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id) `); const r = h as Record; @@ -5307,15 +5315,21 @@ export class PGLiteEngine implements BrainEngine { const embedCoverage = Number(r.embed_coverage); const stalePages = await this.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS }); const orphanOverrides = await loadOrphanPolicyOverrides(this); - const orphanPages = (islandedRows as { slug: string }[]) - .filter(row => !shouldExcludeFromOrphanReporting(row.slug, orphanOverrides)).length; + const linkablePages = (pageScopeRows as { slug: string; islanded: boolean; has_timeline: boolean }[]) + .filter(row => !shouldExcludeFromOrphanReporting(row.slug, orphanOverrides)); + const linkablePageCount = linkablePages.length; + const orphanPages = linkablePages.filter(row => row.islanded).length; + const linkableTimelinePages = linkablePages.filter(row => row.has_timeline).length; const deadLinks = Number(r.dead_links); const linkCount = Number(r.link_count); - const pagesWithTimeline = Number(r.pages_with_timeline); const linkDensity = pageCount > 0 ? Math.min(linkCount / pageCount, 1) : 0; - const timelineCoverageDensity = pageCount > 0 ? Math.min(pagesWithTimeline / pageCount, 1) : 0; - const noOrphans = pageCount > 0 ? 1 - (orphanPages / pageCount) : 1; + // linkablePageCount === 0 gets full marks for the orphan / timeline + // components (same vacuous-truth rule as the empty-brain fix below): + // an all-archive brain has no curated graph to penalize. + const timelineCoverageDensity = + linkablePageCount > 0 ? Math.min(linkableTimelinePages / linkablePageCount, 1) : 1; + const noOrphans = linkablePageCount > 0 ? 1 - (orphanPages / linkablePageCount) : 1; 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. @@ -5335,6 +5349,7 @@ export class PGLiteEngine implements BrainEngine { return { page_count: pageCount, + linkable_page_count: linkablePageCount, embed_coverage: embedCoverage, stale_pages: stalePages, orphan_pages: orphanPages, diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index 645bdea56..b86292b2a 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -5377,7 +5377,6 @@ export class PostgresEngine implements BrainEngine { ) as dead_links, (SELECT count(*) FROM content_chunks WHERE embedded_at IS NULL) as missing_embeddings, (SELECT count(*) FROM links) as link_count, - (SELECT count(DISTINCT page_id) FROM timeline_entries) as pages_with_timeline, (SELECT count(*) FROM entity_pages e WHERE EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = e.id))::float / GREATEST((SELECT count(*) FROM entity_pages), 1)::float as link_coverage, @@ -5395,26 +5394,41 @@ export class PostgresEngine implements BrainEngine { LIMIT 5 `; - const islandedRows = await sql<{ slug: string }[]>` - SELECT p.slug + // Per-page flags for the linkable scope: orphan_pages and the + // no-orphans / timeline-coverage DENOMINATORS are all computed over + // pages the shared orphan-reporting policy considers linkable (the same + // scope `gbrain orphans` and doctor's orphan_ratio use), so one doctor + // report cannot carry two contradictory orphan/coverage numbers. + // Archive (raw/), generated, and daily-log pages are not expected to + // participate in the curated graph. Filtered in TS because the policy + // includes per-brain config overrides. PGLite path has the same logic. + const pageScopeRows = await sql<{ slug: string; islanded: boolean; has_timeline: boolean }[]>` + SELECT p.slug, + (NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id) + AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id)) as islanded, + EXISTS (SELECT 1 FROM timeline_entries te WHERE te.page_id = p.id) as has_timeline FROM pages p - WHERE NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id) - AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id) `; const pageCount = Number(h.page_count); const embedCoverage = Number(h.embed_coverage); const stalePages = await this.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS }); const orphanOverrides = await loadOrphanPolicyOverrides(this); - const orphanPages = islandedRows.filter(row => !shouldExcludeFromOrphanReporting(row.slug, orphanOverrides)).length; + const linkablePages = pageScopeRows.filter(row => !shouldExcludeFromOrphanReporting(row.slug, orphanOverrides)); + const linkablePageCount = linkablePages.length; + const orphanPages = linkablePages.filter(row => row.islanded).length; + const linkableTimelinePages = linkablePages.filter(row => row.has_timeline).length; const deadLinks = Number(h.dead_links); const linkCount = Number(h.link_count); - const pagesWithTimeline = Number(h.pages_with_timeline); // brain_score: 0-100 weighted average const linkDensity = pageCount > 0 ? Math.min(linkCount / pageCount, 1) : 0; - const timelineCoverageWhole = pageCount > 0 ? Math.min(pagesWithTimeline / pageCount, 1) : 0; - const noOrphans = pageCount > 0 ? 1 - (orphanPages / pageCount) : 1; + // linkablePageCount === 0 gets full marks for the orphan / timeline + // components (same vacuous-truth rule as the empty-brain fix below): + // an all-archive brain has no curated graph to penalize. + const timelineCoverageWhole = + linkablePageCount > 0 ? Math.min(linkableTimelinePages / linkablePageCount, 1) : 1; + const noOrphans = linkablePageCount > 0 ? 1 - (orphanPages / linkablePageCount) : 1; const noDeadLinks = pageCount > 0 ? 1 - Math.min(deadLinks / pageCount, 1) : 1; // Per-component points. Sum equals brainScore by construction. // @@ -5433,6 +5447,7 @@ export class PostgresEngine implements BrainEngine { return { page_count: pageCount, + linkable_page_count: linkablePageCount, embed_coverage: embedCoverage, stale_pages: stalePages, orphan_pages: orphanPages, diff --git a/src/core/types.ts b/src/core/types.ts index 8fc339ed2..12b1185ea 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1428,10 +1428,20 @@ export interface BrainStats { export interface BrainHealth { page_count: number; + /** + * Pages inside the linkable scope (src/core/orphan-policy.ts) — the + * pages expected to participate in the curated link graph. Excludes + * archive (raw/), generated, and daily-log pages; the same scope the + * orphans audit uses. Denominator for the no-orphans and + * timeline-coverage score components. + */ + linkable_page_count: number; embed_coverage: number; stale_pages: number; /** - * Islanded pages — zero inbound AND zero outbound links. A hub page + * Islanded pages — zero inbound AND zero outbound links, counted over + * LINKABLE pages only (the same scope as the `gbrain orphans` audit, so + * doctor cannot report two contradictory orphan numbers). A hub page * that has references out but no back-references is NOT an orphan under * this definition (it's working as intended as an index). The metric * aims at "pages I forgot to connect to anything", not the stricter diff --git a/test/advisor-ranking-eval.test.ts b/test/advisor-ranking-eval.test.ts index 3d828c08c..219f33a7f 100644 --- a/test/advisor-ranking-eval.test.ts +++ b/test/advisor-ranking-eval.test.ts @@ -35,7 +35,7 @@ const HEALTHY_STATS = { page_count: 500, chunk_count: 0, embedded_count: 0, link_count: 0, tag_count: 0, timeline_entry_count: 0, pages_by_type: {}, }), getHealth: async () => ({ - page_count: 500, embed_coverage: 0.99, stale_pages: 0, orphan_pages: 0, missing_embeddings: 0, + page_count: 500, linkable_page_count: 500, embed_coverage: 0.99, stale_pages: 0, orphan_pages: 0, missing_embeddings: 0, brain_score: 95, dead_links: 0, link_coverage: 1, timeline_coverage: 1, most_connected: [], embed_coverage_score: 35, link_density_score: 25, timeline_coverage_score: 15, no_orphans_score: 15, no_dead_links_score: 10, }), @@ -55,7 +55,7 @@ const FIXTURES: Fixture[] = [ engine: { ...HEALTHY_STATS, getHealth: async () => ({ - page_count: 500, embed_coverage: 0.3, stale_pages: 0, orphan_pages: 12, missing_embeddings: 350, + page_count: 500, linkable_page_count: 500, embed_coverage: 0.3, stale_pages: 0, orphan_pages: 12, missing_embeddings: 350, brain_score: 40, dead_links: 2, link_coverage: 0.2, timeline_coverage: 0.2, most_connected: [], embed_coverage_score: 10, link_density_score: 5, timeline_coverage_score: 3, no_orphans_score: 2, no_dead_links_score: 8, }), diff --git a/test/brain-score-breakdown.test.ts b/test/brain-score-breakdown.test.ts index 55b9a6e96..5eb609c23 100644 --- a/test/brain-score-breakdown.test.ts +++ b/test/brain-score-breakdown.test.ts @@ -143,3 +143,49 @@ describe('Bug 11 — BrainHealth type shape', () => { expect(typesSource).toContain('0-100'); }); }); + +describe('linkable scope — archive pages do not drag the score', () => { + test('islanded raw/ and daily/ pages are excluded from the orphan component', async () => { + // Curated, connected pages. + await engine.putPage('people/alice-example', { type: 'person', title: 'Alice', compiled_truth: 'x', frontmatter: {} }); + await engine.putPage('companies/acme-example', { type: 'company', title: 'Acme', compiled_truth: 'x', frontmatter: {} }); + const { rows: ids } = await (engine as any).db.query( + `SELECT id, slug FROM pages ORDER BY slug`, + ); + const bySlug = Object.fromEntries(ids.map((r: any) => [r.slug, r.id])); + await (engine as any).db.query( + `INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'works_at')`, + [bySlug['people/alice-example'], bySlug['companies/acme-example']], + ); + // Archive + daily-log pages: no links, no timeline — by design. + await engine.putPage('raw/whatsapp/2025-01/log-page', { type: 'note', title: 'raw log', compiled_truth: 'x', frontmatter: {} }); + await engine.putPage('deals/acme-seed/raw/transcript', { type: 'note', title: 'raw t', compiled_truth: 'x', frontmatter: {} }); + await engine.putPage('daily/calendar/2025/2025-01-01', { type: 'note', title: 'day', compiled_truth: 'x', frontmatter: {} }); + + const h = await engine.getHealth(); + // The three archive/log pages are outside the linkable scope... + expect(h.linkable_page_count).toBe(2); + // ...so none of them is an orphan, and the connected pair keeps 15/15. + expect(h.orphan_pages).toBe(0); + expect(h.no_orphans_score).toBe(15); + }); + + test('timeline coverage is measured over linkable pages only', async () => { + await engine.putPage('people/alice-example', { type: 'person', title: 'Alice', compiled_truth: 'x', frontmatter: {} }); + await engine.addTimelineEntry('people/alice-example', { date: '2025-01-01', source: 'note', summary: 'joined' }); + // A raw archive page without timeline must not dilute coverage. + await engine.putPage('raw/whatsapp/2025-01/log-page', { type: 'note', title: 'raw log', compiled_truth: 'x', frontmatter: {} }); + + const h = await engine.getHealth(); + expect(h.linkable_page_count).toBe(1); + expect(h.timeline_coverage_score).toBe(15); // 1/1 linkable pages covered + }); + + test('an islanded curated page still counts as an orphan', async () => { + await engine.putPage('people/forgotten-example', { type: 'person', title: 'F', compiled_truth: 'x', frontmatter: {} }); + const h = await engine.getHealth(); + expect(h.orphan_pages).toBe(1); + expect(h.linkable_page_count).toBe(1); + expect(h.no_orphans_score).toBe(0); + }); +}); diff --git a/test/brain-score-recommendations.test.ts b/test/brain-score-recommendations.test.ts index 998d5f1b4..d3935de9c 100644 --- a/test/brain-score-recommendations.test.ts +++ b/test/brain-score-recommendations.test.ts @@ -77,6 +77,7 @@ describe('embeddingProviderConfigured (recipe-aware helper)', () => { function makeHealth(overrides: Partial = {}): BrainHealth { return { page_count: 100, + linkable_page_count: 100, embed_coverage: 1.0, stale_pages: 0, orphan_pages: 0, diff --git a/test/orphans-pure-fn.test.ts b/test/orphans-pure-fn.test.ts index 79df84b79..7dfd7a80e 100644 --- a/test/orphans-pure-fn.test.ts +++ b/test/orphans-pure-fn.test.ts @@ -172,6 +172,45 @@ describe('shouldExclude — orphan filter regression (preserve curation)', () => expect(shouldExclude('raw/chats/claude-code/session')).toBe(true); }); + test('leading raw/ segment is excluded (same archive convention)', () => { + expect(shouldExclude('raw/whatsapp/2025-01/chat-log')).toBe(true); + expect(shouldExclude('raw/transcripts/meeting')).toBe(true); + // 'rawhide/...' must NOT match — prefix is 'raw/', not 'raw'. + expect(shouldExclude('rawhide/notes')).toBe(false); + }); + + test('daily-log pages are excluded (calendar/email integrations write these)', () => { + expect(shouldExclude('daily/calendar/2025/2025-01-01')).toBe(true); + expect(shouldExclude('daily/x/2025-06-13')).toBe(true); + }); + + test('outputs/ plural prefix is excluded like output/', () => { + expect(shouldExclude('outputs/render-batch-3')).toBe(true); + }); + + test('readme folder descriptors are excluded at any depth', () => { + expect(shouldExclude('readme')).toBe(true); + expect(shouldExclude('index')).toBe(true); + expect(shouldExclude('projects/readme')).toBe(true); + expect(shouldExclude('media/readme')).toBe(true); + // A page merely mentioning readme in its name is NOT excluded. + expect(shouldExclude('concepts/readme-driven-development')).toBe(false); + }); + + test('machine-generated extracts pages are excluded', () => { + expect(shouldExclude('extracts/2026-06-12/takes.proposed/host/propose-x/round-single')).toBe(true); + }); + + test('inbox intake-tray pages are excluded (same rationale as daily)', () => { + expect(shouldExclude('inbox/some-renewal-notice-2026-06-22')).toBe(true); + }); + + test('root schema and log pages are excluded', () => { + expect(shouldExclude('schema')).toBe(true); + expect(shouldExclude('log')).toBe(true); + expect(shouldExclude('concepts/schema-design')).toBe(false); + }); + test('deny-prefixes are excluded', () => { expect(shouldExclude('templates/meeting')).toBe(true); expect(shouldExclude('dashboards/_index')).toBe(true);