From 3066d9b4eb44db982a1eda9a3478391cc9904654 Mon Sep 17 00:00:00 2001 From: muhammadkh4n Date: Tue, 9 Jun 2026 23:16:57 +0500 Subject: [PATCH 1/5] fix(graph): bind PPR personalization vector into spreading activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recall path already computed per-seed activation weights from vector relevance (core stageActivate: seedActivations from m.relevance, plus entity and project seeds) and passed them through the graph port, but the final layer dropped them. NeuralGraph.spreadActivation forwarded only seedNodeIds, and SpreadingActivation.activate hardcoded `reduce(activation = 1.0, ...)`. The graph therefore ran a uniform exponential-decay walk instead of personalized (seed-weighted) spreading activation — the PPR mechanism it was supposed to provide was unplugged one layer above the Cypher. Forward opts.seedActivations into activate() and bind it as the walk's initial value: `reduce(activation = coalesce($seedWeights[seedId], 1.0), ...)`. Seeds with no weight default to 1.0, so callers that pass no map are byte-identical to before; only callers already supplying weights (recall, pattern-completion) change behavior. Tests: unit (stub driver) asserts the weights reach the query params and the Cypher binds them — the exact regression — and runs anywhere; an integration test asserts a 9:1 seed-weight ratio yields a 9:1 activation ratio with a 1:1 unweighted control (requires NEO4J_TEST_READY). --- packages/graph/src/neural-graph.ts | 10 +-- packages/graph/src/spreading-activation.ts | 15 +++- .../test/spreading-activation-ppr.test.ts | 78 +++++++++++++++++ .../spreading-activation-seed-binding.test.ts | 84 +++++++++++++++++++ 4 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 packages/graph/test/spreading-activation-ppr.test.ts create mode 100644 packages/graph/test/spreading-activation-seed-binding.test.ts diff --git a/packages/graph/src/neural-graph.ts b/packages/graph/src/neural-graph.ts index 8c0a786..ec8d57d 100644 --- a/packages/graph/src/neural-graph.ts +++ b/packages/graph/src/neural-graph.ts @@ -81,7 +81,7 @@ export interface EntitySeedResult { /** Wave 2 spreadActivation options (wraps SpreadingActivation.activate) */ export interface SpreadActivationOpts { seedNodeIds: string[] - /** Optional per-seed initial activation (currently unused by the Cypher query) */ + /** Optional per-seed initial activation — the PPR personalization vector. Seeds with no entry default to 1.0. */ seedActivations?: Map maxHops?: number decay?: number @@ -949,9 +949,9 @@ export class NeuralGraph { * Wave 2 spreading activation facade. Wraps the standalone * SpreadingActivation class with the option shape Wave 2 uses. * - * Note: seedActivations is accepted for forward compatibility but the - * underlying Cypher query uses uniform activation (1.0) per seed. - * Per-seed initial weights are a Wave 3 enhancement. + * Personalized seeding: when `opts.seedActivations` is supplied, each seed's + * walk starts at its given weight (the PPR personalization vector) rather than + * a uniform 1.0; seeds absent from the map default to 1.0. */ async spreadActivation(opts: SpreadActivationOpts): Promise { const sa = new SpreadingActivation(this.driver) @@ -964,7 +964,7 @@ export class NeuralGraph { edgeTypeFilter: (opts.edgeFilter ?? []) as ActivationParams['edgeTypeFilter'], projectId: opts.projectId ?? null, } - const results: ActivationResult[] = await sa.activate(opts.seedNodeIds, params) + const results: ActivationResult[] = await sa.activate(opts.seedNodeIds, params, opts.seedActivations) const activated: ActivatedNode[] = results.map((r) => ({ nodeId: r.nodeId, nodeType: r.nodeType as string, diff --git a/packages/graph/src/spreading-activation.ts b/packages/graph/src/spreading-activation.ts index fd9775d..66dc769 100644 --- a/packages/graph/src/spreading-activation.ts +++ b/packages/graph/src/spreading-activation.ts @@ -21,6 +21,7 @@ export class SpreadingActivation { async activate( seedIds: string[], params?: ActivationParams, + seedActivations?: Map, ): Promise { if (seedIds.length === 0) return [] @@ -30,11 +31,20 @@ export class SpreadingActivation { ? `:${p.edgeTypeFilter.join('|')}` : '' + // Personalized seeding (PPR): each walk starts at its seed's relevance + // weight — the personalization vector — instead of a uniform 1.0, so a + // node reached from a highly relevant seed accrues more activation than one + // reached from a weak seed. Seeds with no explicit weight fall back to 1.0, + // which reproduces the prior unweighted-walk behavior exactly. + const seedWeights: Record = seedActivations + ? Object.fromEntries(seedActivations) + : {} + const cypher = ` UNWIND $seedIds AS seedId MATCH (seed) WHERE seed.id = seedId CALL { - WITH seed + WITH seed, seedId MATCH path = (seed)-[rels${relFilter}*1..${p.maxHops}]-(neighbor) WHERE neighbor <> seed AND ALL(r IN rels WHERE r.weight >= $minWeight) @@ -48,7 +58,7 @@ export class SpreadingActivation { OR coalesce(n.forgottenAt, n.deletedAt) IS NULL) WITH neighbor, reduce( - activation = 1.0, + activation = coalesce($seedWeights[seedId], 1.0), r IN rels | activation * r.weight * $decayPerHop ) AS activation, length(path) AS hops @@ -71,6 +81,7 @@ export class SpreadingActivation { const result = await session.executeRead(async (tx) => { return tx.run(cypher, { seedIds, + seedWeights, minWeight: p.minWeight, decayPerHop: p.decayPerHop, minActivation: p.minActivation, diff --git a/packages/graph/test/spreading-activation-ppr.test.ts b/packages/graph/test/spreading-activation-ppr.test.ts new file mode 100644 index 0000000..7d26725 --- /dev/null +++ b/packages/graph/test/spreading-activation-ppr.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest' +import { NeuralGraph } from '../src/neural-graph.js' +import { SpreadingActivation } from '../src/spreading-activation.js' +import { createTestGraph, createTestActivation, neo4jReady } from './helpers/setup.js' + +/** + * Integration proof for PPR seed-binding (the personalization vector). + * + * Topology: two seeds with byte-identical out-edges to distinct neighbors. + * + * s-high --[ASSOC 0.5]--> n-high + * s-low --[ASSOC 0.5]--> n-low + * + * The ONLY difference between the two one-hop paths is the seed's initial + * activation. So the ratio of neighbor activations must equal the ratio of seed + * weights (edge weight and decay cancel). Weights 0.9 / 0.1 → ratio 9. Without + * weights, both seeds start at 1.0 → ratio 1. That delta is the whole fix. + */ +describe.skipIf(!neo4jReady)('SpreadingActivation PPR seed weighting (integration)', () => { + let graph: NeuralGraph + let activation: SpreadingActivation + + beforeAll(async () => { + graph = await createTestGraph() + activation = createTestActivation() + }) + + afterAll(async () => { + await graph.clearAll() + await graph.dispose() + }) + + beforeEach(async () => { + await graph.clearAll() + await graph.addMemoryNode({ id: 's-high', memoryType: 'episode', label: 'seed high' }) + await graph.addMemoryNode({ id: 's-low', memoryType: 'episode', label: 'seed low' }) + await graph.addMemoryNode({ id: 'n-high', memoryType: 'episode', label: 'neighbor high' }) + await graph.addMemoryNode({ id: 'n-low', memoryType: 'episode', label: 'neighbor low' }) + await graph.addEdge('s-high', 'n-high', 'CO_RECALLED', 0.5) + await graph.addEdge('s-low', 'n-low', 'CO_RECALLED', 0.5) + }) + + it('scales neighbor activation by the seed weight (9:1 in, 9:1 out)', async () => { + const results = await activation.activate( + ['s-high', 's-low'], + { minActivation: 0.01, decayPerHop: 0.6 }, + new Map([ + ['s-high', 0.9], + ['s-low', 0.1], + ]), + ) + + const high = results.find(r => r.nodeId === 'n-high') + const low = results.find(r => r.nodeId === 'n-low') + expect(high, 'n-high should be activated').toBeDefined() + expect(low, 'n-low should be activated').toBeDefined() + expect(high!.activation).toBeGreaterThan(low!.activation) + // 0.9*0.5*0.6 = 0.27 vs 0.1*0.5*0.6 = 0.03 + expect(high!.activation).toBeCloseTo(0.27, 5) + expect(low!.activation).toBeCloseTo(0.03, 5) + expect(high!.activation / low!.activation).toBeCloseTo(9, 1) + }) + + it('activates both neighbors equally when no seed weights are supplied', async () => { + const results = await activation.activate( + ['s-high', 's-low'], + { minActivation: 0.01, decayPerHop: 0.6 }, + ) + + const high = results.find(r => r.nodeId === 'n-high') + const low = results.find(r => r.nodeId === 'n-low') + expect(high, 'n-high should be activated').toBeDefined() + expect(low, 'n-low should be activated').toBeDefined() + // Both seeds default to 1.0 → identical one-hop activation. + expect(high!.activation).toBeCloseTo(low!.activation, 6) + expect(high!.activation).toBeCloseTo(0.3, 5) + }) +}) diff --git a/packages/graph/test/spreading-activation-seed-binding.test.ts b/packages/graph/test/spreading-activation-seed-binding.test.ts new file mode 100644 index 0000000..b9ed09e --- /dev/null +++ b/packages/graph/test/spreading-activation-seed-binding.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest' +import type { Driver } from 'neo4j-driver' +import { SpreadingActivation } from '../src/spreading-activation.js' + +/** + * Unit-level regression guard for the PPR seed-binding fix. + * + * The bug: `spreadActivation` accepted a per-seed `seedActivations` map (the PPR + * personalization vector) but `activate` hardcoded `reduce(activation = 1.0,...)` + * and never bound the map into the Cypher — so the personalization signal was + * computed upstream and silently dropped, leaving a uniform decay walk. + * + * These tests run WITHOUT a Neo4j instance: they stub the driver and capture the + * exact Cypher + params handed to the query, proving the map now reaches the + * query. The Cypher *math* (that weights actually reorder activation) is covered + * by the integration test in spreading-activation-ppr.test.ts. + */ + +interface CapturedRun { + cypher: string + params: Record +} + +function fakeDriver(captured: CapturedRun[]): Driver { + const tx = { + run(cypher: string, params: Record) { + captured.push({ cypher, params }) + return Promise.resolve({ records: [] }) + }, + } + const session = { + executeRead(work: (t: typeof tx) => unknown) { + return Promise.resolve(work(tx)) + }, + close() { + return Promise.resolve() + }, + } + return { session: () => session } as unknown as Driver +} + +describe('SpreadingActivation seed binding (unit, no Neo4j)', () => { + it('threads per-seed weights into the query params and binds them in the Cypher', async () => { + const captured: CapturedRun[] = [] + const sa = new SpreadingActivation(fakeDriver(captured)) + + await sa.activate( + ['a', 'b'], + undefined, + new Map([ + ['a', 0.9], + ['b', 0.2], + ]), + ) + + expect(captured).toHaveLength(1) + const { cypher, params } = captured[0] + // The personalization vector must reach Neo4j as a plain-object param. + expect(params.seedWeights).toEqual({ a: 0.9, b: 0.2 }) + // The query must actually consume it (it used to hardcode `activation = 1.0`). + expect(cypher).toContain('coalesce($seedWeights[seedId], 1.0)') + expect(cypher).not.toContain('activation = 1.0') + }) + + it('defaults to an empty weight map when no seed activations are supplied', async () => { + const captured: CapturedRun[] = [] + const sa = new SpreadingActivation(fakeDriver(captured)) + + await sa.activate(['a', 'b']) + + // Empty map → coalesce(...) falls back to 1.0 per seed, i.e. prior behavior. + expect(captured[0].params.seedWeights).toEqual({}) + }) + + it('returns early without querying when there are no seeds', async () => { + const captured: CapturedRun[] = [] + const sa = new SpreadingActivation(fakeDriver(captured)) + + const out = await sa.activate([]) + + expect(out).toEqual([]) + expect(captured).toHaveLength(0) + }) +}) From d9037101fbcb9f74fd81e79e9a6f0e910238e157 Mon Sep 17 00:00:00 2001 From: muhammadkh4n Date: Wed, 10 Jun 2026 04:32:54 +0500 Subject: [PATCH 2/5] feat(core): salience-gated plasticity + near-duplicate merge at ingest Brain-fidelity recall-quality work (Gaps 3 + 1 from the architecture spec). Both attack the move-zero finding that the associative layer is poisoned by noise and duplicate episodes. Gap 3 - salience-gated plasticity: scoreSalience already scored every episode (acknowledgment 0.10, decision 0.90) but association edges used flat constants, so a heartbeat and a decision wired equally strong. New salienceGate() modulates edge strength by the WEAKEST-LINK salience of the two endpoints (a salient partner cannot rescue a noise endpoint) and drops the edge below a low-cut ("no plasticity without salience"). Wired into createTemporalEdges using the salience already in-hand at ingest. Backward-compatible: no salience map keeps the flat 0.3 behavior byte-identical. Gap 1 - pattern separation (pragmatic tier): ingest inserted unconditionally, so repeated turns (heartbeats, re-emitted tool output) piled up as distinct rows and interfered at recall. New findNearDuplicate() checks the incoming embedding against recent same-session episodes; on a hit (cosine >= dedupeThreshold, default 0.95) the existing memory is reinforced via recordAccess instead of storing a redundant copy. Conservative, configurable (MemoryOptions.dedupeThreshold; <=0 disables), and non-destructive. Pure primitives (salienceGate, findNearDuplicate) are unit-tested; the ingest merge + reinforcement is covered end-to-end. core 509/509, tsc clean. Deferred (storage boundary): gating co-recalled edge strength (upsertCoRecalled) and dream-cycle replay/topical weights; the k-WTA separation code (Gap 1 tier 2). --- packages/core/src/ingestion/near-duplicate.ts | 60 +++++++++++++++++++ packages/core/src/ingestion/plasticity.ts | 60 +++++++++++++++++++ packages/core/src/memory.ts | 44 +++++++++++++- .../core/src/systems/association-manager.ts | 27 +++++++-- .../test/ingestion/near-duplicate.test.ts | 48 +++++++++++++++ .../core/test/ingestion/plasticity.test.ts | 46 ++++++++++++++ packages/core/test/memory.test.ts | 56 ++++++++++++++++- 7 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/ingestion/near-duplicate.ts create mode 100644 packages/core/src/ingestion/plasticity.ts create mode 100644 packages/core/test/ingestion/near-duplicate.test.ts create mode 100644 packages/core/test/ingestion/plasticity.test.ts diff --git a/packages/core/src/ingestion/near-duplicate.ts b/packages/core/src/ingestion/near-duplicate.ts new file mode 100644 index 0000000..5817894 --- /dev/null +++ b/packages/core/src/ingestion/near-duplicate.ts @@ -0,0 +1,60 @@ +/** + * Pattern separation (pragmatic tier) — near-duplicate detection. + * + * Dense embeddings cluster similar items; the dentate gyrus does the opposite, + * orthogonalizing near-identical inputs so they do not interfere. Engram had no + * separation stage, so repeated turns (heartbeats, re-emitted tool output, the + * same statement twice) piled up as distinct rows and interfered at recall. + * + * This is the cheap, high-payoff tier: at ingest, detect when an incoming + * episode is near-identical to a recent one and reinforce the existing memory + * instead of storing a redundant copy. (The faithful tier — a sparse k-WTA + * separation code — is deferred.) + */ +export interface NearDupCandidate { + id: string + embedding: number[] | null +} + +export interface NearDupMatch { + id: string + similarity: number +} + +export function cosineSimilarity(a: readonly number[], b: readonly number[]): number { + if (a.length === 0 || a.length !== b.length) return 0 + let dot = 0 + let normA = 0 + let normB = 0 + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + if (normA === 0 || normB === 0) return 0 + return dot / (Math.sqrt(normA) * Math.sqrt(normB)) +} + +/** + * Return the most-similar candidate at or above `threshold`, or null. Candidates + * without an embedding, or with a mismatched dimension, are skipped (the check + * degrades to "no duplicate" rather than throwing). A threshold ≤ 0 disables. + */ +export function findNearDuplicate( + embedding: readonly number[], + candidates: readonly NearDupCandidate[], + threshold: number, +): NearDupMatch | null { + if (embedding.length === 0 || threshold <= 0) return null + + let best: NearDupMatch | null = null + for (const candidate of candidates) { + const emb = candidate.embedding + if (!emb || emb.length !== embedding.length) continue + const similarity = cosineSimilarity(embedding, emb) + if (similarity >= threshold && (best === null || similarity > best.similarity)) { + best = { id: candidate.id, similarity } + } + } + return best +} diff --git a/packages/core/src/ingestion/plasticity.ts b/packages/core/src/ingestion/plasticity.ts new file mode 100644 index 0000000..9d2f7c2 --- /dev/null +++ b/packages/core/src/ingestion/plasticity.ts @@ -0,0 +1,60 @@ +/** + * Salience-gated plasticity (the neuromodulatory gate). + * + * The brain does not wire associations on raw co-occurrence — it gates + * long-term potentiation on novelty / reward / emotional salience (dopamine, + * noradrenaline), and habituates to the frequent-but-uninformative. Engram + * already scores every episode's salience at ingest (`scoreSalience`) but its + * association edges used flat constants (temporal 0.3, co-recalled 0.8, ...), + * so a heartbeat (salience 0.10) and a decision (0.90) formed equally strong + * edges. That is the mechanism that poisons the associative layer. + * + * `salienceGate` modulates an edge's base strength by the salience of the two + * memories it connects, using a WEAKEST-LINK rule: an edge is only as + * trustworthy as its least-salient endpoint, so a high-salience partner cannot + * rescue a noise endpoint. Below `lowCut` the edge is dropped entirely — no + * plasticity without salience. + */ +export interface SalienceGateOptions { + /** Min-endpoint salience at/below which the edge is dropped (returns 0). Default 0.15. */ + lowCut?: number + /** Min-endpoint salience at/above which the edge keeps full base strength. Default 0.40. */ + fullStrengthAt?: number +} + +const DEFAULT_LOW_CUT = 0.15 +const DEFAULT_FULL_STRENGTH_AT = 0.40 + +function clamp01(x: number): number { + if (Number.isNaN(x)) return 0 + if (x < 0) return 0 + if (x > 1) return 1 + return x +} + +/** + * Modulate an association edge's base strength by the salience of its two + * endpoints. Returns a strength in [0, baseStrength] — the gate only ever + * attenuates, never amplifies. Returns 0 when the edge should not form. + */ +export function salienceGate( + baseStrength: number, + sourceSalience: number, + targetSalience: number, + options?: SalienceGateOptions, +): number { + const lowCut = options?.lowCut ?? DEFAULT_LOW_CUT + const fullStrengthAt = options?.fullStrengthAt ?? DEFAULT_FULL_STRENGTH_AT + + // Weakest-link: the least-salient endpoint caps the edge. min(0.1, 0.9) = 0.1 + // keeps a heartbeat→decision edge weak regardless of the decision's salience. + const minSalience = Math.min(clamp01(sourceSalience), clamp01(targetSalience)) + if (minSalience <= lowCut) return 0 + + const factor = + fullStrengthAt <= lowCut + ? 1 + : Math.min(1, (minSalience - lowCut) / (fullStrengthAt - lowCut)) + + return baseStrength * factor +} diff --git a/packages/core/src/memory.ts b/packages/core/src/memory.ts index 23e9752..e7df7b2 100644 --- a/packages/core/src/memory.ts +++ b/packages/core/src/memory.ts @@ -18,6 +18,7 @@ import { decayPass } from './consolidation/decay-pass.js' import { runAutoConsolidation } from './consolidation/auto-consolidation.js' import type { AutoConsolidationOpts } from './consolidation/auto-consolidation.js' import { scoreSalience } from './ingestion/salience.js' +import { findNearDuplicate } from './ingestion/near-duplicate.js' import { extractEntities } from './ingestion/entity-extractor.js' import { parseContent } from './ingestion/content-parser.js' import { generateId } from './utils/id.js' @@ -80,6 +81,15 @@ export interface MemoryOptions { * latency, and the lift is workload-dependent. */ contextualRetrieval?: boolean + /** + * Pattern separation (Gap 1): near-duplicate merge at ingest. When an + * incoming episode's embedding is ≥ this cosine threshold to a recent + * same-session episode, the existing memory is reinforced (recordAccess) + * instead of storing a redundant copy — mimicking dentate-gyrus separation, + * where redundant encodings collapse and distinct ones stay separate. + * Range (0, 1]; a value ≤ 0 disables the merge. Defaults to 0.95. + */ + dedupeThreshold?: number } export interface SessionHandle { @@ -99,6 +109,12 @@ const DEFAULT_SESSION_ID = 'default' // around 0.25–0.45, and pure gibberish rarely clears 0.35. 0.5 keeps the // targeted-prune ergonomic without false-positives on unrelated content. const DEFAULT_FORGET_MIN_RELEVANCE = 0.5 +// Pattern separation (Gap 1): cosine threshold at/above which an incoming turn +// is treated as a near-duplicate of a recent same-session episode and merged +// (reinforce the existing memory) rather than stored again. 0.95 is deliberately +// conservative — only near-identical turns (heartbeats, re-emitted output, the +// same statement twice) collapse; rephrasings score lower and are kept distinct. +const DEFAULT_DEDUPE_THRESHOLD = 0.95 /** * Build the contextual text the embedding model sees for a single message. @@ -340,6 +356,26 @@ export class Memory { } } + // Pattern separation (Gap 1): collapse a near-identical recent turn into the + // existing memory instead of storing a redundant copy. Scoped to recent + // same-session episodes (the heartbeat / re-emitted-output case), so distinct + // cross-session events are never merged. recordAccess reinforces the prior + // memory so the signal is kept, not lost. + const dedupeThreshold = this.opts.dedupeThreshold ?? DEFAULT_DEDUPE_THRESHOLD + if (embedding && dedupeThreshold > 0) { + const dup = findNearDuplicate( + embedding, + recentEpisodes.map((e) => ({ id: e.id, embedding: e.embedding })), + dedupeThreshold, + ) + if (dup) { + await this.storage.episodes.recordAccess(dup.id).catch(() => { + // reinforcement is best-effort; never block ingest + }) + return + } + } + const metadata: Record = { ...message.metadata, role: message.role, @@ -379,7 +415,13 @@ export class Memory { .map(e => e.id) if (recentIds.length > 0) { - this.associations.createTemporalEdges([...recentIds, episode.id]).catch(() => { + // Salience-gated plasticity (Gap 3): temporal edges touching a noise turn + // (heartbeat, acknowledgment) form weakly or not at all, rather than all + // at a flat 0.3 — the fix for the frequency-poisoned associative layer. + const salienceById = new Map() + for (const e of recentEpisodes) salienceById.set(e.id, e.salience) + salienceById.set(episode.id, salience) + this.associations.createTemporalEdges([...recentIds, episode.id], { salienceById }).catch(() => { // non-fatal: temporal edges are enrichment only }) } diff --git a/packages/core/src/systems/association-manager.ts b/packages/core/src/systems/association-manager.ts index 2f53731..53f42b1 100644 --- a/packages/core/src/systems/association-manager.ts +++ b/packages/core/src/systems/association-manager.ts @@ -1,5 +1,6 @@ import type { MemoryType } from '../types.js' import type { AssociationStorage } from '../adapters/storage.js' +import { salienceGate } from '../ingestion/plasticity.js' const MAX_EDGES_PER_MEMORY = 100 const MAX_CO_RECALLED = 5 @@ -15,22 +16,40 @@ export class AssociationManager { */ async createTemporalEdges( episodeIds: string[], - opts?: { maxDistance?: number } + opts?: { maxDistance?: number; salienceById?: ReadonlyMap } ): Promise { const maxDistance = opts?.maxDistance ?? 5 + const salienceById = opts?.salienceById let created = 0 for (let i = 0; i < episodeIds.length; i++) { for (let j = i + 1; j < episodeIds.length; j++) { if (j - i > maxDistance) break + const sourceId = episodeIds[i] + const targetId = episodeIds[j] + + // Salience-gated plasticity: a temporal edge touching a noise turn + // (heartbeat, acknowledgment) forms weakly or not at all. Unknown + // salience defaults to ordinary (0.3) so callers that pass no map are + // byte-identical to the legacy flat-0.3 behavior. + let strength = 0.3 + if (salienceById) { + strength = salienceGate( + 0.3, + salienceById.get(sourceId) ?? 0.3, + salienceById.get(targetId) ?? 0.3, + ) + if (strength <= 0) continue + } + await this.storage.insert({ - sourceId: episodeIds[i], + sourceId, sourceType: 'episode', - targetId: episodeIds[j], + targetId, targetType: 'episode', edgeType: 'temporal', - strength: 0.3, + strength, lastActivated: null, metadata: {}, }) diff --git a/packages/core/test/ingestion/near-duplicate.test.ts b/packages/core/test/ingestion/near-duplicate.test.ts new file mode 100644 index 0000000..dbfd1ac --- /dev/null +++ b/packages/core/test/ingestion/near-duplicate.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { cosineSimilarity, findNearDuplicate } from '../../src/ingestion/near-duplicate.js' + +describe('cosineSimilarity', () => { + it('is 1 for identical, 0 for orthogonal, -1 for opposite', () => { + expect(cosineSimilarity([1, 0, 0], [1, 0, 0])).toBeCloseTo(1) + expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0) + expect(cosineSimilarity([1, 1], [-1, -1])).toBeCloseTo(-1) + }) + + it('returns 0 on empty, zero, or mismatched-dimension vectors', () => { + expect(cosineSimilarity([], [])).toBe(0) + expect(cosineSimilarity([0, 0], [1, 1])).toBe(0) + expect(cosineSimilarity([1, 2, 3], [1, 2])).toBe(0) + }) +}) + +describe('findNearDuplicate', () => { + const q = [1, 0, 0] + + it('returns the most-similar candidate at or above threshold', () => { + const match = findNearDuplicate(q, [ + { id: 'far', embedding: [0, 1, 0] }, + { id: 'near', embedding: [0.99, 0.01, 0] }, + { id: 'exact', embedding: [1, 0, 0] }, + ], 0.95) + expect(match?.id).toBe('exact') + expect(match?.similarity).toBeCloseTo(1) + }) + + it('returns null when nothing clears the threshold', () => { + expect(findNearDuplicate(q, [{ id: 'far', embedding: [0, 1, 0] }], 0.95)).toBeNull() + }) + + it('skips candidates without an embedding or with a mismatched dimension', () => { + const match = findNearDuplicate(q, [ + { id: 'noemb', embedding: null }, + { id: 'wrongdim', embedding: [1, 0] }, + { id: 'ok', embedding: [1, 0, 0] }, + ], 0.9) + expect(match?.id).toBe('ok') + }) + + it('disables on a non-positive threshold or empty query', () => { + expect(findNearDuplicate(q, [{ id: 'exact', embedding: [1, 0, 0] }], 0)).toBeNull() + expect(findNearDuplicate([], [{ id: 'exact', embedding: [1, 0, 0] }], 0.9)).toBeNull() + }) +}) diff --git a/packages/core/test/ingestion/plasticity.test.ts b/packages/core/test/ingestion/plasticity.test.ts new file mode 100644 index 0000000..7bc5ca0 --- /dev/null +++ b/packages/core/test/ingestion/plasticity.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { salienceGate } from '../../src/ingestion/plasticity.js' + +const BASE = 0.3 // the legacy flat temporal-edge strength + +describe('salienceGate', () => { + it('drops the edge entirely when an endpoint is noise (weakest-link)', () => { + // A heartbeat (0.10) linked to a decision (0.90): the salient partner must + // NOT rescue the noise endpoint — this is the poisoning fix. + expect(salienceGate(BASE, 0.1, 0.9)).toBe(0) + expect(salienceGate(BASE, 0.9, 0.1)).toBe(0) + // acknowledgment ↔ acknowledgment + expect(salienceGate(BASE, 0.1, 0.1)).toBe(0) + }) + + it('keeps full base strength when both endpoints are salient', () => { + expect(salienceGate(BASE, 0.9, 0.85)).toBeCloseTo(BASE) + expect(salienceGate(BASE, 0.4, 0.4)).toBeCloseTo(BASE) // at fullStrengthAt + }) + + it('attenuates ordinary (default-salience) content partially', () => { + // min 0.3 → (0.3 - 0.15) / (0.40 - 0.15) = 0.6 + expect(salienceGate(BASE, 0.3, 0.3)).toBeCloseTo(BASE * 0.6) + }) + + it('never amplifies — result is always within [0, baseStrength]', () => { + for (const [s1, s2] of [[1, 1], [0.5, 0.9], [0.3, 0.7], [0.16, 0.16]]) { + const g = salienceGate(BASE, s1, s2) + expect(g).toBeGreaterThanOrEqual(0) + expect(g).toBeLessThanOrEqual(BASE + 1e-9) + } + }) + + it('clamps out-of-range salience inputs', () => { + expect(salienceGate(BASE, 2, 2)).toBeCloseTo(BASE) // clamped to 1 + expect(salienceGate(BASE, -1, 0.9)).toBe(0) // clamped to 0 → dropped + expect(salienceGate(BASE, NaN, 0.9)).toBe(0) + }) + + it('honors custom cut/full thresholds', () => { + // With lowCut 0 and fullStrengthAt 0, the ramp is disabled → full strength. + expect(salienceGate(BASE, 0.2, 0.2, { lowCut: 0, fullStrengthAt: 0 })).toBeCloseTo(BASE) + // Stricter gate: require 0.5 min to even form an edge. + expect(salienceGate(BASE, 0.4, 0.9, { lowCut: 0.5, fullStrengthAt: 0.8 })).toBe(0) + }) +}) diff --git a/packages/core/test/memory.test.ts b/packages/core/test/memory.test.ts index 7e8bdc6..c69f8d8 100644 --- a/packages/core/test/memory.test.ts +++ b/packages/core/test/memory.test.ts @@ -279,8 +279,15 @@ describe('Memory — ingest() with intelligence adapter', () => { it('embeds all messages in ingestBatch', async () => { const storage = makeStorage() + // Distinct per-message embeddings: the orthogonal 2nd/3rd components keep + // the two turns well below the near-duplicate merge threshold so this test + // exercises batch embedding, not pattern separation. const embedFn = vi.fn().mockImplementation((text: string) => - Promise.resolve([text.length * 0.01, 0.5, 0.3]) + Promise.resolve([ + text.length * 0.01, + text.startsWith('first') ? 1 : 0, + text.startsWith('second') ? 1 : 0, + ]) ) const intelligence: IntelligenceAdapter = { embed: embedFn } const memory = createMemory({ storage, intelligence }) @@ -303,6 +310,53 @@ describe('Memory — ingest() with intelligence adapter', () => { await memory.dispose() }) + + it('merges a near-duplicate turn and reinforces the original (pattern separation)', async () => { + const storage = makeStorage() + // Embedding by semantic group: 'alpha' turns are identical in vector space, + // 'beta' is orthogonal. Mimics real near-duplicates vs distinct content. + const embedFn = vi.fn().mockImplementation((text: string) => { + if (text.includes('alpha')) return Promise.resolve([1, 0, 0]) + if (text.includes('beta')) return Promise.resolve([0, 1, 0]) + return Promise.resolve([0, 0, 1]) + }) + const intelligence: IntelligenceAdapter = { embed: embedFn } + const memory = createMemory({ storage, intelligence }) + await memory.initialize() + + await memory.ingest({ role: 'user', content: 'alpha one', sessionId: 'sep' }) + await memory.ingest({ role: 'user', content: 'alpha two', sessionId: 'sep' }) + + // The near-identical second turn is merged, not stored again. + let episodes = await storage.episodes.getBySession('sep') + expect(episodes).toHaveLength(1) + // ...and the surviving original is reinforced (recordAccess bumped it once). + expect(episodes[0].accessCount).toBe(1) + + // A distinct turn is kept separate. + await memory.ingest({ role: 'user', content: 'beta note', sessionId: 'sep' }) + episodes = await storage.episodes.getBySession('sep') + expect(episodes).toHaveLength(2) + + await memory.dispose() + }) + + it('keeps near-duplicates when dedupeThreshold is disabled', async () => { + const storage = makeStorage() + const intelligence: IntelligenceAdapter = { + embed: vi.fn().mockResolvedValue([1, 0, 0]), + } + const memory = createMemory({ storage, intelligence, dedupeThreshold: 0 }) + await memory.initialize() + + await memory.ingest({ role: 'user', content: 'identical turn one', sessionId: 'nodedup' }) + await memory.ingest({ role: 'user', content: 'identical turn two', sessionId: 'nodedup' }) + + const episodes = await storage.episodes.getBySession('nodedup') + expect(episodes).toHaveLength(2) + + await memory.dispose() + }) }) // --------------------------------------------------------------------------- From 118fb292faf437bcfc66fb3d4f3e6a184db8a3a1 Mon Sep 17 00:00:00 2001 From: muhammadkh4n Date: Wed, 10 Jun 2026 04:43:26 +0500 Subject: [PATCH 3/5] feat(core): salience-gate co-recalled and replay edges (finish Gap 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends salience-gated plasticity to the two remaining frequency-weighted edge sites — the Hebbian co-recall layer that move-zero identified as the poisoned one (heartbeats co-recalled repeatedly, accumulating high strength), and the dream-cycle hippocampal-replay edges. Rather than thread a strength delta through the upsertCoRecalled storage signature (which on PostgREST means an engram_upsert_co_recalled RPC + a schema migration on prod), the gate is applied at the edge-creation DECISION: a co-recall edge whose least-salient endpoint is noise is simply not created or strengthened. Same un-poisoning, zero schema/deploy risk, identical on both storage adapters. - createCoRecalledEdges: accepts optional per-memory salience and skips a pair when salienceGate() drops it (weakest-link; unknown salience -> 0.5, ungated). - reconsolidation: looks up encoding salience for the co-recalled episodes and passes it through. Non-episode (curated semantic/procedural) stay ungated. - dream-cycle replay: the seed query returns m.salience and the TOPICAL replay edge weight is salience-gated, so a replay edge between noise seeds forms weakly or not at all. Backward compatible: every existing caller passes no salience -> 0.5 -> unchanged. core 514/514, tsc clean. Deferred: SQL discoverTopicalEdges + causal/light/deep-sleep weights (lower, diffuse value); the graded strength-delta refinement via the RPC. --- .../core/src/consolidation/dream-cycle.ts | 21 ++++-- .../core/src/retrieval/reconsolidation.ts | 19 ++++-- .../core/src/systems/association-manager.ts | 12 +++- .../test/systems/association-manager.test.ts | 68 +++++++++++++++++++ 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/packages/core/src/consolidation/dream-cycle.ts b/packages/core/src/consolidation/dream-cycle.ts index 914e8ab..e1dfe08 100644 --- a/packages/core/src/consolidation/dream-cycle.ts +++ b/packages/core/src/consolidation/dream-cycle.ts @@ -2,6 +2,7 @@ import type { StorageAdapter } from '../adapters/storage.js' import type { GraphPort } from '../adapters/graph.js' import type { IntelligenceAdapter } from '../adapters/intelligence.js' import type { ConsolidateResult } from '../types.js' +import { salienceGate } from '../ingestion/plasticity.js' export interface DreamCycleOptions { daysLookback?: number @@ -464,13 +465,18 @@ export async function dreamCycle( WHERE m.createdAt IS NOT NULL ORDER BY m.createdAt DESC LIMIT ${seedLimit} - RETURN m.id AS memoryId + RETURN m.id AS memoryId, m.salience AS salience `) - const seeds = seedResult.records.map((r: { get(key: string): unknown }) => ({ memoryId: r.get('memoryId') as string })) + const seeds = seedResult.records.map((r: { get(key: string): unknown }) => ({ + memoryId: r.get('memoryId') as string, + // Unknown/missing salience defaults to ordinary (0.5) so a seed is never + // gated out for lack of the property. + salience: typeof r.get('salience') === 'number' ? (r.get('salience') as number) : 0.5, + })) // Run spreading activation from each seed - const activationResults: Array<{ seedId: string; activatedMemoryIds: Set }> = [] + const activationResults: Array<{ seedId: string; salience: number; activatedMemoryIds: Set }> = [] for (const seed of seeds) { const activated = await graph.spreadActivation({ seedNodeIds: [seed.memoryId], @@ -484,7 +490,7 @@ export async function dreamCycle( .filter(n => n.nodeType === 'Memory') .map(n => n.nodeId), ) - activationResults.push({ seedId: seed.memoryId, activatedMemoryIds }) + activationResults.push({ seedId: seed.memoryId, salience: seed.salience, activatedMemoryIds }) } // Create TOPICAL edges for pairs with >= 3 Memory overlap @@ -496,7 +502,12 @@ export async function dreamCycle( const overlap = new Set([...a.activatedMemoryIds].filter(id => b.activatedMemoryIds.has(id))) if (overlap.size < 3) continue - const edgeWeight = Math.min(0.9, 0.3 + 0.1 * Math.min(overlap.size, 6)) + // Salience-gated plasticity: a replay edge between noise seeds (low + // encoding salience) forms weakly or not at all, instead of purely on + // co-activation overlap. Drops to 0 when either seed is noise. + const baseWeight = Math.min(0.9, 0.3 + 0.1 * Math.min(overlap.size, 6)) + const edgeWeight = salienceGate(baseWeight, a.salience, b.salience) + if (edgeWeight <= 0) continue const now = new Date().toISOString() await graph.runCypherWrite!(` diff --git a/packages/core/src/retrieval/reconsolidation.ts b/packages/core/src/retrieval/reconsolidation.ts index c12c493..65eef9a 100644 --- a/packages/core/src/retrieval/reconsolidation.ts +++ b/packages/core/src/retrieval/reconsolidation.ts @@ -39,10 +39,21 @@ export function stageReconsolidate( }) // Create co_recalled edges through AssociationManager so the 100-edge-per-memory - // cap is enforced (audit finding L5). - const coRecalledUpdate = manager.createCoRecalledEdges( - recalled.slice(0, 5).map((m) => ({ id: m.id, type: m.type })), - ) + // cap is enforced (audit finding L5). Encoding salience is looked up for the + // co-recalled episodes so noise turns do not wire (or strengthen) Hebbian + // edges; non-episode memories (curated semantic/procedural) are left ungated. + const coRecalledUpdate = (async () => { + const top5 = recalled.slice(0, 5) + const episodeIds = top5.filter((m) => m.type === 'episode').map((m) => m.id) + const salienceById = new Map() + if (episodeIds.length > 0) { + const eps = await storage.episodes.getByIds(episodeIds) + for (const e of eps) salienceById.set(e.id, e.salience) + } + return manager.createCoRecalledEdges( + top5.map((m) => ({ id: m.id, type: m.type, salience: salienceById.get(m.id) })), + ) + })() // Wave 2: Neo4j edge strengthening. // Each traversed edge gets weight += 0.02 (capped at 1.0). This is the diff --git a/packages/core/src/systems/association-manager.ts b/packages/core/src/systems/association-manager.ts index 53f42b1..b3a83d6 100644 --- a/packages/core/src/systems/association-manager.ts +++ b/packages/core/src/systems/association-manager.ts @@ -97,7 +97,7 @@ export class AssociationManager { * Returns the number of upserts attempted (edges actually written). */ async createCoRecalledEdges( - memories: Array<{ id: string; type: MemoryType }> + memories: Array<{ id: string; type: MemoryType; salience?: number }> ): Promise { const top = memories.slice(0, MAX_CO_RECALLED) let created = 0 @@ -120,6 +120,16 @@ export class AssociationManager { for (let i = 0; i < top.length; i++) { for (let j = i + 1; j < top.length; j++) { + // Salience-gated plasticity: do not wire (or strengthen) a co-recall + // edge whose least-salient endpoint is noise (heartbeat, acknowledgment). + // This is the move-zero fix — the poisoned Hebbian layer where boilerplate + // co-recalled repeatedly and accumulated high strength. Unknown salience + // (e.g. curated semantic/procedural memories) defaults to ordinary (0.5), + // so they are never gated out and legacy callers are unaffected. + const sourceSalience = top[i].salience ?? 0.5 + const targetSalience = top[j].salience ?? 0.5 + if (salienceGate(1, sourceSalience, targetSalience) <= 0) continue + const sourceCount = await getCount(top[i].id) if (sourceCount > MAX_EDGES_PER_MEMORY) continue diff --git a/packages/core/test/systems/association-manager.test.ts b/packages/core/test/systems/association-manager.test.ts index 143de9a..5f2f7ae 100644 --- a/packages/core/test/systems/association-manager.test.ts +++ b/packages/core/test/systems/association-manager.test.ts @@ -284,3 +284,71 @@ describe('AssociationManager.createSupportEdge', () => { expect(arg.lastActivated).toBeNull() }) }) + +// --------------------------------------------------------------------------- +// Salience-gated plasticity (Gap 3) +// --------------------------------------------------------------------------- + +describe('AssociationManager — salience-gated plasticity', () => { + it('co_recalled: skips a pair whose endpoint is noise', async () => { + const storage = makeMockStorage() + const manager = new AssociationManager(storage) + // `noise` is a heartbeat (0.10): pairs touching it must not wire, no matter + // how salient the partner is (weakest-link). + const memories = [ + { id: 'real1', type: 'episode' as MemoryType, salience: 0.9 }, + { id: 'noise', type: 'episode' as MemoryType, salience: 0.1 }, + { id: 'real2', type: 'episode' as MemoryType, salience: 0.8 }, + ] + const count = await manager.createCoRecalledEdges(memories) + expect(count).toBe(1) // only (real1, real2) survives + expect(storage.upsertCoRecalled).toHaveBeenCalledTimes(1) + const call = vi.mocked(storage.upsertCoRecalled).mock.calls[0] + expect([call[0], call[2]].sort()).toEqual(['real1', 'real2']) + }) + + it('co_recalled: wires every pair when all memories are salient', async () => { + const storage = makeMockStorage() + const manager = new AssociationManager(storage) + const count = await manager.createCoRecalledEdges([ + { id: 'a', type: 'episode', salience: 0.9 }, + { id: 'b', type: 'episode', salience: 0.8 }, + { id: 'c', type: 'episode', salience: 0.7 }, + ]) + expect(count).toBe(3) + }) + + it('co_recalled: leaves pairs ungated when salience is absent (backward compatible)', async () => { + const storage = makeMockStorage() + const manager = new AssociationManager(storage) + const count = await manager.createCoRecalledEdges([ + { id: 'a', type: 'episode' }, + { id: 'b', type: 'episode' }, + ]) + expect(count).toBe(1) // undefined salience → 0.5 → not gated + }) + + it('temporal: drops a noise edge and attenuates an ordinary one', async () => { + const storage = makeMockStorage() + const manager = new AssociationManager(storage) + const salienceById = new Map([ + ['hi', 0.9], + ['noise', 0.1], + ['lo', 0.3], + ]) + // pairs within distance: (hi,noise) drop, (hi,lo) keep, (noise,lo) drop + const count = await manager.createTemporalEdges(['hi', 'noise', 'lo'], { salienceById }) + expect(count).toBe(1) + const [arg] = vi.mocked(storage.insert).mock.calls[0] + expect(arg.strength).toBeGreaterThan(0) + expect(arg.strength).toBeLessThan(0.3) // ordinary 0.3 endpoint attenuates the edge + }) + + it('temporal: keeps the flat 0.3 strength when no salience map is provided', async () => { + const storage = makeMockStorage() + const manager = new AssociationManager(storage) + await manager.createTemporalEdges(['a', 'b']) + const [arg] = vi.mocked(storage.insert).mock.calls[0] + expect(arg.strength).toBe(0.3) + }) +}) From 2d85b7f4b9883e72b0ece923e96a194ee7143fc3 Mon Sep 17 00:00:00 2001 From: muhammadkh4n Date: Wed, 10 Jun 2026 04:54:28 +0500 Subject: [PATCH 4/5] feat(core): context reinstatement + lateral inhibition at recall (Gaps 4 + 5) Both wire signals that were already computed but never used at retrieval. Gap 4 - context reinstatement: the sensory buffer already tracks the topics primed by recent turns (the active conversational context), but recall seeded spreading activation only from query relevance + entities + project. Now the primed topics are looked up and added as soft context seeds (weight 0.45, below entity/project seeds), so the SAME query completes to different associations depending on what is currently in focus (encoding specificity). Best-effort and query-dominant. Gap 5 - lateral inhibition / fan effect: dream-cycle's betweenness pass already flags high-centrality hubs with isBridge, but recall ignored it. Activated nodes are now down-weighted (x0.5) when isBridge, applied before the primary/faint threshold split so a generic hub can fall out of the result set entirely. No-op when betweenness was never computed (no isBridge property present). Both land in stageActivate; the engine passes sensory.getPrimed() through. Backward compatible (no context topics -> unchanged; no isBridge -> unchanged). 4 new tests, core 518/518, tsc clean. Deferred: degree-based divisive normalization in the activation Cypher (a heavier, always-available alternative to the isBridge gate); per-topic boost weighting of context seeds. --- packages/core/src/retrieval/engine.ts | 6 +- .../src/retrieval/spreading-activation.ts | 40 ++++++ .../spreading-activation-gaps.test.ts | 117 ++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/retrieval/spreading-activation-gaps.test.ts diff --git a/packages/core/src/retrieval/engine.ts b/packages/core/src/retrieval/engine.ts index ec0210c..7df5a2c 100644 --- a/packages/core/src/retrieval/engine.ts +++ b/packages/core/src/retrieval/engine.ts @@ -621,7 +621,11 @@ export async function recall( let compositeContext: CompositeMemory | null = null if (strategy.associations && graph !== null) { - const activationResult = await stageActivate(memories, query, graph, strategy, storage, project, projectId) + // Context reinstatement (Gap 4): the topics currently primed in the sensory + // buffer (set by recent turns) are folded into the spreading-activation + // seeds so recall is sensitive to the active conversational context. + const contextTopics = sensory.getPrimed().map((p) => p.topic) + const activationResult = await stageActivate(memories, query, graph, strategy, storage, project, projectId, contextTopics) if (activationResult === null) { // Graph has no nodes for any seed — fall back to SQL walk const legacyStrategy = toRetrievalStrategy(strategy) diff --git a/packages/core/src/retrieval/spreading-activation.ts b/packages/core/src/retrieval/spreading-activation.ts index 4425051..80ef43c 100644 --- a/packages/core/src/retrieval/spreading-activation.ts +++ b/packages/core/src/retrieval/spreading-activation.ts @@ -147,6 +147,16 @@ export interface ActivationResultSet { * Returning null (not empty results) signals "graph could not help" vs * "graph ran and found nothing". These have different implications. */ +// Context reinstatement (Gap 4): seed weight for primed-topic context nodes. +// Below entity (0.5) / project (0.6) seeds — context nudges recall toward what +// is currently in focus; query relevance still dominates. +const CONTEXT_SEED_WEIGHT = 0.45 + +// Lateral inhibition (Gap 5): activation multiplier for high-betweenness hub +// nodes (flagged isBridge by the dream-cycle betweenness pass). < 1 suppresses +// generic connectors that would otherwise flood recall (the fan effect). +const HUB_INHIBITION_FACTOR = 0.5 + export async function stageActivate( recalled: RetrievedMemory[], query: string, @@ -155,6 +165,7 @@ export async function stageActivate( storage: StorageAdapter, project?: string, projectId?: string, + contextTopics?: string[], ): Promise { const params = getActivationParams(strategy) @@ -186,6 +197,24 @@ export async function stageActivate( } } + // --- Context reinstatement (Gap 4) --- + // Seed from the active conversational context — topics primed by recent turns + // (sensory buffer) — so the SAME query completes to different associations + // depending on what is currently in focus (encoding specificity). Best-effort + // and soft-weighted: query relevance dominates, context only nudges. + if (contextTopics && contextTopics.length > 0) { + try { + const contextNodes = await graph.lookupEntityNodes(contextTopics) + for (const node of contextNodes) { + if (!seedActivations.has(node.nodeId)) { + seedActivations.set(node.nodeId, CONTEXT_SEED_WEIGHT) + } + } + } catch { + // context reinstatement is enrichment only; never block recall + } + } + if (seedActivations.size === 0) { // No seeds at all — nothing to activate from return null @@ -213,6 +242,17 @@ export async function stageActivate( return null } + // --- Lateral inhibition / fan effect (Gap 5) --- + // Down-weight high-betweenness hub nodes (flagged isBridge by the dream-cycle + // betweenness pass) so generic connectors don't dominate recall. Applied here, + // before the threshold split, so a suppressed hub can fall out of the primary + // set entirely. No-op when betweenness was never computed (no isBridge prop). + activatedNodes = activatedNodes.map((n) => + n.properties?.['isBridge'] === true + ? { ...n, activation: n.activation * HUB_INHIBITION_FACTOR } + : n, + ) + // --- Mixed population check --- // If activatedNodes only contains context nodes (Person, Topic, etc.) but // no Memory nodes from the seeds, the graph has no records for these diff --git a/packages/core/test/retrieval/spreading-activation-gaps.test.ts b/packages/core/test/retrieval/spreading-activation-gaps.test.ts new file mode 100644 index 0000000..ee714e6 --- /dev/null +++ b/packages/core/test/retrieval/spreading-activation-gaps.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest' +import { stageActivate } from '../../src/retrieval/spreading-activation.js' +import type { GraphPort } from '../../src/adapters/graph.js' +import type { StorageAdapter } from '../../src/adapters/storage.js' +import type { RecallStrategy, RetrievedMemory, Episode } from '../../src/types.js' + +// stageActivate touches only these graph/storage methods. +function makeGraph(overrides: Partial = {}): GraphPort { + return { + lookupEntityNodes: vi.fn().mockResolvedValue([]), + spreadActivation: vi.fn().mockResolvedValue([]), + ...overrides, + } as unknown as GraphPort +} + +function makeStorage(episodes: Episode[]): StorageAdapter { + return { + episodes: { getByIds: vi.fn().mockResolvedValue(episodes) }, + } as unknown as StorageAdapter +} + +const STRATEGY = { mode: 'deep', associations: true } as unknown as RecallStrategy + +function ep(id: string): Episode { + return { + id, + sessionId: 's', + role: 'user', + content: `content ${id}`, + salience: 0.5, + accessCount: 0, + lastAccessed: null, + consolidatedAt: null, + embedding: null, + entities: [], + metadata: {}, + createdAt: new Date(), + projectId: null, + } as Episode +} + +const recalled: RetrievedMemory[] = [ + { id: 'seed-1', type: 'episode', content: 'x', relevance: 0.9, source: 'recall', metadata: {} } as RetrievedMemory, +] + +describe('stageActivate — context reinstatement (Gap 4)', () => { + it('seeds primed-topic context nodes into spreading activation', async () => { + const spreadActivation = vi.fn().mockResolvedValue([]) + const graph = makeGraph({ + // No query entities ('what did we decide' extracts none), so this is + // called only for the context topics. + lookupEntityNodes: vi + .fn() + .mockImplementation(async (names: string[]) => + names.includes('auth') ? [{ nodeId: 'topic:auth', nodeType: 'Topic', name: 'auth' }] : [], + ), + spreadActivation, + }) + + await stageActivate( + recalled, + 'what did we decide', + graph, + STRATEGY, + makeStorage([]), + undefined, + undefined, + ['auth'], + ) + + expect(graph.lookupEntityNodes).toHaveBeenCalledWith(['auth']) + const opts = vi.mocked(spreadActivation).mock.calls[0]![0] as { seedActivations: Map } + expect(opts.seedActivations.get('topic:auth')).toBeCloseTo(0.45) + }) + + it('is a no-op when there are no primed topics', async () => { + const spreadActivation = vi.fn().mockResolvedValue([]) + const graph = makeGraph({ spreadActivation }) + + await stageActivate(recalled, 'plain query', graph, STRATEGY, makeStorage([])) + + // Only the vector seed — no context nodes added. + const opts = vi.mocked(spreadActivation).mock.calls[0]![0] as { seedActivations: Map } + expect([...opts.seedActivations.keys()]).toEqual(['seed-1']) + }) +}) + +describe('stageActivate — lateral inhibition (Gap 5)', () => { + it('halves the activation of high-betweenness hub nodes (isBridge)', async () => { + const spreadActivation = vi.fn().mockResolvedValue([ + { nodeId: 'm-bridge', nodeType: 'Memory', activation: 0.5, depth: 1, properties: { isBridge: true } }, + { nodeId: 'm-plain', nodeType: 'Memory', activation: 0.5, depth: 1, properties: {} }, + ]) + const graph = makeGraph({ spreadActivation }) + const storage = makeStorage([ep('m-bridge'), ep('m-plain')]) + + const result = await stageActivate(recalled, 'query', graph, STRATEGY, storage) + + expect(result).not.toBeNull() + const byId = new Map(result!.associations.map((a) => [a.id, a.relevance])) + expect(byId.get('m-plain')).toBeCloseTo(0.5) + expect(byId.get('m-bridge')).toBeCloseTo(0.25) // suppressed by HUB_INHIBITION_FACTOR + expect(byId.get('m-plain')!).toBeGreaterThan(byId.get('m-bridge')!) + }) + + it('leaves activation untouched when no node is flagged isBridge', async () => { + const spreadActivation = vi.fn().mockResolvedValue([ + { nodeId: 'm1', nodeType: 'Memory', activation: 0.5, depth: 1, properties: {} }, + ]) + const graph = makeGraph({ spreadActivation }) + const storage = makeStorage([ep('m1')]) + + const result = await stageActivate(recalled, 'query', graph, STRATEGY, storage) + const m1 = result!.associations.find((a) => a.id === 'm1') + expect(m1?.relevance).toBeCloseTo(0.5) + }) +}) From 73f81604982ef0e7e243a2293198f42854c15acf Mon Sep 17 00:00:00 2001 From: muhammadkh4n Date: Wed, 10 Jun 2026 16:10:06 +0500 Subject: [PATCH 5/5] fix(core): default near-duplicate merge OFF (opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gap 1 near-dup merge defaulted to 0.95-on, which collapsed the artificially near-identical embeddings used by e2e isolation tests (shared-axis fake embedder) and broke CI in @engram-mem/sqlite. More fundamentally, the merge's recall benefit is unvalidated — by the same gate that kept pattern completion opt-in, dedup should be opt-in until measured on real data. dedupeThreshold now defaults to 0 (off); set ~0.95 to enable. No ingest behavior change by default. The Gap 1 unit test opts in explicitly. sqlite 106/106, postgrest 71/71, core 518/518. --- packages/core/src/memory.ts | 12 ++++-------- packages/core/test/memory.test.ts | 3 ++- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/core/src/memory.ts b/packages/core/src/memory.ts index e7df7b2..dada094 100644 --- a/packages/core/src/memory.ts +++ b/packages/core/src/memory.ts @@ -87,7 +87,9 @@ export interface MemoryOptions { * same-session episode, the existing memory is reinforced (recordAccess) * instead of storing a redundant copy — mimicking dentate-gyrus separation, * where redundant encodings collapse and distinct ones stay separate. - * Range (0, 1]; a value ≤ 0 disables the merge. Defaults to 0.95. + * Range (0, 1]; a value ≤ 0 disables the merge. Defaults to OFF (0) — the + * recall benefit is not yet validated, so this is opt-in; ~0.95 is the + * recommended value when enabling (only near-identical turns collapse). */ dedupeThreshold?: number } @@ -109,12 +111,6 @@ const DEFAULT_SESSION_ID = 'default' // around 0.25–0.45, and pure gibberish rarely clears 0.35. 0.5 keeps the // targeted-prune ergonomic without false-positives on unrelated content. const DEFAULT_FORGET_MIN_RELEVANCE = 0.5 -// Pattern separation (Gap 1): cosine threshold at/above which an incoming turn -// is treated as a near-duplicate of a recent same-session episode and merged -// (reinforce the existing memory) rather than stored again. 0.95 is deliberately -// conservative — only near-identical turns (heartbeats, re-emitted output, the -// same statement twice) collapse; rephrasings score lower and are kept distinct. -const DEFAULT_DEDUPE_THRESHOLD = 0.95 /** * Build the contextual text the embedding model sees for a single message. @@ -361,7 +357,7 @@ export class Memory { // same-session episodes (the heartbeat / re-emitted-output case), so distinct // cross-session events are never merged. recordAccess reinforces the prior // memory so the signal is kept, not lost. - const dedupeThreshold = this.opts.dedupeThreshold ?? DEFAULT_DEDUPE_THRESHOLD + const dedupeThreshold = this.opts.dedupeThreshold ?? 0 if (embedding && dedupeThreshold > 0) { const dup = findNearDuplicate( embedding, diff --git a/packages/core/test/memory.test.ts b/packages/core/test/memory.test.ts index c69f8d8..4935a82 100644 --- a/packages/core/test/memory.test.ts +++ b/packages/core/test/memory.test.ts @@ -321,7 +321,8 @@ describe('Memory — ingest() with intelligence adapter', () => { return Promise.resolve([0, 0, 1]) }) const intelligence: IntelligenceAdapter = { embed: embedFn } - const memory = createMemory({ storage, intelligence }) + // dedup is opt-in (default off); enable it for this test. + const memory = createMemory({ storage, intelligence, dedupeThreshold: 0.95 }) await memory.initialize() await memory.ingest({ role: 'user', content: 'alpha one', sessionId: 'sep' })