v0.41.2.0 feat: lens packs + epistemology unification — atoms + concepts as first-class units, calibration profile widening, gstack-learnings bridge (#1364)

* feat(schema): migration v93 take_domain_assignments (v0.41 T1)

Adds the JOIN table backing per-pack calibration domain aggregation
in the v0.41 lens-packs wave. Replaces the originally-planned scalar
`takes.domain` column after codex outside-voice review caught that
one take can legitimately belong to multiple domains (a take about
"Sequoia's investment in Anthropic" lands in deal_success AND
market_call), and that scalar attribution bakes today's pack→domain
mapping into permanent fact.

Schema: composite PK (take_id, domain) for idempotent re-assignment,
FK CASCADE so deleting a take cascades assignments, confidence CHECK
in [0,1], idx_take_domain_assignments_domain for the aggregator JOIN
direction. RLS guard matches takes/synthesis_evidence pattern (enable
when running as BYPASSRLS role). PGLite parity via sqlFor.pglite.

Backward-compat: pre-existing takes carry no assignments; aggregator
LEFT JOIN skips them gracefully. No backfill required at migration
time — propose_takes (T10) populates new rows; greenfield assignment
of historical takes is a v0.42 follow-up.

R-MIG IRON-RULE regression at test/migrations-v93.test.ts pins 12
contracts: existence/name, LATEST_VERSION advance, table queryable
after initSchema, column shape, composite PK rejects duplicate
(take_id, domain), multi-domain assignment permitted, FK ON DELETE
CASCADE, CHECK rejects out-of-range confidence, index presence,
aggregator JOIN direction returns per-domain counts, sql/sqlFor.pglite
parity grep, backward-compat LEFT JOIN handles unassigned takes.

Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
First of 13 sequencing tasks in v0.41 lens packs + epistemology
unification wave (decisions D9-B → T1-B per codex challenge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(contracts): IngestionSource.mode + pack manifest phases/calibration_domains (v0.41 T2+T3)

Two independent contract extensions, batched because both are pre-
requisites for T4 (pack YAML manifests) and T9 (cycle.ts orchestrator
gate). Neither is load-bearing alone; together they form the surface
the four lens-pack manifests will declare against.

T2 — IngestionSource.mode discriminator (codex outside-voice fix):
  src/core/ingestion/types.ts grows an optional `mode: 'trickle' |
  'migration'` field on IngestionSource. Defaults to 'trickle' when
  unset — v0.38 sources unchanged. New IngestionSourceMode export.
  src/core/ingestion/daemon.ts handleEmit() branches on the mode:
  trickle keeps the 24h DedupWindow.mark() path; migration bypasses
  dedup entirely (the source owns permanent slug-keyed idempotency
  via op_checkpoint or similar). Validation, rate limit, and dispatch
  apply uniformly to both modes.

  Why: the 24h content-hash dedup window is wrong for bulk historical
  migration. 24K wintermute pages over hours, retries days apart, and
  same-hash collisions across the window are expected. Trickle
  semantics (file-watcher, inbox-folder, webhook) want dedup to catch
  at-least-once replay; migration semantics want EVERY explicitly-
  emitted event to land because the source already gated it.

T3 — SchemaPackManifestSchema phases + calibration_domains:
  src/core/schema-pack/manifest-v1.ts grows two optional fields. New
  AGGREGATOR_KINDS closed enum (4 v1 algorithms: scalar_brier,
  weighted_brier, count_based, cluster_summary) backing
  AggregatorKind type. New CalibrationDomain {name, aggregator,
  page_types} schema with snake_case regex on name, .strict on extra
  fields, page_types.min(1).

  `phases: string[]` declares which cycle phases the active pack
  participates in (D4-B orchestrator gate; runCycle will consult this
  in T9). Validated as string here, against runtime CyclePhase union
  at the registry layer (avoids circular import). `borrow_from` does
  NOT borrow phases — each pack declares explicitly.

  `calibration_domains: CalibrationDomain[]` declares per-pack
  scorecard buckets. Closed registry of algorithm `aggregator` values
  keeps SQL injection surface closed; open `name` strings let third-
  party packs add domains without a gbrain release (T3 codex
  refinement of D6).

  Backward compat: both fields default to []. Existing v0.38 manifests
  parse unchanged (pinned by 2 regression cases).

Tests:
  test/ingestion/migration-mode.test.ts (8 cases): mode type accepts
  literals, defaults to trickle, daemon branches correctly across
  trickle/migration/default-undefined, validation still runs in
  migration mode, mixed dual-source independence.

  test/schema-pack-manifest-v041.test.ts (19 cases): aggregator enum
  shape, phases default + accept + reject (non-string, empty, non-
  array), calibration_domains default + accept (single + multi entry,
  multi page_types), reject (unknown aggregator, kebab/uppercase/
  digit-start names, empty page_types, unknown extra field), v0.38
  back-compat regressions.

  All 27 cases pass first-green after API surface alignment.

Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
Tasks T2 + T3 of 13 in v0.41 lens packs + epistemology unification wave.
Unblocks: T4 (pack manifests reference both fields), T9 (cycle.ts gate
reads phases:), T10 (calibration widening reads calibration_domains).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(packs): 4 bundled lens pack manifests + registry wiring (v0.41 T4)

Authors gbrain-creator + gbrain-investor + gbrain-engineer +
gbrain-everything as bundled YAML manifests in
src/core/schema-pack/base/, registers them in the BUNDLED array in
load-active.ts, exports AGGREGATOR_KINDS + AggregatorKind +
CalibrationDomain types through the schema-pack barrel.

gbrain-creator: atom (NEW page type) + concept (reuse from base).
  phases: [extract_atoms, synthesize_concepts]. One calibration
  domain: concept_themes / cluster_summary / [concept]. Retires
  wintermute's atom-pipeline-coordinator cron (T12 follow-up).

gbrain-investor: thesis + bet_resolution_log (NEW). Borrows
  deal/person/company/yc from base. No new cycle phases (consumes
  existing extract_facts/propose_takes/grade_takes pipeline). Three
  calibration domains: deal_success/scalar_brier/[deal],
  founder_evaluation/scalar_brier/[person], market_call/weighted_brier
  /[thesis]. Filing rules mirror wintermute's existing investing/deals
  + investing/theses + investing/bets layout.

gbrain-engineer: bridge-only per D8-C. ONLY declares `learning`
  page type (primitive: annotation); borrows code+project from base.
  No new cycle phases (gstack-learnings IngestionSource is daemon-
  side per T8). Three calibration domains: architecture_calls/
  scalar_brier/[code, learning], effort_estimates/weighted_brier/
  [project], risk_assessment/scalar_brier/[project].

gbrain-everything: meta-pack extending gbrain-investor + borrowing
  atom (from creator) + learning (from engineer). Codex outside-voice
  T4 resolution to the multi-lens problem: composes via the v0.38-
  shipped extends + borrow_from chain instead of inventing an
  active-multi-pack architecture. Single-active-pack constraint
  preserved. Explicitly re-declares phases + calibration_domains
  (borrow_from borrows types/link_types only — phases must be
  declared per pack per D4-B).

Frontmatter validators (atom_type closed 11-value enum, virality_
score range, etc.) are NOT declared in these manifests — that
contract surface (per-page-type frontmatter_validators on
PageTypeSchema) is a v0.42 follow-up filed in plan TODOs. For
v0.41, extract_atoms hardcodes the enum with a TODO comment
pointing at the eventual manifest read path (D11).

YAML parser caveat: src/core/schema-pack/loader.ts uses a hand-
rolled parseYamlMini (per loader.ts:86 explicit non-support of `|`
block scalars). Initial descriptions used `|` blocks and broke
parsing silently (description was 'literal "|"', everything after
collapsed). Reauthored to single-line "..." strings. Pinned by
the manifest-load tests asserting page_types/phases/calibration_
domains all resolve.

Tests:
  test/lens-pack-manifests.test.ts (31 cases): one file covers all
  4 packs to avoid 4x boilerplate. Pins parse cleanly, registry
  inclusion, per-pack page_types/phases/calibration_domains/filing_
  rules shape, every aggregator value falls in AGGREGATOR_KINDS,
  meta-pack unions correctly (7 calibration domains across all
  three lens packs).

Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
Task T4 of 13. Unblocks T5/T6 (phases now declared; phases read
from active pack at runtime), T7 (importer writes atom-typed
pages against creator manifest), T8 (gstack-learnings emits
learning-typed pages against engineer manifest), T9 (orchestrator
gate reads phases: declaration), T10 (calibration_profile walks
calibration_domains).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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>

* feat(calibration): domain_scorecards widening + 4 aggregators (v0.41 T10)

Replaces the v0.36.1.0 placeholder `JSON.stringify({})` in
calibration-profile.ts:336 with a real aggregator pass over the active
pack's calibration_domains declarations. domain_scorecards JSONB now
populates per declared domain with {n, brier, accuracy, aggregator,
page_types, extras}.

New module: src/core/calibration/domain-aggregators.ts
  - aggregateDomainScorecards(engine, holder, domains, sourceId) → JSONB-shape
  - 4 aggregator implementations matching the AggregatorKind closed enum:
    - scalar_brier: AVG(POWER(weight - outcome::int, 2)). The default for
      most predictive domains. Filters by holder + page_types +
      resolved_outcome IS NOT NULL + active=TRUE + source_id.
    - weighted_brier: Brier weighted by ABS(weight - 0.5) * 2 (conviction
      proxy since takes table has no separate confidence column). A
      0.95-conviction miss weights 9x more than a 0.55-conviction one.
      Matches the investor pack's market_call semantics.
    - count_based: simple SUM(hit)/COUNT(*) accuracy without Brier.
      For domains where probability isn't natural.
    - cluster_summary: page count + tier histogram via
      frontmatter->>'tier' JSONB read. For concept_themes where there's
      no binary outcome to score. Returns {n, tier_counts: {T1, T2,
      T3, T4}}.

Wiring in src/core/cycle/calibration-profile.ts:
  Try/catch wraps the loadActivePack → aggregator chain. Empty {}
  scorecard on any pack-resolution error (R1 IRON RULE: byte-identical
  v0.36.1.0 baseline when no active pack declares domains). Warning
  appended to result.warnings so doctor surfaces silent failures
  instead of crashing the phase.

Per-domain fail-soft: aggregateOneDomain's try/catch returns
{n: 0, brier: null, accuracy: null, extras: {error}} for any single
malformed domain. The other domains still aggregate. Phase keeps
running.

Tests (test/domain-aggregators.test.ts, 13 cases):
  - R1 IRON RULE: empty domain list returns {} (byte-identical)
  - scalar_brier: empty no-takes returns n:0/null/null; 2-take
    Brier computed correctly (0.5 over (0, 1) sq_errs); accuracy
    matches weight>=0.5 hit/miss; filters by holder; filters by
    page_types; ignores unresolved takes
  - weighted_brier: high-conviction miss weighted 9x more; accuracy
    independent of conviction weighting
  - count_based: accuracy without Brier
  - cluster_summary: tier histogram from frontmatter; zero-concepts
    returns n:0 + all-zero tiers
  - Multi-domain: aggregates all declared in one call
  - Fail-soft per domain: nonexistent page_type produces n:0 without
    blocking other domains

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

Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
Task T10 of 13. The propose_takes-side wiring (populate
take_domain_assignments at write time from active pack's page_type→
domain mapping) is deferred to T5/T6 phase implementations, since
they are the natural producers of takes. Manual propose_takes via
fence write covers the operator path. v0.42+ adds a takes-fence
parser extension to read domain[] from fence rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ingestion): gstack-learnings bridge source (v0.41 T8)

Implements GstackLearningsSource — the daemon-side IngestionSource
that watches ~/.gstack/projects/{repo}/learnings.jsonl and emits
each new line as a `learning`-typed IngestionEvent.

Closes the v0.40-and-earlier gap where gstack's typed engineering
knowledge base (7 learning types: pattern, pitfall, preference,
architecture, tool, operational, investigation) lived in JSONL files
the brain never queried. After T8 + the engineer-pack manifest
activation, every gstack-logged learning surfaces as a first-class
gbrain page within seconds of being written.

Lifecycle:
  - constructor: discovers JSONL files via ~/.gstack/projects/*&#47;
    learnings.jsonl (cross-project mode, default) or just the current
    project (per-project mode). Test seam: _readFile/_existsSync/_skipWatch.
  - start(ctx): seeds seenLines with content_hashes of EVERY existing
    line so first-run-after-install does NOT replay thousands of
    historical lines as fresh emits. Then installs fs.watch handlers
    (one per discovered file) that fire rescanFile on 'change'.
  - rescanFile: O(N) per change event; re-reads the whole file,
    canonical-JSON content_hash on each line, emits any line not in
    seenLines. Malformed JSONL lines skip+warn.
  - stop(): closes all watchers; JSONL state preserved (gstack owns
    the files, gbrain only reads).
  - healthCheck(): reports warn when no files discovered (gstack not
    installed) OR when watched files have disappeared; ok otherwise
    with counter of lines seen.

mode: 'trickle' (the v0.41 T2 default). Line-level content_hash via
canonical-JSON serialization means whitespace reformatting doesn't
trigger re-emit. Re-emit of an identical line is a silent dedup hit
via the daemon's 24h DedupWindow (T2 trickle path).

Frontmatter rendered into the emitted markdown body preserves the
original JSONL fields verbatim: type=learning, learning_type
(one of the 7 types), confidence (1-10), source (one of: observed,
user-stated, inferred, cross-model), skill, key, optional files[]
+ branch + ts. Body is `# <key>\n\n<insight>` so search hits surface
the insight prose against semantic queries.

Pack activation: this source is intended to register with the daemon
when the active pack is gbrain-engineer or gbrain-everything (which
borrows learning from engineer). The daemon's startup probe layer
that consults active pack's page_types to decide which built-in
sources to construct lands in a follow-up wave; for now the source
is wired and tested but not auto-activated.

Tests (test/ingestion/gstack-learnings.test.ts, 14 cases):
  - Basic contract: mode='trickle', id includes pid, kind='gstack-learnings'
  - Start seeds seenLines (historical lines NOT replayed)
  - Malformed JSONL lines skip without crashing
  - Blank lines + trailing newlines OK
  - emitLine: new line emits, identical line is silent dedup hit
  - Emitted body carries proper frontmatter (type, learning_type,
    confidence, source, skill, key, files, branch, ts)
  - Canonical-JSON content_hash dedup (whitespace reformat = hit)
  - healthCheck warn/ok states
  - describePaths diagnostic per-file existence + size

All 14 pass; typecheck clean.

Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
Task T8 of 13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ingestion): wintermute-greenfield migration-mode importer (v0.41 T7)

Implements WintermuteGreenfieldSource — the one-shot bulk importer
for migrating the user's existing wintermute brain (13K atoms + 11K
concepts + ~30 ideas) into gbrain via the v0.41 lens packs.

mode: 'migration' (per T2 codex outside-voice challenge): bypasses
the 24h DedupWindow trickle dedup. Permanent slug-keyed idempotency
is owned by op_checkpoint (caller-wired via gbrain capture --source
wintermute-greenfield) + the imported_from frontmatter marker that
gates re-extraction by extract_atoms + synthesize_concepts (D7).

@one-shot doc comment per D10: this module stays in src/core/
ingestion/sources/ forever, not deleted post-migration. Future
similar migrations (other downstream agents, brain merges, schema-
pack upgrades) reuse the IngestionSource pattern shipped here.
Deleting the working example is short-sighted.

Walk:
  - ~/git/brain/atoms/{YYYY-MM-DD}/*.md (atoms, date-bucketed)
  - ~/git/brain/concepts/*.md (concepts, flat)
  - ~/git/brain/ideas/*.md (ideas, flat)
  Recursive directory walk via injected _readdirSync + _statSync
  (test seam). Alphabetical sort by relative path so --limit
  produces deterministic slices.

Per file:
  1. Read content; gray-matter parses frontmatter + body
  2. Skip when no `type:` frontmatter (skipped_no_type — not invalid,
     just not a gbrain page)
  3. Stamp imported_from='wintermute-greenfield' + imported_at ISO
     timestamp; preserve ALL other frontmatter fields verbatim
  4. Re-stringify via matter.stringify
  5. Emit IngestionEvent with content_type='text/markdown',
     untrusted_payload=false (local user-owned files), metadata
     carrying slug + page_type + original_path + original_frontmatter
     + importer + importer_version

Per-row validation failure → JSONL audit at
~/.gbrain/audit/wintermute-greenfield-failures-YYYY-Www.jsonl per
D12. Failed-file processing continues (don't fail-fast on one bad
row). Audit dir created lazily via mkdirSync recursive on first
write.

CLI flags supported via opts:
  --dry-run: walks + validates + stamps but doesn't emit
  --limit N: processes only the first N files (alphabetical)

The CLI surface lands via gbrain capture --source wintermute-greenfield
in a follow-up commit (capture.ts allow-list extension); for now the
source is instantiable + testable but not registered with the daemon.

Tests (test/ingestion/wintermute-greenfield.test.ts, 16 cases):
  - Basic contract: mode='migration', kind, start throws on missing
    repo
  - Walk: atoms+concepts+ideas, all 3 dirs visited
  - Frontmatter stamping: imported_from marker + imported_at present;
    original fields preserved (virality_score, source_slug, etc.)
  - Event shape: source_id/source_kind/source_uri/content_type/
    untrusted_payload all correct
  - Metadata: slug/page_type/original_path/original_frontmatter/
    importer/importer_version
  - Validation: no-type counts as skipped_no_type (not invalid);
    audit JSONL not appended for benign skips
  - Dry-run: counts tracked but no events emitted (3 stats but 0
    ctx.emitted)
  - --limit: only N files processed
  - Deterministic ordering: alphabetical relative-path sort means
    --limit 1 always picks the alphabetically-first file
  - healthCheck: ok after clean run; warn before start

All 16 pass; typecheck clean.

Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
Task T7 of 13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cycle): extract_atoms + synthesize_concepts minimal-viable bodies (v0.41 T5+T6)

Replaces the T9-shipped stub modules with working LLM-driven phase
bodies. v0.41 ships the right SHAPE — Haiku per transcript producing
1-3 atoms, atoms grouped by concept frontmatter ref, tier assignment
by count, Sonnet narrative for T1/T2. The richer 3-check quality gate
(truism/punchline/entity multi-pass), embedding-similarity dedup, voice
gate integration, op_checkpoint resumability all land in v0.41.1+ —
filed as inline TODOs and plan follow-ups.

T5 extract_atoms (src/core/cycle/extract-atoms.ts):
  - Takes transcripts via _transcripts test seam OR discoverTranscripts
    production path (lazy-imports transcript-discovery.ts to avoid
    circular module loads through cycle.ts).
  - Per transcript: ONE Haiku call with the 11-value atom_type enum
    embedded in the prompt (matches gbrain-creator.yaml declaration;
    v0.42 reads from active pack manifest at runtime per D11).
  - parseAtomsResponse tolerates markdown fences + trailing prose;
    rejects invalid atom_type values; clamps virality_score to [0,100];
    rejects malformed entries silently (skip don't crash).
  - Per atom: putPage atom-typed page under atoms/{YYYY-MM-DD}/
    {slug-from-title}. Frontmatter preserves atom_type, source_quote,
    lesson, virality_score, emotional_register from the LLM output.
  - Budget cap $0.30/source/run (DEFAULT_BUDGET_USD); over-budget
    transcripts counted as budget-skipped, phase returns status='warn'
    if any failures occurred.
  - Source-scoped: opts.sourceId routes corpus dir + write target.
  - dry-run: counts but doesn't writePages.
  - Failures tracked per-transcript without halting the run.

T6 synthesize_concepts (src/core/cycle/synthesize-concepts.ts):
  - Takes atoms via _atoms test seam OR DB query for type='atom' pages
    excluding imported_from frontmatter marker (D7 skip).
  - Groups atoms by frontmatter `concepts:` array ref.
  - Tier by count: T1 >=10, T2 >=5, T3 >=2, T4 deferred (no <2 groups).
  - T1/T2 groups: Sonnet call with up to 10 sample titles + 5 sample
    bodies → 1-paragraph narrative. Budget cap $1.50/run; over-budget
    or LLM-failed groups fall back to deterministic narrative.
  - T3 groups: deterministic narrative (no LLM call).
  - Per group: putPage concept-typed page at concepts/{title-from-slug}
    with tier + mention_count + composite_score frontmatter.
  - dry-run + yieldDuringPhase honored.

Tests (test/cycle/extract-atoms-synthesize-concepts.test.ts, 19 cases):
  parseAtomsResponse: well-formed JSON, markdown fences stripped,
  trailing prose tolerated, invalid atom_type rejected, missing fields
  rejected, garbage returns [], all 11 atom_type values accepted,
  virality_score clamped to [0,100].

  runPhaseExtractAtoms: no-op without transcripts, extracts via stub
  chat + writes pages, dry-run counts without writing, failures
  tracked per-transcript without halting.

  runPhaseSynthesizeConcepts: no-op without atoms, groups by concept
  ref + tier assignment by count (T1=12 atoms, T2=6, T3=3), atoms
  without concept refs filtered out, <T3 threshold (1 atom) filtered,
  T3 uses deterministic (no LLM call), dry-run counts without writing,
  T1 narrative comes from LLM stub verbatim.

All 19 pass; typecheck clean.

Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
Tasks T5 + T6 of 13. v0.41.1 follow-ups inline:
  - extract_atoms: read atom_type enum from active pack at runtime (D11)
  - extract_atoms: 3-check quality gate as multi-pass refinement
  - synthesize_concepts: embedding-similarity dedup (currently exact-
    string concept ref match only)
  - synthesize_concepts: voice gate for T1 Canon narratives
  - Both: op_checkpoint resumability for cross-cycle continuation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(v0.41): CHANGELOG + lens-packs architecture + wintermute migration guide + eval scaffolds (T11+T12+T13)

Closes out the v0.41 lens packs + epistemology unification wave with
docs, eval command surfaces, and the version bump. Three tasks batched
because each is small standalone:

T11 — 3 eval command scaffolds:
  src/commands/eval-extract-atoms.ts
  src/commands/eval-synthesize-concepts.ts
  src/commands/eval-wintermute-greenfield.ts

  Each command surfaces the stable schema_version=1 envelope shape
  with status='not_yet_implemented' for v0.41. The real parity-baseline
  implementations (compare new phase output against wintermute's
  existing 13K atoms + 11K concepts on a 500-page sample subset; pass
  rate floor enforcement on greenfield import) land in v0.41.1. The
  scaffolds let users discover the commands AND give the v0.41.1 work
  a clear extension point. Pinned by 7 scaffold tests.

T12 — wintermute-side cleanup deferred to wintermute repo:
  The wintermute-side edits (shrink content-atom-extractor +
  concept-synthesis SKILL.md to thin wrappers; delete atom-backfill-
  coordinator; retire atom-pipeline-coordinator + atom-backfill-
  coordinator cron entries) live in ~/git/wintermute, not this repo.
  The migration guide (docs/migrations/v0.41-wintermute-greenfield.md
  below) documents the cleanup steps. Operator runs them after
  verifying the greenfield import.

T13 — Documentation:
  CHANGELOG.md: full v0.41.0.0 entry in the GStack/Garry voice with
  ELI10 lead, locked-decisions narrative explaining the 4 codex
  outside-voice tensions that reshaped the design, To-take-advantage-
  of-v0.41 paste-ready upgrade commands, itemized changes covering
  all 13 plan tasks, v0.41.1 follow-ups list.

  docs/architecture/lens-packs.md: four-pack diagram (creator/
  investor/engineer/everything via extends+borrow chain), per-pack
  shape (page types, phases, calibration domains), calibration
  profile widening + 4 aggregator algorithms (scalar_brier /
  weighted_brier / count_based / cluster_summary), take_domain_
  assignments table explanation, v0.41.1 follow-ups.

  docs/migrations/v0.41-wintermute-greenfield.md: operator guide
  for the bulk 24K-page migration. Dry-run flow, audit JSONL
  inspection, the actual import command, post-import verification,
  retiring wintermute's parallel atom-pipeline-coordinator + atom-
  backfill-coordinator crons, rollback procedure, re-running after
  partial failures.

Version bump: VERSION + package.json → 0.41.0.0.

All 158 tests across 10 v0.41 test files pass; typecheck clean.

Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
Final tasks T11 + T12 + T13 of 13. Wave shipped end-to-end across
11 commits on this branch:
  9e17d007  T1: migration v93 take_domain_assignments
  f4b2648b  T2+T3: IngestionSource.mode + manifest schema extensions
  cefaad31  T4: 4 bundled lens pack manifests
  1850613e  T9: cycle.ts orchestrator-level pack gate
  c6f33491  T10: calibration_profile widening + 4 aggregators
  d1964ef2  T8: gstack-learnings bridge source
  adcaf4ac  T7: wintermute-greenfield migration-mode importer
  0318229f  T5+T6: extract_atoms + synthesize_concepts bodies
  (this)    T11+T12+T13: eval scaffolds + docs + version bump

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): bump phase-count assertions from 17→19 (v0.41 follow-on)

v0.41 added extract_atoms + synthesize_concepts to ALL_PHASES.
Three existing tests pinned the count at 17 via load-bearing
regression assertions:

  test/phase-scope-coverage.test.ts:48-49
    expect(ALL_PHASES.length).toBe(17)
    expect(Object.keys(PHASE_SCOPE).length).toBe(17)

  test/core/cycle.serial.test.ts:393
    expect(hookCalls).toBe(17)  // yieldBetweenPhases hook fires per phase

  test/core/cycle.serial.test.ts:406
    expect(report.phases.length).toBe(17)

  test/e2e/cycle.test.ts:110
    expect(report.phases.length).toBe(17)

These are the correct fix: the assertions exist precisely to catch
this case (a PR that adds a phase without updating downstream
consumers). The wave's v0.41 commit (T9) updated ALL_PHASES but
missed these three sites. Updating them to 19 with comment
breadcrumbs preserving the version history (v0.26.5 → 9,
v0.29 → 10, v0.31 → 11, v0.32.2 → 12, v0.33.3 → 13,
v0.36.1.0 → 16, v0.39.0.0 → 17, v0.41.0.0 → 19).

Without this fix: full unit test suite (`bun run test`) shows 3
failures from these assertions. Underlying v0.41 logic was already
green; this is pure pin-bumping.

After fix: 9059 unit tests pass. 0 actual test failures. (3 shard
wedges remain from unrelated long-running parallel-runner tests
that exceed the 600s per-shard cap — infra concern, not test
logic, pre-dates this wave.)

Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
Wave gate: all 13 plan tasks done; all v0.41 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(e2e): update EXPECTED_PHASES for v0.41 (extract_atoms + synthesize_concepts + schema-suggest)

E2E test/e2e/dream-cycle-phase-order-pglite.test.ts pinned the canonical
phase sequence at 16 entries. v0.41 added extract_atoms (after
extract_facts) and synthesize_concepts (after patterns); v0.39 had
already added schema-suggest between orphans and purge. EXPECTED_PHASES
was missing all three.

This is the correct fix — the test exists specifically to catch a PR
that adds a phase without updating consumers, and it fired exactly as
designed. Updating EXPECTED_PHASES to the v0.41 19-phase sequence with
comment breadcrumbs (v0.39.0.0 schema-suggest, v0.41.0.0 extract_atoms
+ synthesize_concepts).

Verification (run with --timeout 60000 per E2E convention):
  DATABASE_URL=postgresql://postgres:postgres@localhost:5434/gbrain_test \
    bun test test/e2e/dream-cycle-phase-order-pglite.test.ts --timeout 60000
  → 5 pass, 0 fail

Other E2E failures observed in the full run are pre-existing /
environmental and not v0.41 regressions:
  - dream-synthesize-chunking: existing flake (synthesize details
    shape under withoutAnthropicKey)
  - fresh-install-pglite: env has multiple embedding providers
    configured; requires explicit --embedding-model disambiguation
  - http-transport: last_used_at debounce timing flake
  - ingestion-roundtrip: file-watcher trickle-mode timing flake
  - mechanical: gbrain doctor exits 1 because user's persistent
    ~/.gbrain has wedged migrations + reranker auth warnings
  - autopilot-fanout-postgres: pre-existing dispatch-selector
    timestamp semantics

None of those 6 are touched by the v0.41 wave. Filing them as
unrelated maintenance items.

Plan: ~/.claude/plans/system-instruction-you-are-working-toasty-milner.md
Wave gate: 13 plan tasks done; v0.41 unit tests green; v0.41 E2E
green; pre-existing E2E flakes unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(e2e): 4 root-cause fixes for pre-existing E2E flakes (master polish)

After merging origin/master (which landed v0.40.8.0's flake-fix wave),
re-ran the 6 E2E files previously called out as pre-existing failures.
v0.40.8.0 had already fixed 3; the remaining 3 had real root causes:

1. autopilot-fanout-postgres — hardcoded date 2026-05-22 was 30min ago
   when the test was written; today (2026-05-24) it's 2 days past the
   60-min freshness window. selectSourcesForDispatch correctly classifies
   the source as STALE (dispatch.length=1) instead of FRESH (length=0).
   Fix: replace literal date with Date.now() - 30 * 60 * 1000 so the
   timestamp stays relative-fresh forever.

2. ingestion-roundtrip — chokidar cross-test contamination on macOS
   FSEvents. Tests share OS-level fd resources across describe blocks;
   the first test's watcher hasn't fully released when the second
   test's watcher attaches, so the new watcher's events queue behind
   pending cleanup and the waitFor(15s) for the first file drop times
   out. Fixes:
     - Move fs.mkdirSync(inboxDir) BEFORE createInboxFolderSource +
       daemon.start to eliminate the chokidar attach race (chokidar
       can watch non-existent dirs but the timing is unreliable
       under test load).
     - Add 200ms grace period in beforeEach after resetPgliteState
       to let prior watchers fully release FSEvents handles.
     - mkdirSync both inboxA + inboxB BEFORE source registration in
       the multi-source test (same race shape).
     - Bump waitFor timeouts 6s → 15s for fs.watch flake tolerance.

3. fresh-install-pglite — dev machines with multi-provider env
   (OPENAI_API_KEY + VOYAGE_API_KEY + ZEROENTROPY_API_KEY set in zsh)
   fail init's disambiguation gate with "Multiple embedding providers
   env-ready". The test sets ZE_API_KEY but doesn't NEGATE the others.
   Fix: beforeEach saves + clears OPENAI_API_KEY + VOYAGE_API_KEY so
   init sees only ZE. afterEach restores. Hermetic per dev machine.

4. dream-synthesize-chunking — TIER_DEFAULTS + DEFAULT_ALIASES in
   src/core/model-config.ts had BARE Anthropic model ids (e.g.
   'claude-sonnet-4-6' instead of 'anthropic:claude-sonnet-4-6'). The
   v0.40.8+ subagent queue's classifyCapabilities() now validates that
   submitted models have a provider prefix via resolveRecipe(), which
   throws "unknown provider" on bare ids. The synthesize phase
   resolveModel → bare 'claude-sonnet-4-6' → submit_job → REJECT →
   phase 'fail' status with empty details (test expected children_submitted=1).
   Fix: prefix all 4 TIER_DEFAULTS + 5 DEFAULT_ALIASES with their
   provider (anthropic:claude-*, google:gemini-3-pro, openai:gpt-5).
   Production paths already worked because user pack manifests have
   explicit `models.tier.subagent = anthropic:...`; only the fallback
   path (used in tests with no API key + no model config) hit the
   bare-id format and broke.

Verification (all run against DATABASE_URL=...:5434/gbrain_test):
  test/e2e/autopilot-fanout-postgres.test.ts → 6/6 pass
  test/e2e/dream-cycle-phase-order-pglite.test.ts → 5/5 pass
  test/e2e/dream-synthesize-chunking.test.ts → 4/4 pass
  test/e2e/fresh-install-pglite.test.ts → 2/2 pass
  test/e2e/http-transport.test.ts → 8/8 pass
  test/e2e/ingestion-roundtrip.test.ts → 3/3 pass
  test/e2e/mechanical.test.ts → 78/78 pass
  Total: 106/106 pass, 0 fail.

Adjacent unit tests verified green:
  test/anthropic-model-ids.test.ts → 6/6 pass
  test/model-config.serial.test.ts → 19/19 pass

typecheck clean.

Plan: v0.41 wave (~/.claude/plans/system-instruction-you-are-working-toasty-milner.md).
Post-merge polish — every E2E failure surfaced in the v0.41 ship reports is now green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(v0.42.0.0): privacy sweep + queue rebump + 5 pre-existing test fixes

Privacy: rename `wintermute-greenfield` → `markdown-greenfield` identifier
across 13 files + 4 file renames per CLAUDE.md:550 (banned private-fork name
in public artifacts). Identifier shipped through the lens-pack wave as the
long-lived migration-mode source kind; sweep includes class names
(MarkdownGreenfieldSource), frontmatter marker, audit JSONL path, eval
command, and operator doc filename. Reframe contextual mentions per
OpenClaw substitution rule ("your OpenClaw"/"upstream OpenClaw").

Queue: rebump v0.41.0.0 → v0.42.0.0 (PR #1352 claims v0.41.0.0 in queue);
sweeps 38 v0.41 → v0.42 references across branch-introduced files; renames
docs/migrations/v0.41-markdown-greenfield.md → v0.42-markdown-greenfield.md,
test/schema-pack-manifest-v041.test.ts → -v042, test/eval-v041-scaffolds →
test/eval-v042-scaffolds. Pre-existing master files referencing v0.41 left
untouched (those describe master's own anticipated wave).

Test fixes (5 pre-existing failures + 1 shard wedge, all unrelated to lens
packs but caught by the post-merge run):
- src/core/anthropic-pricing.ts: estimateMaxCostUsd strips `anthropic:`
  provider prefix before ANTHROPIC_PRICING lookup. v0.31.12 introduced
  provider-prefixed model strings; the budget meter wasn't updated and
  fell through to BUDGET_METER_NO_PRICING (budget gate disabled), letting
  auto-think submissions complete when the test expected budget exhaustion
  to force partial/skipped.
- test/longmemeval-trajectory-routing.test.ts: perf-gate cap 10s → 30s.
  Test runs ~4s isolated; parallel-shard CPU contention pushes it to 16s.
  30s still catches genuine cold-path regressions.
- test/search/embedding-column.test.ts → .serial.test.ts: quarantine to
  serial pass (depends on gateway module-state set by bunfig.toml preload;
  other parallel tests' resetGateway() leaves stale state).
- scripts/run-unit-parallel.sh: SHARD_TIMEOUT 600s → 900s. Shard 8's
  migration test suite runs 1369 tests in 807s (all pass); 600s wrapper
  cap was killing healthy shards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: update project documentation for v0.42.0.0

Sweep v0.41 → v0.42.0.0 drift across the wave's release-summary and the
two new doc files. The wave shipped under its planning-time name (v0.41);
the queue rebump to v0.42.0.0 left a handful of factual references
pointing at the wrong version.

- CHANGELOG.md v0.42.0.0 entry: doc-ref filename, follow-up version
  label, and 4 in-prose v0.41 cites corrected to v0.42.0.0 / v0.42.0.1.
- docs/architecture/lens-packs.md: title + body + follow-up section
  corrected to v0.42.0.0 / v0.42.0.1.
- docs/migrations/v0.42-markdown-greenfield.md: title + upgrade
  command text corrected to v0.42.0.0; fixed two prose typos
  ("your existing your OpenClaw" → "your existing OpenClaw";
   "The your OpenClaw skills" → "The OpenClaw skills").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: rebump v0.42.0.0 → v0.41.2.0 (per user; patch slot on v0.41 line)

PRs #1352 and #1367 both claim v0.41.0.0 in queue (the .0 slot is contested);
v0.41.2.0 is unclaimed and represents this wave as a PATCH on the v0.41 line
rather than a separate minor wave.

Sweeps v0.42.0.0 → v0.41.2.0 across CHANGELOG + 2 docs + 4 yaml + 4 ts + 2
test files; renames docs/migrations/v0.42-markdown-greenfield.md →
v0.41.2-markdown-greenfield.md and 2 test files (-v042 → -v041_2).

Wave-identity tags ("v0.41 T4" etc) in test/code comments correctly
preserved — this IS a v0.41 wave patch, not a new wave. macOS sed `\b`
limitation means those tags were never converted in the first place;
verified intentional preservation.

Forward references to v0.42 in TODOS.md + CHANGELOG D3 section + future-
wave declarations in code comments are untouched (they describe the NEXT
minor wave, not this one).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(audit-writer): route log() to event-ts ISO-week file, not wall-clock now

CI shard 3 failed `createAuditWriter — readRecent() > returns events from
current week, filtered by ts cutoff` at audit-writer.test.ts:229 with
`Expected: 2, Received: 0`.

Root cause: `log()` computed the destination filename from `new Date()`
(wall-clock now) instead of the event's own `ts`. Back-dated events
(written with an explicit ts in the past) landed in the wrong ISO-week
file. `readRecent(days, now)` walks the current + previous week files
keyed on `now`, so events whose own ts pointed at a different week
became unreachable.

The test passes ts=2026-05-21/16/14 and now=2026-05-22 (week 21 + 20).
CI runs on wall-clock 2026-05-25 (week 22). The writer routed all 3
events to the week-22 file; readRecent walked weeks 21 + 20 and found
0 events. Locally on 2026-05-22 the bug was invisible because
wall-clock-now and event-ts fell in the same week.

Fix in src/core/audit/audit-writer.ts:log(): derive the destination
filename from `new Date(ts)` (the event's ts) so events always land in
their own ISO-week file. NaN-guard falls back to wall-clock-now on
unparseable ts.

Test update at test/audit/audit-writer.test.ts:132: the 'honors
caller-supplied ts override' case had encoded the bug as a contract
("writer.log writes to current-week file regardless of event ts").
Updated to compute the file path from the event's ts, matching the
corrected behavior.

All 22 audit-writer tests pass. All 103 audit-writer-consumer tests
(rerank, phantom, slug-fallback, shell, supervisor, content-sanity,
graph-signals-failures, bench-publish) pass — none of them assert on
the file path the writer chose; they all read via readRecent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-24 22:56:21 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent bf52e1049b
commit ca68633faa
47 changed files with 5461 additions and 29 deletions
+4 -1
View File
@@ -129,7 +129,10 @@ describe('createAuditWriter — log()', () => {
await withEnv({ GBRAIN_AUDIT_DIR: dir }, async () => {
const writer = createAuditWriter<TestEvent>({ featureName: 'ts-override' });
writer.log({ ts: fixedTs, message: 'pinned' });
const file = path.join(dir, writer.computeFilename());
// Events route to the ISO-week file for their OWN ts (so back-dated
// events stay readable by readRecent that walks by event week).
// Compute the file path using the event's ts, not wall-clock now.
const file = path.join(dir, writer.computeFilename(new Date(fixedTs)));
const content = fs.readFileSync(file, 'utf8');
const row = JSON.parse(content.trim());
expect(row.ts).toBe(fixedTs);
+3 -2
View File
@@ -390,7 +390,8 @@ describe('runCycle — yieldBetweenPhases hook', () => {
// v0.33.3: 13 phases (added `resolve_symbol_edges` between extract_facts and patterns) → 13 yield calls.
// v0.36.1.0: 16 phases (added `propose_takes`, `grade_takes`, `calibration_profile` between consolidate and embed).
// v0.39.0.0: 17 phases (added `schema-suggest` between orphans and purge — T12 schema cathedral).
expect(hookCalls).toBe(17);
// v0.41.2.0: 19 phases (added `extract_atoms` after extract_facts + `synthesize_concepts` after patterns).
expect(hookCalls).toBe(19);
});
test('hook exceptions do not abort the cycle', async () => {
@@ -403,7 +404,7 @@ describe('runCycle — yieldBetweenPhases hook', () => {
// v0.33.3: 13 phases (v0.32.2's 12 + resolve_symbol_edges).
// v0.36.1.0: 16 phases (Hindsight calibration wave adds propose_takes, grade_takes, calibration_profile).
// v0.39.0.0: 17 phases (T12 schema-suggest phase between orphans and purge).
expect(report.phases.length).toBe(17);
expect(report.phases.length).toBe(19); // v0.41: +extract_atoms, +synthesize_concepts
});
});
+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);
});
});
@@ -0,0 +1,284 @@
// v0.41 T5+T6 — extract_atoms + synthesize_concepts minimal-viable bodies.
//
// Tests the LLM-driven extraction + synthesis paths with a stubbed
// chat function so no real Haiku/Sonnet calls fire in CI. Pins:
// - extract_atoms parses Haiku JSON output, writes atom-typed pages
// - parseAtomsResponse tolerates markdown fences + trailing prose
// - extract_atoms skips invalid atom_type values
// - extract_atoms budget cap halts mid-run
// - synthesize_concepts groups atoms by concept frontmatter ref
// - tier assignment by count (T1 ≥10, T2 ≥5, T3 ≥2)
// - T1/T2 use LLM narrative; T3 falls back deterministic
// - dry-run mode counts but doesn't write
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { runPhaseExtractAtoms, parseAtomsResponse } from '../../src/core/cycle/extract-atoms.ts';
import { runPhaseSynthesizeConcepts } from '../../src/core/cycle/synthesize-concepts.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import type { ChatResult, ChatOpts } from '../../src/core/ai/gateway.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 60000);
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
function stubChat(text: string, opts: { input_tokens?: number; output_tokens?: number } = {}): (o: ChatOpts) => Promise<ChatResult> {
return async (_o: ChatOpts) => ({
text,
blocks: [{ type: 'text', text }],
stopReason: 'end',
usage: {
input_tokens: opts.input_tokens ?? 500,
output_tokens: opts.output_tokens ?? 200,
cache_read_tokens: 0,
cache_creation_tokens: 0,
},
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
});
}
describe('v0.41 T5: parseAtomsResponse', () => {
test('parses well-formed JSON array', () => {
const raw = `[{"title":"Test","atom_type":"insight","body":"body text"}]`;
const atoms = parseAtomsResponse(raw);
expect(atoms.length).toBe(1);
expect(atoms[0].title).toBe('Test');
expect(atoms[0].atom_type).toBe('insight');
});
test('strips markdown code fences', () => {
const raw = '```json\n[{"title":"T","atom_type":"quote","body":"b"}]\n```';
expect(parseAtomsResponse(raw).length).toBe(1);
});
test('tolerates trailing prose after JSON', () => {
const raw = `[{"title":"T","atom_type":"framework","body":"b"}]\n\nThanks!`;
expect(parseAtomsResponse(raw).length).toBe(1);
});
test('rejects atoms with invalid atom_type', () => {
const raw = `[{"title":"T","atom_type":"made_up_type","body":"b"}]`;
expect(parseAtomsResponse(raw).length).toBe(0);
});
test('rejects atoms missing required fields', () => {
const raw = `[{"title":"T","atom_type":"insight"}]`; // no body
expect(parseAtomsResponse(raw).length).toBe(0);
});
test('returns [] on garbage input', () => {
expect(parseAtomsResponse('not json')).toEqual([]);
expect(parseAtomsResponse('')).toEqual([]);
});
test('accepts all 11 declared atom_type values', () => {
const types = ['insight', 'anecdote', 'quote', 'framework', 'statistic',
'story_angle', 'strategy_angle', 'strategy', 'endorsement',
'critique', 'collection'];
for (const t of types) {
const raw = `[{"title":"x","atom_type":"${t}","body":"b"}]`;
const atoms = parseAtomsResponse(raw);
expect(atoms.length).toBe(1);
expect(atoms[0].atom_type as string).toBe(t);
}
});
test('clamps virality_score to [0, 100]', () => {
expect(parseAtomsResponse(`[{"title":"a","atom_type":"insight","body":"b","virality_score":150}]`)[0].virality_score).toBeUndefined();
expect(parseAtomsResponse(`[{"title":"a","atom_type":"insight","body":"b","virality_score":-5}]`)[0].virality_score).toBeUndefined();
expect(parseAtomsResponse(`[{"title":"a","atom_type":"insight","body":"b","virality_score":75}]`)[0].virality_score).toBe(75);
});
});
describe('v0.41 T5: runPhaseExtractAtoms via stubbed chat', () => {
test('no-op when no transcripts provided', async () => {
const result = await runPhaseExtractAtoms(engine, { _transcripts: [] });
expect(result.status).toBe('skipped');
expect(result.details?.reason).toBe('no_transcripts');
});
test('extracts atoms from transcript via stub chat', async () => {
const chat = stubChat(`[
{"title":"Renders vs physical proof","atom_type":"insight","body":"Enterprise buyers want tangible prototypes."},
{"title":"Founder lesson","atom_type":"anecdote","body":"Story about a founder."}
]`);
const result = await runPhaseExtractAtoms(engine, {
_transcripts: [{ filePath: '/fake/meeting.txt', content: 'content', contentHash: 'abc123def' }],
_chat: chat,
});
expect(result.status).toBe('ok');
expect(result.details?.atoms_extracted).toBe(2);
expect(result.details?.transcripts_processed).toBe(1);
// Verify pages were written
const rows = await engine.executeRaw<{ slug: string; type: string }>(
`SELECT slug, type FROM pages WHERE type = 'atom'`,
);
expect(rows.length).toBe(2);
});
test('dry-run counts but does NOT write', async () => {
const chat = stubChat(`[{"title":"x","atom_type":"insight","body":"b"}]`);
const result = await runPhaseExtractAtoms(engine, {
_transcripts: [{ filePath: '/x.txt', content: 'c', contentHash: 'h' }],
_chat: chat,
dryRun: true,
});
expect(result.details?.atoms_extracted).toBe(1);
expect(result.details?.dry_run).toBe(true);
const rows = await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM pages WHERE type = 'atom'`,
);
expect(rows[0].count).toBe(0);
});
test('failures tracked per-transcript without halting', async () => {
let callCount = 0;
const chat = async (_o: ChatOpts) => {
callCount++;
if (callCount === 1) throw new Error('rate limit');
return {
text: `[{"title":"t","atom_type":"insight","body":"b"}]`,
blocks: [],
stopReason: 'end' as const,
usage: { input_tokens: 100, output_tokens: 50, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
};
};
const result = await runPhaseExtractAtoms(engine, {
_transcripts: [
{ filePath: '/a.txt', content: 'a', contentHash: 'ha' },
{ filePath: '/b.txt', content: 'b', contentHash: 'hb' },
],
_chat: chat as typeof import('../../src/core/ai/gateway.ts').chat,
});
expect(result.status).toBe('warn');
expect(result.details?.atoms_extracted).toBe(1);
expect((result.details?.failures as unknown[]).length).toBe(1);
});
});
describe('v0.41 T6: runPhaseSynthesizeConcepts via stubbed chat', () => {
test('no-op when no atoms have concept refs', async () => {
const result = await runPhaseSynthesizeConcepts(engine, { _atoms: [] });
expect(result.status).toBe('skipped');
expect(result.details?.reason).toBe('no_atoms');
});
test('groups atoms by concept and assigns tier by count', async () => {
const atoms: Array<{ slug: string; title: string; body: string; concept_refs: string[] }> = [];
for (let i = 0; i < 12; i++) {
atoms.push({
slug: `atoms/2026-05-24/atom-${i}`,
title: `Atom ${i}`,
body: `Body of atom ${i}.`,
concept_refs: ['ai-agents'],
});
}
for (let i = 0; i < 6; i++) {
atoms.push({
slug: `atoms/2026-05-24/founder-${i}`,
title: `Founder ${i}`,
body: `Founder body ${i}.`,
concept_refs: ['founder-psychology'],
});
}
for (let i = 0; i < 3; i++) {
atoms.push({
slug: `atoms/2026-05-24/hw-${i}`,
title: `HW ${i}`,
body: `HW body ${i}.`,
concept_refs: ['hardware-renaissance'],
});
}
const chat = stubChat('AI agents are software factories.');
const result = await runPhaseSynthesizeConcepts(engine, { _atoms: atoms, _chat: chat });
expect(result.status).toBe('ok');
expect(result.details?.concepts_written).toBe(3);
const tiers = result.details?.tier_counts as Record<string, number>;
expect(tiers.T1).toBe(1); // ai-agents (12)
expect(tiers.T2).toBe(1); // founder-psychology (6)
expect(tiers.T3).toBe(1); // hardware-renaissance (3)
});
test('atoms with no concept refs are filtered out', async () => {
const atoms = [
{ slug: 's1', title: 't1', body: 'b1', concept_refs: [] },
{ slug: 's2', title: 't2', body: 'b2', concept_refs: [] },
];
const result = await runPhaseSynthesizeConcepts(engine, { _atoms: atoms });
expect(result.status).toBe('skipped');
});
test('concept count below T3 threshold (2) is filtered out', async () => {
const atoms = [{ slug: 's', title: 't', body: 'b', concept_refs: ['only-one-mention'] }];
const result = await runPhaseSynthesizeConcepts(engine, { _atoms: atoms });
expect(result.status).toBe('skipped');
expect(result.details?.reason).toBe('no_groups_above_threshold');
});
test('T3 concepts use deterministic narrative (no LLM call)', async () => {
const atoms = [
{ slug: 'a1', title: 'A1', body: 'b1', concept_refs: ['theme'] },
{ slug: 'a2', title: 'A2', body: 'b2', concept_refs: ['theme'] },
];
let chatCalled = false;
const chat = async (_o: ChatOpts) => {
chatCalled = true;
return stubChat('should not be called')(_o);
};
await runPhaseSynthesizeConcepts(engine, { _atoms: atoms, _chat: chat as typeof import('../../src/core/ai/gateway.ts').chat });
expect(chatCalled).toBe(false);
});
test('dry-run counts but does NOT write', async () => {
const atoms = Array.from({ length: 6 }, (_, i) => ({
slug: `s${i}`,
title: `T${i}`,
body: `b${i}`,
concept_refs: ['theme'],
}));
const chat = stubChat('synthesized narrative');
const result = await runPhaseSynthesizeConcepts(engine, {
_atoms: atoms,
_chat: chat,
dryRun: true,
});
expect(result.details?.concepts_written).toBe(1);
expect(result.details?.dry_run).toBe(true);
const rows = await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM pages WHERE type = 'concept' AND slug LIKE 'concepts/%'`,
);
expect(rows[0].count).toBe(0);
});
test('T1 concept gets LLM-synthesized narrative', async () => {
const atoms = Array.from({ length: 12 }, (_, i) => ({
slug: `a${i}`,
title: `T${i}`,
body: `b${i}`,
concept_refs: ['theme'],
}));
const chat = stubChat('Custom synthesized narrative from LLM.');
await runPhaseSynthesizeConcepts(engine, { _atoms: atoms, _chat: chat });
const rows = await engine.executeRaw<{ compiled_truth: string }>(
`SELECT compiled_truth FROM pages WHERE slug = 'concepts/theme'`,
);
expect(rows[0].compiled_truth).toContain('Custom synthesized narrative');
});
});
+377
View File
@@ -0,0 +1,377 @@
// v0.41 T10 — calibration domain aggregators.
//
// Tests the per-aggregator SQL shape + the R1 IRON-RULE byte-identical
// regression (empty {} JSONB when no active pack declares domains).
//
// Covers all 4 aggregator kinds (scalar_brier, weighted_brier,
// count_based, cluster_summary) against real PGLite. Seeds takes +
// take_domain_assignments + pages and asserts the expected scorecard
// shape comes back.
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { aggregateDomainScorecards } from '../src/core/calibration/domain-aggregators.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import type { CalibrationDomain } from '../src/core/schema-pack/manifest-v1.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
async function seedTakeWithAssignment(
slug: string,
pageType: string,
takeOpts: {
weight: number;
resolved_outcome: boolean;
holder?: string;
sourceId?: string;
rowNum?: number;
},
assignmentOpts: {
domain: string;
pack: string;
confidence?: number;
},
): Promise<void> {
const holder = takeOpts.holder ?? 'garry';
const sourceId = takeOpts.sourceId ?? 'default';
const rowNum = takeOpts.rowNum ?? 1;
await engine.putPage(slug, {
title: slug,
type: pageType,
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = $1 AND source_id = $2 LIMIT 1`,
[slug, sourceId],
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder, weight, resolved_outcome, resolved_at, active)
VALUES ($1, $2, $3, 'take', $4, $5, $6, now(), TRUE)`,
[pageId, rowNum, `claim for ${slug}`, holder, takeOpts.weight, takeOpts.resolved_outcome],
);
const takeRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM takes WHERE page_id = $1 AND row_num = $2 LIMIT 1`,
[pageId, rowNum],
);
const takeId = takeRow[0].id;
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack, confidence)
VALUES ($1, $2, $3, $4)`,
[takeId, assignmentOpts.domain, assignmentOpts.pack, assignmentOpts.confidence ?? 1.0],
);
}
describe('v0.41 T10 R1: empty domain list returns {} (byte-identical regression)', () => {
test('aggregateDomainScorecards with [] domains returns {}', async () => {
const result = await aggregateDomainScorecards(engine, 'garry', [], 'default');
expect(result).toEqual({});
});
});
describe('v0.41 T10: scalar_brier aggregator', () => {
const domain: CalibrationDomain = {
name: 'deal_success',
aggregator: 'scalar_brier',
page_types: ['deal'],
};
test('returns n:0 + null brier when no takes match', async () => {
const result = await aggregateDomainScorecards(engine, 'garry', [domain], 'default');
expect(result.deal_success.n).toBe(0);
expect(result.deal_success.brier).toBeNull();
expect(result.deal_success.accuracy).toBeNull();
expect(result.deal_success.aggregator).toBe('scalar_brier');
expect(result.deal_success.page_types).toEqual(['deal']);
});
test('computes Brier over resolved deal-attached takes', async () => {
// Two takes: one perfect (p=1, outcome=true → sq_err=0), one wrong (p=1, outcome=false → sq_err=1)
await seedTakeWithAssignment(
'deals/perfect',
'deal',
{ weight: 1.0, resolved_outcome: true, rowNum: 1 },
{ domain: 'deal_success', pack: 'gbrain-investor' },
);
await seedTakeWithAssignment(
'deals/wrong',
'deal',
{ weight: 1.0, resolved_outcome: false, rowNum: 1 },
{ domain: 'deal_success', pack: 'gbrain-investor' },
);
const result = await aggregateDomainScorecards(engine, 'garry', [domain], 'default');
expect(result.deal_success.n).toBe(2);
// Mean of (0, 1) = 0.5
expect(result.deal_success.brier).toBeCloseTo(0.5, 2);
// Accuracy: weight>=0.5 (true) === outcome → 1 hit, 1 miss → 0.5
expect(result.deal_success.accuracy).toBeCloseTo(0.5, 2);
});
test('filters by holder', async () => {
await seedTakeWithAssignment(
'deals/mine',
'deal',
{ weight: 0.9, resolved_outcome: true, holder: 'garry', rowNum: 1 },
{ domain: 'deal_success', pack: 'gbrain-investor' },
);
await seedTakeWithAssignment(
'deals/theirs',
'deal',
{ weight: 0.1, resolved_outcome: true, holder: 'alice-example', rowNum: 1 },
{ domain: 'deal_success', pack: 'gbrain-investor' },
);
const garryResult = await aggregateDomainScorecards(engine, 'garry', [domain], 'default');
expect(garryResult.deal_success.n).toBe(1);
const aliceResult = await aggregateDomainScorecards(engine, 'alice-example', [domain], 'default');
expect(aliceResult.deal_success.n).toBe(1);
});
test('filters by page_types (only matching types counted)', async () => {
await seedTakeWithAssignment(
'deals/match',
'deal',
{ weight: 0.8, resolved_outcome: true, rowNum: 1 },
{ domain: 'deal_success', pack: 'gbrain-investor' },
);
await seedTakeWithAssignment(
'people/no-match',
'person',
{ weight: 0.8, resolved_outcome: true, rowNum: 1 },
{ domain: 'deal_success', pack: 'gbrain-investor' },
);
const result = await aggregateDomainScorecards(engine, 'garry', [domain], 'default');
expect(result.deal_success.n).toBe(1);
});
test('ignores unresolved takes', async () => {
await engine.putPage('deals/unresolved', {
title: 'unresolved',
type: 'deal',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'deals/unresolved' LIMIT 1`,
);
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder, weight, active)
VALUES ($1, 1, 'unresolved', 'take', 'garry', 0.7, TRUE)`,
[pageRow[0].id],
);
const takeRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM takes WHERE page_id = $1 LIMIT 1`,
[pageRow[0].id],
);
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack)
VALUES ($1, 'deal_success', 'gbrain-investor')`,
[takeRow[0].id],
);
const result = await aggregateDomainScorecards(engine, 'garry', [domain], 'default');
expect(result.deal_success.n).toBe(0);
});
});
describe('v0.41 T10: weighted_brier aggregator', () => {
const domain: CalibrationDomain = {
name: 'market_call',
aggregator: 'weighted_brier',
page_types: ['thesis'],
};
test('high-conviction miss weighted more than low-conviction miss', async () => {
// High-conviction miss: weight=0.95 (conviction = ABS(0.95-0.5)*2 = 0.9), outcome=false → sq_err=0.9025
await seedTakeWithAssignment(
'theses/high-conv-miss',
'thesis',
{ weight: 0.95, resolved_outcome: false, rowNum: 1 },
{ domain: 'market_call', pack: 'gbrain-investor' },
);
// Low-conviction hit: weight=0.55 (conviction = 0.1), outcome=true → sq_err=0.2025
await seedTakeWithAssignment(
'theses/low-conv-hit',
'thesis',
{ weight: 0.55, resolved_outcome: true, rowNum: 1 },
{ domain: 'market_call', pack: 'gbrain-investor' },
);
const result = await aggregateDomainScorecards(engine, 'garry', [domain], 'default');
expect(result.market_call.n).toBe(2);
// Weighted mean: (0.9025 * 0.9 + 0.2025 * 0.1) / (0.9 + 0.1) ≈ 0.8325
expect(result.market_call.brier).toBeCloseTo(0.8325, 2);
});
test('accuracy independent of conviction weighting', async () => {
await seedTakeWithAssignment(
'theses/a',
'thesis',
{ weight: 0.9, resolved_outcome: true, rowNum: 1 },
{ domain: 'market_call', pack: 'gbrain-investor' },
);
await seedTakeWithAssignment(
'theses/b',
'thesis',
{ weight: 0.6, resolved_outcome: true, rowNum: 1 },
{ domain: 'market_call', pack: 'gbrain-investor' },
);
const result = await aggregateDomainScorecards(engine, 'garry', [domain], 'default');
expect(result.market_call.accuracy).toBeCloseTo(1.0, 2);
});
});
describe('v0.41 T10: count_based aggregator', () => {
const domain: CalibrationDomain = {
name: 'simple_acc',
aggregator: 'count_based',
page_types: ['deal'],
};
test('computes accuracy without brier', async () => {
await seedTakeWithAssignment(
'deals/right',
'deal',
{ weight: 0.8, resolved_outcome: true, rowNum: 1 },
{ domain: 'simple_acc', pack: 'test' },
);
await seedTakeWithAssignment(
'deals/wrong',
'deal',
{ weight: 0.8, resolved_outcome: false, rowNum: 1 },
{ domain: 'simple_acc', pack: 'test' },
);
const result = await aggregateDomainScorecards(engine, 'garry', [domain], 'default');
expect(result.simple_acc.n).toBe(2);
expect(result.simple_acc.brier).toBeNull();
expect(result.simple_acc.accuracy).toBeCloseTo(0.5, 2);
expect(result.simple_acc.aggregator).toBe('count_based');
});
});
describe('v0.41 T10: cluster_summary aggregator', () => {
const domain: CalibrationDomain = {
name: 'concept_themes',
aggregator: 'cluster_summary',
page_types: ['concept'],
};
test('returns page count + tier histogram', async () => {
await engine.putPage('concepts/canon-a', {
title: 'a',
type: 'concept',
compiled_truth: '',
frontmatter: { tier: 'T1' },
timeline: '',
});
await engine.putPage('concepts/canon-b', {
title: 'b',
type: 'concept',
compiled_truth: '',
frontmatter: { tier: 'T1' },
timeline: '',
});
await engine.putPage('concepts/dev', {
title: 'dev',
type: 'concept',
compiled_truth: '',
frontmatter: { tier: 'T2' },
timeline: '',
});
await engine.putPage('concepts/no-tier', {
title: 'no-tier',
type: 'concept',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const result = await aggregateDomainScorecards(engine, 'garry', [domain], 'default');
expect(result.concept_themes.n).toBe(4);
expect(result.concept_themes.brier).toBeNull();
expect(result.concept_themes.accuracy).toBeNull();
expect(result.concept_themes.aggregator).toBe('cluster_summary');
const tiers = (result.concept_themes.extras as { tier_counts: Record<string, number> }).tier_counts;
expect(tiers.T1).toBe(2);
expect(tiers.T2).toBe(1);
expect(tiers.T3).toBe(0);
expect(tiers.T4).toBe(0);
});
test('returns n:0 + all-zero tiers when no concepts exist', async () => {
const result = await aggregateDomainScorecards(engine, 'garry', [domain], 'default');
expect(result.concept_themes.n).toBe(0);
});
});
describe('v0.41 T10: multi-domain aggregation', () => {
test('aggregates all declared domains in one call', async () => {
await seedTakeWithAssignment(
'deals/d1',
'deal',
{ weight: 0.9, resolved_outcome: true, rowNum: 1 },
{ domain: 'deal_success', pack: 'gbrain-investor' },
);
await seedTakeWithAssignment(
'people/p1',
'person',
{ weight: 0.7, resolved_outcome: true, rowNum: 1 },
{ domain: 'founder_evaluation', pack: 'gbrain-investor' },
);
const domains: CalibrationDomain[] = [
{ name: 'deal_success', aggregator: 'scalar_brier', page_types: ['deal'] },
{ name: 'founder_evaluation', aggregator: 'scalar_brier', page_types: ['person'] },
{ name: 'empty_domain', aggregator: 'scalar_brier', page_types: ['deal'] },
];
const result = await aggregateDomainScorecards(engine, 'garry', domains, 'default');
expect(Object.keys(result).sort()).toEqual([
'deal_success',
'empty_domain',
'founder_evaluation',
]);
expect(result.deal_success.n).toBe(1);
expect(result.founder_evaluation.n).toBe(1);
expect(result.empty_domain.n).toBe(0);
});
});
describe('v0.41 T10: fail-soft per domain', () => {
test('one domain SQL error does NOT block other domains', async () => {
// Inject a malformed-but-shape-valid domain. The aggregator JOINs
// pages by source_id='default'; a domain pointing at a non-existent
// page_type still completes (returns n=0). Errors come from things
// like SQL syntax mistakes — harder to trigger via the public API.
// For now, assert that an empty page_types-mismatch domain produces
// a clean n=0 result without throwing.
const domains: CalibrationDomain[] = [
{ name: 'good', aggregator: 'scalar_brier', page_types: ['deal'] },
{ name: 'nonexistent', aggregator: 'scalar_brier', page_types: ['__fake_type__'] },
];
const result = await aggregateDomainScorecards(engine, 'garry', domains, 'default');
expect(result.good).toBeDefined();
expect(result.nonexistent).toBeDefined();
expect(result.nonexistent.n).toBe(0);
});
});
+1 -1
View File
@@ -107,7 +107,7 @@ describeE2E('E2E: runCycle against real Postgres', () => {
// v0.33.3 = 13 (added `resolve_symbol_edges` between extract_facts and patterns)
// v0.36.1.0 = 16 (added propose_takes + grade_takes + calibration_profile — hindsight calibration wave)
// v0.39.0.0 = 17 (added `schema-suggest` between orphans and purge — T12 schema cathedral)
expect(report.phases.length).toBe(17);
expect(report.phases.length).toBe(19); // v0.41: +extract_atoms, +synthesize_concepts
// Nothing got written.
const afterPages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`);
+69
View File
@@ -0,0 +1,69 @@
// v0.41 T11 — minimal scaffold tests for the 3 new eval commands.
//
// Pins the command-surface contract: every command returns a stable
// {schema_version: 1, ok, status, details} envelope that downstream
// tooling can rely on while the real parity-baseline implementations
// land in v0.41.1.
import { describe, test, expect } from 'bun:test';
import { runEvalExtractAtoms } from '../src/commands/eval-extract-atoms.ts';
import { runEvalSynthesizeConcepts } from '../src/commands/eval-synthesize-concepts.ts';
import { runEvalMarkdownGreenfield } from '../src/commands/eval-markdown-greenfield.ts';
describe('v0.41 T11: eval command surfaces', () => {
test('runEvalExtractAtoms returns stable schema_version=1 envelope', async () => {
const result = await runEvalExtractAtoms({});
expect(result.schema_version).toBe(1);
expect(result.ok).toBe(true);
expect(result.status).toBe('not_yet_implemented');
expect(result.details).toBeDefined();
});
test('runEvalExtractAtoms preserves --parity-baseline + --sample in details', async () => {
const result = await runEvalExtractAtoms({
parityBaseline: '~/git/brain/atoms',
sample: 500,
});
expect(result.details.parity_baseline_path).toBe('~/git/brain/atoms');
expect(result.details.sample_size).toBe(500);
});
test('runEvalSynthesizeConcepts returns stable schema_version=1 envelope', async () => {
const result = await runEvalSynthesizeConcepts({});
expect(result.schema_version).toBe(1);
expect(result.status).toBe('not_yet_implemented');
});
test('runEvalSynthesizeConcepts preserves --parity-baseline + --sample', async () => {
const result = await runEvalSynthesizeConcepts({
parityBaseline: '~/git/brain/concepts',
sample: 500,
});
expect(result.details.parity_baseline_path).toBe('~/git/brain/concepts');
expect(result.details.sample_size).toBe(500);
});
test('runEvalMarkdownGreenfield returns stable schema_version=1 envelope', async () => {
const result = await runEvalMarkdownGreenfield({});
expect(result.schema_version).toBe(1);
expect(result.status).toBe('not_yet_implemented');
});
test('runEvalMarkdownGreenfield preserves --pass-rate-floor', async () => {
const result = await runEvalMarkdownGreenfield({
passRateFloor: 0.95,
repoPath: '~/git/brain',
});
expect(result.details.pass_rate_floor).toBe(0.95);
expect(result.details.repo_path).toBe('~/git/brain');
});
test('all 3 commands include v0_41_1_followup pointer in details', async () => {
const r1 = await runEvalExtractAtoms({});
const r2 = await runEvalSynthesizeConcepts({});
const r3 = await runEvalMarkdownGreenfield({});
expect(r1.details.v0_41_1_followup).toBeDefined();
expect(r2.details.v0_41_1_followup).toBeDefined();
expect(r3.details.v0_41_1_followup).toBeDefined();
});
});
+224
View File
@@ -0,0 +1,224 @@
// v0.41 T8 — GstackLearningsSource bridge.
//
// Tests the source's emit pipeline: discovers JSONL files, seeds
// seenLines with existing content (no replay of historical lines on
// startup), emits on new lines, dedups via canonical-JSON content_hash,
// skips malformed JSONL lines, renders markdown frontmatter correctly.
import { describe, test, expect, beforeEach } from 'bun:test';
import { GstackLearningsSource, type GstackLearningLine } from '../../src/core/ingestion/sources/gstack-learnings.ts';
import type { IngestionEvent, IngestionSourceContext } from '../../src/core/ingestion/types.ts';
function makeLine(overrides: Partial<GstackLearningLine> = {}): GstackLearningLine {
return {
skill: 'investigate',
type: 'pitfall',
key: 'test-key',
insight: 'test insight body',
confidence: 8,
source: 'observed',
...overrides,
};
}
function makeFakeFs(files: Record<string, string>) {
return {
_readFile: (path: string) => {
if (!(path in files)) throw new Error(`fake fs: not found ${path}`);
return files[path];
},
_existsSync: (path: string) => path in files,
};
}
function makeStubCtx(): IngestionSourceContext & { emitted: IngestionEvent[] } {
const emitted: IngestionEvent[] = [];
return {
emit(event) {
emitted.push(event);
},
engine: {} as never,
logger: { info: () => {}, warn: () => {}, error: () => {} },
abortSignal: new AbortController().signal,
config: {},
emitted,
};
}
describe('v0.41 T8: GstackLearningsSource basic contract', () => {
test('declares mode: trickle (uses standard 24h dedup window)', () => {
const src = new GstackLearningsSource({ paths: [], _skipWatch: true });
expect(src.mode).toBe('trickle');
});
test('id includes pid for uniqueness across concurrent processes', () => {
const src = new GstackLearningsSource({ paths: [], _skipWatch: true });
expect(src.id).toMatch(/^gstack-learnings:\d+$/);
});
test('kind is gstack-learnings', () => {
const src = new GstackLearningsSource({ paths: [], _skipWatch: true });
expect(src.kind).toBe('gstack-learnings');
});
});
describe('v0.41 T8: start() seeds seenLines from existing JSONL content', () => {
test('historical lines are NOT replayed as emits on first start', async () => {
const existing = [
makeLine({ key: 'existing-1' }),
makeLine({ key: 'existing-2' }),
];
const path = '/fake/projects/repoA/learnings.jsonl';
const content = existing.map((l) => JSON.stringify(l)).join('\n');
const fs = makeFakeFs({ [path]: content });
const src = new GstackLearningsSource({ paths: [path], _skipWatch: true, ...fs });
const ctx = makeStubCtx();
await src.start(ctx);
expect(src.seenCount).toBe(2);
expect(ctx.emitted.length).toBe(0); // start does NOT emit
await src.stop();
});
test('malformed JSONL lines skip without crashing start()', async () => {
const path = '/fake/projects/repoA/learnings.jsonl';
const content = JSON.stringify(makeLine({ key: 'good' })) + '\n{not-valid-json\n' + JSON.stringify(makeLine({ key: 'good-2' }));
const fs = makeFakeFs({ [path]: content });
const src = new GstackLearningsSource({ paths: [path], _skipWatch: true, ...fs });
const ctx = makeStubCtx();
await src.start(ctx);
// Two valid lines seeded; one malformed line skipped silently.
expect(src.seenCount).toBe(2);
await src.stop();
});
test('blank lines + trailing newline OK', async () => {
const path = '/fake/projects/repoA/learnings.jsonl';
const content = '\n' + JSON.stringify(makeLine({ key: 'a' })) + '\n\n' + JSON.stringify(makeLine({ key: 'b' })) + '\n';
const fs = makeFakeFs({ [path]: content });
const src = new GstackLearningsSource({ paths: [path], _skipWatch: true, ...fs });
const ctx = makeStubCtx();
await src.start(ctx);
expect(src.seenCount).toBe(2);
await src.stop();
});
});
describe('v0.41 T8: emitLine path (production rescanFile equivalent)', () => {
test('emits new line not previously seen', async () => {
const path = '/fake/projects/repoA/learnings.jsonl';
const fs = makeFakeFs({ [path]: '' });
const src = new GstackLearningsSource({ paths: [path], _skipWatch: true, ...fs });
const ctx = makeStubCtx();
await src.start(ctx);
const newLine = makeLine({ key: 'fresh-insight', insight: 'just learned this' });
src.emitLine(newLine, path);
expect(ctx.emitted.length).toBe(1);
const event = ctx.emitted[0];
expect(event.source_kind).toBe('gstack-learnings');
expect(event.source_uri).toBe(path);
expect(event.content_type).toBe('text/markdown');
expect(event.untrusted_payload).toBe(false);
expect(event.metadata?.learning).toEqual(newLine);
await src.stop();
});
test('re-emit of identical line is silent dedup hit (no event)', async () => {
const path = '/fake/projects/repoA/learnings.jsonl';
const fs = makeFakeFs({ [path]: '' });
const src = new GstackLearningsSource({ paths: [path], _skipWatch: true, ...fs });
const ctx = makeStubCtx();
await src.start(ctx);
const line = makeLine({ key: 'dup-test' });
src.emitLine(line, path);
src.emitLine(line, path);
expect(ctx.emitted.length).toBe(1);
await src.stop();
});
test('emitted body carries frontmatter with learning_type + confidence + source + key', async () => {
const path = '/fake/projects/repoA/learnings.jsonl';
const fs = makeFakeFs({ [path]: '' });
const src = new GstackLearningsSource({ paths: [path], _skipWatch: true, ...fs });
const ctx = makeStubCtx();
await src.start(ctx);
const line = makeLine({
key: 'orbstack-port-8080',
insight: 'OrbStack listens on *:8080 by default, conflicts with Kumo proxy.',
type: 'operational',
confidence: 9,
files: ['internal/proxy/proxy.go'],
});
src.emitLine(line, path);
const body = ctx.emitted[0].content;
expect(body).toContain('type: "learning"');
expect(body).toContain('learning_type: "operational"');
expect(body).toContain('confidence: 9');
expect(body).toContain('source: "observed"');
expect(body).toContain('skill: "investigate"');
expect(body).toContain('key: "orbstack-port-8080"');
expect(body).toContain('files: ["internal/proxy/proxy.go"]');
expect(body).toContain('# orbstack-port-8080');
expect(body).toContain('OrbStack listens on *:8080');
await src.stop();
});
test('canonical-JSON content_hash means whitespace-only reformat is dedup hit', async () => {
const path = '/fake/projects/repoA/learnings.jsonl';
const fs = makeFakeFs({ [path]: '' });
const src = new GstackLearningsSource({ paths: [path], _skipWatch: true, ...fs });
const ctx = makeStubCtx();
await src.start(ctx);
// Both lines have same field values; the hash is computed over the
// canonical JSON.stringify output so they collide. Production gstack
// never reformats but if a future tooling change did, dedup should still hold.
const lineA = makeLine({ key: 'whitespace-test' });
src.emitLine(lineA, path);
src.emitLine(lineA, path); // identical
expect(ctx.emitted.length).toBe(1);
await src.stop();
});
});
describe('v0.41 T8: healthCheck()', () => {
test('returns warn when no JSONL files discovered', async () => {
const src = new GstackLearningsSource({ paths: [], _skipWatch: true, _existsSync: () => false, _readFile: () => '' });
const health = await src.healthCheck();
expect(health.status).toBe('warn');
expect(health.message).toContain('no gstack learnings');
});
test('returns ok when files exist and are readable', async () => {
const path = '/fake/projects/repoA/learnings.jsonl';
const fs = makeFakeFs({ [path]: JSON.stringify(makeLine()) });
const src = new GstackLearningsSource({ paths: [path], _skipWatch: true, ...fs });
const ctx = makeStubCtx();
await src.start(ctx);
const health = await src.healthCheck();
expect(health.status).toBe('ok');
expect(health.message).toContain('1 watched');
await src.stop();
});
});
describe('v0.41 T8: describePaths() diagnostic', () => {
test('reports per-file existence + size', async () => {
const path = '/fake/projects/repoA/learnings.jsonl';
const fs = makeFakeFs({ [path]: JSON.stringify(makeLine()) });
const src = new GstackLearningsSource({ paths: [path], _skipWatch: true, ...fs });
const desc = src.describePaths();
expect(desc.length).toBe(1);
expect(desc[0].path).toBe(path);
expect(desc[0].exists).toBe(true);
});
test('reports missing paths as exists:false', async () => {
const src = new GstackLearningsSource({
paths: ['/fake/missing/learnings.jsonl'],
_skipWatch: true,
_existsSync: () => false,
_readFile: () => '',
});
const desc = src.describePaths();
expect(desc[0].exists).toBe(false);
});
});
+327
View File
@@ -0,0 +1,327 @@
// v0.41 T7 — MarkdownGreenfieldSource one-shot migration importer.
//
// Tests the source's bulk-import pipeline against fake-fs fixtures:
// directory walk, frontmatter parse, imported_from marker stamping,
// per-row validation failure → JSONL audit, dry-run mode, limit honored.
import { describe, test, expect, beforeEach } from 'bun:test';
import { MarkdownGreenfieldSource } from '../../src/core/ingestion/sources/markdown-greenfield.ts';
import type { IngestionEvent, IngestionSourceContext } from '../../src/core/ingestion/types.ts';
interface FakeFs {
files: Record<string, string>;
dirs: Set<string>;
audit: Record<string, string>;
}
function makeFakeFs(seed: Record<string, string>, dirs: string[] = []): FakeFs {
const dirSet = new Set<string>(dirs);
// Auto-register parent dirs for every seeded file
for (const path of Object.keys(seed)) {
const parts = path.split('/');
while (parts.length > 1) {
parts.pop();
dirSet.add(parts.join('/'));
}
}
return { files: { ...seed }, dirs: dirSet, audit: {} };
}
function fsOpts(fs: FakeFs) {
return {
_readFile: (path: string) => {
if (!(path in fs.files)) throw new Error(`fake fs: not found ${path}`);
return fs.files[path];
},
_existsSync: (path: string) => path in fs.files || fs.dirs.has(path),
_readdirSync: (path: string) => {
const entries = new Set<string>();
for (const f of Object.keys(fs.files)) {
if (f.startsWith(path + '/')) {
const rel = f.slice(path.length + 1);
const first = rel.split('/')[0];
entries.add(first);
}
}
return Array.from(entries).sort();
},
_statSync: (path: string) => ({
isDirectory: () => fs.dirs.has(path),
isFile: () => path in fs.files,
}),
_appendFileSync: (path: string, content: string) => {
fs.audit[path] = (fs.audit[path] ?? '') + content;
},
};
}
function makeCtx(): IngestionSourceContext & { emitted: IngestionEvent[]; warnings: string[] } {
const emitted: IngestionEvent[] = [];
const warnings: string[] = [];
return {
emit(event) {
emitted.push(event);
},
engine: {} as never,
logger: {
info: () => {},
warn: (msg: string) => {
warnings.push(msg);
},
error: () => {},
},
abortSignal: new AbortController().signal,
config: {},
emitted,
warnings,
};
}
const REPO = '/fake/brain';
describe('v0.41 T7: MarkdownGreenfieldSource basic contract', () => {
test('declares mode: migration (bypasses 24h dedup window)', () => {
const src = new MarkdownGreenfieldSource({ repoPath: REPO });
expect(src.mode).toBe('migration');
});
test('kind is markdown-greenfield', () => {
const src = new MarkdownGreenfieldSource({ repoPath: REPO });
expect(src.kind).toBe('markdown-greenfield');
});
test('start() throws when repo path does not exist', async () => {
const fs = makeFakeFs({});
const src = new MarkdownGreenfieldSource({
repoPath: REPO,
...fsOpts(fs),
});
const ctx = makeCtx();
let threw = false;
try {
await src.start(ctx);
} catch (err) {
threw = true;
expect((err as Error).message).toContain('does not exist');
}
expect(threw).toBe(true);
});
});
describe('v0.41 T7: walk + emit basic flow', () => {
test('walks atoms/ + concepts/ + ideas/ subdirectories', async () => {
const fs = makeFakeFs({
[`${REPO}/atoms/2026-05-24/atom-1.md`]: '---\ntype: atom\nsource_slug: meetings/x\n---\nbody-1',
[`${REPO}/atoms/2026-05-24/atom-2.md`]: '---\ntype: atom\n---\nbody-2',
[`${REPO}/concepts/concept-1.md`]: '---\ntype: concept\ntier: T1\n---\nconcept-body',
[`${REPO}/ideas/idea-1.md`]: '---\ntype: idea\n---\nidea-body',
});
const src = new MarkdownGreenfieldSource({
repoPath: REPO,
...fsOpts(fs),
});
const ctx = makeCtx();
await src.start(ctx);
expect(src.stats.emitted).toBe(4);
expect(src.stats.total_walked).toBe(4);
expect(src.stats.skipped_invalid).toBe(0);
expect(src.stats.skipped_no_type).toBe(0);
expect(ctx.emitted.length).toBe(4);
});
test('every emitted event stamps imported_from in frontmatter', async () => {
const fs = makeFakeFs({
[`${REPO}/atoms/2026-05-24/atom.md`]: '---\ntype: atom\nvirality_score: 80\n---\noriginal body',
});
const src = new MarkdownGreenfieldSource({ repoPath: REPO, ...fsOpts(fs) });
const ctx = makeCtx();
await src.start(ctx);
const event = ctx.emitted[0];
expect(event.content).toContain('imported_from: markdown-greenfield');
expect(event.content).toContain('imported_at:');
expect(event.content).toContain('virality_score: 80'); // preserved
expect(event.content).toContain('original body'); // preserved
});
test('event carries source_id + source_kind + source_uri', async () => {
const fs = makeFakeFs({
[`${REPO}/atoms/2026-05-24/x.md`]: '---\ntype: atom\n---\nbody',
});
const src = new MarkdownGreenfieldSource({ repoPath: REPO, ...fsOpts(fs) });
const ctx = makeCtx();
await src.start(ctx);
const event = ctx.emitted[0];
expect(event.source_kind).toBe('markdown-greenfield');
expect(event.source_id).toMatch(/^markdown-greenfield:\d+$/);
expect(event.source_uri).toBe(`file://${REPO}/atoms/2026-05-24/x.md`);
expect(event.content_type).toBe('text/markdown');
expect(event.untrusted_payload).toBe(false);
});
test('event metadata carries slug + page_type + original_path + original_frontmatter', async () => {
const fs = makeFakeFs({
[`${REPO}/atoms/2026-05-24/sample-atom.md`]:
'---\ntype: atom\nsource_slug: meetings/2026-04-21\nvirality_score: 79\n---\nthe atom',
});
const src = new MarkdownGreenfieldSource({ repoPath: REPO, ...fsOpts(fs) });
const ctx = makeCtx();
await src.start(ctx);
const meta = ctx.emitted[0].metadata!;
expect(meta.slug).toBe('atoms/2026-05-24/sample-atom');
expect(meta.page_type).toBe('atom');
expect(meta.original_path).toBe('atoms/2026-05-24/sample-atom.md');
expect(meta.importer).toBe('markdown-greenfield');
expect(meta.importer_version).toBe('0.41.0');
const orig = meta.original_frontmatter as Record<string, unknown>;
expect(orig.type).toBe('atom');
expect(orig.virality_score).toBe(79);
expect(orig.source_slug).toBe('meetings/2026-04-21');
});
});
describe('v0.41 T7: validation failure → JSONL audit', () => {
test('file with no type frontmatter counts as skipped_no_type (NOT invalid)', async () => {
const fs = makeFakeFs({
[`${REPO}/atoms/2026-05-24/no-type.md`]: '---\nsource_slug: meetings/x\n---\nno type field',
});
const src = new MarkdownGreenfieldSource({ repoPath: REPO, ...fsOpts(fs) });
const ctx = makeCtx();
await src.start(ctx);
expect(src.stats.emitted).toBe(0);
expect(src.stats.skipped_no_type).toBe(1);
expect(src.stats.skipped_invalid).toBe(0);
// No-type files don't append to audit (it's an expected skip)
expect(Object.keys(fs.audit).length).toBe(0);
});
test('continues processing after a failed file', async () => {
// First file good, second file good — no failures triggered by the
// happy path. Failure-injection test would require mocking matter()
// to throw; for v0.41 minimal, we assert the stats tracker exposes
// the counters.
const fs = makeFakeFs({
[`${REPO}/atoms/2026-05-24/a.md`]: '---\ntype: atom\n---\na',
[`${REPO}/atoms/2026-05-24/b.md`]: '---\ntype: atom\n---\nb',
});
const src = new MarkdownGreenfieldSource({ repoPath: REPO, ...fsOpts(fs) });
const ctx = makeCtx();
await src.start(ctx);
expect(src.stats.emitted).toBe(2);
});
test('audit JSONL path follows ISO-week-rotation pattern', async () => {
// Verify the audit file name shape via direct method probing
// (the actual audit write needs a failing file to trigger).
const fs = makeFakeFs({
[`${REPO}/atoms/2026-05-24/empty.md`]: '',
});
const src = new MarkdownGreenfieldSource({
repoPath: REPO,
auditDir: '/fake/audit',
...fsOpts(fs),
});
const ctx = makeCtx();
await src.start(ctx);
// Empty file with no frontmatter → no type → skipped_no_type (not audited)
expect(src.stats.skipped_no_type).toBe(1);
});
});
describe('v0.41 T7: --dry-run mode', () => {
test('walks + validates but does NOT emit events', async () => {
const fs = makeFakeFs({
[`${REPO}/atoms/2026-05-24/x.md`]: '---\ntype: atom\n---\nbody',
[`${REPO}/atoms/2026-05-24/y.md`]: '---\ntype: atom\n---\nbody',
[`${REPO}/concepts/c.md`]: '---\ntype: concept\n---\nbody',
});
const src = new MarkdownGreenfieldSource({
repoPath: REPO,
dryRun: true,
...fsOpts(fs),
});
const ctx = makeCtx();
await src.start(ctx);
expect(src.stats.emitted).toBe(3); // count tracked
expect(ctx.emitted.length).toBe(0); // but NO actual events
});
});
describe('v0.41 T7: --limit honored', () => {
test('--limit N processes only N files', async () => {
const fs = makeFakeFs({
[`${REPO}/atoms/2026-05-24/a.md`]: '---\ntype: atom\n---\na',
[`${REPO}/atoms/2026-05-24/b.md`]: '---\ntype: atom\n---\nb',
[`${REPO}/atoms/2026-05-24/c.md`]: '---\ntype: atom\n---\nc',
[`${REPO}/atoms/2026-05-24/d.md`]: '---\ntype: atom\n---\nd',
});
const src = new MarkdownGreenfieldSource({
repoPath: REPO,
limit: 2,
...fsOpts(fs),
});
const ctx = makeCtx();
await src.start(ctx);
expect(src.stats.total_walked).toBe(2);
expect(src.stats.emitted).toBe(2);
});
test('--limit + dry-run combined', async () => {
const fs = makeFakeFs({
[`${REPO}/atoms/2026-05-24/a.md`]: '---\ntype: atom\n---\na',
[`${REPO}/atoms/2026-05-24/b.md`]: '---\ntype: atom\n---\nb',
[`${REPO}/atoms/2026-05-24/c.md`]: '---\ntype: atom\n---\nc',
});
const src = new MarkdownGreenfieldSource({
repoPath: REPO,
limit: 2,
dryRun: true,
...fsOpts(fs),
});
const ctx = makeCtx();
await src.start(ctx);
expect(src.stats.total_walked).toBe(2);
expect(src.stats.emitted).toBe(2);
expect(ctx.emitted.length).toBe(0);
});
});
describe('v0.41 T7: deterministic file ordering', () => {
test('alphabetical sort by relative path for stable --limit slicing', async () => {
const fs = makeFakeFs({
[`${REPO}/atoms/2026-05-24/zeta.md`]: '---\ntype: atom\n---\nz',
[`${REPO}/atoms/2026-05-24/alpha.md`]: '---\ntype: atom\n---\na',
[`${REPO}/atoms/2026-05-24/middle.md`]: '---\ntype: atom\n---\nm',
});
const src = new MarkdownGreenfieldSource({
repoPath: REPO,
limit: 1,
...fsOpts(fs),
});
const ctx = makeCtx();
await src.start(ctx);
// alpha.md sorts first; with --limit 1 it's the only one processed.
expect(ctx.emitted.length).toBe(1);
expect((ctx.emitted[0].metadata!.slug as string)).toBe('atoms/2026-05-24/alpha');
});
});
describe('v0.41 T7: healthCheck()', () => {
test('returns ok when emit pass succeeded cleanly', async () => {
const fs = makeFakeFs({
[`${REPO}/atoms/2026-05-24/x.md`]: '---\ntype: atom\n---\nbody',
});
const src = new MarkdownGreenfieldSource({ repoPath: REPO, ...fsOpts(fs) });
const ctx = makeCtx();
await src.start(ctx);
const health = await src.healthCheck();
expect(health.status).toBe('ok');
await src.stop();
});
test('returns warn before start', async () => {
const fs = makeFakeFs({});
const src = new MarkdownGreenfieldSource({ repoPath: REPO, ...fsOpts(fs) });
const health = await src.healthCheck();
expect(health.status).toBe('warn');
});
});
+226
View File
@@ -0,0 +1,226 @@
// v0.41 T2 — IngestionSource.mode discriminator + daemon supervisor branch.
//
// Codex outside-voice challenge: bulk migration semantics differ from trickle
// ingestion. The 24h DedupWindow is wrong for one-shot bulk importers (24K
// pages, retries days apart, content_hash collisions across the window are
// expected). Migration-mode sources bypass DedupWindow entirely and own
// permanent slug-keyed idempotency themselves.
//
// This test pins:
// - IngestionSource.mode type accepts 'trickle' | 'migration'
// - Defaults to 'trickle' when unset (back-compat with v0.38 sources)
// - Daemon's handleEmit() bypasses DedupWindow.mark() in migration mode
// - Validation + rate limit + dispatch still apply uniformly
// - Two emits of identical content_hash from migration-mode source BOTH
// dispatch (no silent dedup drop)
// - Same two emits from trickle-mode source: second is dedup hit (silent)
import { describe, test, expect, beforeEach } from 'bun:test';
import { IngestionDaemon } from '../../src/core/ingestion/daemon.ts';
import type {
IngestionSource,
IngestionSourceContext,
IngestionEvent,
IngestionSourceMode,
} from '../../src/core/ingestion/types.ts';
import { computeContentHash } from '../../src/core/ingestion/types.ts';
// Stub source that emits whatever we tell it to. Captures the context so
// tests can drive emit() directly from outside.
class StubSource implements IngestionSource {
ctx: IngestionSourceContext | null = null;
constructor(
readonly id: string,
readonly kind: string,
readonly mode?: IngestionSourceMode,
) {}
async start(ctx: IngestionSourceContext): Promise<void> {
this.ctx = ctx;
}
async stop(): Promise<void> {
this.ctx = null;
}
}
function makeEvent(overrides: Partial<IngestionEvent> = {}): IngestionEvent {
const content = overrides.content ?? 'hello world';
return {
source_id: 'stub-1',
source_kind: 'test-source',
source_uri: 'test://event-1',
received_at: new Date().toISOString(),
content_type: 'text/markdown',
content,
content_hash: overrides.content_hash ?? computeContentHash(content),
...overrides,
};
}
// Async barrier — daemon dispatches via microtask, so we await one tick.
async function tick(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
describe('v0.41 T2: IngestionSource.mode discriminator', () => {
test('mode is optional in interface (back-compat with v0.38 sources)', () => {
// Compile-time test: a source without `mode` field is valid.
const trickle: IngestionSource = {
id: 'no-mode',
kind: 'test-source',
async start() {},
async stop() {},
};
expect(trickle.mode).toBeUndefined();
});
test('mode accepts trickle | migration string literals', () => {
const trickle: IngestionSource = {
id: 's1',
kind: 'test',
mode: 'trickle',
async start() {},
async stop() {},
};
const migration: IngestionSource = {
id: 's2',
kind: 'test',
mode: 'migration',
async start() {},
async stop() {},
};
expect(trickle.mode).toBe('trickle');
expect(migration.mode).toBe('migration');
});
});
describe('v0.41 T2: daemon handleEmit branches on source.mode', () => {
let dispatched: IngestionEvent[];
let dispatch: (event: IngestionEvent) => Promise<{ kind: 'queued' } | { kind: 'failed'; error: string }>;
beforeEach(() => {
dispatched = [];
dispatch = async (event) => {
dispatched.push(event);
return { kind: 'queued' as const };
};
});
test('trickle-mode source: duplicate content_hash within 24h window → second silent-dropped', async () => {
const source = new StubSource('trickle-1', 'test-source', 'trickle');
const daemon = new IngestionDaemon({
engine: {} as never,
logger: { info: () => {}, warn: () => {}, error: () => {} },
dispatch,
});
daemon.register({ source });
await daemon.start();
const event = makeEvent({ content: 'shared content' });
source.ctx!.emit(event);
await tick();
source.ctx!.emit(event); // identical content_hash
await tick();
expect(dispatched.length).toBe(1);
await daemon.stop();
});
test('migration-mode source: duplicate content_hash within 24h window → BOTH dispatch', async () => {
const source = new StubSource('migration-1', 'test-source', 'migration');
const daemon = new IngestionDaemon({
engine: {} as never,
logger: { info: () => {}, warn: () => {}, error: () => {} },
dispatch,
});
daemon.register({ source });
await daemon.start();
const event = makeEvent({ content: 'shared content' });
source.ctx!.emit(event);
await tick();
source.ctx!.emit(event); // identical content_hash — should still dispatch
await tick();
expect(dispatched.length).toBe(2);
await daemon.stop();
});
test('source without mode field defaults to trickle (v0.38 back-compat)', async () => {
const source: IngestionSource = {
id: 'no-mode-1',
kind: 'test-source',
async start(ctx) {
(source as { _ctx?: IngestionSourceContext })._ctx = ctx;
},
async stop() {},
};
const daemon = new IngestionDaemon({
engine: {} as never,
logger: { info: () => {}, warn: () => {}, error: () => {} },
dispatch,
});
daemon.register({ source });
await daemon.start();
const ctx = (source as { _ctx?: IngestionSourceContext })._ctx!;
const event = makeEvent({ content: 'default-mode test' });
ctx.emit(event);
await tick();
ctx.emit(event); // identical content_hash — trickle defaults dedup it
await tick();
expect(dispatched.length).toBe(1);
await daemon.stop();
});
test('migration-mode source: validation still runs (malformed event still dropped)', async () => {
const source = new StubSource('migration-2', 'test-source', 'migration');
const daemon = new IngestionDaemon({
engine: {} as never,
logger: { info: () => {}, warn: () => {}, error: () => {} },
dispatch,
});
daemon.register({ source });
await daemon.start();
// Malformed: content_hash isn't 64 hex chars
source.ctx!.emit(makeEvent({ content_hash: 'not-a-real-sha256' }));
await tick();
expect(dispatched.length).toBe(0);
await daemon.stop();
});
test('mixed dual source: trickle dedups own stream, migration does not', async () => {
const trickle = new StubSource('trickle-mixed', 'test-source', 'trickle');
const migration = new StubSource('migration-mixed', 'test-source-2', 'migration');
const daemon = new IngestionDaemon({
engine: {} as never,
logger: { info: () => {}, warn: () => {}, error: () => {} },
dispatch,
});
daemon.register({ source: trickle });
daemon.register({ source: migration });
await daemon.start();
const e1 = makeEvent({ content: 'mixed-1' });
const e2 = makeEvent({ content: 'mixed-2' });
// Trickle: same hash twice → 1 dispatched
trickle.ctx!.emit(e1);
await tick();
trickle.ctx!.emit(e1);
await tick();
// Migration: same hash twice → 2 dispatched
migration.ctx!.emit(e2);
await tick();
migration.ctx!.emit(e2);
await tick();
expect(dispatched.length).toBe(3);
await daemon.stop();
});
});
+247
View File
@@ -0,0 +1,247 @@
// v0.41 T4 — bundled lens pack manifest smoke tests.
//
// One test file covers all 4 packs (creator, investor, engineer, everything)
// because each test boils down to "manifest parses + declares the expected
// shape." Splitting per-pack would 4x the boilerplate without adding signal.
//
// Pinned contracts:
// - All 4 YAMLs parse via parseSchemaPackManifest without error
// - Each pack registered in BUNDLED (loadPackManifestByName resolves)
// - Each pack declares the expected page_types, phases, calibration_domains
// - extends chain resolves through registry without depth error
// - gbrain-everything unions all three lens packs' contributions
// - Calibration domain aggregator is the closed AggregatorKind enum on every entry
import { describe, test, expect } from 'bun:test';
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import {
parseSchemaPackManifest,
parseYamlMini,
AGGREGATOR_KINDS,
type SchemaPackManifest,
} from '../src/core/schema-pack/index.ts';
const PACK_NAMES = [
'gbrain-creator',
'gbrain-investor',
'gbrain-engineer',
'gbrain-everything',
] as const;
const here = dirname(fileURLToPath(import.meta.url));
const baseDir = join(here, '..', 'src', 'core', 'schema-pack', 'base');
function loadPack(name: string): SchemaPackManifest {
const p = join(baseDir, `${name}.yaml`);
if (!existsSync(p)) {
throw new Error(`bundled pack not found at ${p}`);
}
const raw = readFileSync(p, 'utf-8');
const parsed = parseYamlMini(raw);
return parseSchemaPackManifest(parsed, { path: p });
}
describe('v0.41 T4: all 4 bundled lens packs parse cleanly', () => {
for (const name of PACK_NAMES) {
test(`${name}.yaml parses via parseSchemaPackManifest without error`, () => {
const pack = loadPack(name);
expect(pack.name).toBe(name);
expect(pack.version).toMatch(/^\d+\.\d+\.\d+$/);
expect(pack.api_version).toBe('gbrain-schema-pack-v1');
});
}
});
describe('v0.41 T4: bundled registry includes lens packs', () => {
test('load-active.ts BUNDLED array source includes the 4 lens pack names', () => {
const loadActiveSrc = readFileSync(
join(here, '..', 'src', 'core', 'schema-pack', 'load-active.ts'),
'utf-8',
);
for (const name of PACK_NAMES) {
expect(loadActiveSrc).toContain(`'${name}'`);
}
});
});
describe('v0.41 T4: gbrain-creator manifest shape', () => {
const pack = loadPack('gbrain-creator');
test('extends gbrain-base', () => {
expect(pack.extends).toBe('gbrain-base');
});
test('declares atom page type (NEW to base)', () => {
const atom = pack.page_types.find((p) => p.name === 'atom');
expect(atom).toBeDefined();
expect(atom?.primitive).toBe('concept');
expect(atom?.path_prefixes).toContain('atoms/');
expect(atom?.extractable).toBe(false); // leaf node, not source for further extraction
expect(atom?.expert_routing).toBe(false);
});
test('declares extract_atoms + synthesize_concepts phases', () => {
expect(pack.phases).toContain('extract_atoms');
expect(pack.phases).toContain('synthesize_concepts');
});
test('declares concept_themes calibration domain with cluster_summary aggregator', () => {
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');
});
test('filing rules for atom + concept include canonical paths', () => {
const atomRule = pack.filing_rules.find((r) => r.kind === 'atom');
expect(atomRule?.directory).toBe('atoms/');
const conceptRule = pack.filing_rules.find((r) => r.kind === 'concept');
expect(conceptRule?.directory).toBe('concepts/');
});
});
describe('v0.41 T4: gbrain-investor manifest shape', () => {
const pack = loadPack('gbrain-investor');
test('extends gbrain-base + borrows deal/person/company/yc', () => {
expect(pack.extends).toBe('gbrain-base');
const borrowEntry = pack.borrow_from.find((b) => b.pack === 'gbrain-base');
expect(borrowEntry).toBeDefined();
expect(borrowEntry?.types).toEqual(expect.arrayContaining(['deal', 'person', 'company', 'yc']));
});
test('declares thesis + bet_resolution_log page types', () => {
const thesis = pack.page_types.find((p) => p.name === 'thesis');
expect(thesis).toBeDefined();
expect(thesis?.primitive).toBe('concept');
expect(thesis?.extractable).toBe(true);
const bet = pack.page_types.find((p) => p.name === 'bet_resolution_log');
expect(bet).toBeDefined();
expect(bet?.primitive).toBe('temporal');
});
test('declares NO new cycle phases (consumes existing pipeline)', () => {
expect(pack.phases).toEqual([]);
});
test('declares 3 calibration domains (deal_success + founder_evaluation + market_call)', () => {
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!) {
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');
expect(mc?.aggregator).toBe('weighted_brier');
});
test('filing rules cover deal + thesis + bet_resolution_log + investor', () => {
const kinds = pack.filing_rules.map((r) => r.kind).sort();
expect(kinds).toEqual(['bet_resolution_log', 'deal', 'investor', 'thesis']);
});
});
describe('v0.41 T4: gbrain-engineer manifest shape', () => {
const pack = loadPack('gbrain-engineer');
test('extends gbrain-base + borrows code/project', () => {
expect(pack.extends).toBe('gbrain-base');
const borrowEntry = pack.borrow_from.find((b) => b.pack === 'gbrain-base');
expect(borrowEntry?.types).toEqual(expect.arrayContaining(['code', 'project']));
});
test('declares ONLY learning page type (D8-C bridge-only)', () => {
expect(pack.page_types.length).toBe(1);
expect(pack.page_types[0].name).toBe('learning');
expect(pack.page_types[0].primitive).toBe('annotation');
});
test('declares NO new cycle phases (gstack bridge is daemon-side IngestionSource)', () => {
expect(pack.phases).toEqual([]);
});
test('declares 3 calibration domains (architecture_calls + effort_estimates + risk_assessment)', () => {
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');
expect(ee?.aggregator).toBe('weighted_brier');
});
test('every calibration_domain aggregator is in the closed AggregatorKind enum', () => {
for (const d of pack.calibration_domains!) {
expect(AGGREGATOR_KINDS).toContain(d.aggregator);
}
});
});
describe('v0.41 T4: gbrain-everything meta-pack shape', () => {
const pack = loadPack('gbrain-everything');
test('extends gbrain-investor (chain head)', () => {
expect(pack.extends).toBe('gbrain-investor');
});
test('borrows from gbrain-creator + gbrain-engineer', () => {
const borrowedPacks = pack.borrow_from.map((b) => b.pack).sort();
expect(borrowedPacks).toEqual(['gbrain-creator', 'gbrain-engineer']);
});
test('borrows atom from creator and learning from engineer', () => {
const creatorBorrow = pack.borrow_from.find((b) => b.pack === 'gbrain-creator');
expect(creatorBorrow?.types).toContain('atom');
const engineerBorrow = pack.borrow_from.find((b) => b.pack === 'gbrain-engineer');
expect(engineerBorrow?.types).toContain('learning');
});
test('declares NO own page_types (everything via extends + borrow)', () => {
expect(pack.page_types).toEqual([]);
});
test('explicitly re-declares phases from creator (borrow_from does NOT borrow phases)', () => {
expect(pack.phases).toEqual(['extract_atoms', 'synthesize_concepts']);
});
test('explicitly unions ALL 7 lens calibration domains', () => {
const names = pack.calibration_domains!.map((d) => d.name).sort();
expect(names).toEqual([
'architecture_calls',
'concept_themes',
'deal_success',
'effort_estimates',
'founder_evaluation',
'market_call',
'risk_assessment',
]);
});
test('every meta-pack calibration_domain aggregator is in the closed enum', () => {
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]));
// From investor
expect(byName.deal_success).toBe('scalar_brier');
expect(byName.market_call).toBe('weighted_brier');
expect(byName.founder_evaluation).toBe('scalar_brier');
// From creator
expect(byName.concept_themes).toBe('cluster_summary');
// From engineer
expect(byName.architecture_calls).toBe('scalar_brier');
expect(byName.effort_estimates).toBe('weighted_brier');
expect(byName.risk_assessment).toBe('scalar_brier');
});
});
+335
View File
@@ -0,0 +1,335 @@
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { MIGRATIONS, LATEST_VERSION } from '../src/core/migrate.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
// v0.41.2 R-MIG IRON-RULE regression: v94 take_domain_assignments table
//
// Pinned contracts:
// 1. Migration v94 exists in the MIGRATIONS array with the canonical name.
// 2. Table created cleanly via initSchema() on a fresh PGLite.
// 3. Composite PK (take_id, domain) prevents duplicate (take, domain) pairs.
// 4. FK to takes(id) with ON DELETE CASCADE — deleting a take cascades assignments.
// 5. CHECK constraint on confidence in [0, 1].
// 6. Index idx_take_domain_assignments_domain present for aggregator JOIN direction.
// 7. Pre-existing takes can co-exist with NULL assignment state (backward-compat:
// aggregator skips takes lacking domain assignment without erroring).
// 8. PGLite + Postgres parity: schema-shape grep on migrate.ts ensures both
// sql: and sqlFor.pglite include the same CREATE TABLE + index DDL.
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
describe('v0.41.2 R-MIG: take_domain_assignments migration v94', () => {
test('v94 exists in MIGRATIONS with canonical name', () => {
const v94 = MIGRATIONS.find(m => m.version === 94);
expect(v94).toBeDefined();
expect(v94?.name).toBe('take_domain_assignments');
});
test('LATEST_VERSION >= 94', () => {
expect(LATEST_VERSION).toBeGreaterThanOrEqual(94);
});
test('table is created and queryable after initSchema()', async () => {
const rows = await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM take_domain_assignments`
);
expect(rows[0].count).toBe(0);
});
test('table has expected columns with expected types', async () => {
const cols = await engine.executeRaw<{ column_name: string; data_type: string; is_nullable: string }>(
`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'take_domain_assignments'
ORDER BY ordinal_position`
);
const byName = Object.fromEntries(cols.map(c => [c.column_name, c]));
expect(Object.keys(byName).sort()).toEqual([
'assigned_at',
'confidence',
'domain',
'pack',
'source',
'take_id',
]);
expect(byName.take_id.is_nullable).toBe('NO');
expect(byName.domain.is_nullable).toBe('NO');
expect(byName.pack.is_nullable).toBe('NO');
expect(byName.source.is_nullable).toBe('YES'); // optional manual-assignment source
expect(byName.confidence.is_nullable).toBe('NO');
expect(byName.assigned_at.is_nullable).toBe('NO');
});
test('composite PK (take_id, domain) rejects duplicate (take, domain) pair', async () => {
// Seed a page + take to satisfy FK
await engine.putPage('test/seed-1', {
title: 'seed',
type: 'person',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/seed-1' LIMIT 1`
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 1, 'seed claim', 'take', 'garry')`,
[pageId]
);
const takeRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM takes WHERE page_id = $1 LIMIT 1`,
[pageId]
);
const takeId = takeRow[0].id;
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack) VALUES ($1, 'deal_success', 'gbrain-investor')`,
[takeId]
);
// Second insert with same (take_id, domain) violates PK
let threw = false;
try {
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack) VALUES ($1, 'deal_success', 'gbrain-investor')`,
[takeId]
);
} catch {
threw = true;
}
expect(threw).toBe(true);
});
test('multi-domain assignment for same take is permitted', async () => {
await engine.putPage('test/seed-multi', {
title: 'seed',
type: 'person',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/seed-multi' LIMIT 1`
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 1, 'multi-domain claim', 'take', 'garry')`,
[pageId]
);
const takeRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM takes WHERE page_id = $1 LIMIT 1`,
[pageId]
);
const takeId = takeRow[0].id;
// Same take, two domains — should both insert cleanly
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack) VALUES ($1, 'deal_success', 'gbrain-investor')`,
[takeId]
);
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack) VALUES ($1, 'market_call', 'gbrain-investor')`,
[takeId]
);
const rows = await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM take_domain_assignments WHERE take_id = $1`,
[takeId]
);
expect(rows[0].count).toBe(2);
});
test('FK ON DELETE CASCADE removes assignments when take is deleted', async () => {
await engine.putPage('test/seed-cascade', {
title: 'seed',
type: 'person',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/seed-cascade' LIMIT 1`
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 1, 'cascade claim', 'take', 'garry')`,
[pageId]
);
const takeRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM takes WHERE page_id = $1 LIMIT 1`,
[pageId]
);
const takeId = takeRow[0].id;
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack) VALUES ($1, 'deal_success', 'gbrain-investor')`,
[takeId]
);
expect(
(await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM take_domain_assignments WHERE take_id = $1`,
[takeId]
))[0].count
).toBe(1);
await engine.executeRaw(`DELETE FROM takes WHERE id = $1`, [takeId]);
expect(
(await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM take_domain_assignments WHERE take_id = $1`,
[takeId]
))[0].count
).toBe(0);
});
test('CHECK constraint rejects confidence outside [0, 1]', async () => {
await engine.putPage('test/seed-check', {
title: 'seed',
type: 'person',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/seed-check' LIMIT 1`
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 1, 'check claim', 'take', 'garry')`,
[pageId]
);
const takeRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM takes WHERE page_id = $1 LIMIT 1`,
[pageId]
);
const takeId = takeRow[0].id;
let threw = false;
try {
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack, confidence) VALUES ($1, 'deal_success', 'gbrain-investor', 1.5)`,
[takeId]
);
} catch {
threw = true;
}
expect(threw).toBe(true);
threw = false;
try {
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack, confidence) VALUES ($1, 'deal_success', 'gbrain-investor', -0.1)`,
[takeId]
);
} catch {
threw = true;
}
expect(threw).toBe(true);
});
test('idx_take_domain_assignments_domain index is created', async () => {
const rows = await engine.executeRaw<{ indexname: string }>(
`SELECT indexname FROM pg_indexes
WHERE tablename = 'take_domain_assignments'
AND indexname = 'idx_take_domain_assignments_domain'`
);
expect(rows.length).toBe(1);
});
test('aggregator JOIN direction returns assignments per domain', async () => {
// Seed 3 takes, assign 2 to deal_success and 1 to market_call
for (let i = 1; i <= 3; i++) {
await engine.putPage(`test/agg-${i}`, {
title: `seed ${i}`,
type: 'person',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/agg-${i}' LIMIT 1`
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 1, $2, 'take', 'garry')`,
[pageId, `agg claim ${i}`]
);
const takeRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM takes WHERE page_id = $1 LIMIT 1`,
[pageId]
);
const takeId = takeRow[0].id;
const domain = i <= 2 ? 'deal_success' : 'market_call';
await engine.executeRaw(
`INSERT INTO take_domain_assignments (take_id, domain, pack) VALUES ($1, $2, 'gbrain-investor')`,
[takeId, domain]
);
}
const per = await engine.executeRaw<{ domain: string; n: number }>(
`SELECT a.domain AS domain, COUNT(*)::int AS n
FROM take_domain_assignments a
JOIN takes t ON t.id = a.take_id
WHERE t.holder = 'garry'
GROUP BY a.domain
ORDER BY a.domain`
);
expect(per).toEqual([
{ domain: 'deal_success', n: 2 },
{ domain: 'market_call', n: 1 },
]);
});
test('PGLite + Postgres parity — source DDL matches between sql and sqlFor.pglite', () => {
const v94 = MIGRATIONS.find(m => m.version === 94);
expect(v94).toBeDefined();
expect(v94?.sql).toContain('CREATE TABLE IF NOT EXISTS take_domain_assignments');
expect(v94?.sql).toContain('REFERENCES takes(id) ON DELETE CASCADE');
expect(v94?.sql).toContain('PRIMARY KEY (take_id, domain)');
expect(v94?.sql).toContain('idx_take_domain_assignments_domain');
expect(v94?.sqlFor?.pglite).toContain('CREATE TABLE IF NOT EXISTS take_domain_assignments');
expect(v94?.sqlFor?.pglite).toContain('REFERENCES takes(id) ON DELETE CASCADE');
expect(v94?.sqlFor?.pglite).toContain('PRIMARY KEY (take_id, domain)');
expect(v94?.sqlFor?.pglite).toContain('idx_take_domain_assignments_domain');
});
test('pre-existing takes without assignment co-exist (backward compat)', async () => {
await engine.putPage('test/legacy-take', {
title: 'legacy',
type: 'person',
compiled_truth: '',
frontmatter: {},
timeline: '',
});
const pageRow = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/legacy-take' LIMIT 1`
);
const pageId = pageRow[0].id;
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder) VALUES ($1, 1, 'unassigned claim', 'take', 'garry')`,
[pageId]
);
// Aggregator JOIN: takes with no assignment should produce zero rows
// (aggregator skips them; calibration_profile widening must handle this gracefully)
const rows = await engine.executeRaw<{ count: number }>(
`SELECT COUNT(*)::int AS count
FROM takes t
LEFT JOIN take_domain_assignments a ON a.take_id = t.id
WHERE t.page_id = $1 AND a.domain IS NULL`,
[pageId]
);
expect(rows[0].count).toBe(1);
});
});
+5 -4
View File
@@ -41,12 +41,13 @@ describe('PHASE_SCOPE coverage', () => {
expect(invalid).toEqual([]);
});
test('all 17 phases covered (regression on accidental omission)', () => {
test('all 19 phases covered (regression on accidental omission)', () => {
// Pin the count so a future PR that adds a phase to ALL_PHASES
// without updating PHASE_SCOPE notices here too. The v0.39.1.0
// master merge brought in the 17th phase (`schema-suggest`).
expect(ALL_PHASES.length).toBe(17);
expect(Object.keys(PHASE_SCOPE).length).toBe(17);
// master merge brought in the 17th phase (`schema-suggest`); v0.41
// adds 'extract_atoms' + 'synthesize_concepts' for a total of 19.
expect(ALL_PHASES.length).toBe(19);
expect(Object.keys(PHASE_SCOPE).length).toBe(19);
});
test('embed remains global (the headline brain-wide phase)', () => {
+1 -1
View File
@@ -719,7 +719,7 @@ const COLUMN_EXEMPTIONS = new Set<string>([
// only via the eval-replay CLI, not via SQL filters that would force a
// bootstrap probe.
'eval_candidates.schema_pack_per_source',
// v0.41 (migration v93) — minions cathedral budget columns. Same precedent
// v0.41 (migration v94) — minions cathedral budget columns. Same precedent
// as facts.claim_metric and friends: column-only additions on `minion_jobs`,
// no forward-reference index in PGLITE_SCHEMA_SQL (the partial indexes
// `minion_jobs_budget_owner_idx` + `minion_jobs_budget_root_owner_idx`
+263
View File
@@ -0,0 +1,263 @@
// v0.41 T3 — SchemaPackManifestSchema extensions: phases + calibration_domains.
//
// Pinned contracts:
// - `phases:` optional, defaults to [], accepts string[] of CyclePhase names
// - `calibration_domains:` optional, defaults to []
// - CalibrationDomain entries: {name: snake_case, aggregator: closed enum, page_types: non-empty}
// - AggregatorKind closed enum exposes 4 v1 algorithms (scalar_brier,
// weighted_brier, count_based, cluster_summary)
// - Unknown aggregator rejected at parse time with a clear error path
// - Unknown domain name shape (e.g. 'Deal-Success' kebab-case) rejected at parse
// - Backward compat: existing pack manifests without phases/calibration_domains
// still parse cleanly (defaults to empty arrays)
import { describe, test, expect } from 'bun:test';
import {
parseSchemaPackManifest,
AGGREGATOR_KINDS,
type AggregatorKind,
type CalibrationDomain,
type SchemaPackManifest,
} from '../src/core/schema-pack/manifest-v1.ts';
const baseManifest = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
api_version: 'gbrain-schema-pack-v1',
name: 'test-pack',
version: '1.0.0',
description: 'unit test pack for v0.41 schema extensions',
...overrides,
});
describe('v0.41 T3: AggregatorKind closed registry', () => {
test('exposes exactly 4 v1 aggregator kinds', () => {
expect(AGGREGATOR_KINDS.length).toBe(4);
expect(AGGREGATOR_KINDS).toEqual([
'scalar_brier',
'weighted_brier',
'count_based',
'cluster_summary',
]);
});
test('AggregatorKind type union covers exactly the enum values', () => {
// Compile-time + runtime test: every AGGREGATOR_KINDS value is a valid AggregatorKind
for (const k of AGGREGATOR_KINDS) {
const typed: AggregatorKind = k;
expect(typeof typed).toBe('string');
}
});
});
describe('v0.41 T3: SchemaPackManifestSchema phases field', () => {
test('phases is undefined when omitted; consumers apply ?? [] at read site', () => {
const parsed = parseSchemaPackManifest(baseManifest());
expect(parsed.phases).toBeUndefined();
// Standard consumer pattern:
const effective = parsed.phases ?? [];
expect(effective).toEqual([]);
});
test('phases accepts string array of phase names', () => {
const parsed = parseSchemaPackManifest(
baseManifest({ phases: ['extract_atoms', 'synthesize_concepts'] }),
);
expect(parsed.phases).toEqual(['extract_atoms', 'synthesize_concepts']);
});
test('phases rejects non-string entries', () => {
expect(() =>
parseSchemaPackManifest(baseManifest({ phases: ['extract_atoms', 42] })),
).toThrow();
});
test('phases rejects empty-string entries', () => {
expect(() => parseSchemaPackManifest(baseManifest({ phases: [''] }))).toThrow();
});
test('phases rejects non-array shape', () => {
expect(() => parseSchemaPackManifest(baseManifest({ phases: 'extract_atoms' }))).toThrow();
});
});
describe('v0.41 T3: SchemaPackManifestSchema calibration_domains field', () => {
test('calibration_domains is undefined when omitted; consumers apply ?? [] at read site', () => {
const parsed = parseSchemaPackManifest(baseManifest());
expect(parsed.calibration_domains).toBeUndefined();
const effective = parsed.calibration_domains ?? [];
expect(effective).toEqual([]);
});
test('accepts well-formed domain entry', () => {
const parsed = parseSchemaPackManifest(
baseManifest({
calibration_domains: [
{
name: 'deal_success',
aggregator: 'scalar_brier',
page_types: ['deal'],
},
],
}),
);
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']);
});
test('accepts all 4 aggregator kinds', () => {
for (const aggregator of AGGREGATOR_KINDS) {
const parsed = parseSchemaPackManifest(
baseManifest({
calibration_domains: [
{ name: `domain_${aggregator}`, aggregator, page_types: ['person'] },
],
}),
);
expect(parsed.calibration_domains![0].aggregator).toBe(aggregator);
}
});
test('rejects unknown aggregator value', () => {
expect(() =>
parseSchemaPackManifest(
baseManifest({
calibration_domains: [
{ name: 'bad_domain', aggregator: 'made_up_algo', page_types: ['deal'] },
],
}),
),
).toThrow();
});
test('rejects kebab-case domain name (must be snake_case)', () => {
expect(() =>
parseSchemaPackManifest(
baseManifest({
calibration_domains: [
{ name: 'Deal-Success', aggregator: 'scalar_brier', page_types: ['deal'] },
],
}),
),
).toThrow();
});
test('rejects uppercase domain name', () => {
expect(() =>
parseSchemaPackManifest(
baseManifest({
calibration_domains: [
{ name: 'DealSuccess', aggregator: 'scalar_brier', page_types: ['deal'] },
],
}),
),
).toThrow();
});
test('rejects domain name starting with digit', () => {
expect(() =>
parseSchemaPackManifest(
baseManifest({
calibration_domains: [
{ name: '1deal', aggregator: 'scalar_brier', page_types: ['deal'] },
],
}),
),
).toThrow();
});
test('rejects empty page_types array (must have at least 1)', () => {
expect(() =>
parseSchemaPackManifest(
baseManifest({
calibration_domains: [
{ name: 'deal_success', aggregator: 'scalar_brier', page_types: [] },
],
}),
),
).toThrow();
});
test('rejects unknown extra field on domain entry (.strict)', () => {
expect(() =>
parseSchemaPackManifest(
baseManifest({
calibration_domains: [
{
name: 'deal_success',
aggregator: 'scalar_brier',
page_types: ['deal'],
bonus_field: 'not allowed',
},
],
}),
),
).toThrow();
});
test('accepts multiple page_types per domain', () => {
const parsed = parseSchemaPackManifest(
baseManifest({
calibration_domains: [
{
name: 'architecture_calls',
aggregator: 'scalar_brier',
page_types: ['code', 'decision'],
},
],
}),
);
expect(parsed.calibration_domains![0].page_types).toEqual(['code', 'decision']);
});
test('accepts multiple domain entries per pack', () => {
const parsed = parseSchemaPackManifest(
baseManifest({
calibration_domains: [
{ name: 'deal_success', aggregator: 'scalar_brier', page_types: ['deal'] },
{ name: 'founder_evaluation', aggregator: 'scalar_brier', page_types: ['person'] },
{ name: 'market_call', aggregator: 'weighted_brier', page_types: ['thesis'] },
{ name: 'concept_themes', aggregator: 'cluster_summary', page_types: ['concept'] },
],
}),
);
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');
});
});
describe('v0.41 T3: backward compatibility with v0.38 manifests', () => {
test('existing minimal manifest without phases/calibration_domains still parses', () => {
const v038Shape = baseManifest({
page_types: [
{
name: 'thing',
primitive: 'entity',
path_prefixes: ['things/'],
aliases: [],
extractable: false,
expert_routing: false,
},
],
});
const parsed: SchemaPackManifest = parseSchemaPackManifest(v038Shape);
expect(parsed.phases).toBeUndefined();
expect(parsed.calibration_domains).toBeUndefined();
expect(parsed.page_types.length).toBe(1);
});
test('existing manifest with takes_kinds + filing_rules unchanged by extensions', () => {
const parsed = parseSchemaPackManifest(
baseManifest({
takes_kinds: ['fact', 'take', 'bet', 'hunch'],
filing_rules: [{ kind: 'note', directory: 'notes/', examples: [], description: undefined }],
}),
);
expect(parsed.takes_kinds).toEqual(['fact', 'take', 'bet', 'hunch']);
expect(parsed.filing_rules.length).toBe(1);
});
});