feat(extract_atoms): honor pack manifest extractable flag in page discovery

Atom-extraction page discovery hardcoded EXTRACTABLE_PAGE_TYPES and ignored the
active pack's `extractable: true` flags, so a type declared extractable in the
manifest (e.g. `note`) never actually extracted. Closes the D2 TODO the code
already flagged (extract-atoms.ts: 'future pack-aware refactor ... pull from the
active pack manifest').

Resolve the allowlist as: legacy hardcoded floor UNION the pack's extractable
types, MINUS synthesis outputs (atom, concept — extracting from these would loop,
since concepts are synthesized from atoms). Mirrors facts/eligibility.ts, which
excludes concept the same way. Back-compat: gbrain-base brains keep every legacy
target via the union; fail-soft falls back to the legacy floor if the pack can't
load.

- pure unionExtractableTypes() policy, unit-tested
- discoverExtractablePages + countExtractAtomsBacklog resolve from the pack
- page-discovery fixture updated (note now extracts; concept stays excluded)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paolo Belcastro
2026-07-05 17:20:15 +02:00
co-authored by Claude Opus 4.8
parent 814258dda6
commit 78f3ebea33
3 changed files with 96 additions and 12 deletions
@@ -0,0 +1,39 @@
/**
* Pack-driven extractable-type allowlist (unionExtractableTypes): honors the
* schema-pack manifest's `extractable: true` flags while preserving the legacy
* hardcoded floor and excluding synthesis outputs. Closes the D2 TODO in
* extract-atoms.ts (page discovery was ignoring the pack's extractable flag, so
* a type declared extractable — e.g. `note` — never actually extracted).
*/
import { describe, test, expect } from 'bun:test';
import { unionExtractableTypes } from '../src/core/cycle/extract-atoms.ts';
const LEGACY = ['meeting', 'source', 'article', 'video', 'book', 'original'];
describe('unionExtractableTypes', () => {
test('legacy floor is always present (back-compat)', () => {
const r = unionExtractableTypes([]);
for (const t of LEGACY) expect(r).toContain(t);
});
test('pack-declared extractable types are added (e.g. note)', () => {
const r = unionExtractableTypes(['note', 'writing']);
expect(r).toContain('note');
expect(r).toContain('writing');
for (const t of LEGACY) expect(r).toContain(t);
});
test('synthesis outputs are excluded even when the pack marks them extractable', () => {
// gbrain-base declares `concept` extractable:true, but extracting atoms FROM
// concepts would loop (concepts are synthesized from atoms).
const r = unionExtractableTypes(['note', 'concept', 'atom']);
expect(r).toContain('note');
expect(r).not.toContain('concept');
expect(r).not.toContain('atom');
});
test('no duplicates when the pack repeats a legacy type', () => {
const r = unionExtractableTypes(['meeting', 'source']);
expect(r.filter((t) => t === 'meeting')).toHaveLength(1);
});
});
+8 -4
View File
@@ -108,12 +108,15 @@ async function seedPage(opts: {
}
describe('v0.41.2.1: discoverExtractablePages SQL contract', () => {
test('filters by all 6 extractable types', async () => {
for (const type of ['meeting', 'source', 'article', 'video', 'book', 'original']) {
test('discovers legacy + pack-extractable types, excludes synthesis outputs', async () => {
// Legacy floor + `note` (declared extractable:true in gbrain-base, now
// honored via the pack manifest — the D2 fix).
for (const type of ['meeting', 'source', 'article', 'video', 'book', 'original', 'note']) {
await seedPage({ slug: `${type}/x`, type });
}
// Add a non-extractable page that should NOT appear
await seedPage({ slug: 'notes/skip-me', type: 'note' });
// `concept` is also extractable:true in gbrain-base, but extracting atoms
// FROM concepts would loop — synthesis outputs are always excluded.
await seedPage({ slug: 'wiki/concepts/skip-me', type: 'concept' });
const discovered = await discoverExtractablePages(engine, 'default');
const slugs = discovered.map((d) => d.slug).sort();
@@ -121,6 +124,7 @@ describe('v0.41.2.1: discoverExtractablePages SQL contract', () => {
'article/x',
'book/x',
'meeting/x',
'note/x',
'original/x',
'source/x',
'video/x',