From 5d7e2fdf208d4c36c921c979f9e0a39d90f2d3b0 Mon Sep 17 00:00:00 2001 From: idapixl Date: Sun, 25 Oct 2026 04:19:15 -0700 Subject: [PATCH 1/3] fix(dream): abstractions from related members with a concrete shared mechanism, at salience 0.5 (1.12.0) Abstract handed the model one memory from each of 3-4 random categories and asked what connects them; it always found a theme ("friction and identity", "subtraction over addition") and one live run linked a failed MCP integration to a person's family history (#147). Each was stored at salience 0.8, above most organic memories. Members are now the seed's graph neighbours plus near neighbours at cosine >= 0.50 (random pairs p90 0.43 on one store; the graph carries the signal), never existing abstractions. The prompt asks for JSON with the mechanism and the members it spans, "none" as the expected answer, and checkMechanism keeps a synthesis only when the mechanism holds a distinctive, non-abstract term two cited members' own text uses. Abstractions start at 0.5 and link only to the members they cite. Closes #147 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 + package-lock.json | 4 +- package.json | 2 +- src/engines/abstraction-check.test.ts | 74 +++++++ src/engines/abstraction-check.ts | 116 +++++++++++ src/engines/cognition-abstract.test.ts | 189 ++++++++++++----- src/engines/cognition-refine-guard.test.ts | 24 ++- src/engines/cognition-source-origin.test.ts | 42 ++-- src/engines/cognition.ts | 215 +++++++++++++------- src/engines/prompts.test.ts | 2 +- src/engines/prompts.ts | 28 ++- 11 files changed, 544 insertions(+), 162 deletions(-) create mode 100644 src/engines/abstraction-check.test.ts create mode 100644 src/engines/abstraction-check.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a2873..36cd448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [1.12.0] — 2026-10-25 + +### Changed + +- **Abstract samples members that already relate (#147).** Before, each member came from a different random category, so the model was handed unrelated memories and asked what connects them. It always found something by climbing to a theme: one live run linked a failed MCP integration to a person's family history. Each attempt now seeds on a recent memory, and its members are the seed's graph neighbours (any relation but `exemplifies`) plus near neighbours at cosine ≥ `abstraction_relate_floor` (default 0.50). Members from new categories are preferred. With fewer than two related members, the model is not asked at all. + - Why 0.50: on one store, random pairs scored p90 0.43 and edge-linked pairs a median of 0.37–0.42, so the graph carries most of the signal. + - Existing abstractions are never members, so syntheses don't compound on syntheses. +- **An abstraction must name a concrete shared mechanism.** `abstract-synthesis` v3 asks for JSON (`mechanism`, `members`, `name`, `abstraction`) and makes "none" the expected answer. The new `checkMechanism` keeps a synthesis only when its mechanism contains a term that at least two cited members' own text uses. The term must not be an abstract noun such as "complexity", "friction" or "identity", and in pools of 20 or more it must appear in no more than 25% of memories. Refused syntheses are counted in `phases.abstract.rejected`. +- **Abstractions start at salience 0.5** (`ABSTRACTION_SALIENCE`), down from 0.8, so an unverified synthesis no longer outranks most organic memories. They link `exemplifies` only to the members the mechanism spans, and each edge's evidence names the shared terms. + ## [1.11.0] — 2026-10-25 ### Fixed diff --git a/package-lock.json b/package-lock.json index a771b27..d59b8d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fozikio/cortex-engine", - "version": "1.11.0", + "version": "1.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fozikio/cortex-engine", - "version": "1.11.0", + "version": "1.12.0", "license": "MIT", "dependencies": { "@fozikio/reflex": "^0.3.0", diff --git a/package.json b/package.json index 17aa997..943a1c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fozikio/cortex-engine", - "version": "1.11.0", + "version": "1.12.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/engines/abstraction-check.test.ts b/src/engines/abstraction-check.test.ts new file mode 100644 index 0000000..942ad0f --- /dev/null +++ b/src/engines/abstraction-check.test.ts @@ -0,0 +1,74 @@ +/** + * Shared-mechanism check (#147). The rejected fixtures are the shapes of two + * live abstractions: a theme ("friction and identity") gluing a technical + * memory to a personal one, and a term every memory in the store uses. + */ + +import { describe, it, expect } from 'vitest'; +import { checkMechanism, documentFrequency } from './abstraction-check.js'; + +const PUSH = { name: 'Push gate fixed', text: 'The push gate hook denied every Bash command containing git push; fixed by matching the command, not the text.' }; +const GUARD = { name: 'Guardrail hook blocks deletes', text: 'The guardrail hook blocks rm with a tilde path before the command runs.' }; +const FAMILY = { name: 'Family background', text: 'Virgil grew up moving between cities and learned to rebuild routines from scratch.' }; +const members = [PUSH, GUARD, FAMILY]; + +const smallPool = { df: documentFrequency(members.map((m) => `${m.name} ${m.text}`)), poolSize: 3 }; + +describe('checkMechanism', () => { + it('accepts a mechanism that names a term two cited members share', () => { + const r = checkMechanism({ + mechanism: 'a hook that blocks a command before it runs', + cited: ['Push gate fixed', 'Guardrail hook blocks deletes'], + members, ...smallPool, + }); + expect(r.ok).toBe(true); + expect(r.sharedTerms).toEqual(expect.arrayContaining(['hook', 'command'])); + expect(r.citedMembers.map((m) => m.name)).toEqual(['Push gate fixed', 'Guardrail hook blocks deletes']); + }); + + it('rejects a theme that no cited member uses', () => { + const r = checkMechanism({ + mechanism: 'systems develop through friction and identity', + cited: ['Push gate fixed', 'Family background'], + members, ...smallPool, + }); + expect(r.ok).toBe(false); + expect(r.reasons).toContain('mechanism names no concrete term the cited members share'); + }); + + it('rejects a term too common in a real-sized pool to count as shared', () => { + const filler = Array.from({ length: 40 }, (_, i) => `Memory ${i} about a hook in some command.`); + const df = documentFrequency([...members.map((m) => `${m.name} ${m.text}`), ...filler]); + const r = checkMechanism({ + mechanism: 'a hook on a command', + cited: ['Push gate fixed', 'Guardrail hook blocks deletes'], + members, df, poolSize: 43, + }); + expect(r.ok).toBe(false); + }); + + it('needs at least two cited members that were actually shown', () => { + const r = checkMechanism({ + mechanism: 'a hook that blocks a command', + cited: ['Push gate fixed', 'Something never shown'], + members, ...smallPool, + }); + expect(r.ok).toBe(false); + expect(r.reasons[0]).toMatch(/spans 1 of the shown members/); + }); + + it('matches cited names loosely (case, punctuation, shortened)', () => { + const r = checkMechanism({ + mechanism: 'a hook that blocks a command', + cited: ['push gate', 'GUARDRAIL HOOK BLOCKS DELETES.'], + members, ...smallPool, + }); + expect(r.ok).toBe(true); + }); + + it('rejects a missing mechanism or a non-array members field', () => { + expect(checkMechanism({ mechanism: '', cited: ['Push gate fixed', 'Guardrail hook blocks deletes'], members, ...smallPool }).reasons) + .toContain('no mechanism stated'); + expect(checkMechanism({ mechanism: 'a hook', cited: 'Push gate fixed', members, ...smallPool }).ok).toBe(false); + }); +}); diff --git a/src/engines/abstraction-check.ts b/src/engines/abstraction-check.ts new file mode 100644 index 0000000..bf66bd7 --- /dev/null +++ b/src/engines/abstraction-check.ts @@ -0,0 +1,116 @@ +/** + * Shared-mechanism check for dream abstractions (#147). + * + * Asked for "the pattern that connects these concepts", a model nearly always + * finds one by climbing to a level where anything connects: "systems develop + * through friction and identity", "subtraction over addition", "agency and + * constraint". One live run linked a failed MCP integration to a person's + * family history that way. The words that carry such a synthesis are abstract + * nouns that no member actually uses, or words every memory in the store uses. + * + * So the model has to name the mechanism and the members it spans, and the + * mechanism has to hold a term that at least two of those members' own text + * contains — not an abstract noun, and not a word common to a large share of + * the store. A synthesis that can't point to a term the members share is + * glue, however fluent. + */ + +import { extractKeywords } from './keywords.js'; + +/** Nouns a synthesis reaches for when it has no concrete link. */ +const ABSTRACT_NOUNS = new Set([ + 'system', 'pattern', 'complexity', 'integrity', 'authenticity', 'clarity', 'efficiency', 'friction', + 'identity', 'constraint', 'agency', 'autonomy', 'alignment', 'balance', 'tension', 'interplay', + 'evolution', 'growth', 'adaptation', 'adaptability', 'coherence', 'consistency', 'reliability', + 'structure', 'structured', 'process', 'approach', 'dynamic', 'mechanism', 'framework', 'principle', + 'concept', 'context', 'nature', 'value', 'purpose', 'meaning', 'trust', 'boundary', 'boundarie', + 'change', 'flow', 'emergence', 'emergent', 'resilience', 'transformation', 'optimization', + 'optimisation', 'performance', 'simplicity', 'subtraction', 'addition', 'design', 'development', + 'relationship', 'connection', 'interaction', 'feedback', 'loop', 'learning', 'knowledge', + 'understanding', 'insight', 'deeper', 'underlying', 'fundamental', 'effective', 'meaningful', + 'complex', 'iterative', 'intentional', 'organizational', 'organisational', 'technical', 'personal', + 'cognitive', 'creative', 'shift', 'role', 'need', 'importance', 'impact', 'focus', 'state', 'level', +]); + +/** Share of the pool above which a term is too common to count as shared. */ +export const MAX_TERM_SHARE = 0.25; +/** Pool size below which the share rule is not applied. */ +export const MIN_POOL_FOR_SHARE = 20; + +/** Light normalisation so "hooks"/"hook" and "edges"/"edge" meet. */ +export function normaliseTerm(word: string): string { + return word.length > 4 && word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word; +} + +export function termSet(text: string): Set { + return new Set(extractKeywords(text, 500).map(normaliseTerm)); +} + +/** Document frequency of each term over a pool of texts. */ +export function documentFrequency(texts: string[]): Map { + const df = new Map(); + for (const t of texts) for (const term of termSet(t)) df.set(term, (df.get(term) ?? 0) + 1); + return df; +} + +export interface MechanismMember { + name: string; + text: string; +} + +export interface MechanismCheckInput { + /** The mechanism as the model stated it. */ + mechanism: unknown; + /** Names of the members the model says the mechanism spans. */ + cited: unknown; + /** The members the model was shown. */ + members: MechanismMember[]; + /** Document frequency over the pool, and the pool's size. */ + df: Map; + poolSize: number; +} + +export interface MechanismCheckResult { + ok: boolean; + reasons: string[]; + /** Members the model cited that match a shown member, in shown order. */ + citedMembers: MechanismMember[]; + /** Mechanism terms found in at least two cited members. */ + sharedTerms: string[]; +} + +const norm = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); + +export function checkMechanism(input: MechanismCheckInput): MechanismCheckResult { + const reasons: string[] = []; + const mechanism = typeof input.mechanism === 'string' ? input.mechanism.trim() : ''; + const cited = Array.isArray(input.cited) ? input.cited.filter((c): c is string => typeof c === 'string') : []; + + // A cited name matches a shown member when either contains the other + // (models shorten long names, or add the category tag). + const citedMembers = input.members.filter((m) => { + const n = norm(m.name); + return cited.some((c) => { + const cn = norm(c); + return cn.length > 0 && (cn.includes(n) || n.includes(cn)); + }); + }); + + if (!mechanism) reasons.push('no mechanism stated'); + if (citedMembers.length < 2) reasons.push(`mechanism spans ${citedMembers.length} of the shown members (need 2)`); + + const memberTerms = citedMembers.map((m) => termSet(`${m.name} ${m.text}`)); + // "Too common" means something only in a pool big enough to have a + // background; in a handful of memories any shared term is a large share. + const limit = input.poolSize < MIN_POOL_FOR_SHARE ? Infinity : Math.floor(input.poolSize * MAX_TERM_SHARE); + const sharedTerms = [...termSet(mechanism)].filter((term) => + !ABSTRACT_NOUNS.has(term) + && memberTerms.filter((s) => s.has(term)).length >= 2 + && (input.df.get(term) ?? 0) <= limit, + ); + if (mechanism && citedMembers.length >= 2 && sharedTerms.length === 0) { + reasons.push('mechanism names no concrete term the cited members share'); + } + + return { ok: reasons.length === 0, reasons, citedMembers, sharedTerms }; +} diff --git a/src/engines/cognition-abstract.test.ts b/src/engines/cognition-abstract.test.ts index 9766d90..5030850 100644 --- a/src/engines/cognition-abstract.test.ts +++ b/src/engines/cognition-abstract.test.ts @@ -1,5 +1,5 @@ /** - * Abstract phase output hygiene (#83). + * Abstract phase output hygiene (#83) and member sampling / shared mechanism (#147). * * Three things one live store showed in every run: the model's title line * (`Pattern: *X*`) stored verbatim as the memory name with its asterisks and @@ -9,13 +9,13 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { abstractCrossDomain, parseAbstraction } from './cognition.js'; +import { abstractCrossDomain, parseAbstraction, ABSTRACTION_SALIENCE } from './cognition.js'; import { stripMarkdownFormatting } from './thought-quality.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'; +import type { Edge, Memory, MemoryCategory } from '../core/types.js'; describe('parseAbstraction', () => { it('splits a "Pattern: *Title*" line from the body and drops the markdown', () => { @@ -78,94 +78,171 @@ describe('stripMarkdownFormatting single emphasis', () => { // ─── Phase behaviour ────────────────────────────────────────────────────────── -const VEC: Record = { - sky: [1, 0, 0], - sea: [0, 1, 0], - cloud: [0, 0, 1], - sun: [0, 0.6, 0.8], -}; const ABSTRACT_VEC = [0.5, 0.5, 0.5]; -function memory(name: string, definition: string, category: MemoryCategory, embedding: number[], faded = false): Omit { +function memory( + name: string, + definition: string, + category: MemoryCategory, + embedding: number[], + extra: Partial> = {}, +): Omit { const now = new Date(); return { - name, definition, category, embedding, faded, - salience: faded ? 0.3 : 0.7, confidence: 0.7, access_count: 0, + name, definition, category, embedding, + salience: extra.faded ? 0.3 : 0.7, confidence: 0.7, access_count: 0, created_at: now, updated_at: now, last_accessed: now, source_files: [], tags: [], fsrs: newFSRSState(), + ...extra, }; } const ABSTRACTION = - 'The sky, the sea, the clouds and the sun are one weather system. The blue sky, the deep sea, the white clouds and the bright sun each show a part of it, and that matters because no part explains the weather alone.'; + 'A hook that blocks a command before it runs is only as good as its match: the push gate matched text and denied everything, the guardrail matches the command and blocks only the delete. Both say the block belongs on the command, not on the words around it.'; + +/** The synthesis a model returns when the mechanism is real. */ +const GOOD = { + mechanism: 'a hook that blocks a command before it runs', + members: ['Push gate fixed', 'Guardrail hook blocks deletes'], + name: 'Hooks that block commands', + abstraction: ABSTRACTION, +}; -function makeLLM(text = ABSTRACTION): LLMProvider { +type Answer = Record; + +function makeLLM(answer: Answer | (() => Answer) = GOOD): LLMProvider & { prompts: string[] } { + const prompts: string[] = []; return { - name: 'fake', modelId: 'fake', - generate: vi.fn(async () => text), - generateJSON: vi.fn(async () => ({})), - } as unknown as LLMProvider; + name: 'fake', modelId: 'fake', prompts, + generate: vi.fn(async () => 'Unused label'), + generateJSON: vi.fn(async (prompt: string) => { + prompts.push(prompt); + return typeof answer === 'function' ? answer() : answer; + }), + } as unknown as LLMProvider & { prompts: string[] }; } function makeEmbed(): EmbedProvider { return { embed: vi.fn(async () => ABSTRACT_VEC) }; } -async function seedThreeCategories(store: SqliteCortexStore): Promise { - await store.putMemory(memory('The sky is blue', 'The sky is blue on clear days.', 'belief', VEC.sky)); - await store.putMemory(memory('The sea is deep', 'The sea is deep and cold.', 'topic', VEC.sea)); - await store.putMemory(memory('Clouds are white', 'Clouds are white and drift.', 'value', VEC.cloud)); +async function link(store: SqliteCortexStore, a: string, b: string, relation: Edge['relation'] = 'related'): Promise { + await store.putEdge({ source_id: a, target_id: b, relation, weight: 0.5, evidence: 'x', created_at: new Date() }); } -describe('abstractCrossDomain', () => { - it('writes the same synthesis once per run, not once per attempt', async () => { +/** + * Three hook memories joined in the graph, and one memory about a person + * with no edges and an orthogonal embedding — the kind of member the old + * random-category sampling handed the model next to technical memories. + */ +async function seedHooks(store: SqliteCortexStore): Promise> { + const ids: Record = {}; + ids.push = await store.putMemory(memory('Push gate fixed', 'The push gate hook denied every Bash command containing git push; fixed by matching the command, not the text.', 'belief', [1, 0, 0])); + ids.guard = await store.putMemory(memory('Guardrail hook blocks deletes', 'The guardrail hook blocks rm with a tilde path before the command runs.', 'topic', [0, 1, 0])); + ids.bom = await store.putMemory(memory('Hook encoding trap', 'A PowerShell hook script with an em dash breaks without a BOM.', 'value', [0, 0, 1])); + ids.family = await store.putMemory(memory('Family background', 'Virgil grew up moving between cities and learned to rebuild routines from scratch.', 'pattern', [-1, 0, 0])); + await link(store, ids.push, ids.guard); + await link(store, ids.push, ids.bom, 'supports'); + await link(store, ids.guard, ids.bom); + return ids; +} + +const minted = async (store: SqliteCortexStore): Promise => + (await store.getAllMemories()).filter((m) => m.memory_origin === 'abstract'); + +describe('abstractCrossDomain (#147)', () => { + it('stores a real shared mechanism at lower salience, linked only to the members it spans', async () => { const store = new SqliteCortexStore(':memory:'); - await seedThreeCategories(store); + const ids = await seedHooks(store); - const result = await abstractCrossDomain(store, makeEmbed(), makeLLM(), { abstraction_attempts: 5 }); + const result = await abstractCrossDomain(store, makeEmbed(), makeLLM(), { abstraction_attempts: 1 }); expect(result.abstractions).toBe(1); - const all = await store.getAllMemories(); - const minted = all.filter((m) => m.memory_origin === 'abstract'); - expect(minted).toHaveLength(1); - // The first sentence is 62 characters, so the shared 60-character - // heuristic clips it on a word boundary and marks the elision. - expect(minted[0].name).toMatch(/^The sky, the sea, the clouds and the sun/); - expect(minted[0].name.length).toBeLessThanOrEqual(60); - expect(minted[0].name).not.toMatch(/[*\n]/); + const [abs] = await minted(store); + expect(abs.name).toBe('Hooks that block commands'); + expect(abs.definition).toBe(ABSTRACTION); + expect(abs.salience).toBe(ABSTRACTION_SALIENCE); + expect(ABSTRACTION_SALIENCE).toBeLessThan(0.8); + const targets = (await store.getEdgesFrom(abs.id)).filter((e) => e.relation === 'exemplifies').map((e) => e.target_id); + expect(targets.sort()).toEqual([ids.push, ids.guard].sort()); + }); + + it('only shows the model memories related to the seed — never an unconnected one', async () => { + const store = new SqliteCortexStore(':memory:'); + await seedHooks(store); + const llm = makeLLM({ none: true }); + + await abstractCrossDomain(store, makeEmbed(), llm, { abstraction_attempts: 12 }); + + expect(llm.prompts.length).toBeGreaterThan(0); + for (const p of llm.prompts) expect(p).not.toContain('Family background'); }); - it('stores the parsed label as the name and the body as the definition', async () => { + it('writes nothing when the model answers none', async () => { const store = new SqliteCortexStore(':memory:'); - await seedThreeCategories(store); - const llm = makeLLM(`Pattern: *One Weather System* \nExplanation: ${ABSTRACTION}`); + await seedHooks(store); - await abstractCrossDomain(store, makeEmbed(), llm, { abstraction_attempts: 1 }); + const result = await abstractCrossDomain(store, makeEmbed(), makeLLM({ none: true }), { abstraction_attempts: 5 }); - const minted = (await store.getAllMemories()).find((m) => m.memory_origin === 'abstract'); - expect(minted?.name).toBe('One Weather System'); - expect(minted?.definition).toBe(ABSTRACTION); + expect(result.abstractions).toBe(0); + expect(await minted(store)).toHaveLength(0); }); - it('never samples a faded memory, so no abstraction edge lands on one', async () => { + it('rejects a theme that no member uses, however fluent', async () => { const store = new SqliteCortexStore(':memory:'); - await seedThreeCategories(store); - const fadedId = await store.putMemory(memory('The sun is bright', 'The sun is bright at noon.', 'pattern', VEC.sun, true)); - // Distinct text per attempt so the within-run check does not short-circuit sampling. - let n = 0; - const embed: EmbedProvider = { embed: vi.fn(async () => { n++; return [Math.cos(n), Math.sin(n), 0.1 * n]; }) }; + await seedHooks(store); + const theme = { + mechanism: 'agency and constraint shape systems through friction', + members: ['Push gate fixed', 'Guardrail hook blocks deletes'], + name: 'Agency and Constraint', + abstraction: 'The deeper pattern is that agency and constraint shape systems through friction. It matters because boundaries define what can be built.', + }; + + const result = await abstractCrossDomain(store, makeEmbed(), makeLLM(theme), { abstraction_attempts: 3 }); + + expect(result.abstractions).toBe(0); + expect(result.rejected).toBeGreaterThan(0); + expect(await minted(store)).toHaveLength(0); + }); + + it('does not ask the model at all when the seed has fewer than two related memories', async () => { + const store = new SqliteCortexStore(':memory:'); + for (const [i, cat] of (['belief', 'topic', 'value'] as MemoryCategory[]).entries()) { + const v = [0, 0, 0]; v[i] = 1; + await store.putMemory(memory(`Lone ${i}`, `Lone memory ${i} with nothing near it.`, cat, v)); + } const llm = makeLLM(); - await abstractCrossDomain(store, embed, llm, { abstraction_attempts: 10, abstraction_dedupe_threshold: 1.01 }); + const result = await abstractCrossDomain(store, makeEmbed(), llm, { abstraction_attempts: 5 }); - const edges = await store.getEdgesFrom(fadedId); - const incoming = (await store.getAllMemories()) - .filter((m) => m.memory_origin === 'abstract'); - expect(incoming.length).toBeGreaterThan(0); - for (const abs of incoming) { - const fromAbs = await store.getEdgesFrom(abs.id); - expect(fromAbs.some((e) => e.target_id === fadedId)).toBe(false); + expect(result.abstractions).toBe(0); + expect(llm.prompts).toHaveLength(0); + }); + + it('never samples a faded memory or an existing abstraction as a member', async () => { + const store = new SqliteCortexStore(':memory:'); + const ids = await seedHooks(store); + const fadedId = await store.putMemory(memory('Old hook rule', 'An old hook rule blocked every command.', 'insight', [0.9, 0.1, 0], { faded: true })); + const absId = await store.putMemory(memory('Earlier synthesis', 'A previous abstraction about hooks and commands.', 'insight', [0.8, 0.2, 0], { memory_origin: 'abstract' })); + await link(store, ids.push, fadedId); + await link(store, ids.push, absId); + const llm = makeLLM({ none: true }); + + await abstractCrossDomain(store, makeEmbed(), llm, { abstraction_attempts: 12 }); + + for (const p of llm.prompts) { + expect(p).not.toContain('Old hook rule'); + expect(p).not.toContain('Earlier synthesis'); } - expect(edges.some((e) => e.relation === 'exemplifies')).toBe(false); + }); + + it('writes the same synthesis once per run, not once per attempt', async () => { + const store = new SqliteCortexStore(':memory:'); + await seedHooks(store); + + const result = await abstractCrossDomain(store, makeEmbed(), makeLLM(), { abstraction_attempts: 5 }); + + expect(result.abstractions).toBe(1); + expect(await minted(store)).toHaveLength(1); }); }); diff --git a/src/engines/cognition-refine-guard.test.ts b/src/engines/cognition-refine-guard.test.ts index 60eff78..7fe422e 100644 --- a/src/engines/cognition-refine-guard.test.ts +++ b/src/engines/cognition-refine-guard.test.ts @@ -147,20 +147,28 @@ 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]], + ['The sky is blue', 'The sky is blue in clear weather.', 'belief', [1, 0, 0]], + ['The sea is deep', 'The sea is deep and cold in any weather.', 'topic', [0, 1, 0]], + ['Clouds are white', 'Clouds are white and drift with the weather.', 'value', [0, 0, 1]], ]; + const ids: string[] = []; for (const [name, definition, category, embedding] of seeds) { - await store.putMemory(memory({ name, definition, category, embedding })); + ids.push(await store.putMemory(memory({ name, definition, category, embedding }))); + } + for (const [a, b] of [[0, 1], [0, 2], [1, 2]]) { + await store.putEdge({ source_id: ids[a], target_id: ids[b], relation: 'related', weight: 0.5, evidence: 'weather', created_at: new Date() }); } 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.'; + 'The sky, the sea and the clouds are each read through the weather. 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 () => ({})), + // No usable name in the JSON answer, no label line in the text: deriveName labels it. + generateJSON: vi.fn(async () => ({ + mechanism: 'each changes with the weather', + members: ['The sky is blue', 'The sea is deep', 'Clouds are white'], + abstraction: synthesis, + })), + generate: vi.fn(async () => 'One Weather System'), } as unknown as LLMProvider; await abstractCrossDomain(store, { embed: vi.fn(async () => [0.5, 0.5, 0.5]) }, llm, { abstraction_attempts: 1 }); diff --git a/src/engines/cognition-source-origin.test.ts b/src/engines/cognition-source-origin.test.ts index 9d39f2c..129f06c 100644 --- a/src/engines/cognition-source-origin.test.ts +++ b/src/engines/cognition-source-origin.test.ts @@ -129,31 +129,49 @@ describe('source-origin memories are never consolidated', () => { it('REM abstraction never takes a source memory as a cluster member', async () => { const store = new SqliteCortexStore(':memory:'); - // Three source memories and three organic ones, six categories, so the - // phase samples four per attempt and, with source excluded, still has the - // three categories it needs to abstract at all. + // Three source memories and three organic ones, all joined in the graph, + // so every organic seed has source memories among its neighbours (#147: + // members are the seed's related memories). Every definition names the + // weather, so a mechanism citing any two shown members passes the check. const sourceIds = new Set(); const organicIds = new Set(); + const names: string[] = []; const seed = async (name: string, definition: string, category: MemoryCategory, embedding: number[], origin: Memory['memory_origin']) => { const id = await store.putMemory(memory({ name, definition, category, embedding, memory_origin: origin })); (origin === 'source' ? sourceIds : organicIds).add(id); + names.push(name); }; - await seed('The sky is blue', 'The sky is blue on clear days.', 'belief', SKY, 'source'); - await seed('The sea is deep', 'The sea is deep and cold.', 'topic', SEA, 'source'); - await seed('Clouds are white', 'Clouds are white and drift.', 'value', CLOUD, 'source'); - await seed('The sun is bright', 'The sun is bright at noon.', 'pattern', SUN, 'organic'); - await seed('Rain is wet', 'Rain is wet and falls.', 'insight', RAIN, 'organic'); - await seed('Wind is loud', 'Wind is loud at night.', 'project', WIND, 'organic'); + await seed('The sky is blue', 'The sky is blue in clear weather.', 'belief', SKY, 'source'); + await seed('The sea is deep', 'The sea is deep and cold in any weather.', 'topic', SEA, 'source'); + await seed('Clouds are white', 'Clouds are white and drift with the weather.', 'value', CLOUD, 'source'); + await seed('The sun is bright', 'The sun is bright at noon in dry weather.', 'pattern', SUN, 'organic'); + await seed('Rain is wet', 'Rain is wet weather that falls.', 'insight', RAIN, 'organic'); + await seed('Wind is loud', 'Wind is loud in stormy weather at night.', 'project', WIND, 'organic'); + const all = [...sourceIds, ...organicIds]; + for (const a of all) for (const b of all) { + if (a < b) await store.putEdge({ source_id: a, target_id: b, relation: 'related', weight: 0.5, evidence: 'weather', created_at: new Date() }); + } // Distinct embedding per attempt so the within-run check does not short-circuit sampling. let n = 0; const embed: EmbedProvider = { embed: vi.fn(async () => { n++; return [Math.cos(n), Math.sin(n), 0.1 * n]; }) }; - const llm = makeLLM( - 'The sky, the sea, the clouds, the sun, the rain and the wind are one weather system. The blue sky, the deep sea, the white clouds, the bright sun, the wet rain and the loud wind each show a part of it, and that matters because no part explains the weather alone.', - ); + const prompts: string[] = []; + const llm = makeLLM(); + llm.generateJSON.mockImplementation(async (prompt: string) => { + prompts.push(prompt); + return { + mechanism: 'each is a kind of weather', + members: names, + name: 'One weather system', + abstraction: 'The sun, the rain and the wind are each a kind of weather, and none explains the weather alone. That matters because the weather is the system they belong to.', + }; + }); const result = await abstractCrossDomain(store, embed, llm, { abstraction_attempts: 10, abstraction_dedupe_threshold: 1.01 }); + for (const p of prompts) { + for (const sourceName of ['The sky is blue', 'The sea is deep', 'Clouds are white']) expect(p).not.toContain(sourceName); + } expect(result.abstractions).toBeGreaterThan(0); const minted = (await store.getAllMemories()).filter((m) => m.memory_origin === 'abstract'); expect(minted.length).toBe(result.abstractions); diff --git a/src/engines/cognition.ts b/src/engines/cognition.ts index fbd6d47..ffbddb9 100644 --- a/src/engines/cognition.ts +++ b/src/engines/cognition.ts @@ -42,6 +42,7 @@ import { substituteConceptPlaceholders, } from './thought-quality.js'; import { checkRewrite } from './rewrite-guard.js'; +import { checkMechanism, documentFrequency } from './abstraction-check.js'; import { REFINE_DEFINITION, EDGE_REVALIDATE, @@ -89,7 +90,7 @@ export interface DreamResult { connect: { edges_discovered: number }; score: { scored: number }; report: { text: string }; - abstract: { abstractions: number }; + abstract: { abstractions: number; rejected?: number }; hindsight?: { reviewed: number; revised: number; declined?: number }; }; total_processed: number; @@ -119,6 +120,8 @@ export interface DreamOptions { create_limit?: number; /** Max abstraction attempts in REM phase (default: 5) */ abstraction_attempts?: number; + /** Embedding similarity at which a non-neighbour counts as related when sampling abstraction members (default: 0.50, #147) */ + abstraction_relate_floor?: number; /** 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) */ @@ -233,6 +236,8 @@ export interface ScorePhaseResult { export interface AbstractPhaseResult { abstractions: number; + /** Syntheses refused because no concrete shared mechanism held (#147) or the quality gate failed. */ + rejected?: number; } export interface ReportPhaseResult { @@ -1099,9 +1104,81 @@ export function parseAbstraction(raw: string): { name: string; definition: strin return { name, definition, labelled }; } +/** Salience a new abstraction starts at (#147): unverified synthesis, not a finding. Retrieval feedback raises it. */ +export const ABSTRACTION_SALIENCE = 0.5; + +/** + * Embedding similarity at which a non-neighbour counts as related to the seed + * when sampling abstraction members (#147). Measured on one store + * (qwen3-embedding:0.6b): random pairs p90 0.43, edge-linked pairs median + * 0.37–0.42, so similarity alone barely separates related from unrelated and + * the graph does most of the work; 0.50 admits only near neighbours. + */ +const DEFAULT_ABSTRACTION_RELATE_FLOOR = 0.50; +const ABSTRACTION_MAX_OTHERS = 3; + +interface AbstractionJSON { + none?: boolean; + mechanism?: unknown; + members?: unknown; + name?: unknown; + abstraction?: unknown; +} + +/** + * Members for one abstraction attempt: the seed's graph neighbours (any + * relation but `exemplifies`, which abstractions themselves write) and its + * near neighbours by embedding, preferring categories the group doesn't + * have yet. Before #147 each member came from a different random category, + * so the model was handed unrelated memories and asked what connects them. + */ +async function relatedMembers( + store: CortexStore, + seed: Memory, + poolById: Map, + relateFloor: number, +): Promise { + const ids = new Set(); + const edges = await safeStoreRead(store.getEdgesForMemories([seed.id]), [] as Edge[], 'abstract:edges', _dreamStats); + for (const e of edges) { + if (e.relation === 'exemplifies') continue; + ids.add(e.source_id === seed.id ? e.target_id : e.source_id); + } + if (seed.embedding?.length) { + const near = await safeStoreRead(store.findNearest(seed.embedding, 8), [], 'abstract:nearest', _dreamStats); + for (const n of near) if (n.score >= relateFloor) ids.add(n.memory.id); + } + ids.delete(seed.id); + + const candidates = [...ids] + .map((id) => poolById.get(id)) + .filter((m): m is Memory => m !== undefined) + .sort(() => Math.random() - 0.5); + + const chosen: Memory[] = []; + const categories = new Set([seed.category]); + for (const m of candidates) { + if (chosen.length >= ABSTRACTION_MAX_OTHERS) break; + if (!categories.has(m.category)) { chosen.push(m); categories.add(m.category); } + } + for (const m of candidates) { + if (chosen.length >= ABSTRACTION_MAX_OTHERS) break; + if (!chosen.includes(m)) chosen.push(m); + } + return chosen; +} + /** - * REM sleep phase: sample recent memories across categories and attempt - * to synthesize higher-level cross-domain abstractions. + * REM sleep phase: find a concrete mechanism shared by memories that already + * relate, and record it as an abstraction (#147). + * + * Each attempt seeds on a recent memory and samples its related members. + * The model answers in JSON, naming the mechanism and the members it spans; + * `checkMechanism` keeps it only when the mechanism holds a distinctive, + * non-abstract term that two cited members' own text contains. The + * abstraction starts at ABSTRACTION_SALIENCE and links (`exemplifies`) only + * to the members it cites. Abstractions are never members themselves, so + * syntheses don't compound on syntheses. * * Exported for tests; dream callers go through dreamPhaseB / dreamConsolidate. */ @@ -1114,7 +1191,9 @@ export async function abstractCrossDomain( const attempts = options.abstraction_attempts ?? 5; const noveltyThreshold = options.abstraction_novelty_threshold ?? 0.88; const dedupeThreshold = options.abstraction_dedupe_threshold ?? 0.60; + const relateFloor = options.abstraction_relate_floor ?? DEFAULT_ABSTRACTION_RELATE_FLOOR; let abstractions = 0; + let rejected = 0; // Embeddings of abstractions written in this run, for the within-run check. const writtenThisRun: number[][] = []; @@ -1122,107 +1201,93 @@ export async function abstractCrossDomain( try { allMemories = await store.getAllMemories(); } catch { - return { abstractions: 0 }; + return { abstractions: 0, rejected: 0 }; } - // Work from the 60 most recently updated memories. Faded memories are - // excluded: fading lowers a memory's salience on purpose, and an - // abstraction that cites it re-attaches edges to it and pulls it back into - // the graph — one live run put four edges on intentionally faded rows. - // Source memories are excluded too (#114): an abstraction is a belief - // derived from its members, and a mirrored file is not a belief to - // generalise from. An abstraction built on organic memories may still be - // linked to a source memory by connect; it just never cites one as a member. - const recent = allMemories - .filter((m) => !m.faded && !isSource(m)) + // Faded memories are excluded: fading lowers a memory's salience on + // purpose, and an abstraction that cites it re-attaches edges to it and + // pulls it back into the graph. Source memories are excluded (#114): a + // mirrored file is not a belief to generalise from. Abstractions are + // excluded (#147): a synthesis of syntheses is how one generic theme + // spreads across the graph. + const pool = allMemories.filter((m) => !m.faded && !isSource(m) && m.memory_origin !== 'abstract'); + if (pool.length < 3) return { abstractions: 0, rejected: 0 }; + const poolById = new Map(pool.map((m) => [m.id, m])); + const df = documentFrequency(pool.map((m) => `${m.name} ${m.definition}`)); + + // Seeds come from the 60 most recently updated memories. + const recent = [...pool] .sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime()) .slice(0, 60); - // Group by category. - const byCategory = new Map(); - for (const m of recent) { - const group = byCategory.get(m.category) ?? []; - group.push(m); - byCategory.set(m.category, group); - } - - const categories = Array.from(byCategory.keys()); - if (categories.length < 3) return { abstractions: 0 }; - for (let attempt = 0; attempt < attempts; attempt++) { try { - // Pick 4 different random categories (or as many as available, min 3). - const shuffled = categories.sort(() => Math.random() - 0.5); - const selected = shuffled.slice(0, Math.min(4, shuffled.length)); - - // Pick one random memory from each selected category. - const sampledMemories = selected.map((cat) => { - const group = byCategory.get(cat)!; - return group[Math.floor(Math.random() * group.length)]; - }); + const seed = recent[Math.floor(Math.random() * recent.length)]; + const others = await relatedMembers(store, seed, poolById, relateFloor); + if (others.length < 2) continue; + const sampledMemories = [seed, ...others]; const conceptLines = sampledMemories .map((m) => `[${m.category}] ${m.name}: ${m.definition}`) .join('\n\n'); - const result = await llm.generate(ABSTRACT_SYNTHESIS.build({ conceptLines }), { - temperature: 0.4, - maxTokens: 500, + const answer = await llm.generateJSON(ABSTRACT_SYNTHESIS.build({ conceptLines }), { + temperature: 0.3, + maxTokens: 600, }); + if (!answer || answer.none === true || typeof answer.abstraction !== 'string') continue; + + const mechanism = checkMechanism({ + mechanism: answer.mechanism, + cited: answer.members, + members: sampledMemories.map((m) => ({ name: m.name, text: m.definition })), + df, + poolSize: pool.length, + }); + if (!mechanism.ok) { + console.error(`[dream:abstract] Rejected abstraction: ${mechanism.reasons.join('; ')}`); + rejected++; + continue; + } + const cited = sampledMemories.filter((m) => mechanism.citedMembers.some((c) => c.name === m.name)); - const raw = result.trim(); - if (!raw || raw.includes('NO_ABSTRACTION')) continue; - - // Salvage formatting rather than reject on it. A rejected abstraction - // leaves nothing in its place — unlike refine, where the previous - // definition survives — so discarding a real cross-domain synthesis over - // its asterisks or its title line is the more expensive error. Parsing - // changes no content: the label becomes the name, the body becomes the - // definition, and the markdown goes. - const parsed = parseAbstraction(raw); + const parsed = parseAbstraction(answer.abstraction); if (!parsed) continue; const trimmed = parsed.definition; - if (trimmed !== raw) { - console.error('[dream:abstract] Normalised abstraction formatting (title line / markdown)'); - } - // Structural quality gate. Abstractions legitimately introduce new - // vocabulary (that's what abstraction is), so the grounding floor is - // lower than refine's — but a real cross-domain pattern still names - // the concepts it connects, while generic filler names nothing. + // Structural quality gate, against the members the mechanism spans. + // Abstractions legitimately introduce new vocabulary, so the grounding + // floor is lower than refine's. const quality = assessThought(trimmed, { - evidence: sampledMemories.map((m) => `${m.name} ${m.definition}`), + evidence: cited.map((m) => `${m.name} ${m.definition}`), minGrounding: 0.1, }); if (!quality.ok) { console.error(`[dream:abstract] Rejected abstraction: ${quality.reasons.join('; ')}`); + rejected++; continue; } // Check novelty — don't store abstractions too similar to existing memories. const abstEmbedding = await embed.embed(trimmed); const nearest = await store.findNearest(abstEmbedding, 1); + if (nearest.length > 0 && nearest[0].score >= noveltyThreshold) continue; - if (nearest.length > 0 && nearest[0].score >= noveltyThreshold) { - // Too similar to an existing memory — skip. - continue; - } - - // Same idea twice in one run: attempts sample overlapping memories and - // the model restates the same synthesis in new words. The store-wide - // check above misses paraphrases; this one compares against what this - // run has already written. + // Same idea twice in one run: the store-wide check above misses + // paraphrases; this one compares against what this run already wrote. if (writtenThisRun.some((e) => cosineSimilarity(e, abstEmbedding) >= dedupeThreshold)) { console.error('[dream:abstract] Skipped near-duplicate of an abstraction written earlier in this run'); continue; } - // 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); + // The model's own label when it gave a usable one, then a label line in + // the text, then deriveName — never the first clause cut short (#85). + const givenName = typeof answer.name === 'string' + ? answer.name.replace(/\s+/g, ' ').replace(/[\s.!?;:,]+$/, '').trim() + : ''; + const memName = givenName && givenName.length <= NAME_MAX_LEN + ? givenName + : 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. @@ -1231,7 +1296,7 @@ export async function abstractCrossDomain( name: memName, definition: trimmed, category: 'insight', - salience: 0.8, + salience: ABSTRACTION_SALIENCE, confidence: 0.6, access_count: 0, created_at: new Date(), @@ -1243,13 +1308,13 @@ export async function abstractCrossDomain( fsrs: newFSRSState(), memory_origin: 'abstract', }); - for (const sourceMem of sampledMemories) { + for (const sourceMem of cited) { await txn.putEdge({ source_id: abstractionId, target_id: sourceMem.id, relation: 'exemplifies', weight: 0.8, - evidence: `Dream abstraction source: [${sourceMem.category}] ${sourceMem.name}`, + evidence: `Dream abstraction source: [${sourceMem.category}] ${sourceMem.name} (shared: ${mechanism.sharedTerms.slice(0, 5).join(', ')})`, created_at: new Date(), }); } @@ -1263,7 +1328,7 @@ export async function abstractCrossDomain( } } - return { abstractions }; + return { abstractions, rejected }; } // ─── Phase 7: Report ────────────────────────────────────────────────────────── @@ -1757,7 +1822,7 @@ export async function dreamConsolidate( connect: { edges_discovered: connectResult.edges_discovered }, score: { scored: scoreResult.scored }, report: { text: reportResult.text }, - abstract: { abstractions: abstractResult.abstractions }, + abstract: { abstractions: abstractResult.abstractions, rejected: abstractResult.rejected ?? 0 }, hindsight: { reviewed: hindsightResult.reviewed, revised: hindsightResult.revised, diff --git a/src/engines/prompts.test.ts b/src/engines/prompts.test.ts index 5e5b566..03368fd 100644 --- a/src/engines/prompts.test.ts +++ b/src/engines/prompts.test.ts @@ -34,7 +34,7 @@ describe('prompt registry', () => { 'classify-category': 1, 'edge-discover-pair': 1, 'edge-discover-graph': 1, - 'abstract-synthesis': 2, + 'abstract-synthesis': 3, 'hindsight-review': 2, 'dream-report': 1, 'hyde-expand': 1, diff --git a/src/engines/prompts.ts b/src/engines/prompts.ts index bf359e5..b399a09 100644 --- a/src/engines/prompts.ts +++ b/src/engines/prompts.ts @@ -123,15 +123,29 @@ export const EDGE_DISCOVER_GRAPH = definePrompt<{ `If no new edges are needed, respond with: []`, ); -/** Phase 6 — cross-domain abstraction synthesis. */ +/** + * Phase 6 — cross-domain abstraction synthesis. v3 (#147): the members are + * already related in the graph, "none" is the default answer, and the model + * must name the concrete mechanism and which members it spans, so the + * engine can check the mechanism against the members' own text. + */ export const ABSTRACT_SYNTHESIS = definePrompt<{ conceptLines: string }>( - 'abstract-synthesis', 2, (p) => - `Find a higher-level principle or pattern that connects these diverse concepts:\n\n` + + 'abstract-synthesis', 3, (p) => + `These memories are already connected in a memory graph:\n\n` + `${p.conceptLines}\n\n` + - `Write a concise abstraction (2-4 sentences) that captures the deeper connection. ` + - `Be specific — state the pattern in plain words in the first sentence, then explain why it matters, naming the concepts it connects. ` + - `Write plain prose only: no title line, no "Pattern:" label, no markdown, no bullet points. ` + - `If no meaningful connection exists, respond with 'NO_ABSTRACTION'.`, + `Is there one concrete mechanism that at least two of them share — the same tool, process, failure, ` + + `decision, number or cause, named in their own words? Most groups have none; "none" is the expected answer. ` + + `A theme is not a mechanism: "complexity", "integrity", "agency and constraint", "friction", "subtraction", ` + + `"systems evolve" connect anything and do not count. Never connect a person's private life to a technical ` + + `system through a theme.\n\n` + + `If there is a real shared mechanism, write it as a 2-3 sentence abstraction: the mechanism in plain words ` + + `first, then what it explains across the members you name. Plain prose, no markdown.\n\n` + + `Respond with JSON only — no preamble:\n` + + `{"none": true}\n` + + `or\n` + + `{"mechanism": "the shared mechanism in a few words, using the members' own terms", ` + + `"members": ["exact names of the members it spans, at least two"], ` + + `"name": "a short concept label (3-7 words)", "abstraction": "the 2-3 sentences"}`, ); /** Phase 7 — hindsight audit of an entrenched memory. */ From bc533e1f5f54ec180845372ef19f97b96fd765e3 Mon Sep 17 00:00:00 2001 From: idapixl Date: Sun, 25 Oct 2026 04:40:49 -0700 Subject: [PATCH 2/3] fix(dream): members that restate one fact count once in the mechanism check The first copy run on this branch wrote one abstraction: a third retelling of two memories about Muzak's "Stimulus Progression". Concrete terms, so it passed; but two retellings are one member. Cited members whose term sets overlap at Jaccard >= 0.25 now count once (edge-linked pairs on the same store: p95 0.094, p99 0.294; the Muzak pair 0.308). "stimulus", "analysis" and "-ss" words keep their final s. Phase tests drew a random seed with one attempt, and one of the four seeds has no related members by design; they now take enough attempts. Co-Authored-By: Claude Opus 5.5 --- src/engines/abstraction-check.test.ts | 27 ++++++++++++++++++++++ src/engines/abstraction-check.ts | 31 ++++++++++++++++++++++---- src/engines/cognition-abstract.test.ts | 7 ++++-- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/engines/abstraction-check.test.ts b/src/engines/abstraction-check.test.ts index 942ad0f..6ed0ab0 100644 --- a/src/engines/abstraction-check.test.ts +++ b/src/engines/abstraction-check.test.ts @@ -72,3 +72,30 @@ describe('checkMechanism', () => { expect(checkMechanism({ mechanism: 'a hook', cited: 'Push gate fixed', members, ...smallPool }).ok).toBe(false); }); }); + +describe('checkMechanism — restatements (#147)', () => { + // Shapes of the two Muzak memories one copy run "abstracted" into a third copy. + const A = { name: 'Muzak was designed to sedate anxious elevator riders', text: "Muzak was designed to sedate anxious elevator riders through functional acoustic engineering that used patented 'Stimulus Progression', a system of 15-minute blocks engineered to create a subconscious sense of forward movement." }; + const B = { name: 'Muzak Stimulus Progression was a patented system', text: "Muzak's 'Stimulus Progression' was a patented system for behavioral engineering through ambient sound, using 15-minute blocks of music engineered to create a subconscious sense of forward movement." }; + + it('counts two retellings of one fact as one member', () => { + const r = checkMechanism({ + mechanism: 'Stimulus Progression, 15-minute blocks of engineered music', + cited: [A.name, B.name], + members: [A, B], df: documentFrequency([A.text, B.text]), poolSize: 2, + }); + expect(r.ok).toBe(false); + expect(r.reasons.join(' ')).toMatch(/restate one another/); + }); + + it('keeps "stimulus" whole', () => { + const r = checkMechanism({ + mechanism: 'a hook that blocks a command', + cited: ['Push gate fixed', 'Guardrail hook blocks deletes'], + members, ...smallPool, + }); + expect(r.ok).toBe(true); + expect(checkMechanism({ mechanism: 'stimulus', cited: [A.name, PUSH.name], members: [A, PUSH], df: new Map(), poolSize: 2 }).sharedTerms) + .not.toContain('stimulu'); + }); +}); diff --git a/src/engines/abstraction-check.ts b/src/engines/abstraction-check.ts index bf66bd7..a140f2e 100644 --- a/src/engines/abstraction-check.ts +++ b/src/engines/abstraction-check.ts @@ -36,10 +36,24 @@ const ABSTRACT_NOUNS = new Set([ export const MAX_TERM_SHARE = 0.25; /** Pool size below which the share rule is not applied. */ export const MIN_POOL_FOR_SHARE = 20; +/** + * Term-set Jaccard at or above which two members restate one fact and count + * as one. On one store, edge-linked pairs sat at p95 0.094 / p99 0.294; two + * memories retelling Muzak's "Stimulus Progression" scored 0.308, and their + * "abstraction" was a third copy of the same fact. + */ +export const RESTATE_JACCARD = 0.25; + +function jaccard(a: Set, b: Set): number { + let shared = 0; + for (const x of a) if (b.has(x)) shared++; + const union = a.size + b.size - shared; + return union === 0 ? 0 : shared / union; +} -/** Light normalisation so "hooks"/"hook" and "edges"/"edge" meet. */ +/** Light normalisation so "hooks"/"hook" and "edges"/"edge" meet; "stimulus", "analysis", "class" stay whole. */ export function normaliseTerm(word: string): string { - return word.length > 4 && word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word; + return word.length > 4 && word.endsWith('s') && !/(?:ss|us|is)$/.test(word) ? word.slice(0, -1) : word; } export function termSet(text: string): Set { @@ -99,7 +113,16 @@ export function checkMechanism(input: MechanismCheckInput): MechanismCheckResult if (!mechanism) reasons.push('no mechanism stated'); if (citedMembers.length < 2) reasons.push(`mechanism spans ${citedMembers.length} of the shown members (need 2)`); - const memberTerms = citedMembers.map((m) => termSet(`${m.name} ${m.text}`)); + // Members that restate one fact count once: a "synthesis" of two retellings + // is a third retelling, not an abstraction. + const memberTerms: Array> = []; + for (const m of citedMembers) { + const terms = termSet(`${m.name} ${m.text}`); + if (!memberTerms.some((t) => jaccard(t, terms) >= RESTATE_JACCARD)) memberTerms.push(terms); + } + if (citedMembers.length >= 2 && memberTerms.length < 2) { + reasons.push(`cited members restate one another (term overlap >= ${RESTATE_JACCARD})`); + } // "Too common" means something only in a pool big enough to have a // background; in a handful of memories any shared term is a large share. const limit = input.poolSize < MIN_POOL_FOR_SHARE ? Infinity : Math.floor(input.poolSize * MAX_TERM_SHARE); @@ -108,7 +131,7 @@ export function checkMechanism(input: MechanismCheckInput): MechanismCheckResult && memberTerms.filter((s) => s.has(term)).length >= 2 && (input.df.get(term) ?? 0) <= limit, ); - if (mechanism && citedMembers.length >= 2 && sharedTerms.length === 0) { + if (mechanism && memberTerms.length >= 2 && sharedTerms.length === 0) { reasons.push('mechanism names no concrete term the cited members share'); } diff --git a/src/engines/cognition-abstract.test.ts b/src/engines/cognition-abstract.test.ts index 5030850..fe114a6 100644 --- a/src/engines/cognition-abstract.test.ts +++ b/src/engines/cognition-abstract.test.ts @@ -155,7 +155,10 @@ describe('abstractCrossDomain (#147)', () => { const store = new SqliteCortexStore(':memory:'); const ids = await seedHooks(store); - const result = await abstractCrossDomain(store, makeEmbed(), makeLLM(), { abstraction_attempts: 1 }); + // A seed is drawn at random and the family memory has no related members, + // so give the phase enough attempts to draw a hook seed; the within-run + // dedupe keeps it to one write. + const result = await abstractCrossDomain(store, makeEmbed(), makeLLM(), { abstraction_attempts: 20 }); expect(result.abstractions).toBe(1); const [abs] = await minted(store); @@ -198,7 +201,7 @@ describe('abstractCrossDomain (#147)', () => { abstraction: 'The deeper pattern is that agency and constraint shape systems through friction. It matters because boundaries define what can be built.', }; - const result = await abstractCrossDomain(store, makeEmbed(), makeLLM(theme), { abstraction_attempts: 3 }); + const result = await abstractCrossDomain(store, makeEmbed(), makeLLM(theme), { abstraction_attempts: 20 }); expect(result.abstractions).toBe(0); expect(result.rejected).toBeGreaterThan(0); From 161c0311965615ce26f6aa0a506a265f06f3404a Mon Sep 17 00:00:00 2001 From: idapixl Date: Sun, 25 Oct 2026 04:41:11 -0700 Subject: [PATCH 3/3] docs(changelog): record the 1.12.0 abstract measurement and why the phase stays quiet Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36cd448..13cc529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ - **Abstract samples members that already relate (#147).** Before, each member came from a different random category, so the model was handed unrelated memories and asked what connects them. It always found something by climbing to a theme: one live run linked a failed MCP integration to a person's family history. Each attempt now seeds on a recent memory, and its members are the seed's graph neighbours (any relation but `exemplifies`) plus near neighbours at cosine ≥ `abstraction_relate_floor` (default 0.50). Members from new categories are preferred. With fewer than two related members, the model is not asked at all. - Why 0.50: on one store, random pairs scored p90 0.43 and edge-linked pairs a median of 0.37–0.42, so the graph carries most of the signal. - Existing abstractions are never members, so syntheses don't compound on syntheses. -- **An abstraction must name a concrete shared mechanism.** `abstract-synthesis` v3 asks for JSON (`mechanism`, `members`, `name`, `abstraction`) and makes "none" the expected answer. The new `checkMechanism` keeps a synthesis only when its mechanism contains a term that at least two cited members' own text uses. The term must not be an abstract noun such as "complexity", "friction" or "identity", and in pools of 20 or more it must appear in no more than 25% of memories. Refused syntheses are counted in `phases.abstract.rejected`. +- **An abstraction must name a concrete shared mechanism.** `abstract-synthesis` v3 asks for JSON (`mechanism`, `members`, `name`, `abstraction`) and makes "none" the expected answer. The new `checkMechanism` keeps a synthesis only when its mechanism contains a term that at least two cited members' own text uses. The term must not be an abstract noun such as "complexity", "friction" or "identity", and in pools of 20 or more it must appear in no more than 25% of memories. Members that restate one fact (term-set Jaccard ≥ 0.25) count as one member: the first measured run wrote a third retelling of two near-identical memories. Refused syntheses are counted in `phases.abstract.rejected`. +- **The phase now writes few abstractions, on purpose.** I measured 20 attempts on one store with qwen3:14b. With this prompt it wrote none: the model answered "none" for 18 related groups, and its one synthesis was a restatement, which was refused. A variant without the "most groups have none" line wrote 8, of which about half were the same theme-glue under concrete-sounding names. A phase that runs unattended should be quiet rather than wrong. Retrieval feedback is the better signal for loosening it later. - **Abstractions start at salience 0.5** (`ABSTRACTION_SALIENCE`), down from 0.8, so an unverified synthesis no longer outranks most organic memories. They link `exemplifies` only to the members the mechanism spans, and each edge's evidence names the shared terms. ## [1.11.0] — 2026-10-25