From 94fe24e35f9ff7e103ba3790d58b45fe0fae8b5d Mon Sep 17 00:00:00 2001 From: Tengro Date: Mon, 14 Sep 2026 10:27:39 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20folding-strategy=20migration=20util?= =?UTF-8?q?ity=20(kv-stable=20=E2=87=84=20kv-unified)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A store's folding flavor is exactly one state slot — `${ns}/kvunified:presentation-receipt`; everything else the adaptive path persists is shared between solvers. This adds the utility that handles the two things a bare foldingStrategy config flip does not: - validate: dry-runs the strict CanonicalSummaryForest (via previewContext, committing nothing) under each treeification policy (strict / treeify / preserve-gaps) and recommends the first that builds. kv-stable's SummaryTree tolerates scarred chronicles the forest rejects, so this is the go/no-go check before any migration. - to-unified: synthesizes the initial receipt chain from the carried `autobio:resolutions` frontier — the same (repHash, level) pairs the kv-unified draft builder records — so the first solve has a continuity baseline instead of a one-shot presentation jump. No cache reference (the provider cache is genuinely cold after a solver switch). Resolutions pointing at unreachable levels degrade deterministically with warnings. Refuses to overwrite an existing chain unless --overwrite. acceptedAt is an explicit required input — no clock reads, migrations are reproducible. - to-stable: clears the receipt slot, closing the flip-back landmine where a stale chain would be resurrected as the continuity anchor (nothing else reads or clears it). Never opens a ContextManager, so no on-load repairs run. Shipped as bin `context-manager-migrate` (validate / to-unified / to-stable, dry-run by default, --apply to write, --json for tooling), mirroring the agent-framework-recover pattern. The integration tests are the first coverage of the cross-strategy switch path at all: a store compiled under kv-stable is migrated and then loaded + compiled under kv-unified. Also: buildPicker's [picker-config-drift] alarm no longer fires while a previewContext override is in flight — a mid-preview foldingStrategy swap is the caller's requested what-if, not drift. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ej5yriRtwnzjYWNy3YeXeE --- changelog.d/folding-migration.added.md | 1 + package.json | 3 + src/index.ts | 20 + src/migration/folding-migration.ts | 509 +++++++++++++++++++++++++ src/migration/migrate-cli.ts | 232 +++++++++++ src/strategies/autobiographical.ts | 10 +- test/folding-migration.test.ts | 293 ++++++++++++++ 7 files changed, 1066 insertions(+), 2 deletions(-) create mode 100644 changelog.d/folding-migration.added.md create mode 100644 src/migration/folding-migration.ts create mode 100644 src/migration/migrate-cli.ts create mode 100644 test/folding-migration.test.ts diff --git a/changelog.d/folding-migration.added.md b/changelog.d/folding-migration.added.md new file mode 100644 index 0000000..7163730 --- /dev/null +++ b/changelog.d/folding-migration.added.md @@ -0,0 +1 @@ +New `context-manager-migrate` CLI (and `src/migration/` library exports) for switching a store between folding strategies. `validate` dry-runs the strict kv-unified CanonicalSummaryForest against the store under each treeification policy and recommends one; `to-unified` synthesizes the initial presentation-receipt chain from the carried `autobio:resolutions` frontier (so the first kv-unified solve has a continuity baseline instead of a one-shot presentation jump), refusing to overwrite an existing chain without `--overwrite`; `to-stable` clears the receipt slot so a later flip back cannot resurrect a stale continuity anchor. Everything is dry-run unless `--apply` is passed, and the synthesized receipt's `acceptedAt` is an explicit required input so migrations are reproducible. Also: buildPicker's `[picker-config-drift]` alarm no longer fires for previewContext overrides — a mid-preview foldingStrategy swap is the caller's requested what-if, not drift. diff --git a/package.json b/package.json index e3083ce..675e1f5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "dist", "src" ], + "bin": { + "context-manager-migrate": "./dist/src/migration/migrate-cli.js" + }, "scripts": { "build": "tsc", "dev": "tsc --watch", diff --git a/src/index.ts b/src/index.ts index 1afbb8b..b7d963f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,26 @@ export { KnowledgeStrategy } from './strategies/knowledge.js'; export { splitMixedToolMessages, stripUnpairedToolBlocks } from './normalize-tool-messages.js'; export { resolveEffectiveConfig } from './config-provenance.js'; export type { ConfigLayer, ConfigResolutionSemantics, EffectiveConfigReport } from './config-provenance.js'; +export { + discoverAutobioNamespaces, + readFlavor, + validateStoreForKvUnified, + synthesizePresentation, + buildSyntheticChain, + migrateToUnified, + migrateToStable, + slotIds as migrationSlotIds, +} from './migration/folding-migration.js'; +export type { + StoreFlavor, + TreeificationPolicy, + PolicyOutcome, + ValidateResult, + MigrationLeafWarning, + SynthesizedPresentation, + ToUnifiedResult, + ToStableResult, +} from './migration/folding-migration.js'; // Errors — cross-package behavioral surface. agent-framework gates its // OverBudget drain breaker on these errors (AF PR #58, framework.ts diff --git a/src/migration/folding-migration.ts b/src/migration/folding-migration.ts new file mode 100644 index 0000000..abfddb7 --- /dev/null +++ b/src/migration/folding-migration.ts @@ -0,0 +1,509 @@ +/** + * Folding-strategy migration utilities: kv-stable ⇄ kv-unified. + * + * A store's folding "flavor" is exactly one state slot — + * `${ns}/kvunified:presentation-receipt`. Everything else the adaptive path + * persists (summaries, chunks, pins, locks, calibration, and the + * `${ns}/autobio:resolutions` frontier) is shared between solvers and stays + * valid across a switch. These utilities handle the two things a bare config + * flip does not: + * + * - kv-stable → kv-unified: the strict CanonicalSummaryForest can reject a + * store the damage-tolerant SummaryTree happily served for years + * (validate), and an empty receipt slot means the first solve has no + * continuity baseline — a one-shot presentation jump (toUnified + * synthesizes an initial receipt chain from the carried resolutions + * frontier instead). + * + * - kv-unified → kv-stable: nothing reads the receipt slot any more, but + * nothing clears it either — a later flip back would resurrect a stale + * chain as its continuity anchor (toStable clears it). + * + * Determinism: every function here is deterministic given the store contents + * and its explicit inputs. The one non-derivable input — the timestamp a + * synthesized receipt is "accepted" at — is a required parameter, never + * read from the clock. + * + * NOT read-only: opening a store through ContextManager runs the strategy's + * standard on-load canonicalization (empty-summary drops, duplicate-id + * dedupe, dangling-parent repair — see loadPersistedState). Those writes are + * identical to what any agent restart performs, but they are writes. Run + * against a copy first; that is what `validate` is for. + */ + +import { createHash } from 'node:crypto'; +import { JsStore } from '@animalabs/chronicle'; +import { ContextManager } from '../context-manager.js'; +import { AutobiographicalStrategy } from '../strategies/autobiographical.js'; +import type { ChunkRecord } from '../strategies/autobiographical.js'; +import type { + AutobiographicalOptions, + KvUnifiedConfig, + SummaryEntry, +} from '../types/strategy.js'; +import { CanonicalForestError } from '../adaptive/kv-unified.js'; +import type { CanonicalForestIssue } from '../adaptive/kv-unified.js'; +import { SummaryTree } from '../adaptive/summary-tree.js'; +import type { PickerChunk, PickerInputs } from '../adaptive/picker.js'; +import { KvUnifiedReceiptChain } from '../adaptive/kv-unified-receipts.js'; +import type { SerializedReceiptChain } from '../adaptive/kv-unified-receipts.js'; +import type { PresentedLeaf } from '../adaptive/kv-unified-policy.js'; +import type { MessageId, TokenBudget } from '../types/index.js'; + +// ============================================================================ +// Shared plumbing +// ============================================================================ + +/** Slot id helpers — must stay byte-identical to AutobiographicalStrategy's. */ +export const slotIds = { + summaries: (ns: string) => `${ns}/autobio:summaries`, + chunks: (ns: string) => `${ns}/autobio:chunks`, + resolutions: (ns: string) => `${ns}/autobio:resolutions`, + kvUnifiedReceipt: (ns: string) => `${ns}/kvunified:presentation-receipt`, +} as const; + +/** + * Namespaces that carry autobiographical state in this store, discovered from + * the registered state ids. A store opened with no explicit namespace uses + * 'default'. + */ +export function discoverAutobioNamespaces(store: JsStore): string[] { + const suffix = '/autobio:summaries'; + const out: string[] = []; + for (const info of store.listStates()) { + if (info.id.endsWith(suffix)) out.push(info.id.slice(0, -suffix.length)); + } + return out.sort(); +} + +export interface StoreFlavor { + namespace: string; + hasResolutions: boolean; + /** Non-null receipt slot content — the store has run (or been migrated to) + * kv-unified and not been migrated away. */ + hasReceiptChain: boolean; + receiptHeadSequence: number | null; + receiptLeafCount: number | null; +} + +/** Read a store's folding flavor without opening a ContextManager (no + * on-load repairs; safe on a live copy). */ +export function readFlavor(store: JsStore, namespace: string): StoreFlavor { + const registered = new Set(store.listStates().map((s) => s.id)); + const resolutions = registered.has(slotIds.resolutions(namespace)) + ? store.getStateJson(slotIds.resolutions(namespace)) + : null; + const receiptRaw = registered.has(slotIds.kvUnifiedReceipt(namespace)) + ? store.getStateJson(slotIds.kvUnifiedReceipt(namespace)) + : null; + const receipt = + receiptRaw && typeof receiptRaw === 'object' + ? (receiptRaw as unknown as SerializedReceiptChain) + : null; + return { + namespace, + hasResolutions: !!resolutions && typeof resolutions === 'object', + hasReceiptChain: receipt !== null, + receiptHeadSequence: receipt?.head?.sequence ?? null, + receiptLeafCount: receipt ? receipt.leaves.length : null, + }; +} + +/** + * Complete kvUnified configuration used ONLY to drive validation previews. + * The solve's outcome is irrelevant to validation — only the strict forest + * construction at its start matters — but the config gate ("live defaults + * are forbidden") demands a complete object, so here is one, explicitly. + * Values mirror test/kv-unified-strategy-integration.test.ts. + */ +export const VALIDATION_KV_UNIFIED_CONFIG: Omit< + KvUnifiedConfig, + 'treeifyNonContiguousSummaries' | 'preserveGapBearingSummaries' +> = { + policy: { + alpha: 0.7, + budgetLowRatio: 0.5, + budgetHighRatio: 0.9, + budgetUnderLambda: 10, + budgetOverLambda: 10, + cacheLambda: 1, + cacheScale: 1000, + cacheReadPrice: 0.1, + cacheWritePrice: 1.25, + continuityLambda: 1, + continuityScale: 1000, + continuityRecencyHalfLifeTokens: 1000, + continuityRecencyFloor: 0.2, + continuityStableHalfLife: 10, + continuityStableFloor: 0.25, + }, + tokenBucketSize: 100, + continuityBucketSize: 100, + fidelityBucketSize: 100, + labelCeiling: 10_000, + adoptEpsilon: 0, +}; + +// ============================================================================ +// validate — can this store's summary forest be canonicalized? +// ============================================================================ + +export type TreeificationPolicy = 'strict' | 'treeify' | 'preserve-gaps'; + +export interface PolicyOutcome { + policy: TreeificationPolicy; + ok: boolean; + issues: CanonicalForestIssue[]; +} + +export interface ValidateResult { + namespace: string; + /** Issue list from the strict (no-tolerance) forest build. Empty = clean. */ + strictIssues: CanonicalForestIssue[]; + outcomes: PolicyOutcome[]; + /** First policy (in strict → treeify → preserve-gaps order) whose forest + * builds. Null = no policy makes this store canonicalizable — repair the + * summary state before migrating. */ + recommendation: TreeificationPolicy | null; +} + +const POLICY_FLAGS: Record< + TreeificationPolicy, + Pick +> = { + strict: { treeifyNonContiguousSummaries: false, preserveGapBearingSummaries: false }, + treeify: { treeifyNonContiguousSummaries: true, preserveGapBearingSummaries: false }, + 'preserve-gaps': { treeifyNonContiguousSummaries: false, preserveGapBearingSummaries: true }, +}; + +/** + * Dry-run the real kv-unified solve (via previewContext, which commits + * nothing) under each treeification policy and report which ones the store's + * summary forest survives. The preview budget is deliberately huge so the + * early head+tail over-budget check cannot fire before the forest is built. + */ +export async function validateStoreForKvUnified(opts: { + path: string; + namespace?: string; + config?: AutobiographicalOptions; + budget?: TokenBudget; +}): Promise { + const namespace = opts.namespace ?? 'default'; + const strategy = new AutobiographicalStrategy({ + ...opts.config, + adaptiveResolution: true, + }); + const manager = await ContextManager.open({ path: opts.path, namespace, strategy }); + try { + const budget: TokenBudget = opts.budget ?? { + maxTokens: 10_000_000, + reserveForResponse: 0, + }; + const outcomes: PolicyOutcome[] = []; + for (const policy of ['strict', 'treeify', 'preserve-gaps'] as const) { + try { + manager.previewContext(budget, { + foldingStrategy: 'kv-unified', + kvUnified: { ...VALIDATION_KV_UNIFIED_CONFIG, ...POLICY_FLAGS[policy] }, + }); + outcomes.push({ policy, ok: true, issues: [] }); + } catch (err) { + if (!(err instanceof CanonicalForestError)) throw err; + outcomes.push({ policy, ok: false, issues: [...err.issues] }); + } + } + const recommendation = outcomes.find((o) => o.ok)?.policy ?? null; + return { + namespace, + strictIssues: outcomes[0].issues, + outcomes, + recommendation, + }; + } finally { + manager.close(); + } +} + +// ============================================================================ +// to-unified — synthesize an initial receipt chain from the carried frontier +// ============================================================================ + +export interface MigrationLeafWarning { + messageId: MessageId; + requestedLevel: number; + usedLevel: number; + reason: 'no-summary-at-level'; +} + +export interface SynthesizedPresentation { + leaves: Map; + foldedCount: number; + warnings: MigrationLeafWarning[]; +} + +/** + * Pure core of to-unified: reconstruct, per message, the presentation the + * carried resolutions frontier describes — the same (repHash, level) pairs + * the kv-unified draft builder would record for it (`raw:{id}` / + * `summary:{summaryId}`; see selectAdaptive's draft construction). + * + * A resolution pointing at a level with no reachable summary ancestor (a + * kv-stable-era scar) degrades deterministically to the deepest level that + * IS reachable, and is reported as a warning rather than thrown: the receipt + * must describe a presentable state, and "what the renderer would actually + * have shown" is the honest baseline. + * + * All leaves get lastChangedSeq 1 — the synthesized chain's first (and only) + * sequence. A uniform baseline is the only defensible choice: the real + * change history under kv-stable was never recorded per-leaf. + */ +export function synthesizePresentation( + messageIds: readonly MessageId[], + resolutions: ReadonlyMap, + summaries: readonly SummaryEntry[], + chunkRecords: readonly ChunkRecord[], +): SynthesizedPresentation { + // L1 coverage: the chunk-record production ledger first, then live L1 + // sourceIds as the coverage authority fallback — the same two-step + // buildPicker uses (fold-path fallback, 2026-08-04). + const l1ByMessage = new Map(); + for (const record of chunkRecords) { + if (!record.summaryId) continue; + for (const mid of record.sourceIds) { + if (!l1ByMessage.has(mid)) l1ByMessage.set(mid, record.summaryId); + } + } + for (const s of summaries) { + if (s.level !== 1) continue; + for (const mid of s.sourceIds) { + if (!l1ByMessage.has(mid)) l1ByMessage.set(mid, s.id); + } + } + + const chunks: PickerChunk[] = messageIds.map((id, i) => ({ + id, + sequence: i, + rawTokens: 1, + currentResolution: resolutions.get(id) ?? 0, + lockedByAgent: false, + pinned: false, + l1Id: l1ByMessage.get(id), + })); + const summariesMap = new Map(summaries.map((s) => [s.id, s])); + const inputs: PickerInputs = { + chunks, + summaries: summariesMap, + headTokens: 0, + tailTokens: 0, + headChunkIds: new Set(), + tailChunkIds: new Set(), + }; + // SummaryTree (not CanonicalSummaryForest) on purpose: the receipt must + // describe what the damage-tolerant production renderer served, scars + // included. + const tree = new SummaryTree(inputs); + + const leaves = new Map(); + const warnings: MigrationLeafWarning[] = []; + let foldedCount = 0; + for (const chunk of chunks) { + const requested = chunk.currentResolution; + let level = requested; + let summaryId: string | undefined; + while (level > 0) { + const ancestor = tree.ancestorAt(chunk.id, level); + if (ancestor) { + summaryId = ancestor.id; + break; + } + level--; + } + if (level !== requested) { + warnings.push({ + messageId: chunk.id, + requestedLevel: requested, + usedLevel: level, + reason: 'no-summary-at-level', + }); + } + if (level > 0) foldedCount++; + leaves.set(chunk.id, { + repHash: level === 0 ? `raw:${chunk.id}` : `summary:${summaryId}`, + level, + lastChangedSeq: 1, + }); + } + return { leaves, foldedCount, warnings }; +} + +/** + * Pure: wrap a synthesized presentation in a properly hash-chained, + * single-receipt KvUnifiedReceiptChain. No cache reference — the provider + * cache is genuinely cold after a solver switch, and claiming otherwise + * would mis-price the first solve. + */ +export function buildSyntheticChain( + presentation: SynthesizedPresentation, + acceptedAt: number, +): KvUnifiedReceiptChain { + const layoutFingerprint = createHash('sha256') + .update( + JSON.stringify( + [...presentation.leaves.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([id, leaf]) => [id, leaf.repHash, leaf.level]), + ), + ) + .digest('hex'); + const chain = new KvUnifiedReceiptChain(); + const submissionId = `migration:${layoutFingerprint.slice(0, 16)}`; + chain.begin({ + submissionId, + requestHash: 'migration:synthesized-from-resolutions', + layoutHash: `migration:${layoutFingerprint}`, + leaves: presentation.leaves, + }); + chain.accept(submissionId, acceptedAt, null); + return chain; +} + +export interface ToUnifiedResult { + namespace: string; + applied: boolean; + messageCount: number; + foldedCount: number; + warnings: MigrationLeafWarning[]; + receiptHeadHash: string; + /** Present when the store already carried a receipt chain and `overwrite` + * was not set: nothing was written. */ + refusedExistingChain?: { headSequence: number | null; leafCount: number }; +} + +/** + * Migrate a store toward kv-unified: synthesize the initial receipt chain + * from the carried resolutions frontier and (with `apply`) persist it to + * `${ns}/kvunified:presentation-receipt`. The agent's own config must then + * flip foldingStrategy to 'kv-unified' — this utility prepares the store, + * it does not touch configs. + * + * Refuses to overwrite an existing chain unless `overwrite` is set: a live + * chain means either the store already runs kv-unified (nothing to migrate) + * or a stale chain survived a previous flip away (inspect it first — + * overwriting is the fix, but it should be a decision, not a default). + */ +export async function migrateToUnified(opts: { + path: string; + namespace?: string; + config?: AutobiographicalOptions; + /** Timestamp recorded as the synthetic receipt's acceptedAt. Required and + * explicit — the migration must be reproducible, so no clock reads. */ + acceptedAt: number; + apply: boolean; + overwrite?: boolean; +}): Promise { + const namespace = opts.namespace ?? 'default'; + const strategy = new AutobiographicalStrategy({ + ...opts.config, + adaptiveResolution: true, + }); + const manager = await ContextManager.open({ path: opts.path, namespace, strategy }); + try { + const store = manager.getStore(); + const receiptSlot = slotIds.kvUnifiedReceipt(namespace); + + const flavor = readFlavor(store, namespace); + if (flavor.hasReceiptChain && !opts.overwrite) { + return { + namespace, + applied: false, + messageCount: 0, + foldedCount: 0, + warnings: [], + receiptHeadHash: '', + refusedExistingChain: { + headSequence: flavor.receiptHeadSequence, + leafCount: flavor.receiptLeafCount ?? 0, + }, + }; + } + + const messageIds = manager.getAllMessages().map((m) => m.id); + const resolutionsRaw = store.getStateJson(slotIds.resolutions(namespace)); + const resolutions = new Map(); + if (resolutionsRaw && typeof resolutionsRaw === 'object') { + for (const [k, v] of Object.entries(resolutionsRaw as Record)) { + if (typeof v === 'number' && v > 0) resolutions.set(k, v); + } + } + const summariesRaw = store.getStateJson(slotIds.summaries(namespace)); + const summaries = Array.isArray(summariesRaw) ? (summariesRaw as SummaryEntry[]) : []; + const chunksRaw = store.getStateJson(slotIds.chunks(namespace)); + const chunkRecords = Array.isArray(chunksRaw) ? (chunksRaw as ChunkRecord[]) : []; + + const presentation = synthesizePresentation(messageIds, resolutions, summaries, chunkRecords); + const chain = buildSyntheticChain(presentation, opts.acceptedAt); + + if (opts.apply) { + try { + store.registerState({ id: receiptSlot, strategy: 'snapshot' }); + } catch { + /* already registered */ + } + store.setStateJson(receiptSlot, chain.serialize()); + } + return { + namespace, + applied: opts.apply, + messageCount: messageIds.length, + foldedCount: presentation.foldedCount, + warnings: presentation.warnings, + receiptHeadHash: chain.head?.receiptHash ?? '', + }; + } finally { + manager.close(); + } +} + +// ============================================================================ +// to-stable — clear the receipt slot so a later flip back can't resurrect it +// ============================================================================ + +export interface ToStableResult { + namespace: string; + applied: boolean; + /** What was (or would be) cleared. Null = the slot was already empty and + * there is nothing to do. */ + cleared: { headSequence: number | null; leafCount: number } | null; +} + +/** + * Migrate a store toward kv-stable (or any non-kv-unified solver): clear the + * receipt chain. The shared resolutions slot already carries the frontier + * kv-stable will seed from, so this is the whole job. The chronicle is + * append-only — the cleared chain remains recoverable from state history. + * + * Deliberately does NOT open a ContextManager: no strategy state is needed, + * so no on-load repairs run. This operation is read-only until `apply`. + */ +export function migrateToStable(opts: { + store: JsStore; + namespace?: string; + apply: boolean; +}): ToStableResult { + const namespace = opts.namespace ?? 'default'; + const flavor = readFlavor(opts.store, namespace); + if (!flavor.hasReceiptChain) { + return { namespace, applied: false, cleared: null }; + } + if (opts.apply) { + opts.store.setStateJson(slotIds.kvUnifiedReceipt(namespace), null); + } + return { + namespace, + applied: opts.apply, + cleared: { + headSequence: flavor.receiptHeadSequence, + leafCount: flavor.receiptLeafCount ?? 0, + }, + }; +} diff --git a/src/migration/migrate-cli.ts b/src/migration/migrate-cli.ts new file mode 100644 index 0000000..6d99d09 --- /dev/null +++ b/src/migration/migrate-cli.ts @@ -0,0 +1,232 @@ +#!/usr/bin/env node +/** + * context-manager-migrate — folding-strategy migration CLI (kv-stable ⇄ + * kv-unified). See folding-migration.ts for the underlying semantics. + * + * context-manager-migrate validate --store [--ns ] [--config ] [--json] + * context-manager-migrate to-unified --store --accepted-at [--ns ] + * [--config ] [--apply] [--overwrite] [--json] + * context-manager-migrate to-stable --store [--ns ] [--apply] [--json] + * + * Everything is a dry run unless --apply is passed. `to-stable` and the + * dry-run paths never open a ContextManager; `validate` and `to-unified` do, + * which runs the strategy's standard on-load canonicalization (the same + * repairs any agent restart performs) — run against a copy first. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { JsStore } from '@animalabs/chronicle'; +import type { AutobiographicalOptions } from '../types/strategy.js'; +import { + discoverAutobioNamespaces, + migrateToStable, + migrateToUnified, + readFlavor, + validateStoreForKvUnified, +} from './folding-migration.js'; + +interface Args { + command: string; + store: string; + ns?: string; + config?: string; + acceptedAt?: number; + apply: boolean; + overwrite: boolean; + json: boolean; +} + +function usage(exitCode: number): never { + console.error( + [ + 'Usage:', + ' context-manager-migrate validate --store [--ns ] [--config ] [--json]', + ' context-manager-migrate to-unified --store --accepted-at [--ns ]', + ' [--config ] [--apply] [--overwrite] [--json]', + ' context-manager-migrate to-stable --store [--ns ] [--apply] [--json]', + '', + 'Dry-run by default; --apply writes. --config is the agent\'s', + 'AutobiographicalOptions as JSON (chunking/window settings should match', + 'the agent that owns the store). --accepted-at is the timestamp stamped', + 'on the synthesized receipt (e.g. `--accepted-at $(date +%s%3N)`) —', + 'explicit so the migration is reproducible.', + '', + 'validate and to-unified open the store through the strategy, which runs', + 'its standard on-load repairs (identical to any agent restart). Run', + 'against a copy of a live store, never the original in place.', + ].join('\n'), + ); + process.exit(exitCode); +} + +function parseArgs(argv: string[]): Args { + const [command, ...rest] = argv; + if (!command || !['validate', 'to-unified', 'to-stable'].includes(command)) usage(2); + const args: Args = { command, store: '', apply: false, overwrite: false, json: false }; + for (let i = 0; i < rest.length; i++) { + const a = rest[i]; + const next = () => { + const v = rest[++i]; + if (v === undefined) usage(2); + return v; + }; + switch (a) { + case '--store': args.store = next(); break; + case '--ns': args.ns = next(); break; + case '--config': args.config = next(); break; + case '--accepted-at': args.acceptedAt = Number(next()); break; + case '--apply': args.apply = true; break; + case '--overwrite': args.overwrite = true; break; + case '--json': args.json = true; break; + case '--help': case '-h': usage(0); break; + default: + console.error(`Unknown argument: ${a}`); + usage(2); + } + } + if (!args.store) usage(2); + return args; +} + +function loadConfig(path: string | undefined): AutobiographicalOptions | undefined { + if (!path) return undefined; + const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`--config ${path}: expected a JSON object of AutobiographicalOptions`); + } + return parsed as AutobiographicalOptions; +} + +/** Resolve the namespace: explicit --ns wins; otherwise discover, and refuse + * to guess when the store carries more than one. */ +function resolveNamespace(storePath: string, explicit: string | undefined): string { + if (explicit) return explicit; + const store = JsStore.open({ path: storePath }); + try { + const found = discoverAutobioNamespaces(store); + if (found.length === 1) return found[0]; + if (found.length === 0) { + console.error( + 'No autobiographical state found in this store (no */autobio:summaries slot). ' + + 'Pass --ns explicitly if the namespace has produced no summaries yet.', + ); + process.exit(1); + } + console.error( + `Store carries ${found.length} autobiographical namespaces — pass --ns to pick one:\n` + + found.map((n) => ` ${n}`).join('\n'), + ); + process.exit(1); + } finally { + store.close(); + } +} + +function emit(json: boolean, data: unknown, human: string): void { + if (json) console.log(JSON.stringify(data, null, 2)); + else console.log(human); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + if (!existsSync(args.store)) { + console.error(`Store path does not exist: ${args.store}`); + process.exit(1); + } + const namespace = resolveNamespace(args.store, args.ns); + const config = loadConfig(args.config); + + switch (args.command) { + case 'validate': { + const result = await validateStoreForKvUnified({ path: args.store, namespace, config }); + const lines = [ + `namespace: ${result.namespace}`, + result.strictIssues.length === 0 + ? 'strict forest: CLEAN — no structural issues' + : `strict forest: ${result.strictIssues.length} issue(s):`, + ...result.strictIssues.map( + (i) => ` [${i.code}] ${i.message}` + + (i.summaryIds.length ? ` (summaries: ${i.summaryIds.slice(0, 5).join(', ')}${i.summaryIds.length > 5 ? ', …' : ''})` : ''), + ), + ...result.outcomes.map( + (o) => `policy ${o.policy}: ${o.ok ? 'OK' : `rejected (${o.issues.length} issue(s))`}`, + ), + result.recommendation + ? `recommendation: migrate with policy "${result.recommendation}"` + : 'recommendation: NONE — no treeification policy canonicalizes this store; repair summary state first', + ]; + emit(args.json, result, lines.join('\n')); + if (!result.recommendation) process.exit(3); + break; + } + case 'to-unified': { + if (args.acceptedAt === undefined || !Number.isFinite(args.acceptedAt)) { + console.error('to-unified requires --accepted-at (explicit for reproducibility).'); + process.exit(2); + } + const result = await migrateToUnified({ + path: args.store, + namespace, + config, + acceptedAt: args.acceptedAt, + apply: args.apply, + overwrite: args.overwrite, + }); + if (result.refusedExistingChain) { + emit( + args.json, + result, + `REFUSED: ${namespace} already carries a receipt chain (head sequence ` + + `${result.refusedExistingChain.headSequence}, ${result.refusedExistingChain.leafCount} leaves). ` + + 'If it is stale (left behind by a previous flip away from kv-unified), re-run with --overwrite.', + ); + process.exit(4); + } + const lines = [ + `namespace: ${result.namespace}`, + `presentation: ${result.messageCount} leaves, ${result.foldedCount} folded`, + ...result.warnings.map( + (w) => + ` warning: ${w.messageId} resolution L${w.requestedLevel} has no summary at that level; ` + + `recorded L${w.usedLevel}`, + ), + `receipt head: ${result.receiptHeadHash}`, + result.applied + ? 'APPLIED — receipt chain written. Flip the agent config to foldingStrategy "kv-unified" to complete the migration.' + : 'DRY RUN — nothing written. Re-run with --apply to write the receipt chain.', + ]; + emit(args.json, result, lines.join('\n')); + break; + } + case 'to-stable': { + const store = JsStore.open({ path: args.store }); + try { + const flavorBefore = readFlavor(store, namespace); + const result = migrateToStable({ store, namespace, apply: args.apply }); + const lines = [ + `namespace: ${result.namespace}`, + result.cleared === null + ? 'receipt slot already empty — nothing to do' + : `receipt chain: head sequence ${result.cleared.headSequence}, ${result.cleared.leafCount} leaves` + + (flavorBefore.hasResolutions + ? ' (resolutions frontier present; kv-stable will seed from it)' + : ' (no resolutions frontier — kv-stable will bootstrap)'), + result.cleared === null + ? '' + : result.applied + ? 'APPLIED — receipt chain cleared (recoverable from chronicle state history). Flip the agent config to complete the migration.' + : 'DRY RUN — nothing written. Re-run with --apply to clear the receipt chain.', + ].filter(Boolean); + emit(args.json, result, lines.join('\n')); + } finally { + store.close(); + } + break; + } + } +} + +main().catch((err) => { + console.error(err instanceof Error ? err.stack ?? err.message : String(err)); + process.exit(1); +}); diff --git a/src/strategies/autobiographical.ts b/src/strategies/autobiographical.ts index 07cfcb6..1d3d953 100644 --- a/src/strategies/autobiographical.ts +++ b/src/strategies/autobiographical.ts @@ -8681,8 +8681,14 @@ export class AutobiographicalStrategy implements ResettableStrategy { // and THIS compile is about to run under the wrong cache policy. The // 2026-08-07 fable investigation found exactly one such compile // ([picker-unrealizable] solver=flat-profile in a kv-stable agent) with - // no way to attribute it; this line is the attribution. - if (this.config.foldingStrategy !== this._constructedFoldingStrategy) { + // no way to attribute it; this line is the attribution. The one + // sanctioned exception is a previewContext override: previews swap the + // config deliberately (and restore it in `finally`), so a mid-preview + // mismatch is the caller's requested what-if, not drift. + if ( + this.config.foldingStrategy !== this._constructedFoldingStrategy && + !this._previewInFlight + ) { console.error( `[picker-config-drift] foldingStrategy=${JSON.stringify(this.config.foldingStrategy)} ` + `differs from constructed=${JSON.stringify(this._constructedFoldingStrategy)} — ` + diff --git a/test/folding-migration.test.ts b/test/folding-migration.test.ts new file mode 100644 index 0000000..6f24aac --- /dev/null +++ b/test/folding-migration.test.ts @@ -0,0 +1,293 @@ +import { afterEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, rmSync } from 'node:fs'; +import { JsStore } from '@animalabs/chronicle'; +import { ContextManager, AutobiographicalStrategy } from '../src/index.js'; +import type { SummaryEntry, AutobiographicalOptions } from '../src/types/strategy.js'; +import type { KvUnifiedReceiptChain } from '../src/adaptive/kv-unified-receipts.js'; +import { + buildSyntheticChain, + migrateToStable, + migrateToUnified, + readFlavor, + slotIds, + synthesizePresentation, + validateStoreForKvUnified, +} from '../src/migration/folding-migration.js'; +import { VALIDATION_KV_UNIFIED_CONFIG } from '../src/migration/folding-migration.js'; + +const STORES: string[] = []; +function storePath(name: string): string { + const p = `./test-folding-migration-${name}`; + STORES.push(p); + return p; +} + +afterEach(() => { + for (const p of STORES.splice(0)) { + if (existsSync(p)) rmSync(p, { recursive: true, force: true }); + } +}); + +function l1(id: string, sourceIds: string[]): SummaryEntry { + return { + id, + level: 1, + content: `summary ${id} covering ${sourceIds.join(',')}`, + tokens: 10, + sourceLevel: 0, + sourceIds, + sourceRange: { first: sourceIds[0], last: sourceIds[sourceIds.length - 1] }, + created: 1, + }; +} + +const KV_STABLE_CONFIG: AutobiographicalOptions = { + adaptiveResolution: true, + foldingStrategy: 'kv-stable', + headWindowTokens: 0, + recentWindowTokens: 100, +}; + +const KV_UNIFIED_CONFIG: AutobiographicalOptions = { + adaptiveResolution: true, + foldingStrategy: 'kv-unified', + headWindowTokens: 0, + recentWindowTokens: 100, + kvUnified: { + ...VALIDATION_KV_UNIFIED_CONFIG, + treeifyNonContiguousSummaries: false, + preserveGapBearingSummaries: false, + }, +}; + +function receipts(strategy: AutobiographicalStrategy): KvUnifiedReceiptChain { + return (strategy as unknown as { kvUnifiedReceipts: KvUnifiedReceiptChain }).kvUnifiedReceipts; +} + +// ============================================================================ +// synthesizePresentation (pure) +// ============================================================================ + +test('synthesizePresentation: raw-only store yields all-raw leaves', () => { + const out = synthesizePresentation(['m1', 'm2'], new Map(), [], []); + assert.equal(out.leaves.size, 2); + assert.equal(out.foldedCount, 0); + assert.equal(out.warnings.length, 0); + assert.deepEqual(out.leaves.get('m1'), { repHash: 'raw:m1', level: 0, lastChangedSeq: 1 }); +}); + +test('synthesizePresentation: folded leaves point at their L1 via the chunk-record ledger', () => { + const out = synthesizePresentation( + ['m1', 'm2', 'm3'], + new Map([['m1', 1], ['m2', 1]]), + [l1('L1-0', ['m1', 'm2'])], + [{ id: 'c-0', sourceIds: ['m1', 'm2'], compressed: true, summaryId: 'L1-0' }], + ); + assert.equal(out.foldedCount, 2); + assert.deepEqual(out.leaves.get('m1'), { repHash: 'summary:L1-0', level: 1, lastChangedSeq: 1 }); + assert.deepEqual(out.leaves.get('m3'), { repHash: 'raw:m3', level: 0, lastChangedSeq: 1 }); +}); + +test('synthesizePresentation: L1 sourceIds are the coverage fallback when the ledger has no pointer', () => { + const out = synthesizePresentation( + ['m1'], + new Map([['m1', 1]]), + [l1('L1-9', ['m1'])], + [], + ); + assert.deepEqual(out.leaves.get('m1'), { repHash: 'summary:L1-9', level: 1, lastChangedSeq: 1 }); +}); + +test('synthesizePresentation: unreachable level degrades to deepest available with a warning', () => { + const out = synthesizePresentation( + ['m1'], + new Map([['m1', 2]]), + [l1('L1-0', ['m1'])], + [], + ); + assert.deepEqual(out.leaves.get('m1'), { repHash: 'summary:L1-0', level: 1, lastChangedSeq: 1 }); + assert.deepEqual(out.warnings, [ + { messageId: 'm1', requestedLevel: 2, usedLevel: 1, reason: 'no-summary-at-level' }, + ]); +}); + +// ============================================================================ +// buildSyntheticChain (pure) +// ============================================================================ + +test('buildSyntheticChain: deterministic for identical inputs, sensitive to acceptedAt', () => { + const presentation = synthesizePresentation(['m1', 'm2'], new Map(), [], []); + const a = buildSyntheticChain(presentation, 1234); + const b = buildSyntheticChain(presentation, 1234); + const c = buildSyntheticChain(presentation, 5678); + assert.ok(a.head); + assert.equal(a.head.sequence, 1); + assert.equal(a.head.receiptHash, b.head?.receiptHash); + assert.notEqual(a.head.receiptHash, c.head?.receiptHash); + assert.equal(a.leaves.size, 2); + assert.equal(a.cache, null); + assert.equal(a.inFlightSubmissionId, null); +}); + +// ============================================================================ +// to-unified / to-stable on a real store (first cross-strategy coverage) +// ============================================================================ + +test('to-unified synthesizes a chain the kv-unified strategy loads and compiles from', async () => { + const path = storePath('to-unified'); + { + const manager = await ContextManager.open({ + path, + strategy: new AutobiographicalStrategy(KV_STABLE_CONFIG), + }); + manager.addMessage('user', [{ type: 'text', text: 'first message' }]); + manager.addMessage('agent', [{ type: 'text', text: 'second message' }]); + manager.addMessage('user', [{ type: 'text', text: 'third message' }]); + await manager.compile({ maxTokens: 10_000, reserveForResponse: 0 }); + manager.close(); + } + + const dry = await migrateToUnified({ path, acceptedAt: 1234, apply: false }); + assert.equal(dry.applied, false); + assert.equal(dry.messageCount, 3); + { + const store = JsStore.open({ path }); + assert.equal(readFlavor(store, 'default').hasReceiptChain, false); + store.close(); + } + + const applied = await migrateToUnified({ path, acceptedAt: 1234, apply: true }); + assert.equal(applied.applied, true); + assert.equal(applied.messageCount, 3); + assert.ok(applied.receiptHeadHash); + // Deterministic: the dry run predicted exactly what apply wrote. + assert.equal(dry.receiptHeadHash, applied.receiptHeadHash); + + const strategy = new AutobiographicalStrategy(KV_UNIFIED_CONFIG); + const manager = await ContextManager.open({ path, strategy }); + const chain = receipts(strategy); + assert.ok(chain.head, 'kv-unified loaded the synthesized chain'); + assert.equal(chain.head.sequence, 1); + assert.equal(chain.head.receiptHash, applied.receiptHeadHash); + assert.equal(chain.leaves.size, 3); + for (const [id, leaf] of chain.leaves) { + assert.equal(leaf.repHash, `raw:${id}`); + assert.equal(leaf.level, 0); + } + // The migrated store must actually compile under kv-unified. + const result = await manager.compile({ maxTokens: 10_000, reserveForResponse: 0 }); + assert.ok(result.messages.length > 0); + manager.close(); +}); + +test('to-unified refuses an existing chain unless overwrite is set', async () => { + const path = storePath('refuse'); + { + const manager = await ContextManager.open({ + path, + strategy: new AutobiographicalStrategy(KV_STABLE_CONFIG), + }); + manager.addMessage('user', [{ type: 'text', text: 'hello' }]); + await manager.compile({ maxTokens: 10_000, reserveForResponse: 0 }); + manager.close(); + } + await migrateToUnified({ path, acceptedAt: 1, apply: true }); + + const refused = await migrateToUnified({ path, acceptedAt: 2, apply: true }); + assert.equal(refused.applied, false); + assert.ok(refused.refusedExistingChain); + assert.equal(refused.refusedExistingChain.headSequence, 1); + + const overwritten = await migrateToUnified({ path, acceptedAt: 2, apply: true, overwrite: true }); + assert.equal(overwritten.applied, true); + assert.equal(overwritten.refusedExistingChain, undefined); +}); + +test('to-stable clears the receipt chain and is idempotent', async () => { + const path = storePath('to-stable'); + { + const manager = await ContextManager.open({ + path, + strategy: new AutobiographicalStrategy(KV_STABLE_CONFIG), + }); + manager.addMessage('user', [{ type: 'text', text: 'hello' }]); + await manager.compile({ maxTokens: 10_000, reserveForResponse: 0 }); + manager.close(); + } + await migrateToUnified({ path, acceptedAt: 1, apply: true }); + + const store = JsStore.open({ path }); + assert.equal(readFlavor(store, 'default').hasReceiptChain, true); + + const dry = migrateToStable({ store, apply: false }); + assert.equal(dry.applied, false); + assert.deepEqual(dry.cleared, { headSequence: 1, leafCount: 1 }); + assert.equal(readFlavor(store, 'default').hasReceiptChain, true, 'dry run wrote nothing'); + + const applied = migrateToStable({ store, apply: true }); + assert.equal(applied.applied, true); + assert.equal(readFlavor(store, 'default').hasReceiptChain, false); + + const again = migrateToStable({ store, apply: true }); + assert.equal(again.cleared, null, 'already-empty slot is a no-op'); + store.close(); +}); + +// ============================================================================ +// validate +// ============================================================================ + +test('validate: clean store canonicalizes strictly', async () => { + const path = storePath('validate-clean'); + { + const manager = await ContextManager.open({ + path, + strategy: new AutobiographicalStrategy(KV_STABLE_CONFIG), + }); + manager.addMessage('user', [{ type: 'text', text: 'hello there' }]); + manager.addMessage('agent', [{ type: 'text', text: 'general kenobi' }]); + await manager.compile({ maxTokens: 10_000, reserveForResponse: 0 }); + manager.close(); + } + const result = await validateStoreForKvUnified({ path }); + assert.equal(result.strictIssues.length, 0); + assert.equal(result.recommendation, 'strict'); +}); + +test('validate: non-contiguous summary fails strict, passes treeify', async () => { + const path = storePath('validate-scarred'); + let ids: string[] = []; + { + const manager = await ContextManager.open({ + path, + strategy: new AutobiographicalStrategy(KV_STABLE_CONFIG), + }); + // Big enough that the first two leave the 100-token recent window — tail + // chunks carry no l1Id, so a summary over tail-resident messages never + // enters the forest's ownership graph at all. + manager.addMessage('user', [{ type: 'text', text: 'one '.repeat(200) }]); + manager.addMessage('agent', [{ type: 'text', text: 'two '.repeat(200) }]); + manager.addMessage('user', [{ type: 'text', text: 'three '.repeat(200) }]); + await manager.compile({ maxTokens: 10_000, reserveForResponse: 0 }); + ids = manager.getAllMessages().map((m) => m.id); + manager.close(); + } + // Scar the store the way kv-stable-era surgery could: an L1 whose ownership + // skips the message between its sources. + { + const store = JsStore.open({ path }); + store.setStateJson(slotIds.summaries('default'), [l1('L1-scar', [ids[0], ids[2]])]); + store.close(); + } + const result = await validateStoreForKvUnified({ path, config: KV_STABLE_CONFIG }); + assert.ok( + result.strictIssues.some((i) => i.code === 'non-contiguous-ownership'), + `strict issues: ${JSON.stringify(result.strictIssues.map((i) => i.code))}`, + ); + const strict = result.outcomes.find((o) => o.policy === 'strict'); + const treeify = result.outcomes.find((o) => o.policy === 'treeify'); + assert.equal(strict?.ok, false); + assert.equal(treeify?.ok, true); + assert.equal(result.recommendation, 'treeify'); +}); From 2e0ac5adc691e790576c70b256061bf66385fe39 Mon Sep 17 00:00:00 2001 From: Tengro Date: Mon, 14 Sep 2026 11:37:17 +0000 Subject: [PATCH 2/2] fix(migration): survive real-store scale in validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field-tested against a production resident store (137MB, 6k messages, 2.5 weeks of life). Two fixes: - A solver capacity limit (SparseLabelCeilingError / ExactEnumerationLimitError) hit AFTER forest construction now counts as a pass with a reported note, not a validate failure: the forest is built at solve start, and the forest is what validate is for. The real deployment tunes its own solver config. - The canned validation solver config is sized for real stores instead of the unit-test toys it started as (the production store blew the 10k label ceiling at 10,471): coarse 2k buckets, 500k ceiling. Field results, for the record: the store's strict forest carries two non-contiguous-ownership scars (validate correctly recommends a tolerance policy — matching the Sep 9 production incident where kv-unified went hard-down on exactly this forest), and to-unified correctly refused the existing receipt chain left behind by the Sep 10 revert to kv-stable — the flip-back landmine to-stable exists to clear. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ej5yriRtwnzjYWNy3YeXeE --- src/migration/folding-migration.ts | 39 ++++++++++++++++++++++++------ src/migration/migrate-cli.ts | 4 ++- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/migration/folding-migration.ts b/src/migration/folding-migration.ts index abfddb7..2555455 100644 --- a/src/migration/folding-migration.ts +++ b/src/migration/folding-migration.ts @@ -41,7 +41,11 @@ import type { KvUnifiedConfig, SummaryEntry, } from '../types/strategy.js'; -import { CanonicalForestError } from '../adaptive/kv-unified.js'; +import { + CanonicalForestError, + ExactEnumerationLimitError, + SparseLabelCeilingError, +} from '../adaptive/kv-unified.js'; import type { CanonicalForestIssue } from '../adaptive/kv-unified.js'; import { SummaryTree } from '../adaptive/summary-tree.js'; import type { PickerChunk, PickerInputs } from '../adaptive/picker.js'; @@ -137,10 +141,14 @@ export const VALIDATION_KV_UNIFIED_CONFIG: Omit< continuityStableHalfLife: 10, continuityStableFloor: 0.25, }, - tokenBucketSize: 100, - continuityBucketSize: 100, - fidelityBucketSize: 100, - labelCeiling: 10_000, + // Sized for real stores, not the unit-test toys these values started as: a + // 2.5-week production store blew a 10k label ceiling at 10,471. Coarse + // buckets + a high ceiling keep the validation solve cheap; and a ceiling + // overrun is tolerated anyway (see the solver-limit catch in validate). + tokenBucketSize: 2_000, + continuityBucketSize: 2_000, + fidelityBucketSize: 2_000, + labelCeiling: 500_000, adoptEpsilon: 0, }; @@ -152,8 +160,14 @@ export type TreeificationPolicy = 'strict' | 'treeify' | 'preserve-gaps'; export interface PolicyOutcome { policy: TreeificationPolicy; + /** The forest canonicalized under this policy — the thing validate is for. */ ok: boolean; issues: CanonicalForestIssue[]; + /** Set when the forest built but the validation solve then hit a solver + * capacity limit (label ceiling / enumeration cap). Irrelevant to the + * migration verdict — the real deployment tunes its own solver config — + * but reported so an operator knows the preview stopped early. */ + solverLimit?: string; } export interface ValidateResult { @@ -208,8 +222,19 @@ export async function validateStoreForKvUnified(opts: { }); outcomes.push({ policy, ok: true, issues: [] }); } catch (err) { - if (!(err instanceof CanonicalForestError)) throw err; - outcomes.push({ policy, ok: false, issues: [...err.issues] }); + if (err instanceof CanonicalForestError) { + outcomes.push({ policy, ok: false, issues: [...err.issues] }); + } else if ( + err instanceof SparseLabelCeilingError || + err instanceof ExactEnumerationLimitError + ) { + // The forest is constructed at solve start; a capacity limit hit + // afterwards proves the structure canonicalized. The deployment's + // own kvUnified config governs the real solve. + outcomes.push({ policy, ok: true, issues: [], solverLimit: err.message }); + } else { + throw err; + } } } const recommendation = outcomes.find((o) => o.ok)?.policy ?? null; diff --git a/src/migration/migrate-cli.ts b/src/migration/migrate-cli.ts index 6d99d09..271cfb4 100644 --- a/src/migration/migrate-cli.ts +++ b/src/migration/migrate-cli.ts @@ -149,7 +149,9 @@ async function main(): Promise { (i.summaryIds.length ? ` (summaries: ${i.summaryIds.slice(0, 5).join(', ')}${i.summaryIds.length > 5 ? ', …' : ''})` : ''), ), ...result.outcomes.map( - (o) => `policy ${o.policy}: ${o.ok ? 'OK' : `rejected (${o.issues.length} issue(s))`}`, + (o) => + `policy ${o.policy}: ${o.ok ? 'OK' : `rejected (${o.issues.length} issue(s))`}` + + (o.solverLimit ? ` [forest built; validation solve stopped early: ${o.solverLimit}]` : ''), ), result.recommendation ? `recommendation: migrate with policy "${result.recommendation}"`