Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions packages/core/src/consolidation/dream-cycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string> }> = []
const activationResults: Array<{ seedId: string; salience: number; activatedMemoryIds: Set<string> }> = []
for (const seed of seeds) {
const activated = await graph.spreadActivation({
seedNodeIds: [seed.memoryId],
Expand All @@ -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
Expand All @@ -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!(`
Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/ingestion/near-duplicate.ts
Original file line number Diff line number Diff line change
@@ -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
}
60 changes: 60 additions & 0 deletions packages/core/src/ingestion/plasticity.ts
Original file line number Diff line number Diff line change
@@ -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
}
40 changes: 39 additions & 1 deletion packages/core/src/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -80,6 +81,17 @@ 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 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
}

export interface SessionHandle {
Expand Down Expand Up @@ -340,6 +352,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 ?? 0
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<string, unknown> = {
...message.metadata,
role: message.role,
Expand Down Expand Up @@ -379,7 +411,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<string, number>()
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
})
}
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/retrieval/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 15 additions & 4 deletions packages/core/src/retrieval/reconsolidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>()
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
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/retrieval/spreading-activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -155,6 +165,7 @@ export async function stageActivate(
storage: StorageAdapter,
project?: string,
projectId?: string,
contextTopics?: string[],
): Promise<ActivationResultSet | null> {
const params = getActivationParams(strategy)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading