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>
This commit is contained in:
Garry Tan
2026-05-24 00:04:28 -07:00
co-authored by Claude Opus 4.7
parent 9e17d0076a
commit f4b2648ba0
5 changed files with 610 additions and 5 deletions
+16 -5
View File
@@ -432,12 +432,23 @@ export class IngestionDaemon {
// is lying about its identity. Trust source.kind over event.source_kind.
const effectiveEvent: IngestionEvent = { ...event, source_kind: sourceKind, source_id: sourceId };
// 2. Dedup.
const isNew = this.dedup.mark(sourceKind, effectiveEvent.content_hash);
if (!isNew) {
// Silent dedup hit. dedup.hits counter already incremented.
return;
// 2. Dedup — TRICKLE MODE ONLY (v0.41 T2). Migration-mode sources own
// their own permanent slug-keyed idempotency (via op_checkpoint); the
// 24h DedupWindow is wrong for bulk historical replay where retries
// happen days apart and content_hash collisions across the import
// window are expected. Default-undefined source.mode treats as
// 'trickle' for v0.38 back-compat.
const sourceMode = state.registration.source.mode ?? 'trickle';
if (sourceMode === 'trickle') {
const isNew = this.dedup.mark(sourceKind, effectiveEvent.content_hash);
if (!isNew) {
// Silent dedup hit. dedup.hits counter already incremented.
return;
}
}
// Migration mode: skip the dedup window entirely. The source's own
// idempotency layer is the authoritative duplicate gate; reaching here
// means the source already decided this event should land.
// 3. Rate limit (token-bucket-ish: count events in trailing window).
const nowMs = this.now();
+27
View File
@@ -141,6 +141,27 @@ export interface IngestionSourceHealth {
* that need richer semantics (transient vs fatal) are a v2 concern; for
* v1, "throw to fail" is the entire contract.
*/
/**
* Source operating mode (v0.41 T2 — codex outside-voice challenge: bulk
* migration semantics differ from trickle ingestion). The daemon branches
* on this flag to decide whether to apply the 24h content-hash dedup window
* (trickle) or bypass it (migration; the source owns its own permanent
* slug-keyed idempotency via op_checkpoint or similar).
*
* Defaults to 'trickle' when unset — preserves v0.38-shipped source
* behavior unchanged. Any source declaring mode: 'migration' opts into
* the bulk semantics:
* - Daemon bypasses DedupWindow.mark() so retries beyond 24h still ingest.
* - Source MUST implement permanent idempotency (slug + content_hash
* forever, NOT a windowed dedup) on its own. The greenfield importer
* uses op_checkpoint keyed on the importer's fingerprint.
* - Rate limit + validate + dispatch still apply uniformly.
*
* Migration-mode sources are one-shot bulk importers. Trickle-mode sources
* are file-watcher / inbox-folder / webhook (the v0.38 default shape).
*/
export type IngestionSourceMode = 'trickle' | 'migration';
export interface IngestionSource {
/** Unique source instance id. Two file-watcher sources pointing at
* different directories MUST have different ids. The daemon dedups
@@ -150,6 +171,12 @@ export interface IngestionSource {
/** Source kind taxonomy. The router uses this to look up processors
* and the dedup window to scope content-hash keys. */
readonly kind: string;
/**
* v0.41 T2 — operating mode discriminator. Defaults to 'trickle' when
* unset (preserves v0.38 shipped behavior). 'migration' bypasses the
* 24h dedup window; the source owns its own permanent idempotency.
*/
readonly mode?: IngestionSourceMode;
/**
* Begin emitting events. MUST resolve when the source is ready to emit;
* MAY throw on unrecoverable startup failure. The daemon catches throws
+83
View File
@@ -89,6 +89,68 @@ const FilingRuleSchema = z.object({
description: z.string().optional(),
}).strict();
/**
* v0.41 T3 — closed registry of calibration aggregator algorithms.
*
* Codex outside-voice refinement of D6: domain NAMES stay open (any pack
* can declare `pricing_judgment` or `hiring_quality` without a gbrain
* release), but the AGGREGATOR — the actual SQL/code that computes a
* scorecard for that domain — must be a closed enum. New aggregator
* algorithms ship via gbrain release; new domain names ship via pack
* manifest. This splits the "what" (open) from the "how" (closed),
* preserving extensibility without SQL injection surface.
*
* v1 aggregators:
* - `scalar_brier` — standard Brier score over resolved binary takes
* (sum((p - outcome)^2) / n). Default for most
* predictive domains.
* - `weighted_brier` — Brier weighted by take.confidence. Use when
* calibration cares more about high-conviction
* predictions than low-stakes ones.
* - `count_based` — simple accuracy ratio (correct / resolved).
* Use when binary outcomes don't have natural
* probability semantics (e.g. did/didn't happen).
* - `cluster_summary` — descriptive rollup (tier counts, dominant
* topics, time span) instead of Brier. Used by
* the creator pack's concept_themes domain where
* there is no "right answer" to score against.
*
* Expand this enum in v0.42+ as real lens-pack usage surfaces new
* aggregation needs. Each addition is a versioned gbrain release.
*/
export const AGGREGATOR_KINDS = [
'scalar_brier',
'weighted_brier',
'count_based',
'cluster_summary',
] as const;
export type AggregatorKind = typeof AGGREGATOR_KINDS[number];
const AggregatorKindSchema = z.enum(AGGREGATOR_KINDS);
/**
* v0.41 T3 — per-pack calibration domain declaration. The calibration_profile
* cycle phase widens at v0.41 from a placeholder `{}` JSONB to an aggregator
* pass over each active pack's declared domains. Each entry binds:
* - `name` — open string label visible in scorecards
* (`deal_success`, `architecture_calls`, etc.)
* - `aggregator` — closed-enum algorithm to compute the scorecard
* - `page_types` — page types whose takes feed this domain (the
* propose_takes phase populates take_domain_assignments
* at write time from this mapping)
*
* Loaded by the registry at pack-load; validated against AggregatorKindSchema
* before any aggregator code runs. Unknown aggregator values fail the pack
* load with a paste-ready `gbrain models doctor`-style hint.
*/
const CalibrationDomainSchema = z.object({
name: z.string().min(1).regex(/^[a-z][a-z0-9_]*$/, 'domain name must be lowercase snake_case'),
aggregator: AggregatorKindSchema,
page_types: z.array(z.string().min(1)).min(1),
}).strict();
export type CalibrationDomain = z.infer<typeof CalibrationDomainSchema>;
/**
* SchemaPackManifest v1 — the parsed + validated pack file shape.
* `extends` resolution + closure expansion are done by registry.ts, not at
@@ -118,6 +180,27 @@ export const SchemaPackManifestSchema = z.object({
takes_kinds: z.array(z.string()).default(['fact', 'take', 'bet', 'hunch']),
enrichable_types: z.array(EnrichableSchema).default([]),
filing_rules: z.array(FilingRuleSchema).default([]),
/**
* v0.41 T3/D4 — phase participation declaration. The runCycle orchestrator
* consults active pack's `phases:` to decide which pack-flavored cycle
* phases run (extract_atoms, synthesize_concepts, future pack phases).
* Pre-existing 17 core phases (lint, sync, extract, extract_facts,
* propose_takes, etc.) ALWAYS run regardless of this declaration —
* `phases:` is additive, not subtractive. `borrow_from` does NOT borrow
* phases; each pack declares its own participation explicitly.
*
* Phase names are validated as strings at parse time and against the
* runtime CyclePhase union at pack-load by the registry (kept as string[]
* here to avoid a circular import from src/core/cycle.ts).
*/
phases: z.array(z.string().min(1)).default([]),
/**
* v0.41 T3 — per-pack calibration domain declarations. The
* calibration_profile cycle phase widens at v0.41 from `{}` placeholder
* JSONB to a real aggregator pass over each declared domain. See
* CalibrationDomainSchema for the per-entry shape.
*/
calibration_domains: z.array(CalibrationDomainSchema).default([]),
}).strict();
export type SchemaPackManifest = z.infer<typeof SchemaPackManifestSchema>;
+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: 'ok' | 'failed'; error?: string }>;
beforeEach(() => {
dispatched = [];
dispatch = async (event) => {
dispatched.push(event);
return { kind: 'ok' 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();
});
});
+258
View File
@@ -0,0 +1,258 @@
// 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 defaults to empty array when omitted', () => {
const parsed = parseSchemaPackManifest(baseManifest());
expect(parsed.phases).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 defaults to empty array when omitted', () => {
const parsed = parseSchemaPackManifest(baseManifest());
expect(parsed.calibration_domains).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).toEqual([]);
expect(parsed.calibration_domains).toEqual([]);
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);
});
});