Skip to content

Commit 00ee687

Browse files
committed
feat(memory): FactStore — in-memory fact-graph keyed by scope/subject/predicate
1 parent eba4c9f commit 00ee687

3 files changed

Lines changed: 205 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* @file FactStore.ts
3+
* @description In-memory `(scope, scopeId, subjectHash, predicateHash) →
4+
* FactStoreEntry` map. Populated at session-ingest time by
5+
* `FactExtractor`; queried from `HybridRetriever` at query time.
6+
*
7+
* The store is per-case-scoped (no cross-run persistence in MVP); callers
8+
* that want persistent fact storage wire a SQLite-backed variant on top
9+
* of the same interface in a follow-up.
10+
*
11+
* @module agentos/memory/retrieval/fact-graph/FactStore
12+
*/
13+
14+
import type { Fact, FactStoreEntry } from './types.js';
15+
import {
16+
canonicalizeSubject,
17+
hashPredicate,
18+
hashSubject,
19+
isValidPredicate,
20+
} from './canonicalization.js';
21+
22+
export class FactStore {
23+
private readonly map = new Map<string, FactStoreEntry>();
24+
25+
private keyOf(
26+
scope: string,
27+
scopeId: string,
28+
subjectHash: string,
29+
predicateHash: string,
30+
): string {
31+
return `${scope}|${scopeId}|${subjectHash}|${predicateHash}`;
32+
}
33+
34+
/**
35+
* Insert facts. Facts with predicates outside the closed schema are
36+
* silently dropped (matches the `FactExtractor` contract). Subjects
37+
* are canonicalized; the stored form carries the canonical subject.
38+
* Per-(subject, predicate) entries stay time-sorted ascending so
39+
* {@link getLatest} is O(1) and {@link getAllTimeOrdered} is O(n).
40+
*/
41+
upsert(scope: string, scopeId: string, facts: readonly Fact[]): void {
42+
for (const raw of facts) {
43+
if (!isValidPredicate(raw.predicate)) continue;
44+
const subject = canonicalizeSubject(raw.subject);
45+
const fact: Fact = { ...raw, subject };
46+
const key = this.keyOf(
47+
scope,
48+
scopeId,
49+
hashSubject(subject),
50+
hashPredicate(fact.predicate),
51+
);
52+
let entry = this.map.get(key);
53+
if (!entry) {
54+
entry = { facts: [] };
55+
this.map.set(key, entry);
56+
}
57+
entry.facts.push(fact);
58+
entry.facts.sort((a, b) => a.timestamp - b.timestamp);
59+
}
60+
}
61+
62+
/**
63+
* Return the latest fact for (subject, predicate) or null. Supports
64+
* un-canonicalized subject input (canonicalized internally). Returns
65+
* null for predicates outside the closed schema.
66+
*/
67+
getLatest(
68+
scope: string,
69+
scopeId: string,
70+
subject: string,
71+
predicate: string,
72+
): Fact | null {
73+
if (!isValidPredicate(predicate)) return null;
74+
const canonicalSubject = canonicalizeSubject(subject);
75+
const key = this.keyOf(
76+
scope,
77+
scopeId,
78+
hashSubject(canonicalSubject),
79+
hashPredicate(predicate),
80+
);
81+
const entry = this.map.get(key);
82+
if (!entry || entry.facts.length === 0) return null;
83+
return entry.facts[entry.facts.length - 1]!;
84+
}
85+
86+
/**
87+
* Return ALL facts for a subject (across predicates), time-sorted
88+
* ascending. Used for temporal queries where history matters.
89+
*/
90+
getAllTimeOrdered(scope: string, scopeId: string, subject: string): Fact[] {
91+
const canonicalSubject = canonicalizeSubject(subject);
92+
const subjectHash = hashSubject(canonicalSubject);
93+
const prefix = `${scope}|${scopeId}|${subjectHash}|`;
94+
const out: Fact[] = [];
95+
for (const [k, entry] of this.map) {
96+
if (!k.startsWith(prefix)) continue;
97+
out.push(...entry.facts);
98+
}
99+
out.sort((a, b) => a.timestamp - b.timestamp);
100+
return out;
101+
}
102+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { FactStore } from '../FactStore.js';
3+
import type { Fact } from '../types.js';
4+
5+
function f(subject: string, predicate: string, object: string, ts: number): Fact {
6+
return {
7+
subject,
8+
predicate,
9+
object,
10+
timestamp: ts,
11+
sourceTraceIds: [`trace-${ts}`],
12+
sourceSpan: `${subject} ${predicate} ${object}`,
13+
};
14+
}
15+
16+
describe('FactStore', () => {
17+
it('upserts and returns the latest fact per (subject, predicate)', () => {
18+
const store = new FactStore();
19+
store.upsert('user', 'bench', [f('user', 'livesIn', 'NYC', 1)]);
20+
store.upsert('user', 'bench', [f('user', 'livesIn', 'Berlin', 2)]);
21+
expect(store.getLatest('user', 'bench', 'user', 'livesIn')?.object).toBe('Berlin');
22+
});
23+
24+
it('returns time-sorted ascending list for getAllTimeOrdered', () => {
25+
const store = new FactStore();
26+
store.upsert('user', 'bench', [f('user', 'livesIn', 'NYC', 2)]);
27+
store.upsert('user', 'bench', [f('user', 'livesIn', 'Berlin', 3)]);
28+
store.upsert('user', 'bench', [f('user', 'livesIn', 'Boston', 1)]);
29+
const all = store.getAllTimeOrdered('user', 'bench', 'user');
30+
expect(all.map((x) => x.object)).toEqual(['Boston', 'NYC', 'Berlin']);
31+
});
32+
33+
it('isolates facts across (scope, scopeId) pairs', () => {
34+
const store = new FactStore();
35+
store.upsert('user', 'u1', [f('user', 'prefers', 'tea', 1)]);
36+
store.upsert('user', 'u2', [f('user', 'prefers', 'coffee', 1)]);
37+
expect(store.getLatest('user', 'u1', 'user', 'prefers')?.object).toBe('tea');
38+
expect(store.getLatest('user', 'u2', 'user', 'prefers')?.object).toBe('coffee');
39+
});
40+
41+
it('canonicalizes subjects on upsert (I → user)', () => {
42+
const store = new FactStore();
43+
store.upsert('user', 'bench', [f('I', 'prefers', 'tea', 1)]);
44+
expect(store.getLatest('user', 'bench', 'user', 'prefers')?.object).toBe('tea');
45+
expect(store.getLatest('user', 'bench', 'I', 'prefers')?.object).toBe('tea');
46+
});
47+
48+
it('drops facts with predicates outside the closed schema', () => {
49+
const store = new FactStore();
50+
store.upsert('user', 'bench', [f('user', 'mentioned', 'something', 1)]);
51+
expect(store.getAllTimeOrdered('user', 'bench', 'user')).toEqual([]);
52+
});
53+
54+
it('getLatest returns null for predicates outside the schema', () => {
55+
const store = new FactStore();
56+
store.upsert('user', 'bench', [f('user', 'prefers', 'tea', 1)]);
57+
expect(store.getLatest('user', 'bench', 'user', 'mentioned')).toBeNull();
58+
});
59+
60+
it('getLatest returns null for missing (subject, predicate)', () => {
61+
const store = new FactStore();
62+
expect(store.getLatest('user', 'bench', 'user', 'livesIn')).toBeNull();
63+
});
64+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @file types.ts
3+
* @description `Fact` + `FactStoreEntry` interfaces for the Step 9
4+
* fact-graph. See the Step 9 design spec §4.2 for rationale behind the
5+
* closed predicate schema + literal-object contract.
6+
*
7+
* @module agentos/memory/retrieval/fact-graph/types
8+
*/
9+
10+
/**
11+
* A single extracted fact tuple. `object` MUST be a literal span from
12+
* the source turn (never paraphrased); this contract is the design
13+
* delta from Steps 5/7/8 whose summary-based approaches erased
14+
* specific-value tokens.
15+
*/
16+
export interface Fact {
17+
/** Canonical subject ("user" for first-person, lowercase otherwise). */
18+
subject: string;
19+
/** Predicate from the closed schema (see {@link PREDICATE_SCHEMA}). */
20+
predicate: string;
21+
/** Literal object span from the source turn — NEVER paraphrased. */
22+
object: string;
23+
/** ms since epoch. */
24+
timestamp: number;
25+
/** Trace or session IDs this fact was extracted from. */
26+
sourceTraceIds: string[];
27+
/** The full sentence the fact came from (for audit, not retrieval). */
28+
sourceSpan: string;
29+
}
30+
31+
/**
32+
* Time-sorted-ascending list of facts for a single (subject, predicate)
33+
* pair. The latest fact supersedes earlier ones for `getLatest`
34+
* queries; all of them are visible to temporal queries via
35+
* `getAllTimeOrdered`.
36+
*/
37+
export interface FactStoreEntry {
38+
facts: Fact[];
39+
}

0 commit comments

Comments
 (0)