mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
co-authored by
Claude Opus 4.7
parent
cefaad31fa
commit
1850613e53
+150
-1
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user