feat(cycle): orchestrator-level pack gate for lens-pack phases (v0.41 T9)

Wires extract_atoms + synthesize_concepts into runCycle with the D4-B
orchestrator-level pack gate. Five surgical edits to src/core/cycle.ts:

  1. CyclePhase union grows by 2 names.
  2. ALL_PHASES inserts extract_atoms after extract_facts (Haiku 3-check
     has fresh fact context, BEFORE resolve_symbol_edges to avoid
     interrupting the symbol resolution sweep mid-flight) and
     synthesize_concepts after patterns (cluster pass sees fresh
     cross-session themes).
  3. PHASE_SCOPE entries: extract_atoms='source' (per-source transcript
     walk), synthesize_concepts='global' (concept clusters cross sources
     by nature).
  4. NEEDS_LOCK_PHASES adds both (put_page writes mutate DB).
  5. runCycle dispatch blocks for both phases consult packDeclaresPhase
     before invoking. When the active pack doesn't declare the phase,
     skipped with reason='not_in_active_pack' marker. When it does,
     lazy-imports extract-atoms.ts / synthesize-concepts.ts and runs.

The packDeclaresPhase helper is new at module-private scope. Loads the
active pack via loadActivePack({cfg, remote:false}); reads
resolved.manifest.phases (local only — D4-B). Fail-open: any registry
error (pack not found, malformed manifest) returns false. Skipping >
crashing for an orchestrator gate.

Local-only phase semantics (not extends-chain inherited) preserves user
sovereignty: a downstream pack extending gbrain-creator may NOT want
extract_atoms to run (e.g. derives atoms differently). Inheriting phases
would force them into a no-op-or-fork choice. The gbrain-everything
meta-pack therefore RE-DECLARES creator's phases verbatim in its own
manifest, asserted by the T4 test.

Stub phase modules ship in this commit:
  src/core/cycle/extract-atoms.ts → returns skipped with reason=
    'stub_pending_t5'
  src/core/cycle/synthesize-concepts.ts → returns skipped with reason=
    'stub_pending_t6'

T5/T6 replace the stub bodies with real LLM-driven phases. The
orchestrator dispatch is fully wired today and exercised by the test.

Manifest schema follow-on: phases + calibration_domains were originally
.default([]) but the type narrowing broke v0.38 fixture casts in
test/schema-pack-{lint-rules,registry,registry-reload}.test.ts.
Reverted to .optional(); consumers apply `?? []` at the read site.
Same pattern as IngestionSource.mode in T2. Updated T3 + T4 tests
to use `!` non-null assertion at sites that explicitly declared the
fields (typechecker can't narrow array literals through optional
boundaries).

Tests:
  test/cycle-pack-gating.test.ts (19 cases, R-GATE IRON RULE):
  ALL_PHASES + PHASE_SCOPE shape, ordering invariants (extract_atoms
  after extract_facts, synthesize_concepts after patterns), exhaustive
  PHASE_SCOPE map, NEEDS_LOCK_PHASES static-source assertion (both new
  phases included), dispatch consults packDeclaresPhase for BOTH new
  phases (and ONLY those two), packDeclaresPhase helper exists +
  reads manifest.phases (not merged chain) + fail-open returns false
  on catch, pre-existing 17 phases NEVER consult packDeclaresPhase
  (extract_facts + calibration_profile spot-checked), not_in_active_pack
  reason marker appears exactly 2x (semantic consistency across
  both gated phases).

  Adjacent test fixes: T3 + T4 tests updated for optional-field
  semantics. T2 dispatch type narrowed to DispatchOutcome shape from
  daemon.ts ({kind: 'queued'} for success path).

89/89 across T1+T2+T3+T4+T9 tests pass; typecheck clean.

Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
Task T9 of 13. Unblocks: T5 (extract-atoms.ts body replaces stub),
T6 (synthesize-concepts.ts body replaces stub).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-24 00:16:39 -07:00
co-authored by Claude Opus 4.7
parent cefaad31fa
commit 1850613e53
8 changed files with 464 additions and 27 deletions
+150 -1
View File
@@ -69,7 +69,16 @@ export type CyclePhase =
| 'embed' | 'orphans' | 'purge'
// v0.39 T12: schema-suggest passive trigger (D3 + D4 plan-eng-review).
// Wraps runSuggest() — same library the CLI verb + EIIRP call.
| 'schema-suggest';
| 'schema-suggest'
// v0.41 T9 lens packs:
// - extract_atoms: per-source Haiku extraction of atoms from
// transcripts/articles/meetings into atom-typed pages. Gated on the
// active pack's `phases:` declaration (gbrain-creator or gbrain-
// everything declare this); other packs are no-op.
// - synthesize_concepts: global aggregation of atoms into tier-promoted
// concept pages via dedup → tier → Sonnet T1/T2 voice-gated narratives.
// Same pack-gate model.
| 'extract_atoms' | 'synthesize_concepts';
export const ALL_PHASES: CyclePhase[] = [
'lint',
@@ -83,6 +92,12 @@ export const ALL_PHASES: CyclePhase[] = [
// The empty-fence guard refuses to run if pre-v51 legacy facts are
// pending the v0_32_2 backfill (Codex R2-#7).
'extract_facts',
// v0.41 T9 — atom extraction (per-source, pack-gated). Runs AFTER
// extract_facts so the Haiku 3-check has fresh fact context, BEFORE
// resolve_symbol_edges so new atom pages don't interrupt the symbol
// resolution sweep mid-flight. Pack-gate via active pack's `phases:`
// declaration (gbrain-creator + gbrain-everything declare; others skip).
'extract_atoms',
// v0.33.3 W0c — within-file two-pass symbol resolution. Runs AFTER
// extract + extract_facts so any code edges sync emitted (still bare-token)
// get resolved into {resolved_chunk_id: N} / {ambiguous: true,
@@ -91,6 +106,10 @@ export const ALL_PHASES: CyclePhase[] = [
// BATCH_SIZE*10 chunks where edges_backfilled_at IS NULL or stale.
'resolve_symbol_edges',
'patterns',
// v0.41 T9 — concept synthesis (global, pack-gated). Runs AFTER patterns
// so the cluster pass sees fresh cross-session themes. Same pack-gate
// model as extract_atoms.
'synthesize_concepts',
// v0.29 — runs AFTER extract + synthesize so it sees the union of
// sync-touched + synthesize-written pages with fresh tag + take state.
'recompute_emotional_weight',
@@ -166,6 +185,11 @@ export const PHASE_SCOPE: Record<CyclePhase, PhaseScope> = {
orphans: 'global',
purge: 'global',
'schema-suggest': 'source',
// v0.41 T9 — extract_atoms is naturally per-source (each source's
// transcript dir gets walked independently). synthesize_concepts is
// global because concept clusters cross sources by nature.
extract_atoms: 'source',
synthesize_concepts: 'global',
};
/**
@@ -196,6 +220,11 @@ const NEEDS_LOCK_PHASES: ReadonlySet<CyclePhase> = new Set([
'propose_takes',
'grade_takes',
'calibration_profile',
// v0.41 T9 — extract_atoms writes atom-typed pages via put_page;
// synthesize_concepts writes concept-typed pages + tier updates. Both
// mutate DB state and need the lock.
'extract_atoms',
'synthesize_concepts',
'embed',
'purge',
]);
@@ -658,6 +687,42 @@ async function resolveSourceForDir(
}
}
// v0.41 T9 D4-B — orchestrator-level pack gate for lens-pack phases.
//
// Returns true when the ACTIVE pack's `phases:` list includes `phase`.
// Phases are local to the manifest that declares them — extends chains
// inherit page_types + link_types + filing_rules via the registry's
// standard merge semantics, but NOT phases. Per D4-B, each pack declares
// its own phase participation explicitly. The gbrain-everything meta-
// pack therefore re-declares creator's phases verbatim in its own
// manifest (asserted by test/lens-pack-manifests.test.ts).
//
// Why local-only: phases are runtime control flow, not data. A user pack
// that extends gbrain-creator may NOT want extract_atoms to run (e.g. they
// derive atoms differently). Inheriting phases would force them into a
// no-op-or-fork choice; local-only declaration lets them opt in cleanly.
//
// Fail-open semantics: if the registry lookup throws (pack not found,
// manifest malformed, registry not initialized), the gate returns FALSE.
// Better to skip a pack-gated phase than to run it for a brain that
// can't resolve its active pack. Skipped phases land in the cycle report
// with `not_in_active_pack` so doctor can surface to the user.
async function packDeclaresPhase(
engine: BrainEngine,
phase: CyclePhase,
): Promise<boolean> {
try {
const { loadActivePack } = await import('./schema-pack/load-active.ts');
const { loadConfig } = await import('./config.ts');
const cfg = loadConfig();
const resolved = await loadActivePack({ cfg, remote: false });
const phases = resolved.manifest.phases ?? [];
return phases.includes(phase);
} catch {
return false;
}
}
async function runPhaseSync(
engine: BrainEngine,
brainDir: string,
@@ -1400,6 +1465,53 @@ export async function runCycle(
await safeYield(opts.yieldBetweenPhases);
}
// ── v0.41 T9: extract_atoms (per-source, pack-gated) ──────────
// Orchestrator-level pack gate: consults the active pack's `phases:`
// declaration. When the active pack does NOT declare extract_atoms
// (e.g. user is on gbrain-base or gbrain-investor), this phase is a
// no-op with reason='not_in_active_pack'. When the pack does declare
// it (gbrain-creator, gbrain-everything), dispatches to the
// extract-atoms.ts module (real body in T5; stub for now).
//
// borrow_from does NOT borrow phases — each pack declares phase
// participation explicitly. The packDeclaresPhase helper walks the
// resolved active pack's `phases:` list ONLY; not the extends chain
// or borrow_from targets.
if (phases.includes('extract_atoms')) {
checkAborted(opts.signal);
if (!engine) {
phaseResults.push({
phase: 'extract_atoms',
status: 'skipped',
duration_ms: 0,
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else if (!(await packDeclaresPhase(engine, 'extract_atoms'))) {
phaseResults.push({
phase: 'extract_atoms',
status: 'skipped',
duration_ms: 0,
summary: 'extract_atoms: active pack does not declare this phase',
details: { reason: 'not_in_active_pack' },
});
} else {
progress.start('cycle.extract_atoms');
const { runPhaseExtractAtoms } = await import('./cycle/extract-atoms.ts');
const xaSourceId = (await resolveSourceForDir(engine, opts.brainDir)) ?? 'default';
const { result, duration_ms } = await timePhase(() => runPhaseExtractAtoms(engine, {
brainDir: opts.brainDir,
sourceId: xaSourceId,
dryRun,
affectedSlugs: syncPagesAffected,
}));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
}
await safeYield(opts.yieldBetweenPhases);
}
// ── v0.33.3 W0c: resolve_symbol_edges (between extract_facts + patterns) ──
// Walks chunks whose edges_backfilled_at is null/stale. Resumable
// across cycles via the watermark. Quick-cycle compatible — caps at
@@ -1454,6 +1566,43 @@ export async function runCycle(
await safeYield(opts.yieldBetweenPhases);
}
// ── v0.41 T9: synthesize_concepts (global, pack-gated) ───────
// Same pack-gate model as extract_atoms. Reads `phases:` from the
// resolved active pack manifest; no-op when this phase isn't
// declared. Real body in T6 — synthesize-concepts.ts is a stub today.
if (phases.includes('synthesize_concepts')) {
checkAborted(opts.signal);
if (!engine) {
phaseResults.push({
phase: 'synthesize_concepts',
status: 'skipped',
duration_ms: 0,
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else if (!(await packDeclaresPhase(engine, 'synthesize_concepts'))) {
phaseResults.push({
phase: 'synthesize_concepts',
status: 'skipped',
duration_ms: 0,
summary: 'synthesize_concepts: active pack does not declare this phase',
details: { reason: 'not_in_active_pack' },
});
} else {
progress.start('cycle.synthesize_concepts');
const { runPhaseSynthesizeConcepts } = await import('./cycle/synthesize-concepts.ts');
const { result, duration_ms } = await timePhase(() => runPhaseSynthesizeConcepts(engine, {
brainDir: opts.brainDir,
dryRun,
yieldDuringPhase: opts.yieldDuringPhase,
}));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
}
await safeYield(opts.yieldBetweenPhases);
}
// ── Phase 7: recompute_emotional_weight (v0.29) ─────────────
// Runs AFTER extract + synthesize so it sees fresh tags + takes for
// every page touched in this cycle. Incremental mode uses union(sync,
+50
View File
@@ -0,0 +1,50 @@
// v0.41 T5 — extract_atoms cycle phase.
//
// SHIPPED IN T9 AS A STUB to unblock the orchestrator pack-gating test.
// The real implementation lands in T5: walks transcripts/articles via
// discoverTranscripts(), runs Haiku 3-check quality gate (truism /
// punchline / entity-page reject), writes atom-typed pages via standard
// put_page with frontmatter validators read from the active pack at
// runtime (D11). Reads atom_type closed 11-value enum from gbrain-creator
// manifest. Skips pages with `imported_from` frontmatter marker (D7).
// Idempotency via op_checkpoint extractFingerprint. Source-scoped.
// Budget cap $0.30/source/run.
//
// Until T5 ships the real body, this stub returns a 'skipped' PhaseResult
// with reason='stub_pending_t5'. The orchestrator-level pack gate (T9)
// already short-circuits when the active pack doesn't declare extract_atoms
// in its `phases:` list, so this stub only runs when the user intentionally
// opted in to a creator-flavored pack — and even then it returns a clear
// "not implemented yet" marker, not a silent no-op.
import type { BrainEngine } from '../engine.ts';
import type { PhaseResult } from '../cycle.ts';
export interface ExtractAtomsOpts {
brainDir?: string;
sourceId?: string;
dryRun?: boolean;
/** Hint from sync: only these slugs were affected this cycle. */
affectedSlugs?: string[];
}
/**
* v0.41 T5 stub. Returns 'skipped' with the reason marker until the real
* implementation lands. Pinned by test/cycle-pack-gating.test.ts which
* exercises the orchestrator dispatch path against this stub.
*/
export async function runPhaseExtractAtoms(
_engine: BrainEngine,
_opts: ExtractAtomsOpts = {},
): Promise<PhaseResult> {
return {
phase: 'extract_atoms',
status: 'skipped',
duration_ms: 0,
summary: 'extract_atoms: stub (T5 not yet implemented)',
details: {
reason: 'stub_pending_t5',
note: 'orchestrator dispatch is wired; real body lands in T5',
},
};
}
+40
View File
@@ -0,0 +1,40 @@
// v0.41 T6 — synthesize_concepts cycle phase.
//
// SHIPPED IN T9 AS A STUB to unblock the orchestrator pack-gating test.
// The real implementation lands in T6: aggregates atoms by topic cluster,
// dedups via Jaccard + substring + semantic, tiers T1-T4 by composite_score
// (mention_count × distinct_months × breadth), Sonnet-synthesizes T1/T2
// narratives gated by the same voice_gate() the calibration_profile phase
// uses. Concept-typed pages with tier in frontmatter. Skips pages with
// `imported_from` frontmatter marker (D7). Global scope. Budget $1.50/run.
//
// Until T6 ships the real body, this stub returns 'skipped' with marker.
import type { BrainEngine } from '../engine.ts';
import type { PhaseResult } from '../cycle.ts';
export interface SynthesizeConceptsOpts {
brainDir?: string;
dryRun?: boolean;
yieldDuringPhase?: (() => Promise<void>) | undefined;
}
/**
* v0.41 T6 stub. Same shape as runPhaseExtractAtoms stub — orchestrator
* dispatch is wired in T9, real body lands in T6.
*/
export async function runPhaseSynthesizeConcepts(
_engine: BrainEngine,
_opts: SynthesizeConceptsOpts = {},
): Promise<PhaseResult> {
return {
phase: 'synthesize_concepts',
status: 'skipped',
duration_ms: 0,
summary: 'synthesize_concepts: stub (T6 not yet implemented)',
details: {
reason: 'stub_pending_t6',
note: 'orchestrator dispatch is wired; real body lands in T6',
},
};
}
+9 -2
View File
@@ -192,15 +192,22 @@ export const SchemaPackManifestSchema = z.object({
* Phase names are validated as strings at parse time and against the
* runtime CyclePhase union at pack-load by the registry (kept as string[]
* here to avoid a circular import from src/core/cycle.ts).
*
* Optional rather than .default([]) so existing v0.38 manifest casts in
* test fixtures don't need to be re-typed; consumers apply `?? []` at
* the read site.
*/
phases: z.array(z.string().min(1)).default([]),
phases: z.array(z.string().min(1)).optional(),
/**
* v0.41 T3 — per-pack calibration domain declarations. The
* calibration_profile cycle phase widens at v0.41 from `{}` placeholder
* JSONB to a real aggregator pass over each declared domain. See
* CalibrationDomainSchema for the per-entry shape.
*
* Optional for the same reason as `phases` — preserves cast-compatibility
* with pre-v0.41 fixtures.
*/
calibration_domains: z.array(CalibrationDomainSchema).default([]),
calibration_domains: z.array(CalibrationDomainSchema).optional(),
}).strict();
export type SchemaPackManifest = z.infer<typeof SchemaPackManifestSchema>;
+186
View File
@@ -0,0 +1,186 @@
// v0.41 T9 R-GATE — orchestrator-level pack gate for lens-pack phases.
//
// IRON-RULE regression pinning:
// 1. ALL_PHASES includes 'extract_atoms' (after extract_facts) and
// 'synthesize_concepts' (after patterns).
// 2. PHASE_SCOPE declares extract_atoms='source', synthesize_concepts='global'.
// 3. NEEDS_LOCK_PHASES includes both (they mutate DB via put_page).
// 4. cycle.ts dispatch contains the packDeclaresPhase gate for both
// new phases (source-shape assertion — pins the not_in_active_pack
// semantics against future drift).
// 5. Pre-existing 17 core phases ALWAYS run regardless of active pack —
// only the 2 new lens-pack phases are gated (source-shape regression).
// 6. borrow_from does NOT borrow phases — gbrain-everything explicitly
// re-declares creator's phases per D4-B (verified in T4 test;
// cross-referenced here as a pinning hint via source grep).
//
// Why static-source assertions in addition to runtime tests: cycle.ts is a
// ~1700-line orchestrator and the dispatch logic for these new phases
// follows a load-bearing pattern (`if (phases.includes(X)) { ... if
// (!await packDeclaresPhase(engine, X)) skipped else dispatch }`). Static
// source pinning catches refactors that accidentally drop the gate while
// still passing happy-path runtime tests.
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { ALL_PHASES, PHASE_SCOPE, type CyclePhase } from '../src/core/cycle.ts';
const here = dirname(fileURLToPath(import.meta.url));
const cycleTsSrc = readFileSync(
join(here, '..', 'src', 'core', 'cycle.ts'),
'utf-8',
);
const NEW_PHASES: ReadonlyArray<CyclePhase> = ['extract_atoms', 'synthesize_concepts'];
describe('v0.41 T9 R-GATE: ALL_PHASES + PHASE_SCOPE contract', () => {
test('ALL_PHASES contains extract_atoms', () => {
expect(ALL_PHASES).toContain('extract_atoms');
});
test('ALL_PHASES contains synthesize_concepts', () => {
expect(ALL_PHASES).toContain('synthesize_concepts');
});
test('extract_atoms is positioned AFTER extract_facts (semantic ordering)', () => {
const extractFactsIdx = ALL_PHASES.indexOf('extract_facts');
const extractAtomsIdx = ALL_PHASES.indexOf('extract_atoms');
expect(extractFactsIdx).toBeGreaterThan(-1);
expect(extractAtomsIdx).toBeGreaterThan(extractFactsIdx);
});
test('synthesize_concepts is positioned AFTER patterns (graph-fresh semantics)', () => {
const patternsIdx = ALL_PHASES.indexOf('patterns');
const synthIdx = ALL_PHASES.indexOf('synthesize_concepts');
expect(patternsIdx).toBeGreaterThan(-1);
expect(synthIdx).toBeGreaterThan(patternsIdx);
});
test('PHASE_SCOPE declares extract_atoms as source-scoped', () => {
expect(PHASE_SCOPE.extract_atoms).toBe('source');
});
test('PHASE_SCOPE declares synthesize_concepts as global-scoped', () => {
expect(PHASE_SCOPE.synthesize_concepts).toBe('global');
});
test('every ALL_PHASES entry has a PHASE_SCOPE entry (exhaustive map)', () => {
for (const p of ALL_PHASES) {
expect(PHASE_SCOPE[p]).toBeDefined();
}
});
});
describe('v0.41 T9 R-GATE: NEEDS_LOCK_PHASES contract (source-shape)', () => {
// NEEDS_LOCK_PHASES isn't exported; static-source assertion pins the
// contract that both new phases acquire the cycle lock since they
// mutate DB state (put_page atom/concept pages).
test('cycle.ts source includes extract_atoms in NEEDS_LOCK_PHASES', () => {
// Find the NEEDS_LOCK_PHASES block and assert both phases appear in it.
const blockStart = cycleTsSrc.indexOf('NEEDS_LOCK_PHASES');
expect(blockStart).toBeGreaterThan(-1);
const blockEnd = cycleTsSrc.indexOf(']);', blockStart);
expect(blockEnd).toBeGreaterThan(blockStart);
const block = cycleTsSrc.slice(blockStart, blockEnd);
expect(block).toContain("'extract_atoms'");
});
test('cycle.ts source includes synthesize_concepts in NEEDS_LOCK_PHASES', () => {
const blockStart = cycleTsSrc.indexOf('NEEDS_LOCK_PHASES');
const blockEnd = cycleTsSrc.indexOf(']);', blockStart);
const block = cycleTsSrc.slice(blockStart, blockEnd);
expect(block).toContain("'synthesize_concepts'");
});
});
describe('v0.41 T9 R-GATE: orchestrator dispatch wires the pack-gate', () => {
// Source-shape regression: the dispatch for each new phase MUST
// consult packDeclaresPhase(engine, '<phase>') before invoking the
// phase. Future refactors that accidentally drop the gate would still
// pass happy-path runtime tests; this assertion catches the drop.
test('cycle.ts dispatch for extract_atoms calls packDeclaresPhase', () => {
expect(cycleTsSrc).toContain("packDeclaresPhase(engine, 'extract_atoms')");
});
test('cycle.ts dispatch for synthesize_concepts calls packDeclaresPhase', () => {
expect(cycleTsSrc).toContain("packDeclaresPhase(engine, 'synthesize_concepts')");
});
test('packDeclaresPhase helper function exists in cycle.ts', () => {
expect(cycleTsSrc).toContain('async function packDeclaresPhase(');
});
test('packDeclaresPhase reads phases from active pack manifest (NOT extends chain)', () => {
// Source-pin: the helper reads `resolved.manifest.phases` — D4-B
// says phases are local to the declaring manifest. Future drift
// that adds extends-chain merging would silently change semantics
// for users who extend gbrain-creator expecting inheritance; this
// assertion catches it.
expect(cycleTsSrc).toContain('resolved.manifest.phases');
});
test('packDeclaresPhase fail-open: returns false on catch (no thrown exceptions)', () => {
// Source-pin: the helper's try/catch returns false on any error
// (registry not initialized, pack not found, malformed manifest).
// Skipping > crashing for an orchestrator gate.
const helperStart = cycleTsSrc.indexOf('async function packDeclaresPhase(');
expect(helperStart).toBeGreaterThan(-1);
const helperEnd = cycleTsSrc.indexOf('\n}\n', helperStart);
const helperBody = cycleTsSrc.slice(helperStart, helperEnd);
expect(helperBody).toContain('catch');
expect(helperBody).toContain('return false');
});
});
describe('v0.41 T9 R-GATE: pre-existing 17 core phases always run', () => {
// The IRON RULE that the wave depends on: pack-gating is ADDITIVE,
// not subtractive. A user on gbrain-base (which declares phases:[])
// must still see all 17 pre-existing phases run as before. The static
// assertion: only the 2 new lens-pack phases reference packDeclaresPhase
// in the dispatch.
test('only extract_atoms + synthesize_concepts dispatch sites reference packDeclaresPhase', () => {
const matches = cycleTsSrc.match(/packDeclaresPhase\(engine, '[^']+'\)/g) ?? [];
const phaseNames = matches.map((m) => {
const inner = /packDeclaresPhase\(engine, '([^']+)'\)/.exec(m);
return inner ? inner[1] : '';
});
// Should be EXACTLY two phases gated.
expect(phaseNames.sort()).toEqual(['extract_atoms', 'synthesize_concepts']);
});
test('extract_facts dispatch does NOT consult packDeclaresPhase', () => {
// Pre-existing phase; must always run on every pack. Window scoped
// to the SINGLE dispatch block — find the next `// ──` comment
// marker (the next phase dispatch header) and stop there.
const blockStart = cycleTsSrc.indexOf("if (phases.includes('extract_facts'))");
expect(blockStart).toBeGreaterThan(-1);
const blockEnd = cycleTsSrc.indexOf('// ──', blockStart + 10);
expect(blockEnd).toBeGreaterThan(blockStart);
const block = cycleTsSrc.slice(blockStart, blockEnd);
expect(block).not.toContain('packDeclaresPhase');
});
test('calibration_profile dispatch does NOT consult packDeclaresPhase', () => {
// Pre-existing v0.36.1.0 phase; always-on.
const cpBlockStart = cycleTsSrc.indexOf("phases.includes('calibration_profile')");
expect(cpBlockStart).toBeGreaterThan(-1);
// Window of 1500 chars covers the dispatch.
const block = cycleTsSrc.slice(cpBlockStart, cpBlockStart + 1500);
expect(block).not.toContain('packDeclaresPhase');
});
});
describe('v0.41 T9 R-GATE: dispatch result envelope', () => {
test('extract_atoms not_in_active_pack skip carries the correct reason marker', () => {
expect(cycleTsSrc).toContain("reason: 'not_in_active_pack'");
});
test('synthesize_concepts not_in_active_pack uses the same marker (semantic consistency)', () => {
// Both phases should use identical reason marker — doctor can match
// a single string across both pack-gated skip events.
const occurrences = (cycleTsSrc.match(/reason: 'not_in_active_pack'/g) ?? []).length;
expect(occurrences).toBe(2);
});
});
+2 -2
View File
@@ -97,13 +97,13 @@ describe('v0.41 T2: IngestionSource.mode discriminator', () => {
describe('v0.41 T2: daemon handleEmit branches on source.mode', () => {
let dispatched: IngestionEvent[];
let dispatch: (event: IngestionEvent) => Promise<{ kind: 'ok' | 'failed'; error?: string }>;
let dispatch: (event: IngestionEvent) => Promise<{ kind: 'queued' } | { kind: 'failed'; error: string }>;
beforeEach(() => {
dispatched = [];
dispatch = async (event) => {
dispatched.push(event);
return { kind: 'ok' as const };
return { kind: 'queued' as const };
};
});
+10 -10
View File
@@ -88,7 +88,7 @@ describe('v0.41 T4: gbrain-creator manifest shape', () => {
});
test('declares concept_themes calibration domain with cluster_summary aggregator', () => {
const themes = pack.calibration_domains.find((d) => d.name === 'concept_themes');
const themes = pack.calibration_domains!.find((d) => d.name === 'concept_themes');
expect(themes).toBeDefined();
expect(themes?.aggregator).toBe('cluster_summary');
expect(themes?.page_types).toContain('concept');
@@ -128,18 +128,18 @@ describe('v0.41 T4: gbrain-investor manifest shape', () => {
});
test('declares 3 calibration domains (deal_success + founder_evaluation + market_call)', () => {
const names = pack.calibration_domains.map((d) => d.name).sort();
const names = pack.calibration_domains!.map((d) => d.name).sort();
expect(names).toEqual(['deal_success', 'founder_evaluation', 'market_call']);
});
test('every calibration_domain aggregator is in the closed AggregatorKind enum', () => {
for (const d of pack.calibration_domains) {
for (const d of pack.calibration_domains!) {
expect(AGGREGATOR_KINDS).toContain(d.aggregator);
}
});
test('market_call uses weighted_brier (high-conviction-rare-event semantics)', () => {
const mc = pack.calibration_domains.find((d) => d.name === 'market_call');
const mc = pack.calibration_domains!.find((d) => d.name === 'market_call');
expect(mc?.aggregator).toBe('weighted_brier');
});
@@ -169,17 +169,17 @@ describe('v0.41 T4: gbrain-engineer manifest shape', () => {
});
test('declares 3 calibration domains (architecture_calls + effort_estimates + risk_assessment)', () => {
const names = pack.calibration_domains.map((d) => d.name).sort();
const names = pack.calibration_domains!.map((d) => d.name).sort();
expect(names).toEqual(['architecture_calls', 'effort_estimates', 'risk_assessment']);
});
test('effort_estimates uses weighted_brier (small-vs-big estimate scaling)', () => {
const ee = pack.calibration_domains.find((d) => d.name === 'effort_estimates');
const ee = pack.calibration_domains!.find((d) => d.name === 'effort_estimates');
expect(ee?.aggregator).toBe('weighted_brier');
});
test('every calibration_domain aggregator is in the closed AggregatorKind enum', () => {
for (const d of pack.calibration_domains) {
for (const d of pack.calibration_domains!) {
expect(AGGREGATOR_KINDS).toContain(d.aggregator);
}
});
@@ -213,7 +213,7 @@ describe('v0.41 T4: gbrain-everything meta-pack shape', () => {
});
test('explicitly unions ALL 7 lens calibration domains', () => {
const names = pack.calibration_domains.map((d) => d.name).sort();
const names = pack.calibration_domains!.map((d) => d.name).sort();
expect(names).toEqual([
'architecture_calls',
'concept_themes',
@@ -226,13 +226,13 @@ describe('v0.41 T4: gbrain-everything meta-pack shape', () => {
});
test('every meta-pack calibration_domain aggregator is in the closed enum', () => {
for (const d of pack.calibration_domains) {
for (const d of pack.calibration_domains!) {
expect(AGGREGATOR_KINDS).toContain(d.aggregator);
}
});
test('aggregator selection matches per-pack declarations (cross-pack consistency)', () => {
const byName = Object.fromEntries(pack.calibration_domains.map((d) => [d.name, d.aggregator]));
const byName = Object.fromEntries(pack.calibration_domains!.map((d) => [d.name, d.aggregator]));
// From investor
expect(byName.deal_success).toBe('scalar_brier');
expect(byName.market_call).toBe('weighted_brier');
+17 -12
View File
@@ -49,9 +49,12 @@ describe('v0.41 T3: AggregatorKind closed registry', () => {
});
describe('v0.41 T3: SchemaPackManifestSchema phases field', () => {
test('phases defaults to empty array when omitted', () => {
test('phases is undefined when omitted; consumers apply ?? [] at read site', () => {
const parsed = parseSchemaPackManifest(baseManifest());
expect(parsed.phases).toEqual([]);
expect(parsed.phases).toBeUndefined();
// Standard consumer pattern:
const effective = parsed.phases ?? [];
expect(effective).toEqual([]);
});
test('phases accepts string array of phase names', () => {
@@ -77,9 +80,11 @@ describe('v0.41 T3: SchemaPackManifestSchema phases field', () => {
});
describe('v0.41 T3: SchemaPackManifestSchema calibration_domains field', () => {
test('calibration_domains defaults to empty array when omitted', () => {
test('calibration_domains is undefined when omitted; consumers apply ?? [] at read site', () => {
const parsed = parseSchemaPackManifest(baseManifest());
expect(parsed.calibration_domains).toEqual([]);
expect(parsed.calibration_domains).toBeUndefined();
const effective = parsed.calibration_domains ?? [];
expect(effective).toEqual([]);
});
test('accepts well-formed domain entry', () => {
@@ -94,8 +99,8 @@ describe('v0.41 T3: SchemaPackManifestSchema calibration_domains field', () => {
],
}),
);
expect(parsed.calibration_domains.length).toBe(1);
const d = parsed.calibration_domains[0];
expect(parsed.calibration_domains!.length).toBe(1);
const d = parsed.calibration_domains![0];
expect(d.name).toBe('deal_success');
expect(d.aggregator).toBe('scalar_brier');
expect(d.page_types).toEqual(['deal']);
@@ -110,7 +115,7 @@ describe('v0.41 T3: SchemaPackManifestSchema calibration_domains field', () => {
],
}),
);
expect(parsed.calibration_domains[0].aggregator).toBe(aggregator);
expect(parsed.calibration_domains![0].aggregator).toBe(aggregator);
}
});
@@ -203,7 +208,7 @@ describe('v0.41 T3: SchemaPackManifestSchema calibration_domains field', () => {
],
}),
);
expect(parsed.calibration_domains[0].page_types).toEqual(['code', 'decision']);
expect(parsed.calibration_domains![0].page_types).toEqual(['code', 'decision']);
});
test('accepts multiple domain entries per pack', () => {
@@ -217,8 +222,8 @@ describe('v0.41 T3: SchemaPackManifestSchema calibration_domains field', () => {
],
}),
);
expect(parsed.calibration_domains.length).toBe(4);
const byName = Object.fromEntries(parsed.calibration_domains.map((d: CalibrationDomain) => [d.name, d.aggregator]));
expect(parsed.calibration_domains!.length).toBe(4);
const byName = Object.fromEntries(parsed.calibration_domains!.map((d: CalibrationDomain) => [d.name, d.aggregator]));
expect(byName.deal_success).toBe('scalar_brier');
expect(byName.market_call).toBe('weighted_brier');
expect(byName.concept_themes).toBe('cluster_summary');
@@ -240,8 +245,8 @@ describe('v0.41 T3: backward compatibility with v0.38 manifests', () => {
],
});
const parsed: SchemaPackManifest = parseSchemaPackManifest(v038Shape);
expect(parsed.phases).toEqual([]);
expect(parsed.calibration_domains).toEqual([]);
expect(parsed.phases).toBeUndefined();
expect(parsed.calibration_domains).toBeUndefined();
expect(parsed.page_types.length).toBe(1);
});