openclaw-knowledge-engine/test/fact-store.test.ts
Claudia 8964d93c60 feat: knowledge-engine v0.1.0 — all Cerberus findings fixed
- 83/83 tests passing (was 32/45)
- New: src/http-client.ts (shared HTTP/HTTPS client, fixes C2+H1)
- Fixed: proper_noun regex exclusions (C6)
- Fixed: shutdown hooks registered in hooks.ts (C3)
- Fixed: all timers use .unref() (H6)
- Fixed: resolveConfig split into smaller functions (C4)
- Fixed: extract() split with processMatch helper (C5)
- Fixed: FactStore.addFact isLoaded guard (H3)
- Fixed: validateConfig split (H2)
- Fixed: type-safe config merge, removed as any (H4)
- Added: http-client tests, expanded coverage (H5)
- Fixed: LLM batch await (S1), fresh RegExp per call (S2)
- 1530 LOC source, 1298 LOC tests, strict TypeScript
2026-02-17 16:10:13 +01:00

261 lines
11 KiB
TypeScript

// test/fact-store.test.ts
import { describe, it, before, after, beforeEach } from 'node:test';
import * as assert from 'node:assert';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { FactStore } from '../src/fact-store.js';
import type { KnowledgeConfig, Logger, Fact } from '../src/types.js';
const createMockLogger = (): Logger => ({
info: () => {}, warn: () => {}, error: () => {}, debug: () => {},
});
const mockConfig: KnowledgeConfig['storage'] = {
maxEntities: 100, maxFacts: 10, writeDebounceMs: 0,
};
describe('FactStore', () => {
const testDir = path.join('/tmp', `fact-store-test-${Date.now()}`);
let factStore: FactStore;
before(async () => await fs.mkdir(testDir, { recursive: true }));
after(async () => await fs.rm(testDir, { recursive: true, force: true }));
beforeEach(async () => {
const filePath = path.join(testDir, 'facts.json');
try { await fs.unlink(filePath); } catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
}
factStore = new FactStore(testDir, mockConfig, createMockLogger());
await factStore.load();
});
it('should add a new fact to the store', () => {
factStore.addFact({ subject: 's1', predicate: 'p1', object: 'o1', source: 'extracted-llm' });
const facts = factStore.query({});
assert.strictEqual(facts.length, 1);
});
it('should throw if addFact called before load', async () => {
const unloaded = new FactStore(testDir, mockConfig, createMockLogger());
assert.throws(() => {
unloaded.addFact({ subject: 's', predicate: 'p', object: 'o', source: 'ingested' });
}, /not been loaded/);
});
it('should deduplicate identical facts by boosting relevance', () => {
const f1 = factStore.addFact({ subject: 's', predicate: 'p', object: 'o', source: 'ingested' });
assert.strictEqual(f1.relevance, 1.0);
// Decay the fact first so we can verify the boost
factStore.decayFacts(0.5);
const decayed = factStore.getFact(f1.id);
assert.ok(decayed);
// After decay + access boost the relevance should be < 1.0 but > 0.5
const preBoost = decayed.relevance;
// Adding same fact again should boost it
const f2 = factStore.addFact({ subject: 's', predicate: 'p', object: 'o', source: 'ingested' });
assert.strictEqual(f1.id, f2.id); // Same fact
assert.ok(f2.relevance >= preBoost);
});
describe('getFact', () => {
it('should retrieve a fact by ID', () => {
const added = factStore.addFact({ subject: 's', predicate: 'p', object: 'o', source: 'ingested' });
const retrieved = factStore.getFact(added.id);
assert.ok(retrieved);
assert.strictEqual(retrieved.subject, 's');
assert.strictEqual(retrieved.predicate, 'p');
assert.strictEqual(retrieved.object, 'o');
});
it('should return undefined for non-existent ID', () => {
const result = factStore.getFact('non-existent-id');
assert.strictEqual(result, undefined);
});
it('should boost relevance on access', () => {
const f = factStore.addFact({ subject: 's', predicate: 'p', object: 'o', source: 'ingested' });
factStore.decayFacts(0.5); // Decay to 0.5ish
const decayedFact = factStore.query({ subject: 's' })[0];
const decayedRelevance = decayedFact.relevance;
const accessed = factStore.getFact(f.id);
assert.ok(accessed);
assert.ok(accessed.relevance > decayedRelevance, 'Relevance should increase on access');
});
it('should update lastAccessed timestamp', () => {
const f = factStore.addFact({ subject: 's', predicate: 'p', object: 'o', source: 'ingested' });
const before = f.lastAccessed;
// Small delay to get a different timestamp
const accessed = factStore.getFact(f.id);
assert.ok(accessed);
assert.ok(new Date(accessed.lastAccessed) >= new Date(before));
});
});
describe('query', () => {
it('should query by subject', () => {
factStore.addFact({ subject: 'alice', predicate: 'knows', object: 'bob', source: 'ingested' });
factStore.addFact({ subject: 'charlie', predicate: 'knows', object: 'bob', source: 'ingested' });
const results = factStore.query({ subject: 'alice' });
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].subject, 'alice');
});
it('should query by predicate', () => {
factStore.addFact({ subject: 'a', predicate: 'is-a', object: 'b', source: 'ingested' });
factStore.addFact({ subject: 'c', predicate: 'works-at', object: 'd', source: 'ingested' });
const results = factStore.query({ predicate: 'is-a' });
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].predicate, 'is-a');
});
it('should query by object', () => {
factStore.addFact({ subject: 'a', predicate: 'p', object: 'target', source: 'ingested' });
factStore.addFact({ subject: 'b', predicate: 'p', object: 'other', source: 'ingested' });
const results = factStore.query({ object: 'target' });
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].object, 'target');
});
it('should query with multiple filters', () => {
factStore.addFact({ subject: 'a', predicate: 'p1', object: 'o1', source: 'ingested' });
factStore.addFact({ subject: 'a', predicate: 'p2', object: 'o2', source: 'ingested' });
factStore.addFact({ subject: 'b', predicate: 'p1', object: 'o1', source: 'ingested' });
const results = factStore.query({ subject: 'a', predicate: 'p1' });
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].object, 'o1');
});
it('should return all facts when query is empty', () => {
factStore.addFact({ subject: 'a', predicate: 'p', object: 'o1', source: 'ingested' });
factStore.addFact({ subject: 'b', predicate: 'p', object: 'o2', source: 'ingested' });
const results = factStore.query({});
assert.strictEqual(results.length, 2);
});
it('should sort results by relevance descending', () => {
const f1 = factStore.addFact({ subject: 'a', predicate: 'p', object: 'o1', source: 'ingested' });
factStore.addFact({ subject: 'b', predicate: 'p', object: 'o2', source: 'ingested' });
// Decay all, then access f1 to boost it
factStore.decayFacts(0.5);
factStore.getFact(f1.id);
const results = factStore.query({});
assert.strictEqual(results[0].subject, 'a'); // f1 has higher relevance after boost
});
});
describe('decayFacts', () => {
it('should reduce relevance of all facts', () => {
factStore.addFact({ subject: 'a', predicate: 'p', object: 'o', source: 'ingested' });
const { decayedCount } = factStore.decayFacts(0.5);
assert.strictEqual(decayedCount, 1);
const facts = factStore.query({});
assert.ok(facts[0].relevance < 1.0);
assert.ok(facts[0].relevance >= 0.1); // Min relevance floor
});
it('should not decay below the minimum relevance of 0.1', () => {
factStore.addFact({ subject: 'a', predicate: 'p', object: 'o', source: 'ingested' });
// Apply extreme decay many times
for (let i = 0; i < 100; i++) factStore.decayFacts(0.99);
const facts = factStore.query({});
assert.ok(facts[0].relevance >= 0.1);
});
it('should return 0 when no facts exist', () => {
const { decayedCount } = factStore.decayFacts(0.1);
assert.strictEqual(decayedCount, 0);
});
});
describe('getUnembeddedFacts', () => {
it('should return facts without embedded timestamp', () => {
factStore.addFact({ subject: 'a', predicate: 'p', object: 'o1', source: 'ingested' });
factStore.addFact({ subject: 'b', predicate: 'p', object: 'o2', source: 'ingested' });
const unembedded = factStore.getUnembeddedFacts();
assert.strictEqual(unembedded.length, 2);
});
it('should exclude embedded facts', () => {
const f1 = factStore.addFact({ subject: 'a', predicate: 'p', object: 'o1', source: 'ingested' });
factStore.addFact({ subject: 'b', predicate: 'p', object: 'o2', source: 'ingested' });
factStore.markFactsAsEmbedded([f1.id]);
const unembedded = factStore.getUnembeddedFacts();
assert.strictEqual(unembedded.length, 1);
assert.strictEqual(unembedded[0].subject, 'b');
});
it('should return empty array when all facts are embedded', () => {
const f1 = factStore.addFact({ subject: 'a', predicate: 'p', object: 'o', source: 'ingested' });
factStore.markFactsAsEmbedded([f1.id]);
const unembedded = factStore.getUnembeddedFacts();
assert.strictEqual(unembedded.length, 0);
});
});
describe('markFactsAsEmbedded', () => {
it('should set the embedded timestamp on specified facts', () => {
const f1 = factStore.addFact({ subject: 'a', predicate: 'p', object: 'o', source: 'ingested' });
assert.strictEqual(f1.embedded, undefined);
factStore.markFactsAsEmbedded([f1.id]);
const updated = factStore.getFact(f1.id);
assert.ok(updated);
assert.ok(updated.embedded);
assert.ok(typeof updated.embedded === 'string');
});
it('should handle non-existent fact IDs gracefully', () => {
factStore.addFact({ subject: 'a', predicate: 'p', object: 'o', source: 'ingested' });
// Should not throw
factStore.markFactsAsEmbedded(['non-existent-id']);
assert.ok(true);
});
it('should only update specified facts', () => {
const f1 = factStore.addFact({ subject: 'a', predicate: 'p', object: 'o1', source: 'ingested' });
const f2 = factStore.addFact({ subject: 'b', predicate: 'p', object: 'o2', source: 'ingested' });
factStore.markFactsAsEmbedded([f1.id]);
const updated1 = factStore.getFact(f1.id);
const updated2 = factStore.getFact(f2.id);
assert.ok(updated1?.embedded);
assert.strictEqual(updated2?.embedded, undefined);
});
});
it('should remove the least recently accessed facts when pruning', () => {
for (let i = 0; i < 11; i++) {
const fact = factStore.addFact({ subject: 's', predicate: 'p', object: `o${i}`, source: 'ingested' });
const internalFact = (factStore as Record<string, unknown> as { facts: Map<string, Fact> }).facts.get(fact.id);
if (internalFact) {
internalFact.lastAccessed = new Date(Date.now() - (10 - i) * 1000).toISOString();
}
}
const facts = factStore.query({});
assert.strictEqual(facts.length, 10);
const objects = facts.map(f => f.object);
assert.strictEqual(objects.includes('o0'), false, 'Fact "o0" (oldest) should have been pruned');
assert.strictEqual(objects.includes('o1'), true, 'Fact "o1" should still exist');
});
});