From 5f61313d120606c1e5f981929a5fc37a8ab2fcd1 Mon Sep 17 00:00:00 2001 From: idapixl Date: Sun, 25 Oct 2026 03:19:41 -0700 Subject: [PATCH] fix(dream): refine runs the rewrite guard; cluster joins at 0.75; abstractions get real names (1.11.0) An attended dream on 1.10.0 passed the signature gate while refine did two damaging rewrites (#85): a paper summary that joined an unrelated memory at 0.712 was appended to it and the memory lost its subject ("Virgil's memoir-agent idea"), and another memory lost "(v0.8.1)". Only hindsight ran checkRewrite, and the number check missed version strings. Refine now runs the guard with a growth limit. The guard also refuses dropped named entities and a lost opening subject, and compares entities by whole word, case-insensitively. Cluster's join floor rises to 0.75 (the legitimate join in that run scored 0.797), configurable per namespace. An abstraction with no label line is named by deriveName instead of its first clause cut short. Measured on a copy of the same store with this build: the paper became its own memory, the version-dropping rewrite was declined, 0 damaged rows, 0 belief rows to audit, abstraction names are concept labels. Refs #85 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++ package-lock.json | 4 +- package.json | 2 +- src/core/config.ts | 8 + src/engines/auto-consolidate.ts | 1 + src/engines/cognition-abstract.test.ts | 3 + src/engines/cognition-refine-guard.test.ts | 172 +++++++++++++++++++++ src/engines/cognition.ts | 64 ++++++-- src/engines/rewrite-guard.test.ts | 64 ++++++++ src/engines/rewrite-guard.ts | 75 ++++++++- src/tools/dream.ts | 1 + 11 files changed, 394 insertions(+), 15 deletions(-) create mode 100644 src/engines/cognition-refine-guard.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 751848a..11a2873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## [Unreleased] +## [1.11.0] — 2026-10-25 + +### Fixed + +- **Refine runs the rewrite guard (#85).** Only hindsight called `checkRewrite`, so refine could drop what a definition committed to or append an observation verbatim, and the signature gate still called the run clean. In one live run, a paper summary was appended to an unrelated memory and its opening "Virgil's memoir-agent idea:" was dropped; another memory lost its closing "(v0.8.1)". Refine now declines both and keeps the old definition. Declined rewrites are counted in `phases.refine.declined`. +- **The guard catches more:** + - **Version numbers:** the number check now sees them ("v0.8.1" → "0.8.1"; before, `\b` found no boundary after the `v`). + - **Dropped named entities:** a capitalised word from mid-sentence in the old definition that is gone from the new one. + - **Lost opening subject:** the subject the old definition opened with ("Virgil's …") is gone. + - **Growth:** with `limitGrowth` (refine sets it), a definition that grows past max(1.4×, +300 chars) is declined. + - **Case:** entity checks ignore case and match whole words, so "Cortex" opening a sentence is not new next to "the cortex API". +- **Cluster joins an existing memory only at similarity ≥ 0.75**, up from 0.70. A wrong join hands refine unrelated text; a missed join only mints a separate memory. In the run above, the bad join scored 0.712 and the legitimate one 0.797. Set it per namespace with `cluster_threshold`, which `dream` and auto-consolidation now pass through. +- **Unlabelled abstractions get a real name.** With no `Pattern:` line in the model output, the name used to be the definition's first clause cut short ("The deeper pattern is that systems—whether…"). Abstract now asks `deriveName` for a concept label, the way create names memories. `parseAbstraction` returns `labelled`. +- `phases.hindsight.declined` is now reported by `dreamConsolidate`. It was counted but dropped from the result. + ## [1.10.0] — 2026-10-25 ### Fixed diff --git a/package-lock.json b/package-lock.json index ee0fa39..a771b27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fozikio/cortex-engine", - "version": "1.10.0", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fozikio/cortex-engine", - "version": "1.10.0", + "version": "1.11.0", "license": "MIT", "dependencies": { "@fozikio/reflex": "^0.3.0", diff --git a/package.json b/package.json index e013b67..17aa997 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fozikio/cortex-engine", - "version": "1.10.0", + "version": "1.11.0", "description": "Portable cognitive engine for AI agents — storage, embeddings, memory, FSRS, and MCP server", "type": "module", "main": "dist/index.js", diff --git a/src/core/config.ts b/src/core/config.ts index f8d80db..56d3473 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -27,6 +27,14 @@ export interface NamespaceConfig { * the paraphrase pairs and the genuinely distinct ones. */ abstraction_dedupe_threshold?: number; + /** + * Similarity an observation needs to join an existing memory in dream's + * cluster phase and become evidence for refining it (default: 0.75). A + * wrong join lets refine fold unrelated text into a memory; a missed join + * only mints a separate memory. Measure the nearest-memory scores of a + * run's observations and put the line above the joins that do not belong. + */ + cluster_threshold?: number; /** Whether other namespaces can read this namespace's memories (default: false). */ queryable?: boolean; } diff --git a/src/engines/auto-consolidate.ts b/src/engines/auto-consolidate.ts index 3c25980..db383ed 100644 --- a/src/engines/auto-consolidate.ts +++ b/src/engines/auto-consolidate.ts @@ -161,6 +161,7 @@ export class SessionConsolidator { observation_limit: 50, similarity_merge: nsConfig.similarity_merge, similarity_link: nsConfig.similarity_link, + cluster_threshold: nsConfig.cluster_threshold, }).then(() => {}).catch((err: unknown) => { // Auto-consolidation is best-effort — never crash the serving process. if (process.env['CORTEX_DEBUG']) { diff --git a/src/engines/cognition-abstract.test.ts b/src/engines/cognition-abstract.test.ts index 52f9309..9766d90 100644 --- a/src/engines/cognition-abstract.test.ts +++ b/src/engines/cognition-abstract.test.ts @@ -25,6 +25,7 @@ describe('parseAbstraction', () => { expect(parsed).toEqual({ name: 'Silent Success as a Structural Failure Mode', definition: 'This abstraction connects the silent wander failure and the markdown folder. It matters because invisible failure becomes systemic.', + labelled: true, }); }); @@ -49,6 +50,8 @@ describe('parseAbstraction', () => { const parsed = parseAbstraction(text); expect(parsed?.name).toBe('Silent failure is structural, not incidental'); expect(parsed?.definition).toBe(text); + // Unlabelled: abstractCrossDomain asks deriveName for a real label (#85). + expect(parsed?.labelled).toBe(false); }); it('returns null for a title with no body', () => { diff --git a/src/engines/cognition-refine-guard.test.ts b/src/engines/cognition-refine-guard.test.ts new file mode 100644 index 0000000..60eff78 --- /dev/null +++ b/src/engines/cognition-refine-guard.test.ts @@ -0,0 +1,172 @@ +/** + * #85: refine runs the rewrite guard, cluster joins only above 0.75, and an + * unlabelled abstraction gets a real name. + * + * The refine fixture is the shape one live run produced: an observation that + * shared vocabulary with a memory (a paper summary vs. a memoir idea) joined + * it, and refine dropped the memory's subject and appended the observation. + * The signature gate called that clean; the guard has to refuse it. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { dreamPhaseA, abstractCrossDomain } from './cognition.js'; +import { newFSRSState } from './fsrs.js'; +import { SqliteCortexStore } from '../stores/sqlite.js'; +import type { EmbedProvider } from '../core/embed.js'; +import type { LLMProvider } from '../core/llm.js'; +import type { Memory, MemoryCategory } from '../core/types.js'; + +const MEMO = [1, 0, 0]; +const OTHER_A = [0, 0, 1]; +const OTHER_B = [0, 0.6, 0.8]; + +const MEMOIR_DEF = + "Virgil's memoir-agent idea: feed someone's draft memoir into a cortex agent and write from the lived experience. A product direction nobody else could do."; + +function memory(overrides: Partial>): Omit { + const now = new Date(); + return { + name: "Virgil's memoir-agent idea", + definition: MEMOIR_DEF, + category: 'belief', + salience: 0.7, + confidence: 0.7, + access_count: 0, + created_at: now, + updated_at: now, + last_accessed: now, + source_files: [], + embedding: MEMO, + tags: [], + fsrs: newFSRSState(), + ...overrides, + }; +} + +/** A memory with two outgoing edges, so cluster treats its schema as established. */ +async function seedDenseMemoir(store: SqliteCortexStore): Promise { + const id = await store.putMemory(memory({})); + const a = await store.putMemory(memory({ name: 'A', definition: 'Alpha is first.', embedding: OTHER_A })); + const b = await store.putMemory(memory({ name: 'B', definition: 'Beta is second.', embedding: OTHER_B })); + for (const target of [a, b]) { + await store.putEdge({ source_id: id, target_id: target, relation: 'supports', weight: 0.5, evidence: 'x', created_at: new Date() }); + } + return id; +} + +async function observe(store: SqliteCortexStore, content: string, embedding: number[]): Promise { + await store.putObservation({ + content, source_file: '', source_section: '', salience: 0.5, processed: false, + prediction_error: null, created_at: new Date(), updated_at: new Date(), embedding, keywords: [], + }); +} + +function llmReturning(text: string): LLMProvider & { generate: ReturnType } { + return { + name: 'fake', modelId: 'fake', + generate: vi.fn(async () => text), + generateJSON: vi.fn(async () => ({ category: 'belief', valid: true })), + } as unknown as LLMProvider & { generate: ReturnType }; +} + +const embed: EmbedProvider = { embed: vi.fn(async () => MEMO) }; + +/** Unit vector at cosine `c` from MEMO. */ +const at = (c: number): number[] => [c, Math.sqrt(1 - c * c), 0]; + +describe('refine runs the rewrite guard (#85)', () => { + it('declines a refinement that drops the subject and appends the observation', async () => { + const store = new SqliteCortexStore(':memory:'); + const id = await seedDenseMemoir(store); + const paper = 'Generative Agents (Park et al., UIST 2023) keep a memory stream scored by recency, importance and relevance, and reflect when summed importance exceeds 150.'; + await observe(store, paper, at(0.9)); + const bled = `Feed someone's draft memoir into a cortex agent and write from the lived experience. A product direction nobody else could do. ${paper} Cortex differs in using FSRS retrievability, novelty gating and dream consolidation over a graph, but lacks evidence pointers in reflections and any importance-based reflection schedule.`; + + const result = await dreamPhaseA(store, embed, llmReturning(bled)); + + expect(result.cluster.clustered).toBe(1); + expect(result.refine.refined).toBe(0); + expect(result.refine.declined).toBe(1); + expect((await store.getMemory(id))?.definition).toBe(MEMOIR_DEF); + expect(await store.getBeliefHistory(id)).toHaveLength(0); + }); + + it('declines a refinement that drops a version number', async () => { + const store = new SqliteCortexStore(':memory:'); + const def = 'The agency check became an ops decision loaded at every session start (v0.8.1). It runs before anything else.'; + const id = await seedDenseMemoir(store); + await store.updateMemory(id, { definition: def, name: 'Agency check wiring' }); + await observe(store, 'The agency check is logged with ops_append at session end.', at(0.9)); + + const result = await dreamPhaseA(store, embed, llmReturning( + 'The agency check became an ops decision loaded at every session start, and it is logged with ops_append at session end.', + )); + + expect(result.refine.declined).toBe(1); + expect((await store.getMemory(id))?.definition).toBe(def); + }); + + it('still applies a refinement that integrates the evidence', async () => { + const store = new SqliteCortexStore(':memory:'); + const id = await seedDenseMemoir(store); + await observe(store, 'Virgil wants the memoir agent to keep the writer’s own voice.', at(0.9)); + const next = `${MEMOIR_DEF} It keeps the writer's own voice.`; + + const result = await dreamPhaseA(store, embed, llmReturning(next)); + + expect(result.refine.refined).toBe(1); + expect(result.refine.declined).toBe(0); + expect((await store.getMemory(id))?.definition).toBe(next); + }); +}); + +describe('cluster join floor (#85)', () => { + it('does not join an observation at 0.72 by default; it stays novel', async () => { + const store = new SqliteCortexStore(':memory:'); + await seedDenseMemoir(store); + await observe(store, 'A paper about agent memory streams.', at(0.72)); + + const result = await dreamPhaseA(store, embed, llmReturning('unused')); + + expect(result.cluster.clustered).toBe(0); + expect(result.cluster.unclustered).toBe(1); + }); + + it('joins at 0.72 when the namespace sets cluster_threshold 0.70', async () => { + const store = new SqliteCortexStore(':memory:'); + await seedDenseMemoir(store); + await observe(store, 'A paper about agent memory streams.', at(0.72)); + + const result = await dreamPhaseA(store, embed, llmReturning(MEMOIR_DEF), { cluster_threshold: 0.70 }); + + expect(result.cluster.clustered).toBe(1); + }); +}); + +describe('abstract names an unlabelled synthesis (#85)', () => { + it('asks for a concept label instead of clipping the first clause', async () => { + const store = new SqliteCortexStore(':memory:'); + const seeds: Array<[string, string, MemoryCategory, number[]]> = [ + ['The sky is blue', 'The sky is blue on clear days.', 'belief', [1, 0, 0]], + ['The sea is deep', 'The sea is deep and cold.', 'topic', [0, 1, 0]], + ['Clouds are white', 'Clouds are white and drift.', 'value', [0, 0, 1]], + ]; + for (const [name, definition, category, embedding] of seeds) { + await store.putMemory(memory({ name, definition, category, embedding })); + } + const synthesis = + 'The deeper pattern is that the sky, the sea and the clouds are one weather system. The blue sky, the deep sea and the white clouds each show a part of it, and that matters because no part explains the weather alone.'; + const llm = { + name: 'fake', modelId: 'fake', + // First call is the synthesis, the second is deriveName's label. + generate: vi.fn().mockResolvedValueOnce(synthesis).mockResolvedValue('One Weather System'), + generateJSON: vi.fn(async () => ({})), + } as unknown as LLMProvider; + + await abstractCrossDomain(store, { embed: vi.fn(async () => [0.5, 0.5, 0.5]) }, llm, { abstraction_attempts: 1 }); + + const minted = (await store.getAllMemories()).find((m) => m.memory_origin === 'abstract'); + expect(minted?.name).toBe('One Weather System'); + expect(minted?.definition).toBe(synthesis); + }); +}); diff --git a/src/engines/cognition.ts b/src/engines/cognition.ts index 9f30c8e..fbd6d47 100644 --- a/src/engines/cognition.ts +++ b/src/engines/cognition.ts @@ -84,7 +84,7 @@ function dreamFailure(label: string, err: unknown): void { export interface DreamResult { phases: { cluster: { clustered: number; unclustered: number }; - refine: { refined: number }; + refine: { refined: number; declined?: number }; create: { created: number }; connect: { edges_discovered: number }; score: { scored: number }; @@ -119,7 +119,7 @@ export interface DreamOptions { create_limit?: number; /** Max abstraction attempts in REM phase (default: 5) */ abstraction_attempts?: number; - /** Similarity threshold for clustering (default: 0.70) */ + /** Similarity an observation needs to join an existing memory (default: 0.75, see DEFAULT_CLUSTER_THRESHOLD) */ cluster_threshold?: number; /** Similarity threshold for detecting duplicate abstractions (default: 0.88) */ abstraction_novelty_threshold?: number; @@ -212,6 +212,11 @@ export interface ClusterPhaseResult { export interface RefinePhaseResult { refined: number; + /** + * Rewrites the guard refused (#85): the old definition is kept and no + * belief row is written. See checkRewrite with limitGrowth. + */ + declined?: number; } export interface CreatePhaseResult { @@ -258,6 +263,18 @@ export interface HindsightPhaseResult { */ const CLUSTER_NEIGHBOURS = 5; +/** + * Similarity an observation needs to join an existing memory (and become + * evidence for refining it). Joining is the riskier route: a wrong join + * hands refine unrelated text to fold into someone's memory, while a + * missed join only mints a separate memory that connect can link. Was 0.70; + * on one store (qwen3-embedding:0.6b) a paper summary joined an unrelated + * memoir-idea memory at 0.712 and refine appended it, while the legitimate + * same-journal join in that run scored 0.797 (#85). Tune per deployment with + * the namespace's `cluster_threshold`. + */ +const DEFAULT_CLUSTER_THRESHOLD = 0.75; + /** * Route unprocessed observations to the nearest existing memory that is not * a source mirror. Observations above cluster_threshold are linked and marked @@ -269,7 +286,7 @@ async function clusterObservations( options: DreamOptions, ): Promise { const limit = options.observation_limit ?? 50; - const threshold = options.cluster_threshold ?? 0.70; + const threshold = options.cluster_threshold ?? DEFAULT_CLUSTER_THRESHOLD; let clustered = 0; const unclusteredObs: Observation[] = []; @@ -373,6 +390,7 @@ async function refineMemories( clusteredEvidence?: Map, ): Promise { let refined = 0; + let declined = 0; const recentMemories = await safeStoreRead( store.getRecentMemories(7, 100), @@ -448,6 +466,24 @@ async function refineMemories( continue; } + // The rewrite guard hindsight uses, with the growth limit on (#85): a + // refinement keeps what the definition committed to — its numbers, + // quotations, voice, named entities and opening subject — and integrates + // the evidence rather than appending it. + const guard = checkRewrite({ + name: memory.name, + old: memory.definition, + next: newDefinition.trim(), + evidence: usableEvidence, + origin: memory.memory_origin, + limitGrowth: true, + }); + if (!guard.ok) { + console.error(`[dream:refine] Declined rewrite for ${memory.id}: ${guard.reasons.join('; ')}`); + declined++; + continue; + } + // Embed before the transaction — LLM calls inside withTransaction // would hold the writer mutex open. See docs/concurrency.md. const totalEvidence = allEvidence.length; @@ -516,7 +552,7 @@ async function refineMemories( } } - return { refined }; + return { refined, declined }; } // ─── Phase 3: Create ────────────────────────────────────────────────────────── @@ -1021,7 +1057,7 @@ async function scoreMemories( * Returns null when nothing but a title is present: a label with no body is * not an abstraction. */ -export function parseAbstraction(raw: string): { name: string; definition: string } | null { +export function parseAbstraction(raw: string): { name: string; definition: string; labelled: boolean } | null { const text = stripMarkdownFormatting(raw ?? '').trim(); if (!text) return null; @@ -1056,10 +1092,11 @@ export function parseAbstraction(raw: string): { name: string; definition: strin if (!definition) return null; name = name.replace(/\s+/g, ' ').replace(/[\s.!?;:,]+$/, '').trim(); + const labelled = name.length > 0; if (!name) name = deriveNameHeuristic(definition); else if (name.length > NAME_MAX_LEN) name = deriveNameHeuristic(name); - return { name, definition }; + return { name, definition, labelled }; } /** @@ -1180,7 +1217,12 @@ export async function abstractCrossDomain( continue; } - const memName = parsed.name; + // No label line means the heuristic name is the definition's first + // clause cut short ("The deeper pattern is that systems—whether…"), + // which says nothing about the pattern (#85). Ask for a concept label + // the way create names its memories; deriveName falls back to the + // heuristic itself if the LLM fails. + const memName = parsed.labelled ? parsed.name : await deriveName(trimmed, llm); // Abstraction memory + its provenance edges land together. Otherwise // a partial commit produces an "insight" with no traceable sources. @@ -1710,13 +1752,17 @@ export async function dreamConsolidate( return { phases: { cluster: { clustered: clusterResult.clustered, unclustered: clusterResult.unclustered }, - refine: { refined: refineResult.refined }, + refine: { refined: refineResult.refined, declined: refineResult.declined ?? 0 }, create: { created: createResult.created }, connect: { edges_discovered: connectResult.edges_discovered }, score: { scored: scoreResult.scored }, report: { text: reportResult.text }, abstract: { abstractions: abstractResult.abstractions }, - hindsight: { reviewed: hindsightResult.reviewed, revised: hindsightResult.revised }, + hindsight: { + reviewed: hindsightResult.reviewed, + revised: hindsightResult.revised, + declined: hindsightResult.declined, + }, }, total_processed: clusterResult.clustered + createResult.created, duration_ms, diff --git a/src/engines/rewrite-guard.test.ts b/src/engines/rewrite-guard.test.ts index 7dfe38d..ef4028c 100644 --- a/src/engines/rewrite-guard.test.ts +++ b/src/engines/rewrite-guard.test.ts @@ -85,3 +85,67 @@ describe('checkRewrite', () => { expect(r).toEqual({ ok: true, reasons: [] }); }); }); + +// #85: two refinements one live run's signature gate called clean. +const MEMOIR_OLD = + "Virgil's memoir-agent idea: feed someone's draft memoir into a cortex agent, let it 'experience' the story through cortex's cognitive pipeline (observe, wonder, believe, dream), keep the original as truth reference, then write from the 'lived' experience rather than shuffling text through a personality prompt. The key insight: current AI writing tools fail because they optimize for tokens and generic rules, stripping soul from writing. An agent that has actually processed the material through prediction-error gating and belief evolution would produce output with texture because the processing has texture. This is a potential product direction for cortex that nobody else could do."; +const MEMOIR_BLED = + "Feed someone's draft memoir into a cortex agent, let it 'experience' the story through cortex's cognitive pipeline (observe, wonder, believe, dream), keep the original as truth reference, then write from the 'lived' experience rather than shuffling text through a personality prompt. The key insight: current AI writing tools fail because they optimize for tokens and generic rules, stripping soul from writing. An agent that has actually processed the material through prediction-error gating and belief evolution would produce output with texture because the processing has texture. Generative Agents (Park et al., UIST 2023; arXiv 2304.03442v2) use a memory stream of observation, reflection, and plan records with retrieval scores based on recency (exponential decay, factor 0.995 per game hour since last access), importance (LLM-rated 1–10), and relevance (embedding cosine), and reflections are triggered when summed importance exceeds 150; cortex differs in using FSRS retrievability, novelty/prediction-error gating, and dream() consolidation over a graph, but lacks explicit evidence pointers in reflections and reflection scheduling based on importance accumulation."; +const PAPER_EVIDENCE = + 'Generative Agents (Park et al., UIST 2023) use a memory stream with recency, importance and relevance; reflections fire when summed importance exceeds 150. Cortex uses FSRS retrievability.'; + +const CONSISTENCY_OLD = + 'The audit found one failure shape three times, and the unqueued-choice check was never logged because it lived in prose. Conclusion: consistency is plumbing I maintain, not a trait I have. I wired the check the same hour: the agency check became an ops decision loaded at every session start (v0.8.1).'; +const CONSISTENCY_DROPPED = + 'The audit found one failure shape three times, and the unqueued-choice check was never logged because it lived in prose. Consistency is plumbing I maintain, not a trait I have, and every part of me that drifted did so where nothing measured.'; + +describe('checkRewrite — #85 refine damage', () => { + it('keeps the number in a version string', () => { + const r = checkRewrite({ name: 'Consistency as Plumbing', old: CONSISTENCY_OLD, next: CONSISTENCY_DROPPED }); + expect(r.ok).toBe(false); + expect(r.reasons).toContain('dropped numbers: 0.8.1'); + }); + + it('does not count a reworded sentence-initial word as a dropped entity', () => { + const r = checkRewrite({ name: 'Consistency as Plumbing', old: CONSISTENCY_OLD, next: CONSISTENCY_DROPPED }); + expect(r.reasons.join(' ')).not.toMatch(/Conclusion/); + }); + + it('refuses a rewrite that loses the opening subject and balloons (the real 688 → 1,177 refinement)', () => { + const r = checkRewrite({ + name: "Virgil's memoir-agent idea", + old: MEMOIR_OLD, + next: MEMOIR_BLED, + evidence: [PAPER_EVIDENCE], + limitGrowth: true, + }); + expect(r.ok).toBe(false); + expect(r.reasons).toContain('lost opening subject: Virgil'); + expect(r.reasons.some((x) => x.startsWith('grew '))).toBe(true); + }); + + it('applies the growth limit only when asked', () => { + const r = checkRewrite({ name: 'x', old: 'Short and specific.', next: `Short and specific. ${'More words follow here. '.repeat(30)}`, limitGrowth: false }); + expect(r.reasons.some((x) => x.startsWith('grew '))).toBe(false); + const limited = checkRewrite({ name: 'x', old: 'Short and specific.', next: `Short and specific. ${'More words follow here. '.repeat(30)}`, limitGrowth: true }); + expect(limited.reasons.some((x) => x.startsWith('grew '))).toBe(true); + }); + + it('flags a named entity dropped from mid-sentence, matching case-insensitively', () => { + const old = 'The dashboard reads the cortex search API and posts to GitHub Project Board 6.'; + expect(checkRewrite({ name: 'Dashboard', old, next: 'The dashboard reads the cortex search API and posts to a board 6.' }).reasons) + .toContain('dropped entities: GitHub, Project'); + expect(checkRewrite({ name: 'Dashboard', old, next: 'The dashboard reads the Cortex search API and posts to github project board 6.' }).ok) + .toBe(true); + }); + + it('passes a refinement that integrates evidence without losing anything', () => { + const r = checkRewrite({ + name: "Virgil's memoir-agent idea", + old: MEMOIR_OLD, + next: `${MEMOIR_OLD} A memoir processed this way keeps the writer's own voice.`, + limitGrowth: true, + }); + expect(r).toEqual({ ok: true, reasons: [] }); + }); +}); diff --git a/src/engines/rewrite-guard.ts b/src/engines/rewrite-guard.ts index 1fa9e44..ba8168b 100644 --- a/src/engines/rewrite-guard.ts +++ b/src/engines/rewrite-guard.ts @@ -12,6 +12,13 @@ * before the write means the old definition is kept and no belief-history * row is spent on the damage. * + * Refine runs the same guard (#85), plus three checks that came out of one + * live run where the signature gate called two damaging refinements clean: + * a named entity of the old definition is gone, the subject it opened with + * is gone ("Virgil's memoir-agent idea: …" lost whose idea it was), or the + * definition grew far past its old length (a misclustered observation + * appended verbatim). The first two apply to every caller. + * * One input short-circuits the text checks: a memory whose origin is * `source` (#114, a verbatim mirror of a file or a doc section) has no * rewrite that keeps everything it committed to, because what it committed @@ -33,6 +40,13 @@ export interface RewriteGuardInput { evidence?: string[]; /** The memory's origin; `source` is refused before any text check runs. */ origin?: Memory['memory_origin']; + /** + * Refuse a rewrite that grows past max(GROWTH_RATIO × old, old + GROWTH_SLACK) + * characters. Refine sets it: a refinement integrates evidence into the + * definition, and a definition that balloons from a few observations is + * having the evidence appended to it (#85). + */ + limitGrowth?: boolean; } export interface RewriteGuardResult { @@ -43,7 +57,10 @@ export interface RewriteGuardResult { } const BOILERPLATE_OPENER = /^(this|the) (concept|memory|belief|refined concept|insight|pattern)\b|^concept [ab]\b/i; -const NUMBER = /\b\d[\d.,%:/-]*\b/g; +// A digit run not preceded by a digit or a dot, so a version string keeps its +// number ("v0.8.1" → "0.8.1"); `\b` found no boundary between the "v" and the +// "0" and the number check missed it (#85). +const NUMBER = /(? => new Set((s.match(HEDGE) ?? []).map((h const entities = (s: string): Set => new Set((s.match(CAPITALISED) ?? []).filter((w) => !STOP_CAPS.has(w))); +/** Lower-cased word tokens, for "is this name still mentioned, in any case". */ +const words = (s: string): Set => + new Set(s.toLowerCase().match(/[a-z0-9#][a-z0-9#.-]*[a-z0-9#]|[a-z0-9#]/g) ?? []); + +const GROWTH_RATIO = 1.4; +const GROWTH_SLACK = 300; + +/** + * Capitalised words that are not the first word of a sentence or line — the + * ones that name something, as opposed to "Conclusion:" or "Fixed" opening a + * sentence. Used for the dropped-entity check, where a sentence-initial word + * reworded away would be a false alarm. + */ +function namedEntities(s: string): Set { + const found = new Set(); + for (const sentence of s.split(/(?<=[.!?:;])\s+|\n+/)) { + const body = sentence.replace(/^[^A-Za-z0-9]+/, ''); + for (const w of body.match(CAPITALISED) ?? []) { + if (!STOP_CAPS.has(w) && !body.startsWith(w)) found.add(w); + } + } + return found; +} + +/** + * The capitalised subject a definition opens with ("Virgil's memoir-agent + * idea: …" → "Virgil"), or null when it opens with a function word. Losing it + * loses whose idea, which system, or which person the memory is about. + */ +function openingSubject(s: string): string | null { + const m = s.trim().match(/^([A-Z][A-Za-z0-9#.-]{2,})(?:['’]s\b)?/); + if (!m || STOP_CAPS.has(m[1])) return null; + return m[1]; +} + /** True when `text` opens with `name` followed by a separator or a copula. */ function opensWithName(text: string, name: string): boolean { const n = name.trim(); @@ -106,10 +158,27 @@ export function checkRewrite(input: RewriteGuardInput): RewriteGuardResult { if (firstPerson(old) > 0 && firstPerson(next) === 0) reasons.push('first person became third person'); - const known = entities(`${old} ${evidence}`); - const foreign = [...entities(next)].filter((w) => !known.has(w)); + // A word the old text or evidence already uses, in any case, is not new: + // "Cortex" opening a sentence is the same thing as "the cortex API". + const knownWords = words(`${old} ${evidence}`); + const foreign = [...entities(next)].filter((w) => !knownWords.has(w.toLowerCase())); if (foreign.length > 0) reasons.push(`new entities: ${foreign.slice(0, 8).join(', ')}`); + // What the old definition named must still be named (#85: refine + // generalised specifics away and dropped whose idea a memory recorded). + // Case-insensitive: "the cortex REST API" still names Cortex. + const nextWords = words(next); + const subject = openingSubject(old); + if (subject && !nextWords.has(subject.toLowerCase())) reasons.push(`lost opening subject: ${subject}`); + const dropped = [...namedEntities(old)] + .filter((w) => w !== subject && !nextWords.has(w.toLowerCase())); + if (dropped.length > 0) reasons.push(`dropped entities: ${dropped.slice(0, 8).join(', ')}`); + + if (input.limitGrowth) { + const limit = Math.max(Math.round(old.length * GROWTH_RATIO), old.length + GROWTH_SLACK); + if (next.length > limit) reasons.push(`grew ${old.length} → ${next.length} chars (limit ${limit})`); + } + const allowed = hedges(`${old} ${evidence}`); const added = [...hedges(next)].filter((h) => !allowed.has(h)); if (added.length > 0) reasons.push(`added hedges: ${added.slice(0, 5).join(', ')}`); diff --git a/src/tools/dream.ts b/src/tools/dream.ts index 006e60b..f3d50cb 100644 --- a/src/tools/dream.ts +++ b/src/tools/dream.ts @@ -33,6 +33,7 @@ export const dreamTool: ToolDefinition = { similarity_merge: nsConfig.similarity_merge, similarity_link: nsConfig.similarity_link, abstraction_dedupe_threshold: nsConfig.abstraction_dedupe_threshold, + cluster_threshold: nsConfig.cluster_threshold, }); // Record the run. sleep_pressure and consolidation_status both read this