Files
waggle-os/packages/hive-mind-core/tests/mind/knowledge-hive-mind.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

190 lines
7.1 KiB
TypeScript

/**
* KnowledgeGraph tests — full-file port from
* hive-mind/packages/core/src/mind/knowledge.test.ts.
*
* Memory Sync Repair Step 2. Source: hive-mind file at HEAD c363257.
*
* Filename suffix `-hive-mind` keeps this distinct from waggle-os's own
* `knowledge.test.ts`. Hive-mind covers:
* - bfsDistances shortcut-vs-via-path edge case
* - getEntitiesValidAt at distinct time instants
* - getEntityTypeCounts/getEntityCount summary surface
* - setValidationSchema enforcement on createRelation (allowedRelations)
* — surfaces waggle-os covers differently.
*
* Adapted imports: `./db.js`, `./knowledge.js` → `../../src/mind/...`.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { rmSync, existsSync } from 'node:fs';
import { MindDB } from '../../src/mind/db.js';
import { KnowledgeGraph, type ValidationSchema } from '../../src/mind/knowledge.js';
describe('KnowledgeGraph (hive-mind port)', () => {
let dbPath: string;
let db: MindDB;
let kg: KnowledgeGraph;
beforeEach(() => {
dbPath = join(tmpdir(), `waggle-mind-kg-test-${Date.now()}-${Math.random()}.mind`);
db = new MindDB(dbPath);
kg = new KnowledgeGraph(db);
});
afterEach(() => {
db.close();
if (existsSync(dbPath)) rmSync(dbPath);
for (const suffix of ['-shm', '-wal']) {
if (existsSync(dbPath + suffix)) rmSync(dbPath + suffix);
}
});
it('createEntity stores type/name/properties and getEntity round-trips', () => {
const e = kg.createEntity('person', 'Ada', { role: 'engineer' });
expect(e.entity_type).toBe('person');
expect(e.name).toBe('Ada');
expect(JSON.parse(e.properties)).toEqual({ role: 'engineer' });
expect(e.valid_to).toBeNull();
const loaded = kg.getEntity(e.id);
expect(loaded?.id).toBe(e.id);
expect(loaded?.name).toBe('Ada');
});
it('updateEntity rewrites name and properties', () => {
const e = kg.createEntity('person', 'Ada', { role: 'engineer' });
const updated = kg.updateEntity(e.id, { name: 'Ada Lovelace', properties: { role: 'mathematician' } });
expect(updated.name).toBe('Ada Lovelace');
expect(JSON.parse(updated.properties)).toEqual({ role: 'mathematician' });
});
it('retireEntity sets valid_to and excludes the entity from active listings', () => {
const a = kg.createEntity('person', 'Alice', {});
const b = kg.createEntity('person', 'Bob', {});
kg.retireEntity(a.id);
const active = kg.getEntitiesByType('person');
expect(active.map((e) => e.id)).toEqual([b.id]);
expect(kg.getEntity(a.id)?.valid_to).not.toBeNull();
});
it('createRelation + getRelationsFrom/getRelationsTo round-trip', () => {
const alice = kg.createEntity('person', 'Alice', {});
const acme = kg.createEntity('org', 'Acme', {});
const rel = kg.createRelation(alice.id, acme.id, 'works_at', 0.9, { since: '2020' });
expect(rel.confidence).toBe(0.9);
expect(JSON.parse(rel.properties)).toEqual({ since: '2020' });
const fromAlice = kg.getRelationsFrom(alice.id);
expect(fromAlice).toHaveLength(1);
expect(fromAlice[0].target_id).toBe(acme.id);
const toAcme = kg.getRelationsTo(acme.id, 'works_at');
expect(toAcme).toHaveLength(1);
expect(toAcme[0].source_id).toBe(alice.id);
});
it('retireRelation hides the edge from active queries', () => {
const a = kg.createEntity('person', 'A', {});
const b = kg.createEntity('person', 'B', {});
const rel = kg.createRelation(a.id, b.id, 'knows');
kg.retireRelation(rel.id);
expect(kg.getRelationsFrom(a.id)).toEqual([]);
expect(kg.getRelationsTo(b.id)).toEqual([]);
});
it('traverse does typed-edge BFS bounded by maxDepth', () => {
const a = kg.createEntity('person', 'A', {});
const b = kg.createEntity('person', 'B', {});
const c = kg.createEntity('person', 'C', {});
const d = kg.createEntity('person', 'D', {});
kg.createRelation(a.id, b.id, 'knows');
kg.createRelation(b.id, c.id, 'knows');
kg.createRelation(c.id, d.id, 'knows');
kg.createRelation(a.id, d.id, 'dislikes'); // Different type — must not appear.
const hop1 = kg.traverse(a.id, 'knows', 1).map((e) => e.name);
expect(hop1).toEqual(['B']);
const hop3 = kg.traverse(a.id, 'knows', 3).map((e) => e.name).sort();
expect(hop3).toEqual(['B', 'C', 'D']);
});
it('bfsDistances returns shortest distance to each reachable entity', () => {
const a = kg.createEntity('t', 'A', {});
const b = kg.createEntity('t', 'B', {});
const c = kg.createEntity('t', 'C', {});
const d = kg.createEntity('t', 'D', {});
kg.createRelation(a.id, b.id, 'edge');
kg.createRelation(b.id, c.id, 'edge');
kg.createRelation(a.id, c.id, 'edge'); // shortcut: A→C direct
const distances = kg.bfsDistances(a.id, 3);
expect(distances.get(b.id)).toBe(1);
expect(distances.get(c.id)).toBe(1); // shortcut wins over via-B path
expect(distances.has(d.id)).toBe(false);
});
it('getEntitiesValidAt reconstructs the graph at a given instant', () => {
const past = '2020-01-01T00:00:00Z';
const now = '2025-01-01T00:00:00Z';
const oldEntity = kg.createEntity('t', 'Old', {}, { valid_from: past, valid_to: '2023-01-01T00:00:00Z' });
const current = kg.createEntity('t', 'Current', {}, { valid_from: past });
const asOf2022 = kg.getEntitiesValidAt('2022-01-01T00:00:00Z').map((e) => e.name).sort();
expect(asOf2022).toEqual(['Current', 'Old']);
const asOfNow = kg.getEntitiesValidAt(now).map((e) => e.name);
expect(asOfNow).toEqual(['Current']);
expect(oldEntity.id).not.toBe(current.id);
});
it('searchEntities LIKE matches by name substring', () => {
kg.createEntity('t', 'Alice', {});
kg.createEntity('t', 'Alicia', {});
kg.createEntity('t', 'Bob', {});
const hits = kg.searchEntities('Ali').map((e) => e.name).sort();
expect(hits).toEqual(['Alice', 'Alicia']);
});
it('getEntityTypeCounts + getEntityCount summarize the active graph', () => {
kg.createEntity('person', 'A', {});
kg.createEntity('person', 'B', {});
kg.createEntity('org', 'Acme', {});
const retired = kg.createEntity('org', 'Defunct', {});
kg.retireEntity(retired.id);
expect(kg.getEntityCount()).toBe(3);
const counts = kg.getEntityTypeCounts();
const map = new Map(counts.map((c) => [c.type, c.count]));
expect(map.get('person')).toBe(2);
expect(map.get('org')).toBe(1);
});
it('validation schema enforces required props and allowed relations', () => {
const schema: ValidationSchema = {
person: { required: ['email'], allowedRelations: ['works_at'] },
};
kg.setValidationSchema(schema);
expect(() => kg.createEntity('person', 'Alice', {})).toThrow(/required property 'email'/);
const alice = kg.createEntity('person', 'Alice', { email: 'a@x' });
const acme = kg.createEntity('org', 'Acme', {});
expect(() => kg.createRelation(alice.id, acme.id, 'dislikes')).toThrow(
/relation 'dislikes' not allowed/,
);
const ok = kg.createRelation(alice.id, acme.id, 'works_at');
expect(ok.relation_type).toBe('works_at');
});
});