diff --git a/package.json b/package.json index 18fe1ed..c01dc3c 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "test:dreams-compat": "node --import tsx/esm --test tests/dreams-compat.test.ts", "test:dreams-anthropic": "node --import tsx/esm --test tests/dreams-anthropic.test.ts", "test:dreams-bridge": "node --import tsx/esm --test tests/dreams-bridge.test.ts", - "test": "node --import tsx/esm --test --test-concurrency=1 tests/integration.test.ts tests/llm-paths.test.ts tests/http-api.test.ts tests/cache.test.ts tests/embedding-migration.test.ts tests/outcome-revision.test.ts tests/reflection-revision.test.ts tests/selective-forgetting.test.ts tests/multi-device.test.ts tests/dream-runs.test.ts tests/dreams-compat.test.ts tests/dreams-anthropic.test.ts tests/dreams-bridge.test.ts tests/sentiment-tagging.test.ts tests/adaptive-sleep.test.ts tests/epistemic-confidence.test.ts tests/explainable-memory.test.ts tests/causal-graph.test.ts tests/abstractions.test.ts", + "test": "node --import tsx/esm --test --test-concurrency=1 tests/integration.test.ts tests/llm-paths.test.ts tests/http-api.test.ts tests/cache.test.ts tests/embedding-migration.test.ts tests/outcome-revision.test.ts tests/reflection-revision.test.ts tests/selective-forgetting.test.ts tests/multi-device.test.ts tests/dream-runs.test.ts tests/dreams-compat.test.ts tests/dreams-anthropic.test.ts tests/dreams-bridge.test.ts tests/sentiment-tagging.test.ts tests/adaptive-sleep.test.ts tests/epistemic-confidence.test.ts tests/explainable-memory.test.ts tests/causal-graph.test.ts tests/abstractions.test.ts tests/bootstrap.test.ts tests/contested-conflicts.test.ts", "test:multi-device": "node --import tsx/esm --test tests/multi-device.test.ts", "test:sentiment-tagging": "node --import tsx/esm --test tests/sentiment-tagging.test.ts", "test:adaptive-sleep": "node --import tsx/esm --test tests/adaptive-sleep.test.ts", @@ -53,6 +53,8 @@ "test:explainable-memory": "node --import tsx/esm --test tests/explainable-memory.test.ts", "test:causal-graph": "node --import tsx/esm --test tests/causal-graph.test.ts", "test:abstractions": "node --import tsx/esm --test tests/abstractions.test.ts", + "test:bootstrap": "node --import tsx/esm --test tests/bootstrap.test.ts", + "test:contested-conflicts": "node --import tsx/esm --test tests/contested-conflicts.test.ts", "benchmark:longmemeval": "node --import tsx/esm benchmarks/longmemeval/run.ts", "benchmark:download": "node --import tsx/esm benchmarks/longmemeval/download.ts", "benchmark:ingest": "node --import tsx/esm benchmarks/longmemeval/ingest.ts", diff --git a/python/memforge/client.py b/python/memforge/client.py index a7e0db8..b3eb97c 100644 --- a/python/memforge/client.py +++ b/python/memforge/client.py @@ -377,6 +377,35 @@ async def get_abstractions( raw = await self._get(f"/memory/{agent_id}/abstractions", params) return raw if isinstance(raw, list) else [] + # ── Cross-Agent Transfer Learning (v3.12) ───────────────────────────── + + async def bootstrap_agent( + self, + agent_id: str, + source_agent_id: str, + namespace: str | None = None, + max_memories: int | None = None, + max_procedures: int | None = None, + max_principles: int | None = None, + ) -> dict[str, Any]: + """Bootstrap ``agent_id`` (the target) from an experienced source + agent (v3.12): copies established memories, active procedures, and + active principles at half confidence. Idempotent — knowledge the + target already carries is skipped and counts report only rows + actually written. + """ + body: dict[str, Any] = {"source_agent_id": source_agent_id} + if namespace is not None: + body["namespace"] = namespace + if max_memories is not None: + body["max_memories"] = max_memories + if max_procedures is not None: + body["max_procedures"] = max_procedures + if max_principles is not None: + body["max_principles"] = max_principles + raw = await self._post(f"/memory/{agent_id}/bootstrap", body) + return raw if isinstance(raw, dict) else {} + async def resume(self, agent_id: str, limit: int = 5, namespace: str | None = None) -> ResumeContext: """Get session resumption context bundle.""" params: dict[str, Any] = {"limit": limit} diff --git a/python/memforge/resilient.py b/python/memforge/resilient.py index 0c6ad8b..0f480ac 100644 --- a/python/memforge/resilient.py +++ b/python/memforge/resilient.py @@ -180,6 +180,13 @@ async def get_abstractions(self, agent_id: str, level: str | None = None, namesp self._handle(e) return [] + async def bootstrap_agent(self, agent_id: str, source_agent_id: str, **kwargs: Any) -> dict[str, Any] | None: + try: + return await self._client.bootstrap_agent(agent_id, source_agent_id, **kwargs) + except Exception as e: + self._handle(e) + return None + async def resume(self, agent_id: str, limit: int = 5, namespace: str | None = None) -> ResumeContext | None: try: return await self._client.resume(agent_id, limit, namespace) diff --git a/schema/schema.sql b/schema/schema.sql index c96dc6e..52392d2 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -372,7 +372,7 @@ CREATE TABLE IF NOT EXISTS audit_chain ( chain_hash TEXT NOT NULL, -- HMAC-SHA256(previous_hash + content_hash + operation + valid_from) -- Context - triggered_by TEXT NOT NULL DEFAULT 'api', -- 'api', 'sleep_cycle', 'consolidation', 'reflection', 'dedup', 'feedback' + triggered_by TEXT NOT NULL DEFAULT 'api', -- 'api', 'sleep_cycle', 'consolidation', 'reflection', 'dedup', 'feedback', 'bootstrap' model_used TEXT, -- LLM model if this was an AI-driven change created_at TIMESTAMPTZ NOT NULL DEFAULT now() diff --git a/src/app.ts b/src/app.ts index 63442bb..76699ce 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,7 +18,7 @@ import { httpRequestDurationSeconds, } from './metrics.js'; import { bearerAuth, requireScope, getClientId } from './auth.js'; -import { NamespaceSchema, AddMemorySchema, ConsolidateSchema, SleepSchema, ColdTierSearchSchema, ColdTierRestoreSchema, ConfigReloadSchema, CreateDreamRunSchema, ListDreamRunsQuerySchema, AnthropicDreamCreateSchema, AnthropicPushSchema, AnthropicPullSchema, EpistemicFilterSchema, PredictSchema, AbstractionLevelSchema } from './schemas.js'; +import { NamespaceSchema, AddMemorySchema, ConsolidateSchema, SleepSchema, ColdTierSearchSchema, ColdTierRestoreSchema, ConfigReloadSchema, CreateDreamRunSchema, ListDreamRunsQuerySchema, AnthropicDreamCreateSchema, AnthropicPushSchema, AnthropicPullSchema, EpistemicFilterSchema, PredictSchema, AbstractionLevelSchema, BootstrapSchema } from './schemas.js'; import { reloadConfig } from './config.js'; import { cacheGet, @@ -751,6 +751,51 @@ export function createApp(deps: AppDependencies): express.Express { } }); + // ─── Phase 5: Cross-Agent Transfer Learning ────────────────────────────── + + /** + * POST /memory/:agentId/bootstrap + * Body: { source_agent_id, namespace?, max_memories?, max_procedures?, max_principles? } + * + * The path agent is the TARGET; the body names the source. Reads the source + * agent's memory — both agents share this deployment's token scope, so this + * is an intra-trust-domain operation by design. + */ + app.post('/memory/:agentId/bootstrap', requireScope('memforge:write'), async (req: Request, res: Response) => { + const parsed = BootstrapSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + fail(res, 400, issue ? `${issue.path.join('.')}: ${issue.message}` : '"source_agent_id" (string) is required'); + return; + } + + let targetAgentId: string; + try { + targetAgentId = getAgentId(req); + } catch (err) { + fail(res, 400, (err as Error).message); + return; + } + + try { + const result = await manager.bootstrapAgent({ + sourceAgentId: parsed.data.source_agent_id, + targetAgentId, + namespace: parsed.data.namespace, + maxMemories: parsed.data.max_memories, + maxProcedures: parsed.data.max_procedures, + maxPrinciples: parsed.data.max_principles, + }); + ok(res, result); + } catch (err) { + if (err instanceof TypeError) { + fail(res, 400, err.message); + } else { + fail(res, 500, (err as Error).message); + } + } + }); + /** * GET /memory/:agentId/timeline?[from=][&to=][&limit=] */ diff --git a/src/audit.ts b/src/audit.ts index b7a480b..e6c6c69 100644 --- a/src/audit.ts +++ b/src/audit.ts @@ -20,7 +20,7 @@ type QueryExecutor = Pick; // ─── Types ────────────────────────────────────────────────────────────────────── export type AuditOperation = 'create' | 'update' | 'delete' | 'revise' | 'merge' | 'evict' | 'score' | 'feedback'; -export type AuditTrigger = 'api' | 'sleep_cycle' | 'consolidation' | 'reflection' | 'dedup' | 'feedback'; +export type AuditTrigger = 'api' | 'sleep_cycle' | 'consolidation' | 'reflection' | 'dedup' | 'feedback' | 'bootstrap'; export interface AuditEntry { id: bigint; diff --git a/src/client.ts b/src/client.ts index fcc38a5..76303ee 100644 --- a/src/client.ts +++ b/src/client.ts @@ -62,6 +62,7 @@ import type { PredictionResult, Abstraction, AbstractionLevel, + BootstrapResult, } from './types.js'; // ─── Config ────────────────────────────────────────────────────────────────── @@ -439,6 +440,28 @@ export class MemForgeClient { return this.get(`/memory/${enc(agentId)}/abstractions${qs ? '?' + qs : ''}`); } + // ─── Phase 5: Cross-Agent Transfer Learning ────────────────────────────── + + /** Bootstrap the given (target) agent from an experienced source agent + * (v3.12): copies established memories, active procedures, and active + * principles at 0.5× confidence. Idempotent — already-carried knowledge is + * skipped and counts report only rows actually written. */ + async bootstrapAgent(agentId: string, options: { + sourceAgentId: string; + namespace?: string; + maxMemories?: number; + maxProcedures?: number; + maxPrinciples?: number; + }): Promise { + return this.post(`/memory/${enc(agentId)}/bootstrap`, { + source_agent_id: options.sourceAgentId, + namespace: options.namespace, + max_memories: options.maxMemories, + max_procedures: options.maxProcedures, + max_principles: options.maxPrinciples, + }); + } + /** Generate a session resumption context for an agent. */ async resume(agentId: string, limit?: number, namespace?: string): Promise { const params = new URLSearchParams(); @@ -777,6 +800,16 @@ export class ResilientMemForgeClient { return this.safe('getAbstractions', () => this.client.getAbstractions(agentId, level, namespace), []); } + async bootstrapAgent(agentId: string, options: { + sourceAgentId: string; + namespace?: string; + maxMemories?: number; + maxProcedures?: number; + maxPrinciples?: number; + }): Promise { + return this.safe('bootstrapAgent', () => this.client.bootstrapAgent(agentId, options), null); + } + async resume(agentId: string, limit?: number, namespace?: string): Promise { return this.safe('resume', () => this.client.resume(agentId, limit, namespace), null); } diff --git a/src/mcp.ts b/src/mcp.ts index b92ba6e..502a9be 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -640,14 +640,30 @@ const TOOLS: MCPToolDefinition[] = [ required: ['agent_id'], }, }, + { + name: 'memforge_bootstrap', + description: 'Bootstrap a new agent from an experienced one: copies established memories, active procedures, and active principles at half confidence. Memories and procedures are marked _transferred_from (principles carry no marker — no metadata column). Idempotent — knowledge the target already carries is skipped. Both agents must live in this deployment.', + inputSchema: { + type: 'object', + properties: { + source_agent_id: { type: 'string', description: 'Agent to copy knowledge from' }, + target_agent_id: { type: 'string', description: 'Agent to bootstrap' }, + namespace: { type: 'string', description: 'Memory namespace (default: "default")' }, + max_memories: { type: 'integer', description: 'Max memories to transfer (default 100, max 1000)', minimum: 0, maximum: 1000 }, + max_procedures: { type: 'integer', description: 'Max procedures to transfer (default 20, max 100)', minimum: 0, maximum: 100 }, + max_principles: { type: 'integer', description: 'Max principles to transfer (default 10, max 100)', minimum: 0, maximum: 100 }, + }, + required: ['source_agent_id', 'target_agent_id'], + }, + }, ]; // ─── Input Validation ──────────────────────────────────────────────────────── const AGENT_ID_RE = /^[\w.@:=-]+$/; -// Tools that use pool_id as primary key instead of agent_id -const POOL_ONLY_TOOLS = new Set(['memforge_shared_procedures', 'memforge_expertise']); +// Tools that use pool_id or their own agent pair instead of a generic agent_id +const POOL_ONLY_TOOLS = new Set(['memforge_shared_procedures', 'memforge_expertise', 'memforge_bootstrap']); function validateToolArgs(name: string, args: Record): void { // agent_id: required for agent-scoped tools @@ -718,6 +734,19 @@ function validateToolArgs(name: string, args: Record): void { } } + // memforge_bootstrap: validates its own agent pair (POOL_ONLY skips the generic agent_id rule) + if (name === 'memforge_bootstrap') { + for (const key of ['source_agent_id', 'target_agent_id'] as const) { + const value = args[key]; + if (typeof value !== 'string' || value.length < 1 || value.length > 256 || !AGENT_ID_RE.test(value)) { + throw new Error(`${key} must be a string of 1-256 characters matching /^[\\w.@:=-]+$/`); + } + } + if (args['source_agent_id'] === args['target_agent_id']) { + throw new Error('source_agent_id and target_agent_id must be different'); + } + } + // memforge_sleep tokenBudget: max 200000 if (name === 'memforge_sleep' && 'token_budget' in args && args['token_budget'] !== undefined) { const tokenBudget = args['token_budget']; @@ -954,6 +983,15 @@ async function executeTool(client: MemForgeClient, name: string, args: Record { return this.getAbstractions(agentId, 'principle', namespace); } + + // ─── Phase 5: Cross-Agent Transfer Learning ──────────────────────────────── + + /** + * Bootstraps a target agent from an experienced source agent (v3.12): + * copies established warm memories, active procedures, and active + * principles with confidence/importance discounted to 0.5× and a + * `_transferred_from` metadata marker. Transferred memories arrive as + * epistemic_status 'inferred' — the target has not observed them itself. + * + * Idempotent: knowledge the target already carries is skipped (memories by + * exact content — organic rows hash content differently, so equality is the + * only honest check; procedures by condition+action+namespace; principles + * by the abstractions hash constraint), so counts report only rows actually + * written. All three transfers commit atomically. + * + * Trust note: this reads the source agent's memory. Both agents live in the + * same deployment and token scope — cross-deployment transfer is not a + * supported path. + * + * Availability: transferred memories are FTS-searchable immediately; + * semantic search sees them after subsequent sleep cycles backfill their + * embeddings (Phase 5.9). They surface under epistemic filters once + * corroborated retrievals promote them via Phase 5.12 (or immediately with + * epistemic=all). Principles carry no _transferred_from marker — the + * abstractions table has no metadata column. + */ + async bootstrapAgent(options: BootstrapOptions): Promise { + this.assertAgentId(options.sourceAgentId); + this.assertAgentId(options.targetAgentId); + if (options.sourceAgentId === options.targetAgentId) { + throw new TypeError('source and target agent must be different'); + } + + const ns = resolveNamespace(options.namespace); + const maxMemories = options.maxMemories ?? 100; + const maxProcedures = options.maxProcedures ?? 20; + const maxPrinciples = options.maxPrinciples ?? 10; + + let memoriesTransferred = 0; + let proceduresTransferred = 0; + let principlesTransferred = 0; + + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + + if (this.config.autoRegisterAgents) { + await client.query( + `INSERT INTO agents (id) VALUES ($1) ON CONFLICT DO NOTHING`, + [options.targetAgentId], + ); + } + + if (maxMemories > 0) { + // Dedup is namespace-scoped: a copy the target holds only in another + // namespace would never surface in this namespace's queries, so it + // must not suppress the transfer. + const { rows: createdMemories } = await client.query<{ id: bigint; content: string }>( + `INSERT INTO warm_tier (agent_id, content, content_hash, metadata, importance, confidence, namespace, epistemic_status) + SELECT $1, w.content, md5(w.content), + w.metadata || jsonb_build_object('_transferred_from', $2::text), + w.importance * 0.5, w.confidence * 0.5, w.namespace, 'inferred' + FROM warm_tier w + WHERE w.agent_id = $2 AND w.namespace = $3 AND w.epistemic_status = 'established' + AND NOT EXISTS ( + SELECT 1 FROM warm_tier t + WHERE t.agent_id = $1 AND t.namespace = w.namespace AND t.content = w.content + ) + ORDER BY w.importance DESC + LIMIT $4 + RETURNING id, content`, + [options.targetAgentId, options.sourceAgentId, ns, maxMemories], + ); + memoriesTransferred = createdMemories.length; + + // Warm-tier creates carry hash-chained audit records everywhere else + // (consolidation, cold restore) — a cross-agent bulk write is the last + // path that should be exempt. The chain hash replaces the md5 + // placeholder in content_hash, matching the organic convention. + if (this.audit) { + for (const row of createdMemories) { + const cHash = await this.audit.record( + options.targetAgentId, 'warm_tier', row.id, 'create', + null, row.content, + { transferred_from: options.sourceAgentId }, + 'bootstrap', null, client, + ); + await client.query(`UPDATE warm_tier SET content_hash = $2 WHERE id = $1`, [row.id, cHash]); + } + } + } + + if (maxProcedures > 0) { + // procedures has no unique constraint, so dedup is the NOT EXISTS — + // an ON CONFLICT here would be a silent no-op. + const { rowCount } = await client.query( + `INSERT INTO procedures (agent_id, condition, action, confidence, namespace, metadata) + SELECT $1, p.condition, p.action, p.confidence * 0.5, p.namespace, + p.metadata || jsonb_build_object('_transferred_from', $2::text) + FROM procedures p + WHERE p.agent_id = $2 AND p.namespace = $3 AND p.active = true + AND NOT EXISTS ( + SELECT 1 FROM procedures t + WHERE t.agent_id = $1 AND t.condition = p.condition + AND t.action = p.action AND t.namespace = p.namespace + ) + ORDER BY p.confidence DESC + LIMIT $4`, + [options.targetAgentId, options.sourceAgentId, ns, maxProcedures], + ); + proceduresTransferred = rowCount ?? 0; + } + + if (maxPrinciples > 0) { + // source_reflection_ids stays empty: the source's reflection ids are + // meaningless (and FK-invalid provenance) in the target's memory. + // Dedup runs BEFORE the LIMIT so already-carried principles free + // their cap slot for the next candidate (like the two statements + // above); ON CONFLICT remains only as a concurrent-writer guard. + const { rowCount } = await client.query( + `INSERT INTO abstractions (agent_id, level, content, source_reflection_ids, confidence, namespace) + SELECT $1, a.level, a.content, '{}', a.confidence * 0.5, a.namespace + FROM abstractions a + WHERE a.agent_id = $2 AND a.namespace = $3 AND a.active = true AND a.level = 'principle' + AND NOT EXISTS ( + SELECT 1 FROM abstractions t + WHERE t.agent_id = $1 AND t.level = a.level AND t.namespace = a.namespace + AND t.content_hash = md5(a.content) + ) + ORDER BY a.confidence DESC + LIMIT $4 + ON CONFLICT (agent_id, level, namespace, content_hash) DO NOTHING`, + [options.targetAgentId, options.sourceAgentId, ns, maxPrinciples], + ); + principlesTransferred = rowCount ?? 0; + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + log.info({ + sourceAgentId: options.sourceAgentId, + targetAgentId: options.targetAgentId, + memoriesTransferred, + proceduresTransferred, + principlesTransferred, + }, 'agent bootstrap complete'); + + return { + memories_transferred: memoriesTransferred, + procedures_transferred: proceduresTransferred, + principles_transferred: principlesTransferred, + source_agent_id: options.sourceAgentId, + target_agent_id: options.targetAgentId, + }; + } } diff --git a/src/openapi.ts b/src/openapi.ts index 454536f..df1617e 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1035,6 +1035,62 @@ export function buildOpenApiSpec(port: number): Record { }, }, }, + '/memory/{agentId}/bootstrap': { + post: { + summary: 'Bootstrap this agent from an experienced source agent (transfer learning)', + description: 'Copies established memories, active procedures, and active principles from the source agent at half confidence; memories and procedures are marked _transferred_from (principles carry no marker). Memories arrive as epistemic_status "inferred": FTS-searchable immediately, semantically searchable after sleep cycles backfill embeddings, and visible under epistemic filters once corroboration promotes them (or with epistemic=all). Idempotent: knowledge the target already carries is skipped and counts report only rows actually written. Reads the source agent\'s memory — both agents share this deployment\'s token scope.', + tags: ['Memory'], + security: [{ bearerAuth: [] }], + parameters: [ + { name: 'agentId', in: 'path', required: true, schema: { type: 'string' }, description: 'Target agent to bootstrap' }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['source_agent_id'], + properties: { + source_agent_id: { type: 'string', minLength: 1, maxLength: 256, pattern: '^[\\w.@:=-]+$', description: 'Agent to copy knowledge from (must differ from the target)' }, + namespace: { type: 'string', pattern: '^[a-z0-9][a-z0-9_-]*$', description: 'Memory namespace (default: "default")' }, + max_memories: { type: 'integer', minimum: 0, maximum: 1000, default: 100 }, + max_procedures: { type: 'integer', minimum: 0, maximum: 100, default: 20 }, + max_principles: { type: 'integer', minimum: 0, maximum: 100, default: 10 }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Transfer counts — rows actually written this call.', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean', example: true }, + data: { + type: 'object', + properties: { + memories_transferred: { type: 'integer' }, + procedures_transferred: { type: 'integer' }, + principles_transferred: { type: 'integer' }, + source_agent_id: { type: 'string' }, + target_agent_id: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + '400': { '$ref': '#/components/responses/BadRequest' }, + '500': { '$ref': '#/components/responses/InternalError' }, + }, + }, + }, '/pool/{poolId}/procedures/publish/{agentId}': { post: { summary: 'Publish agent procedures to a shared pool', diff --git a/src/schemas.ts b/src/schemas.ts index 28d54eb..1cbae79 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -232,6 +232,14 @@ export const PredictSchema = z.object({ namespace: NamespaceSchema.optional(), }); +export const BootstrapSchema = z.object({ + source_agent_id: z.string().min(1).max(256).regex(/^[\w.@:=-]+$/, 'source_agent_id must match /^[\\w.@:=-]+$/'), + namespace: NamespaceSchema.optional(), + max_memories: z.number().int().min(0).max(1000).optional(), + max_procedures: z.number().int().min(0).max(100).optional(), + max_principles: z.number().int().min(0).max(100).optional(), +}); + // ─── Phase 5: Hierarchical Abstraction ────────────────────────────────────── /** Abstraction hierarchy level filter for abstraction reads (v3.11). */ diff --git a/src/sleep-cycle.ts b/src/sleep-cycle.ts index 4cc040b..803c369 100644 --- a/src/sleep-cycle.ts +++ b/src/sleep-cycle.ts @@ -1041,27 +1041,61 @@ ${wrapUserContent('related_memories', relatedList || 'None')}`; } } - const winnerId = scoreA >= scoreB ? a.id : b.id; - const strategy = scoreA >= scoreB - ? `multi_factor(A=${scoreA},B=${scoreB})` - : `multi_factor(A=${scoreA},B=${scoreB})`; + // Epistemic honesty on close calls: a gap of at most one point — one + // factor's worth of noise on this small integer scale — must not pick a + // winner. Mark both memories 'contested' instead. The threshold is an + // ABSOLUTE delta, not a ratio: ratios invert on quantized scores (a + // pure-noise 1-vs-0 reads as 100% while a well-evidenced 6-vs-5 reads + // as 17%) and are distorted by the mutual +100 supersession offset. + // Superseded rows never contest — supersession is an absolute signal + // and marking stale facts 'contested' would resurface them as live + // disputes. Contested is not terminal: Phase 5.12 promotes contested + // rows that later earn the corroboration bar, and a decisive + // re-resolution below clears the winner. + const scoreDelta = Math.abs(scoreA - scoreB); + const aSuperseded = Boolean((a.metadata as Record)?.['_superseded']); + const bSuperseded = Boolean((b.metadata as Record)?.['_superseded']); + + if (scoreDelta <= 1 && !aSuperseded && !bSuperseded) { + await this.pool.query( + `UPDATE memory_conflicts SET resolved = true, resolution_strategy = $2, resolved_at = now() + WHERE id = $1`, + [conflict.id, `contested(A=${scoreA},B=${scoreB},delta=${scoreDelta})`], + ); + await this.pool.query( + `UPDATE warm_tier SET epistemic_status = 'contested' + WHERE id = ANY($1) AND agent_id = $2`, + [[a.id, b.id], agentId], + ); + } else { + const winnerId = scoreA >= scoreB ? a.id : b.id; + const strategy = `multi_factor(A=${scoreA},B=${scoreB})`; + const loserId = winnerId === a.id ? b.id : a.id; - const loserId = winnerId === a.id ? b.id : a.id; + // Mark conflict resolved + await this.pool.query( + `UPDATE memory_conflicts SET resolved = true, winner_id = $2, resolution_strategy = $3, resolved_at = now() + WHERE id = $1`, + [conflict.id, winnerId, strategy], + ); - // Mark conflict resolved - await this.pool.query( - `UPDATE memory_conflicts SET resolved = true, winner_id = $2, resolution_strategy = $3, resolved_at = now() - WHERE id = $1`, - [conflict.id, winnerId, strategy], - ); + // Reduce loser's confidence (agent-scoped for defense-in-depth) + await this.pool.query( + `UPDATE warm_tier SET confidence = LEAST(confidence, 0.2), + metadata = metadata || '{"_conflict_loser": true}'::jsonb + WHERE id = $1 AND agent_id = $2`, + [loserId, agentId], + ); - // Reduce loser's confidence (agent-scoped for defense-in-depth) - await this.pool.query( - `UPDATE warm_tier SET confidence = LEAST(confidence, 0.2), - metadata = metadata || '{"_conflict_loser": true}'::jsonb - WHERE id = $1 AND agent_id = $2`, - [loserId, agentId], - ); + // A decisive win over a previously-contested dispute clears the + // winner's contested badge (back to provisional — winning one + // adjudication is not the corroboration bar for 'established'). + await this.pool.query( + `UPDATE warm_tier SET epistemic_status = 'provisional' + WHERE id = $1 AND agent_id = $2 AND epistemic_status = 'contested'`, + [winnerId, agentId], + ); + } resolved++; } @@ -1717,12 +1751,17 @@ Extract the cross-cutting principles.`; // separately; the caller can see the net change via getEpistemicProfile). private async phaseEpistemicPromotion(agentId: string): Promise { - // Promote provisional → established when sufficient evidence exists + // Promote provisional → established when sufficient evidence exists. + // 'inferred' rows (sleep-cycle derivations and bootstrapped transfers, + // v3.12) and 'contested' rows (close-call conflict resolutions) earn + // promotion by the same evidence bar — without an exit path either + // status is terminally second-class: excluded by the default epistemic + // filters with no way out regardless of corroboration. const { rowCount: promoted } = await this.pool.query( `UPDATE warm_tier SET epistemic_status = 'established', last_corroborated_at = now() WHERE agent_id = $1 - AND epistemic_status = 'provisional' + AND epistemic_status IN ('provisional', 'inferred', 'contested') AND evidence_count >= 3 AND id IN ( SELECT rl.warm_tier_id diff --git a/src/tool-definitions.ts b/src/tool-definitions.ts index bff23e7..5232f58 100644 --- a/src/tool-definitions.ts +++ b/src/tool-definitions.ts @@ -563,6 +563,22 @@ export const tools: ToolDefinition[] = [ required: ['agent_id'], }, }, + { + name: 'memforge_bootstrap', + description: 'Bootstrap a new agent from an experienced one: copies established memories, active procedures, and active principles at half confidence. Memories and procedures are marked _transferred_from (principles carry no marker — no metadata column). Idempotent — knowledge the target already carries is skipped. Both agents must live in this deployment.', + input_schema: { + type: 'object', + properties: { + source_agent_id: { type: 'string', description: 'Agent to copy knowledge from' }, + target_agent_id: { type: 'string', description: 'Agent to bootstrap' }, + namespace: { type: 'string', description: 'Memory namespace; defaults to "default"' }, + max_memories: { type: 'integer', description: 'Max memories to transfer (default 100)', minimum: 0, maximum: 1000 }, + max_procedures: { type: 'integer', description: 'Max procedures to transfer (default 20)', minimum: 0, maximum: 100 }, + max_principles: { type: 'integer', description: 'Max principles to transfer (default 10)', minimum: 0, maximum: 100 }, + }, + required: ['source_agent_id', 'target_agent_id'], + }, + }, ]; /** Convert MemForge tool definitions to OpenAI function calling format. */ diff --git a/src/types.ts b/src/types.ts index 837920f..172e611 100644 --- a/src/types.ts +++ b/src/types.ts @@ -147,6 +147,34 @@ export interface Abstraction { created_at: Date; } +// ─── Phase 5: Cross-Agent Transfer Learning ────────────────────────────────── + +/** Options for bootstrapAgent() (v3.12). Limits are enforced by the request + * schemas at the REST/MCP boundary (0-1000 memories, 0-100 procedures and + * principles). */ +export interface BootstrapOptions { + sourceAgentId: string; + targetAgentId: string; + namespace?: string; + maxMemories?: number; + maxProcedures?: number; + maxPrinciples?: number; +} + +/** + * Result of bootstrapAgent() (v3.12). Counts reflect rows actually written — + * knowledge the target already carries (by exact content / condition+action / + * principle hash) is skipped, so re-running a bootstrap is idempotent and + * reports zeros. + */ +export interface BootstrapResult { + memories_transferred: number; + procedures_transferred: number; + principles_transferred: number; + source_agent_id: string; + target_agent_id: string; +} + // ─── Hot tier ──────────────────────────────────────────────────────────────── export interface HotRow { diff --git a/tests/bootstrap.test.ts b/tests/bootstrap.test.ts new file mode 100644 index 0000000..8cf67fd --- /dev/null +++ b/tests/bootstrap.test.ts @@ -0,0 +1,588 @@ +// MemForge — Cross-Agent Transfer Learning tests (Phase 5 Feature 7, v3.12) +// +// Three layers: +// Integration — bootstrapAgent() against real DB (transfer semantics, +// discounts, dedup/idempotency, scoping) +// E2E — POST /memory/:id/bootstrap via HTTP +// +// No migration layer — bootstrap writes into existing tables. +// +// Run: node --import tsx/esm --test tests/bootstrap.test.ts +// Requires: DATABASE_URL (with migration-v3.11.sql applied) + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { Pool } from 'pg'; + +const { MemoryManager } = await import('../src/memory-manager.js'); +const { SleepCycleEngine } = await import('../src/sleep-cycle.js'); +const { NoOpEmbeddingProvider } = await import('../src/embedding.js'); +const { closePool } = await import('../src/db.js'); +const { createApp } = await import('../src/app.js'); +const { createDefaultRegistry } = await import('../src/classifier.js'); + +// ─── Config ────────────────────────────────────────────────────────────────── + +const DATABASE_URL = process.env['DATABASE_URL']; +if (!DATABASE_URL) { + console.error('[test] DATABASE_URL is required — set it to a test database'); + process.exit(1); +} + +const SOURCE = 'test-agent-bootstrap-source'; +const TARGET = 'test-agent-bootstrap-target'; +const pool = new Pool({ connectionString: DATABASE_URL }); + +const manager = new MemoryManager({ + databaseUrl: DATABASE_URL, + consolidationBatchSize: 500, + consolidationThreshold: 1, + autoRegisterAgents: true, + consolidationMode: 'concat', + temporalDecayRate: 0, + embeddingProvider: new NoOpEmbeddingProvider(), + llmProvider: null, + sleepCycle: { + tokenBudget: 100_000, + evictionThreshold: 0.05, + revisionThreshold: 0.4, + includeReflection: false, + weights: { recency: 0.25, frequency: 0.20, centrality: 0.20, reflection: 0.15, stability: 0.20 }, + }, +}); + +// ─── Cleanup / seed helpers ────────────────────────────────────────────────── + +async function cleanupAgent(agentId: string): Promise { + await pool.query(`DELETE FROM abstractions WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM procedures WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM retrieval_log WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM knowledge_gaps WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM reflections WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM causal_edges WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM memory_sequences WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM warm_tier WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM hot_tier WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM sleep_phase_analytics WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM agents WHERE id = $1`, [agentId]); +} + +async function cleanupBoth(): Promise { + await cleanupAgent(SOURCE); + await cleanupAgent(TARGET); +} + +async function ensureAgent(agentId: string): Promise { + await pool.query(`INSERT INTO agents (id) VALUES ($1) ON CONFLICT DO NOTHING`, [agentId]); +} + +async function seedSourceMemory( + content: string, + hash: string, + opts: { epistemic?: string; importance?: number; confidence?: number; namespace?: string } = {}, +): Promise { + await pool.query( + `INSERT INTO warm_tier (agent_id, content, content_hash, epistemic_status, importance, confidence, namespace) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [SOURCE, content, hash, opts.epistemic ?? 'established', opts.importance ?? 0.8, opts.confidence ?? 0.8, opts.namespace ?? 'default'], + ); +} + +async function seedSourceProcedure(condition: string, action: string, opts: { active?: boolean; confidence?: number } = {}): Promise { + await pool.query( + `INSERT INTO procedures (agent_id, condition, action, confidence, active) + VALUES ($1, $2, $3, $4, $5)`, + [SOURCE, condition, action, opts.confidence ?? 0.8, opts.active ?? true], + ); +} + +async function seedSourcePrinciple(content: string, opts: { active?: boolean; confidence?: number } = {}): Promise { + await pool.query( + `INSERT INTO abstractions (agent_id, level, content, confidence, active) + VALUES ($1, 'principle', $2, $3, $4)`, + [SOURCE, content, opts.confidence ?? 0.8, opts.active ?? true], + ); +} + +// ─── Integration tests — bootstrapAgent() ──────────────────────────────────── + +describe('bootstrapAgent — memory transfer', () => { + // One bootstrap runs in before(); the tests assert its observable outcome + // independently. Only the idempotency test performs a second action. + let firstRun: Awaited>; + + before(async () => { + await cleanupBoth(); + await ensureAgent(SOURCE); + await seedSourceMemory('Established fact worth inheriting', 'bs-mem-1', { importance: 0.9, confidence: 0.8 }); + await seedSourceMemory('Provisional guess not worth inheriting', 'bs-mem-2', { epistemic: 'provisional' }); + await seedSourceMemory('Contested claim not worth inheriting', 'bs-mem-3', { epistemic: 'contested' }); + await seedSourceMemory('Other-namespace fact', 'bs-mem-4', { namespace: 'sidechannel' }); + + firstRun = await manager.bootstrapAgent({ sourceAgentId: SOURCE, targetAgentId: TARGET }); + }); + after(cleanupBoth); + + it('copies established memories at half importance/confidence as inferred, marked with provenance', async () => { + const result = firstRun; + + assert.equal(result.memories_transferred, 1, 'only the default-namespace established memory qualifies'); + const { rows } = await pool.query<{ + content: string; importance: number; confidence: number; + epistemic_status: string; metadata: Record; + }>( + `SELECT content, importance, confidence, epistemic_status, metadata FROM warm_tier WHERE agent_id = $1`, + [TARGET], + ); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.content, 'Established fact worth inheriting'); + assert.ok(Math.abs((rows[0]?.importance ?? 0) - 0.45) < 1e-6, 'importance 0.9 × 0.5'); + assert.ok(Math.abs((rows[0]?.confidence ?? 0) - 0.4) < 1e-6, 'confidence 0.8 × 0.5'); + assert.equal(rows[0]?.epistemic_status, 'inferred', 'the target has not observed this itself'); + assert.equal(rows[0]?.metadata['_transferred_from'], SOURCE); + }); + + it('a second bootstrap transfers nothing (idempotent by content hash)', async () => { + const result = await manager.bootstrapAgent({ sourceAgentId: SOURCE, targetAgentId: TARGET }); + + assert.equal(result.memories_transferred, 0); + const { rows } = await pool.query<{ count: string }>( + `SELECT count(*) FROM warm_tier WHERE agent_id = $1`, [TARGET], + ); + assert.equal(rows[0]?.count, '1', 'no duplicate rows on re-run'); + }); + + it('auto-registers the target agent', async () => { + const { rows } = await pool.query(`SELECT 1 FROM agents WHERE id = $1`, [TARGET]); + + assert.equal(rows.length, 1, 'bootstrap must create the target agents row'); + }); +}); + +describe('bootstrapAgent — organic-content dedup', () => { + before(async () => { + await cleanupBoth(); + await ensureAgent(SOURCE); + await ensureAgent(TARGET); + await seedSourceMemory('Insight the target already learned on its own', 'bs-organic-src'); + // The target owns the same text organically — its content_hash comes from + // a different scheme (audit chain / default ''), never md5(content). + await pool.query( + `INSERT INTO warm_tier (agent_id, content, content_hash, epistemic_status) + VALUES ($1, 'Insight the target already learned on its own', 'audit-chain-hash-xyz', 'established')`, + [TARGET], + ); + }); + after(cleanupBoth); + + it('skips memories whose exact content the target already owns, regardless of hash scheme', async () => { + const result = await manager.bootstrapAgent({ sourceAgentId: SOURCE, targetAgentId: TARGET }); + + assert.equal(result.memories_transferred, 0); + const { rows } = await pool.query<{ count: string }>( + `SELECT count(*) FROM warm_tier WHERE agent_id = $1`, [TARGET], + ); + assert.equal(rows[0]?.count, '1', 'the organic row must remain the only copy'); + }); +}); + +describe('bootstrapAgent — caps and ordering', () => { + before(async () => { + await cleanupBoth(); + await ensureAgent(SOURCE); + await seedSourceMemory('Most important source fact', 'bs-cap-1', { importance: 0.9 }); + await seedSourceMemory('Middling source fact', 'bs-cap-2', { importance: 0.6 }); + await seedSourceMemory('Least important source fact', 'bs-cap-3', { importance: 0.3 }); + }); + after(cleanupBoth); + + it('respects max_memories and takes the highest-importance rows first', async () => { + const result = await manager.bootstrapAgent({ sourceAgentId: SOURCE, targetAgentId: TARGET, maxMemories: 2 }); + + assert.equal(result.memories_transferred, 2); + const { rows } = await pool.query<{ content: string }>( + `SELECT content FROM warm_tier WHERE agent_id = $1 ORDER BY importance DESC`, [TARGET], + ); + assert.deepEqual( + rows.map((r) => r.content), + ['Most important source fact', 'Middling source fact'], + ); + }); + + it('a zero cap skips the category entirely', async () => { + await pool.query(`DELETE FROM warm_tier WHERE agent_id = $1`, [TARGET]); + + const result = await manager.bootstrapAgent({ sourceAgentId: SOURCE, targetAgentId: TARGET, maxMemories: 0 }); + + assert.equal(result.memories_transferred, 0); + }); +}); + +describe('bootstrapAgent — procedures and principles', () => { + let firstRun: Awaited>; + + before(async () => { + await cleanupBoth(); + await ensureAgent(SOURCE); + await seedSourceProcedure('deploy fails with timeout', 'roll back and page on-call', { confidence: 0.8 }); + await seedSourceProcedure('retired condition', 'retired action', { active: false }); + await seedSourcePrinciple('Verify configuration in staging first', { confidence: 0.9 }); + await seedSourcePrinciple('Retired principle', { active: false }); + + firstRun = await manager.bootstrapAgent({ sourceAgentId: SOURCE, targetAgentId: TARGET }); + }); + after(cleanupBoth); + + it('copies active procedures at half confidence and skips inactive ones', async () => { + const result = firstRun; + + assert.equal(result.procedures_transferred, 1); + const { rows } = await pool.query<{ condition: string; confidence: number; metadata: Record }>( + `SELECT condition, confidence, metadata FROM procedures WHERE agent_id = $1`, [TARGET], + ); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.condition, 'deploy fails with timeout'); + assert.ok(Math.abs((rows[0]?.confidence ?? 0) - 0.4) < 1e-6, 'confidence 0.8 × 0.5'); + assert.equal(rows[0]?.metadata['_transferred_from'], SOURCE); + }); + + it('copies active principles at half confidence with cleared reflection provenance', async () => { + const { rows } = await pool.query<{ content: string; confidence: number; source_reflection_ids: string[] }>( + `SELECT content, confidence, source_reflection_ids FROM abstractions WHERE agent_id = $1`, [TARGET], + ); + + assert.equal(rows.length, 1, 'only the active principle transfers'); + assert.equal(rows[0]?.content, 'Verify configuration in staging first'); + assert.ok(Math.abs((rows[0]?.confidence ?? 0) - 0.45) < 1e-6, 'confidence 0.9 × 0.5'); + assert.deepEqual(rows[0]?.source_reflection_ids, [], "the source's reflection ids are meaningless in the target"); + }); + + it('re-running transfers no duplicate procedures or principles', async () => { + const result = await manager.bootstrapAgent({ sourceAgentId: SOURCE, targetAgentId: TARGET }); + + assert.equal(result.procedures_transferred, 0); + assert.equal(result.principles_transferred, 0); + }); +}); + +describe('bootstrapAgent — per-category caps', () => { + before(async () => { + await cleanupBoth(); + await ensureAgent(SOURCE); + await ensureAgent(TARGET); + await seedSourceProcedure('condition one', 'action one', { confidence: 0.9 }); + await seedSourceProcedure('condition two', 'action two', { confidence: 0.7 }); + await seedSourceProcedure('condition three', 'action three', { confidence: 0.5 }); + await seedSourcePrinciple('Top principle by confidence', { confidence: 0.9 }); + await seedSourcePrinciple('Middle principle by confidence', { confidence: 0.7 }); + await seedSourcePrinciple('Bottom principle by confidence', { confidence: 0.5 }); + // The target already carries the top principle — its cap slot must go to + // the next candidate, not be consumed by the skip. + await pool.query( + `INSERT INTO abstractions (agent_id, level, content, confidence) + VALUES ($1, 'principle', 'Top principle by confidence', 0.45)`, + [TARGET], + ); + }); + after(cleanupBoth); + + it('respects max_procedures and takes the highest-confidence rules first', async () => { + const result = await manager.bootstrapAgent({ + sourceAgentId: SOURCE, targetAgentId: TARGET, + maxMemories: 0, maxProcedures: 2, maxPrinciples: 0, + }); + + assert.equal(result.procedures_transferred, 2); + const { rows } = await pool.query<{ condition: string }>( + `SELECT condition FROM procedures WHERE agent_id = $1 ORDER BY confidence DESC`, [TARGET], + ); + assert.deepEqual(rows.map((r) => r.condition), ['condition one', 'condition two']); + }); + + it('already-carried principles do not consume max_principles slots', async () => { + const result = await manager.bootstrapAgent({ + sourceAgentId: SOURCE, targetAgentId: TARGET, + maxMemories: 0, maxProcedures: 0, maxPrinciples: 1, + }); + + assert.equal(result.principles_transferred, 1, 'the single slot must go to a transferable principle'); + const { rows } = await pool.query<{ count: string }>( + `SELECT count(*) FROM abstractions WHERE agent_id = $1 AND content = 'Middle principle by confidence'`, + [TARGET], + ); + assert.equal(rows[0]?.count, '1', 'the carried top principle is skipped, not slot-consumed'); + }); +}); + +describe('bootstrapAgent — namespace scoping', () => { + before(async () => { + await cleanupBoth(); + await ensureAgent(SOURCE); + await ensureAgent(TARGET); + await seedSourceMemory('Sidechannel operational fact', 'bs-ns-1', { namespace: 'sidechannel' }); + await pool.query( + `INSERT INTO procedures (agent_id, condition, action, confidence, namespace) + VALUES ($1, 'sidechannel condition', 'sidechannel action', 0.8, 'sidechannel')`, + [SOURCE], + ); + await seedSourceMemory('Default-namespace fact', 'bs-ns-2'); + }); + after(cleanupBoth); + + it('a namespaced bootstrap transfers only that namespace, into that namespace', async () => { + const result = await manager.bootstrapAgent({ sourceAgentId: SOURCE, targetAgentId: TARGET, namespace: 'sidechannel' }); + + assert.equal(result.memories_transferred, 1); + assert.equal(result.procedures_transferred, 1); + const { rows } = await pool.query<{ namespace: string; content: string }>( + `SELECT namespace, content FROM warm_tier WHERE agent_id = $1`, [TARGET], + ); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.namespace, 'sidechannel'); + assert.equal(rows[0]?.content, 'Sidechannel operational fact'); + }); + + it('content held only in another target namespace does not suppress the transfer', async () => { + // The target owns the default-namespace text — in 'default'. A bootstrap + // scoped to 'sidechannel' where the source ALSO holds that text must + // still transfer it there: queries in 'sidechannel' cannot see 'default'. + await pool.query( + `INSERT INTO warm_tier (agent_id, content, content_hash, epistemic_status) + VALUES ($1, 'Default-namespace fact', 'bs-ns-organic', 'established')`, + [TARGET], + ); + await seedSourceMemory('Default-namespace fact', 'bs-ns-3', { namespace: 'sidechannel' }); + + const result = await manager.bootstrapAgent({ sourceAgentId: SOURCE, targetAgentId: TARGET, namespace: 'sidechannel' }); + + assert.equal(result.memories_transferred, 1, 'the sidechannel copy must transfer despite the default-namespace twin'); + const { rows } = await pool.query<{ count: string }>( + `SELECT count(*) FROM warm_tier WHERE agent_id = $1 AND namespace = 'sidechannel' AND content = 'Default-namespace fact'`, + [TARGET], + ); + assert.equal(rows[0]?.count, '1'); + }); +}); + +describe('bootstrapAgent — transaction atomicity', () => { + const FAIL_TRIGGER = 'bootstrap_test_fail_abstractions'; + + before(async () => { + await cleanupBoth(); + await ensureAgent(SOURCE); + await ensureAgent(TARGET); + await seedSourceMemory('Memory that must roll back', 'bs-atomic-1'); + await seedSourceProcedure('atomic condition', 'atomic action'); + await seedSourcePrinciple('Principle whose insert is sabotaged'); + // Sabotage the third statement so the first two must roll back. + await pool.query(` + CREATE OR REPLACE FUNCTION bootstrap_test_fail() RETURNS trigger AS $$ + BEGIN + IF NEW.agent_id = '${TARGET}' THEN + RAISE EXCEPTION 'bootstrap-test sabotage'; + END IF; + RETURN NEW; + END $$ LANGUAGE plpgsql`); + await pool.query(`CREATE TRIGGER ${FAIL_TRIGGER} BEFORE INSERT ON abstractions FOR EACH ROW EXECUTE FUNCTION bootstrap_test_fail()`); + }); + after(async () => { + await pool.query(`DROP TRIGGER IF EXISTS ${FAIL_TRIGGER} ON abstractions`); + await pool.query(`DROP FUNCTION IF EXISTS bootstrap_test_fail()`); + await cleanupBoth(); + }); + + it('a failure in the principles statement rolls back the memories and procedures', async () => { + await assert.rejects( + manager.bootstrapAgent({ sourceAgentId: SOURCE, targetAgentId: TARGET }), + /bootstrap-test sabotage/, + ); + + const { rows: warm } = await pool.query<{ count: string }>( + `SELECT count(*) FROM warm_tier WHERE agent_id = $1`, [TARGET], + ); + const { rows: procs } = await pool.query<{ count: string }>( + `SELECT count(*) FROM procedures WHERE agent_id = $1`, [TARGET], + ); + assert.equal(warm[0]?.count, '0', 'memories must roll back'); + assert.equal(procs[0]?.count, '0', 'procedures must roll back'); + }); +}); + +describe('bootstrapAgent — epistemic promotion path for inferred rows', () => { + const PROMO_TARGET = `${TARGET}-promo`; + const engine = new SleepCycleEngine( + pool, + { chat: async () => '', summarize: async () => ({ summary: '', keyFacts: [], entities: [], relationships: [], sentiment: 'neutral' as const }) } as never, + new NoOpEmbeddingProvider(), + { + tokenBudget: 100_000, + evictionThreshold: 0.05, + revisionThreshold: 0.4, + includeReflection: false, + weights: { recency: 0.25, frequency: 0.20, centrality: 0.20, reflection: 0.15, stability: 0.20 }, + }, + null, + ); + + before(async () => { + await cleanupAgent(PROMO_TARGET); + await ensureAgent(PROMO_TARGET); + }); + after(() => cleanupAgent(PROMO_TARGET)); + + it('a corroborated inferred (bootstrapped) memory is promoted to established by Phase 5.12', async () => { + const { rows } = await pool.query<{ id: string }>( + `INSERT INTO warm_tier (agent_id, content, content_hash, epistemic_status, evidence_count, importance) + VALUES ($1, 'Transferred then independently corroborated', 'bs-promo-1', 'inferred', 3, 0.8) + RETURNING id`, + [PROMO_TARGET], + ); + await pool.query( + `INSERT INTO retrieval_log (agent_id, warm_tier_id, query_text, query_mode, rank_position, namespace, outcome) + VALUES + ($1, $2, 'probe', 'keyword', 1, 'default', 'positive'), + ($1, $2, 'probe', 'keyword', 1, 'workspace', 'positive')`, + [PROMO_TARGET, rows[0]!.id], + ); + + await engine.run(PROMO_TARGET); + + const { rows: after } = await pool.query<{ epistemic_status: string }>( + `SELECT epistemic_status FROM warm_tier WHERE id = $1`, [rows[0]!.id], + ); + assert.equal(after[0]?.epistemic_status, 'established', 'inferred rows earn promotion by the same evidence bar'); + }); +}); + +describe('bootstrapAgent — input validation', () => { + it('rejects a bootstrap from an agent onto itself', async () => { + await assert.rejects( + manager.bootstrapAgent({ sourceAgentId: SOURCE, targetAgentId: SOURCE }), + (err: Error) => err instanceof TypeError && err.message.includes('different'), + ); + }); +}); + +// ─── E2E tests — HTTP via real server ──────────────────────────────────────── + +describe('Transfer Learning — E2E (HTTP)', () => { + let server: Server; + let baseUrl: string; + const E2E_SOURCE = `${SOURCE}-e2e`; + const E2E_TARGET = `${TARGET}-e2e`; + + before(async () => { + await cleanupAgent(E2E_SOURCE); + await cleanupAgent(E2E_TARGET); + await ensureAgent(E2E_SOURCE); + await pool.query( + `INSERT INTO warm_tier (agent_id, content, content_hash, epistemic_status, importance) + VALUES ($1, 'E2E established source memory', 'bs-e2e-1', 'established', 0.9)`, + [E2E_SOURCE], + ); + + const app = createApp({ + manager, + auditChain: null, + classifierRegistry: createDefaultRegistry(), + rateLimitMax: 0, + }); + server = app.listen(0); + const addr = server.address() as AddressInfo; + baseUrl = `http://localhost:${addr.port}`; + }); + + after(async () => { + server.close(); + await cleanupAgent(E2E_SOURCE); + await cleanupAgent(E2E_TARGET); + }); + + it('POST /memory/:id/bootstrap transfers and reports counts', async () => { + const res = await fetch(`${baseUrl}/memory/${E2E_TARGET}/bootstrap`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_agent_id: E2E_SOURCE }), + }); + + assert.equal(res.status, 200); + const body = await res.json() as { ok: boolean; data: { memories_transferred: number; source_agent_id: string; target_agent_id: string } }; + assert.equal(body.ok, true); + assert.equal(body.data.memories_transferred, 1); + assert.equal(body.data.source_agent_id, E2E_SOURCE); + assert.equal(body.data.target_agent_id, E2E_TARGET); + }); + + it('POST /memory/:id/bootstrap without source_agent_id returns 400', async () => { + const res = await fetch(`${baseUrl}/memory/${E2E_TARGET}/bootstrap`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + assert.equal(res.status, 400); + const body = await res.json() as { ok: boolean; error: string }; + assert.equal(body.ok, false); + assert.ok(body.error.includes('source_agent_id'), `error must mention source_agent_id: ${body.error}`); + }); + + it('POST /memory/:id/bootstrap with source == target returns 400', async () => { + const res = await fetch(`${baseUrl}/memory/${E2E_TARGET}/bootstrap`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_agent_id: E2E_TARGET }), + }); + + assert.equal(res.status, 400); + const body = await res.json() as { ok: boolean; error: string }; + assert.equal(body.ok, false); + assert.ok(body.error.includes('different'), `error must explain the constraint: ${body.error}`); + }); + + it('POST /memory/:id/bootstrap with max_memories over the schema cap returns 400', async () => { + const res = await fetch(`${baseUrl}/memory/${E2E_TARGET}/bootstrap`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_agent_id: E2E_SOURCE, max_memories: 2000 }), + }); + + assert.equal(res.status, 400); + const body = await res.json() as { ok: boolean; error: string }; + assert.equal(body.ok, false); + assert.ok(body.error.includes('max_memories'), `error must mention max_memories: ${body.error}`); + }); + + it('POST /memory/:id/bootstrap with a malformed source agent id returns 400', async () => { + const res = await fetch(`${baseUrl}/memory/${E2E_TARGET}/bootstrap`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_agent_id: 'bad agent id' }), + }); + + assert.equal(res.status, 400); + const body = await res.json() as { ok: boolean }; + assert.equal(body.ok, false); + }); + + it('POST /memory/:id/bootstrap with an invalid target agent id returns 400', async () => { + const res = await fetch(`${baseUrl}/memory/bad%20agent/bootstrap`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_agent_id: E2E_SOURCE }), + }); + + assert.equal(res.status, 400); + const body = await res.json() as { ok: boolean; error: string }; + assert.equal(body.ok, false); + assert.ok(body.error.includes('agentId'), `error must mention agentId: ${body.error}`); + }); +}); + +// ─── Teardown ──────────────────────────────────────────────────────────────── + +after(async () => { + await pool.end(); + await closePool(); +}); diff --git a/tests/contested-conflicts.test.ts b/tests/contested-conflicts.test.ts new file mode 100644 index 0000000..518c224 --- /dev/null +++ b/tests/contested-conflicts.test.ts @@ -0,0 +1,307 @@ +// MemForge — Contested conflict resolution tests (Feature 1 remnant, v3.12) +// +// Phase 2.5 conflict resolution: when the multi-factor score gap between two +// conflicting live (non-superseded) memories is at most one point — one +// factor's worth of noise — both are marked epistemic_status='contested' +// instead of a noise-driven winner being picked. Decisive gaps keep the +// winner/loser behavior, and a decisive win clears a prior contested badge. +// +// Run: node --import tsx/esm --test tests/contested-conflicts.test.ts +// Requires: DATABASE_URL + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { Pool } from 'pg'; + +const { SleepCycleEngine } = await import('../src/sleep-cycle.js'); +const { NoOpEmbeddingProvider } = await import('../src/embedding.js'); +const { closePool } = await import('../src/db.js'); + +// ─── Config ────────────────────────────────────────────────────────────────── + +const DATABASE_URL = process.env['DATABASE_URL']; +if (!DATABASE_URL) { + console.error('[test] DATABASE_URL is required — set it to a test database'); + process.exit(1); +} + +const TEST_AGENT = 'test-agent-contested-conflicts'; +const pool = new Pool({ connectionString: DATABASE_URL }); + +const engine = new SleepCycleEngine( + pool, + { chat: async () => '', summarize: async () => ({ summary: '', keyFacts: [], entities: [], relationships: [], sentiment: 'neutral' as const }) } as never, + new NoOpEmbeddingProvider(), + { + tokenBudget: 100_000, + evictionThreshold: 0.05, + revisionThreshold: 0.4, + includeReflection: false, + weights: { recency: 0.25, frequency: 0.20, centrality: 0.20, reflection: 0.15, stability: 0.20 }, + }, + null, +); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +async function cleanupAgent(agentId: string): Promise { + await pool.query(`DELETE FROM memory_conflicts WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM retrieval_log WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM causal_edges WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM memory_sequences WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM warm_tier WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM sleep_phase_analytics WHERE agent_id = $1`, [agentId]); + await pool.query(`DELETE FROM agents WHERE id = $1`, [agentId]); +} + +async function ensureAgent(agentId: string): Promise { + await pool.query(`INSERT INTO agents (id) VALUES ($1) ON CONFLICT DO NOTHING`, [agentId]); +} + +/** + * Seed a warm row with controlled scoring inputs. Callers MUST pass the same + * `consolidatedAt` to both sides of a conflict — separate INSERTs see + * different now() values, and even a microsecond difference hands one side + * the +3 recency factor. The timestamp is relative to the test run (no + * wall-clock cliffs). With recency neutralized and zero retrieval successes, + * the only live factor is confidence (Math.round(2 × confidence) points): + * 0.5 → 1 point, 0.2 → 0, 0.9 → 2. + */ +async function seedConflictMemory( + agentId: string, + content: string, + hash: string, + consolidatedAt: Date, + opts: { superseded?: boolean; confidence?: number } = {}, +): Promise { + const metadata = opts.superseded ? { _superseded: true } : {}; + const { rows } = await pool.query<{ id: string }>( + `INSERT INTO warm_tier (agent_id, content, content_hash, consolidated_at, retrieval_success_count, confidence, metadata) + VALUES ($1, $2, $3, $6, 0, $5, $4) + RETURNING id`, + [agentId, content, hash, JSON.stringify(metadata), opts.confidence ?? 0.5, consolidatedAt], + ); + return rows[0]!.id; +} + +/** One shared timestamp per conflict pair, an hour in the past. */ +function pairTimestamp(): Date { + return new Date(Date.now() - 3600_000); +} + +async function seedConflict(agentId: string, idA: string, idB: string): Promise { + const { rows } = await pool.query<{ id: string }>( + `INSERT INTO memory_conflicts (agent_id, warm_tier_id_a, warm_tier_id_b) + VALUES ($1, $2, $3) RETURNING id`, + [agentId, idA, idB], + ); + return rows[0]!.id; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('conflict resolution — contested close calls', () => { + const AGENT = `${TEST_AGENT}-close`; + let idA: string, idB: string, conflictId: string; + + before(async () => { + await cleanupAgent(AGENT); + await ensureAgent(AGENT); + // Identical scoring inputs on both sides → score delta 0 → contested. + const ts = pairTimestamp(); + idA = await seedConflictMemory(AGENT, 'The deploy window is Tuesday', 'cc-close-a', ts); + idB = await seedConflictMemory(AGENT, 'The deploy window is Thursday', 'cc-close-b', ts); + conflictId = await seedConflict(AGENT, idA, idB); + + await engine.run(AGENT); + }); + after(() => cleanupAgent(AGENT)); + + it('marks both memories contested instead of picking a winner', async () => { + const { rows } = await pool.query<{ id: string; epistemic_status: string }>( + `SELECT id, epistemic_status FROM warm_tier WHERE agent_id = $1 ORDER BY id`, + [AGENT], + ); + + assert.equal(rows.length, 2); + assert.ok(rows.every((r) => r.epistemic_status === 'contested'), 'both sides of a close call must be contested'); + }); + + it('resolves the conflict with a contested strategy and no winner', async () => { + const { rows } = await pool.query<{ resolved: boolean; winner_id: string | null; resolution_strategy: string }>( + `SELECT resolved, winner_id, resolution_strategy FROM memory_conflicts WHERE id = $1`, + [conflictId], + ); + + assert.equal(rows[0]?.resolved, true); + assert.equal(rows[0]?.winner_id, null, 'a contested resolution names no winner'); + assert.ok(rows[0]?.resolution_strategy?.startsWith('contested('), `strategy must record the contested call: ${rows[0]?.resolution_strategy}`); + }); + + it('does not apply the loser confidence penalty to either side', async () => { + const { rows } = await pool.query<{ confidence: number; metadata: Record }>( + `SELECT confidence, metadata FROM warm_tier WHERE agent_id = $1`, + [AGENT], + ); + + for (const r of rows) { + assert.equal(r.confidence, 0.5, 'confidence must be untouched on contested calls'); + assert.ok(!('_conflict_loser' in r.metadata), 'no side of a contested call is a loser'); + } + }); +}); + +describe('conflict resolution — the delta boundary', () => { + it('a one-point gap is contested (one factor of noise must not decide)', async () => { + const AGENT = `${TEST_AGENT}-delta1`; + try { + await cleanupAgent(AGENT); + await ensureAgent(AGENT); + // confidence 0.5 → 1 point vs 0.2 → 0 points: delta exactly 1 + const ts = pairTimestamp(); + const idA = await seedConflictMemory(AGENT, 'Config lives in etcd', 'cc-d1-a', ts, { confidence: 0.5 }); + const idB = await seedConflictMemory(AGENT, 'Config lives in consul', 'cc-d1-b', ts, { confidence: 0.2 }); + const conflictId = await seedConflict(AGENT, idA, idB); + + await engine.run(AGENT); + + const { rows } = await pool.query<{ resolution_strategy: string }>( + `SELECT resolution_strategy FROM memory_conflicts WHERE id = $1`, [conflictId], + ); + assert.ok(rows[0]?.resolution_strategy?.startsWith('contested('), `delta 1 must contest: ${rows[0]?.resolution_strategy}`); + } finally { + await cleanupAgent(AGENT); + } + }); + + it('a two-point gap is decisive', async () => { + const AGENT = `${TEST_AGENT}-delta2`; + try { + await cleanupAgent(AGENT); + await ensureAgent(AGENT); + // confidence 0.9 → 2 points vs 0.2 → 0 points: delta exactly 2 + const ts = pairTimestamp(); + const idWinner = await seedConflictMemory(AGENT, 'The port is 5432', 'cc-d2-a', ts, { confidence: 0.9 }); + const idLoser = await seedConflictMemory(AGENT, 'The port is 5433', 'cc-d2-b', ts, { confidence: 0.2 }); + const conflictId = await seedConflict(AGENT, idWinner, idLoser); + + await engine.run(AGENT); + + const { rows } = await pool.query<{ winner_id: string; resolution_strategy: string }>( + `SELECT winner_id, resolution_strategy FROM memory_conflicts WHERE id = $1`, [conflictId], + ); + assert.equal(rows[0]?.winner_id, idWinner); + assert.ok(rows[0]?.resolution_strategy?.startsWith('multi_factor('), `delta 2 must decide: ${rows[0]?.resolution_strategy}`); + } finally { + await cleanupAgent(AGENT); + } + }); +}); + +describe('conflict resolution — decisive gaps keep winner/loser semantics', () => { + const AGENT = `${TEST_AGENT}-decisive`; + let idWinner: string, idLoser: string, conflictId: string; + + before(async () => { + await cleanupAgent(AGENT); + await ensureAgent(AGENT); + // Supersession contributes +100 to the other side → decisive gap. + const ts = pairTimestamp(); + idWinner = await seedConflictMemory(AGENT, 'The current deploy window is Friday', 'cc-dec-w', ts); + idLoser = await seedConflictMemory(AGENT, 'The old deploy window was Monday', 'cc-dec-l', ts, { superseded: true }); + conflictId = await seedConflict(AGENT, idWinner, idLoser); + + await engine.run(AGENT); + }); + after(() => cleanupAgent(AGENT)); + + it('picks the decisively stronger memory as winner', async () => { + const { rows } = await pool.query<{ winner_id: string; resolution_strategy: string }>( + `SELECT winner_id, resolution_strategy FROM memory_conflicts WHERE id = $1`, + [conflictId], + ); + + assert.equal(rows[0]?.winner_id, idWinner); + assert.ok(rows[0]?.resolution_strategy?.startsWith('multi_factor('), `decisive calls keep the multi_factor strategy: ${rows[0]?.resolution_strategy}`); + }); + + it('penalizes the loser and leaves the winner untouched', async () => { + const { rows } = await pool.query<{ id: string; confidence: number; epistemic_status: string; metadata: Record }>( + `SELECT id, confidence, epistemic_status, metadata FROM warm_tier WHERE agent_id = $1`, + [AGENT], + ); + + const winner = rows.find((r) => r.id === idWinner); + const loser = rows.find((r) => r.id === idLoser); + assert.ok(winner && loser); + assert.notEqual(winner.epistemic_status, 'contested', 'decisive winner must not be contested'); + assert.equal(winner.confidence, 0.5, "winner's confidence must be untouched"); + assert.ok(loser.confidence <= 0.2, 'loser confidence must be capped at 0.2'); + assert.equal(loser.metadata['_conflict_loser'], true); + }); +}); + +describe('conflict resolution — supersession and contested recovery', () => { + it('a both-superseded pair resolves decisively, never contested', async () => { + const AGENT = `${TEST_AGENT}-bothsup`; + try { + await cleanupAgent(AGENT); + await ensureAgent(AGENT); + // Identical real factors + mutual +100 offset: the old ratio rule + // would have contested two stale facts; the gate must not. + const ts = pairTimestamp(); + const idA = await seedConflictMemory(AGENT, 'API limit was 100/min', 'cc-sup-a', ts, { superseded: true }); + const idB = await seedConflictMemory(AGENT, 'API limit was 500/min', 'cc-sup-b', ts, { superseded: true }); + const conflictId = await seedConflict(AGENT, idA, idB); + + await engine.run(AGENT); + + const { rows } = await pool.query<{ winner_id: string | null; resolution_strategy: string }>( + `SELECT winner_id, resolution_strategy FROM memory_conflicts WHERE id = $1`, [conflictId], + ); + assert.ok(rows[0]?.winner_id, 'a winner must be named'); + assert.ok(rows[0]?.resolution_strategy?.startsWith('multi_factor('), 'stale facts must not be revived as contested'); + const { rows: statuses } = await pool.query<{ epistemic_status: string }>( + `SELECT epistemic_status FROM warm_tier WHERE agent_id = $1`, [AGENT], + ); + assert.ok(statuses.every((r) => r.epistemic_status !== 'contested')); + } finally { + await cleanupAgent(AGENT); + } + }); + + it('a decisive re-resolution clears the contested badge on the winner only', async () => { + const AGENT = `${TEST_AGENT}-recover`; + try { + await cleanupAgent(AGENT); + await ensureAgent(AGENT); + const ts = pairTimestamp(); + const idWinner = await seedConflictMemory(AGENT, 'Retention is 90 days', 'cc-rec-w', ts, { confidence: 0.9 }); + const idLoser = await seedConflictMemory(AGENT, 'Retention is 30 days', 'cc-rec-l', ts, { confidence: 0.2 }); + // Both carry a contested badge from an earlier close call. + await pool.query( + `UPDATE warm_tier SET epistemic_status = 'contested' WHERE agent_id = $1`, [AGENT], + ); + await seedConflict(AGENT, idWinner, idLoser); + + await engine.run(AGENT); + + const { rows } = await pool.query<{ id: string; epistemic_status: string }>( + `SELECT id, epistemic_status FROM warm_tier WHERE agent_id = $1`, [AGENT], + ); + const winner = rows.find((r) => r.id === idWinner); + const loser = rows.find((r) => r.id === idLoser); + assert.equal(winner?.epistemic_status, 'provisional', 'the decisive winner sheds the contested badge (to provisional, not established)'); + assert.equal(loser?.epistemic_status, 'contested', 'the loser keeps its contested badge'); + } finally { + await cleanupAgent(AGENT); + } + }); +}); + +// ─── Teardown ──────────────────────────────────────────────────────────────── + +after(async () => { + await pool.end(); + await closePool(); +}); diff --git a/tests/epistemic-confidence.test.ts b/tests/epistemic-confidence.test.ts index 792cc88..c1b9f29 100644 --- a/tests/epistemic-confidence.test.ts +++ b/tests/epistemic-confidence.test.ts @@ -412,17 +412,18 @@ describe('Phase 5.12 — epistemic promotion', () => { assert.equal(after[0]?.epistemic_status, 'established', 'established row must remain established'); }); - it('does not touch contested rows during promotion pass', async () => { + it('promotes corroborated contested rows — contested is not terminal (v3.12)', async () => { const { rows } = await pool.query<{ id: bigint }>( `INSERT INTO warm_tier (agent_id, content, content_hash, epistemic_status, evidence_count, importance) - VALUES ($1, 'Contested memory that should stay contested', 'ep-promo-5', 'contested', 5, 0.8) + VALUES ($1, 'Contested memory that earns its way out', 'ep-promo-5', 'contested', 5, 0.8) RETURNING id`, [PROMO_AGENT], ); const warmId = rows[0]?.id; assert.ok(warmId); - // Multi-namespace positive retrievals (would promote provisional but NOT contested) + // Multi-namespace positive retrievals — the same evidence bar that + // promotes provisional and inferred rows clears the contested badge. await pool.query( `INSERT INTO retrieval_log (agent_id, warm_tier_id, query_text, query_mode, rank_position, namespace, outcome) VALUES @@ -437,7 +438,26 @@ describe('Phase 5.12 — epistemic promotion', () => { `SELECT epistemic_status FROM warm_tier WHERE id = $1`, [warmId], ); - assert.equal(after[0]?.epistemic_status, 'contested', 'contested row must remain contested'); + assert.equal(after[0]?.epistemic_status, 'established', 'corroborated contested rows must be promoted'); + }); + + it('does not promote contested rows lacking the evidence bar', async () => { + const { rows } = await pool.query<{ id: bigint }>( + `INSERT INTO warm_tier (agent_id, content, content_hash, epistemic_status, evidence_count, importance) + VALUES ($1, 'Contested memory without corroboration', 'ep-promo-7', 'contested', 1, 0.8) + RETURNING id`, + [PROMO_AGENT], + ); + const warmId = rows[0]?.id; + assert.ok(warmId); + + await engine.run(PROMO_AGENT); + + const { rows: after } = await pool.query<{ epistemic_status: string }>( + `SELECT epistemic_status FROM warm_tier WHERE id = $1`, + [warmId], + ); + assert.equal(after[0]?.epistemic_status, 'contested', 'uncorroborated contested rows stay contested'); }); it('sets last_corroborated_at when a row is promoted', async () => {