Skip to content

Commit ce3ab97

Browse files
committed
feat(memory/typed-network): TypedNetworkRetriever for canonical-shaped retrieval
Adapter that turns the typed-network store + spreading activation into ScoredMemoryTrace[] output, drop-in compatible with the bench's existing canonical-hybrid reader pipeline. Pipeline per query: 1. Extract candidate entities (proper nouns >= 3 chars + quoted strings) 2. Find seed facts whose entities intersect query entities (case-insensitive) 3. Run TypedSpreadingActivation with Hindsight Eq. 12 max-aggregate 4. Top-K activated facts -> ScoredMemoryTrace via typedFactToScoredTrace 5. Bank-prefixed content ("[WORLD] Berlin is in Germany") for reader hint Includes typedFactToScoredTrace helper that namespaces IDs as 'typed-network:<factId>' and tags rows with 'typed-network', 'bank:<bank>' for downstream attribution. 15 unit tests cover entity extraction, seed matching, activation ranking, scope handling, and graph traversal.
1 parent bbf93d8 commit ce3ab97

3 files changed

Lines changed: 404 additions & 0 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* @file TypedNetworkRetriever.ts
3+
* @description Retrieval adapter that turns the typed-network into a
4+
* source of {@link ScoredMemoryTrace}s — drop-in compatible with the
5+
* existing canonical-hybrid retrieval pipeline.
6+
*
7+
* **Pipeline (per query):**
8+
*
9+
* 1. Extract candidate entities from the query text via regex
10+
* (proper nouns ≥ 3 chars, quoted strings).
11+
* 2. Find seed facts in the {@link TypedNetworkStore} whose
12+
* `entities` set intersects the query entities.
13+
* 3. Run {@link TypedSpreadingActivation} from the seed set with
14+
* Hindsight Eq. 12 max-aggregation.
15+
* 4. Take top-K activated facts (sorted by activation level
16+
* descending).
17+
* 5. Convert each typed fact to a `ScoredMemoryTrace`-shaped object
18+
* so the bench's reader pipeline picks it up alongside canonical
19+
* chunks.
20+
*
21+
* The retriever is stateless aside from the store + spreading-
22+
* activation engine it wraps. Safe to share across concurrent
23+
* retrieves on the same store.
24+
*
25+
* @module @framers/agentos/memory/retrieval/typed-network/TypedNetworkRetriever
26+
*/
27+
28+
import type { ScoredMemoryTrace, MemoryScope } from '../../core/types.js';
29+
import type { TypedFact } from './types.js';
30+
import type { TypedNetworkStore } from './TypedNetworkStore.js';
31+
import type { TypedSpreadingActivation } from './TypedSpreadingActivation.js';
32+
33+
/**
34+
* Extract candidate entity strings from a query. Matches the
35+
* Mem0-v3-style regex extractor used at ingest time so query and
36+
* fact entities use the same canonicalization.
37+
*
38+
* Captures:
39+
* - Capitalized words ≥ 3 characters (proper nouns: "Berlin",
40+
* "Docker", "TypeScript")
41+
* - Double-quoted strings ("hello world")
42+
* - Single-quoted strings ('like this')
43+
*
44+
* Returns deduplicated entity strings preserving original casing
45+
* (case-sensitive comparison happens upstream).
46+
*/
47+
export function extractQueryEntities(text: string): string[] {
48+
const properNouns = text.match(/\b[A-Z][a-zA-Z]{2,}\b/g) ?? [];
49+
const dq = text.match(/"([^"]+)"/g)?.map((s) => s.slice(1, -1)) ?? [];
50+
const sq = text.match(/'([^']+)'/g)?.map((s) => s.slice(1, -1)) ?? [];
51+
return [...new Set([...properNouns, ...dq, ...sq])];
52+
}
53+
54+
/**
55+
* Construction options.
56+
*/
57+
export interface TypedNetworkRetrieverOptions {
58+
/** The typed-network store populated at ingest time. */
59+
store: TypedNetworkStore;
60+
/** Pre-constructed spreading-activation engine. */
61+
spreading: TypedSpreadingActivation;
62+
/** Maximum hops for spreading activation. Default 3. */
63+
maxDepth?: number;
64+
/** Activation cutoff for spreading. Default 0.05. */
65+
activationThreshold?: number;
66+
}
67+
68+
/**
69+
* Per-query retrieval options.
70+
*/
71+
export interface TypedNetworkRetrieveOptions {
72+
/** Top-K facts to return after activation ranking. */
73+
topK: number;
74+
/** Memory scope (matches the canonical retrieval scope). */
75+
scope: { scope: MemoryScope; scopeId: string };
76+
/**
77+
* Pre-extracted query entities. Pass when the consumer has done
78+
* its own entity extraction (e.g. via a stronger NER model);
79+
* skipping passes the query through {@link extractQueryEntities}.
80+
*/
81+
queryEntities?: string[];
82+
}
83+
84+
/**
85+
* Adapter that produces canonical-shaped retrieval results from the
86+
* typed-network store. Plugs into the bench's existing reader
87+
* pipeline without requiring changes to downstream code.
88+
*/
89+
export class TypedNetworkRetriever {
90+
private readonly store: TypedNetworkStore;
91+
private readonly spreading: TypedSpreadingActivation;
92+
private readonly maxDepth: number;
93+
private readonly activationThreshold: number;
94+
95+
constructor(opts: TypedNetworkRetrieverOptions) {
96+
this.store = opts.store;
97+
this.spreading = opts.spreading;
98+
this.maxDepth = opts.maxDepth ?? 3;
99+
this.activationThreshold = opts.activationThreshold ?? 0.05;
100+
}
101+
102+
/**
103+
* Retrieve top-K typed facts for the query, formatted as
104+
* {@link ScoredMemoryTrace}s. Returns an empty array when no
105+
* query entities match seed facts in the store (e.g. queries with
106+
* no proper nouns or quoted strings, or queries whose entities
107+
* the typed network has not yet observed).
108+
*/
109+
async retrieve(
110+
query: string,
111+
options: TypedNetworkRetrieveOptions,
112+
): Promise<ScoredMemoryTrace[]> {
113+
const entities = options.queryEntities ?? extractQueryEntities(query);
114+
if (entities.length === 0) return [];
115+
116+
// Seed selection: any fact whose entity set intersects the query
117+
// entities. Case-insensitive intersection because LLM-extracted
118+
// fact entities sometimes drop capitalization.
119+
const lowerEntities = new Set(entities.map((e) => e.toLowerCase()));
120+
const seedIds: string[] = [];
121+
for (const fact of this.store.iterateFacts()) {
122+
if (fact.entities.some((e) => lowerEntities.has(e.toLowerCase()))) {
123+
seedIds.push(fact.id);
124+
}
125+
}
126+
if (seedIds.length === 0) return [];
127+
128+
// Spreading activation with Eq. 12 max-aggregate.
129+
const activations = this.spreading.spread(this.store, seedIds, {
130+
maxDepth: this.maxDepth,
131+
activationThreshold: this.activationThreshold,
132+
});
133+
134+
// Rank by activation, take top-K.
135+
const ranked = [...activations.entries()]
136+
.sort((a, b) => b[1] - a[1])
137+
.slice(0, options.topK);
138+
139+
const traces: ScoredMemoryTrace[] = [];
140+
for (const [factId, activation] of ranked) {
141+
const fact = this.store.getFact(factId);
142+
if (!fact) continue;
143+
traces.push(typedFactToScoredTrace(fact, activation, options.scope));
144+
}
145+
return traces;
146+
}
147+
}
148+
149+
/**
150+
* Convert a {@link TypedFact} into a {@link ScoredMemoryTrace} for
151+
* the bench's downstream reader pipeline. Renders the bank label
152+
* inline in the content so the reader can distinguish typed facts
153+
* from raw chunks at prompt time.
154+
*
155+
* Defaults follow the {@link HybridRetriever.factToScoredTrace}
156+
* pattern: encoding strength 1, retrieval score = activation level,
157+
* neutral emotional context, lifecycle timestamps drawn from the
158+
* fact's mention timestamp.
159+
*/
160+
export function typedFactToScoredTrace(
161+
fact: TypedFact,
162+
activation: number,
163+
scope: { scope: MemoryScope; scopeId: string },
164+
): ScoredMemoryTrace {
165+
const mentionMs = Date.parse(fact.temporal.mention);
166+
const ts = Number.isNaN(mentionMs) ? Date.now() : mentionMs;
167+
// Bank-prefixed content gives the reader a hint about fact kind.
168+
const content = `[${fact.bank}] ${fact.text}`;
169+
return {
170+
id: `typed-network:${fact.id}`,
171+
type: 'semantic',
172+
scope: scope.scope,
173+
scopeId: scope.scopeId,
174+
content,
175+
entities: fact.entities,
176+
tags: ['typed-network', `bank:${fact.bank}`],
177+
provenance: {
178+
sourceType: 'typed_network',
179+
sourceTimestamp: ts,
180+
confidence: fact.confidence,
181+
verificationCount: 0,
182+
},
183+
emotionalContext: {
184+
valence: 0,
185+
arousal: 0,
186+
dominance: 0,
187+
intensity: 0,
188+
gmiMood: '',
189+
},
190+
encodingStrength: 1,
191+
stability: 1,
192+
retrievalCount: 0,
193+
lastAccessedAt: ts,
194+
accessCount: 0,
195+
reinforcementInterval: 0,
196+
associatedTraceIds: [],
197+
createdAt: ts,
198+
updatedAt: ts,
199+
isActive: true,
200+
retrievalScore: activation,
201+
scoreBreakdown: {
202+
strengthScore: 1,
203+
similarityScore: 0,
204+
recencyScore: 0,
205+
emotionalCongruenceScore: 0,
206+
graphActivationScore: activation,
207+
importanceScore: fact.confidence,
208+
},
209+
};
210+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* @file TypedNetworkRetriever.test.ts
3+
* @description Contract tests for the TypedNetworkRetriever adapter.
4+
* Pin: query-entity extraction (regex), seed-set construction (entity
5+
* intersection), spreading-activation order, and ScoredMemoryTrace
6+
* shape coming out of typedFactToScoredTrace.
7+
*/
8+
9+
import { describe, it, expect, beforeEach } from 'vitest';
10+
import {
11+
TypedNetworkRetriever,
12+
extractQueryEntities,
13+
typedFactToScoredTrace,
14+
} from '../TypedNetworkRetriever.js';
15+
import { TypedNetworkStore } from '../TypedNetworkStore.js';
16+
import { TypedSpreadingActivation } from '../TypedSpreadingActivation.js';
17+
import type { TypedFact, BankId } from '../types.js';
18+
19+
function makeFact(
20+
id: string,
21+
text: string,
22+
entities: string[],
23+
bank: BankId = 'WORLD',
24+
mention = '2026-04-26T10:00:00Z',
25+
): TypedFact {
26+
return {
27+
id,
28+
bank,
29+
text,
30+
embedding: [],
31+
temporal: { mention },
32+
participants: [],
33+
reasoningMarkers: [],
34+
entities,
35+
confidence: 1.0,
36+
};
37+
}
38+
39+
describe('extractQueryEntities', () => {
40+
it('extracts capitalized proper nouns ≥ 3 chars', () => {
41+
expect(extractQueryEntities('Where does Alice live?')).toEqual(['Where', 'Alice']);
42+
expect(extractQueryEntities('I deployed Docker yesterday')).toEqual(['Docker']);
43+
});
44+
45+
it('extracts double-quoted strings', () => {
46+
const out = extractQueryEntities('Find the "deployment server" config');
47+
expect(out).toContain('deployment server');
48+
});
49+
50+
it('extracts single-quoted strings', () => {
51+
const out = extractQueryEntities("She said 'hello world' to me");
52+
expect(out).toContain('hello world');
53+
});
54+
55+
it('deduplicates entities', () => {
56+
const out = extractQueryEntities('Berlin Berlin Berlin');
57+
expect(out).toEqual(['Berlin']);
58+
});
59+
60+
it('returns empty for queries with no proper nouns or quotes', () => {
61+
expect(extractQueryEntities('what time is it')).toEqual([]);
62+
expect(extractQueryEntities('a b c')).toEqual([]);
63+
});
64+
});
65+
66+
describe('typedFactToScoredTrace', () => {
67+
it('produces a valid ScoredMemoryTrace shape', () => {
68+
const fact = makeFact('f1', 'Berlin is in Germany', ['Berlin', 'Germany']);
69+
const trace = typedFactToScoredTrace(fact, 0.75, { scope: 'user', scopeId: 'bench' });
70+
expect(trace.id).toBe('typed-network:f1');
71+
expect(trace.type).toBe('semantic');
72+
expect(trace.scope).toBe('user');
73+
expect(trace.scopeId).toBe('bench');
74+
expect(trace.content).toBe('[WORLD] Berlin is in Germany');
75+
expect(trace.retrievalScore).toBe(0.75);
76+
expect(trace.provenance.sourceType).toBe('typed_network');
77+
expect(trace.tags).toContain('typed-network');
78+
expect(trace.tags).toContain('bank:WORLD');
79+
expect(trace.entities).toEqual(['Berlin', 'Germany']);
80+
expect(trace.scoreBreakdown.graphActivationScore).toBe(0.75);
81+
});
82+
83+
it('includes bank label in content for reader disambiguation', () => {
84+
const fact = makeFact('f2', 'I prefer TypeScript', ['TypeScript'], 'OPINION');
85+
const trace = typedFactToScoredTrace(fact, 0.5, { scope: 'user', scopeId: 'b' });
86+
expect(trace.content.startsWith('[OPINION]')).toBe(true);
87+
});
88+
89+
it('uses fact mention timestamp for lifecycle fields', () => {
90+
const fact = makeFact('f3', 'X', [], 'WORLD', '2026-01-01T00:00:00Z');
91+
const trace = typedFactToScoredTrace(fact, 1.0, { scope: 'user', scopeId: 'b' });
92+
expect(trace.lastAccessedAt).toBe(Date.parse('2026-01-01T00:00:00Z'));
93+
expect(trace.createdAt).toBe(Date.parse('2026-01-01T00:00:00Z'));
94+
});
95+
96+
it('falls back to current time on invalid mention timestamp', () => {
97+
const fact = makeFact('f4', 'X', [], 'WORLD', 'not-a-date');
98+
const before = Date.now();
99+
const trace = typedFactToScoredTrace(fact, 1.0, { scope: 'user', scopeId: 'b' });
100+
const after = Date.now();
101+
expect(trace.lastAccessedAt).toBeGreaterThanOrEqual(before);
102+
expect(trace.lastAccessedAt).toBeLessThanOrEqual(after);
103+
});
104+
});
105+
106+
describe('TypedNetworkRetriever.retrieve', () => {
107+
let store: TypedNetworkStore;
108+
let spreading: TypedSpreadingActivation;
109+
let retriever: TypedNetworkRetriever;
110+
111+
beforeEach(() => {
112+
store = new TypedNetworkStore();
113+
spreading = new TypedSpreadingActivation({ decay: 0.5 });
114+
retriever = new TypedNetworkRetriever({ store, spreading });
115+
});
116+
117+
it('returns empty array when query has no entities', async () => {
118+
store.addFact(makeFact('f1', 'X', ['Berlin']));
119+
const out = await retriever.retrieve('what time is it', {
120+
topK: 5,
121+
scope: { scope: 'user', scopeId: 'b' },
122+
});
123+
expect(out).toEqual([]);
124+
});
125+
126+
it('returns empty array when no facts match query entities', async () => {
127+
store.addFact(makeFact('f1', 'X', ['Berlin']));
128+
const out = await retriever.retrieve('Where is Tokyo?', {
129+
topK: 5,
130+
scope: { scope: 'user', scopeId: 'b' },
131+
});
132+
expect(out).toEqual([]);
133+
});
134+
135+
it('returns matching facts ordered by spreading-activation level', async () => {
136+
store.addFact(makeFact('f1', 'A', ['Berlin']));
137+
store.addFact(makeFact('f2', 'B', ['Germany']));
138+
store.addFact(makeFact('f3', 'C', ['Other']));
139+
// f1 is the seed (entity match); f2 connected via entity edge.
140+
store.addEdge({ fromFactId: 'f1', toFactId: 'f2', kind: 'entity', weight: 1.0 });
141+
const out = await retriever.retrieve('Where is Berlin?', {
142+
topK: 5,
143+
scope: { scope: 'user', scopeId: 'b' },
144+
});
145+
// f1 is seed (activation 1.0); f2 is 1-hop entity (activation 0.5).
146+
// f3 has no edges, so no activation.
147+
expect(out).toHaveLength(2);
148+
expect(out[0].id).toBe('typed-network:f1');
149+
expect(out[0].retrievalScore).toBe(1.0);
150+
expect(out[1].id).toBe('typed-network:f2');
151+
expect(out[1].retrievalScore).toBe(0.5);
152+
});
153+
154+
it('case-insensitive entity matching', async () => {
155+
store.addFact(makeFact('f1', 'X', ['BERLIN'])); // uppercase in fact
156+
const out = await retriever.retrieve('where is berlin', {
157+
// lowercase in query — should still match
158+
topK: 5,
159+
scope: { scope: 'user', scopeId: 'b' },
160+
queryEntities: ['berlin'],
161+
});
162+
expect(out).toHaveLength(1);
163+
expect(out[0].id).toBe('typed-network:f1');
164+
});
165+
166+
it('respects topK cutoff', async () => {
167+
for (let i = 0; i < 10; i++) {
168+
store.addFact(makeFact(`f${i}`, `X${i}`, ['Berlin']));
169+
}
170+
const out = await retriever.retrieve('Where is Berlin?', {
171+
topK: 3,
172+
scope: { scope: 'user', scopeId: 'b' },
173+
});
174+
expect(out).toHaveLength(3);
175+
});
176+
177+
it('accepts explicit queryEntities to skip regex extraction', async () => {
178+
store.addFact(makeFact('f1', 'X', ['custom-entity-name']));
179+
const out = await retriever.retrieve('any text', {
180+
topK: 5,
181+
scope: { scope: 'user', scopeId: 'b' },
182+
queryEntities: ['custom-entity-name'],
183+
});
184+
expect(out).toHaveLength(1);
185+
});
186+
});

0 commit comments

Comments
 (0)