feat(palace): knowledge graph triple CRUD with temporal queries, contradiction detection (N3)

This commit is contained in:
saravanakumardb1 2026-04-10 01:30:02 -07:00
parent aa675e855b
commit 31bdb0ada9
2 changed files with 301 additions and 0 deletions

View File

@ -0,0 +1,144 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { resetMemoryDatastore, TEST_USER_ID, TEST_PRODUCT_ID } from '../../test-helpers.js';
import {
ensureWing,
addTriple,
invalidateTriple,
queryEntity,
queryRelation,
entityTimeline,
findKGContradictions,
} from './repository.js';
const USER_A = TEST_USER_ID;
const USER_B = 'test-user-2';
const PRODUCT = TEST_PRODUCT_ID;
describe('Palace Knowledge Graph (N3)', () => {
beforeEach(() => {
resetMemoryDatastore();
});
it('adds and queries a triple', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
const triple = await addTriple(
USER_A, PRODUCT, wing.id,
'React Router', 'replaced_by', 'TanStack Router', 0.9,
);
expect(triple.subject).toBe('React Router');
expect(triple.predicate).toBe('replaced_by');
expect(triple.object).toBe('TanStack Router');
expect(triple.confidence).toBe(0.9);
expect(triple.validTo).toBeUndefined();
});
it('queryEntity returns triples about an entity', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
await addTriple(USER_A, PRODUCT, wing.id, 'React Router', 'replaced_by', 'TanStack Router', 0.9);
await addTriple(USER_A, PRODUCT, wing.id, 'React Router', 'used_in', 'Project Alpha', 0.8);
await addTriple(USER_A, PRODUCT, wing.id, 'PostgreSQL', 'used_for', 'user data', 0.95);
const results = await queryEntity(USER_A, PRODUCT, 'react router');
expect(results.length).toBe(2);
});
it('invalidateTriple marks a triple as ended', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
await addTriple(USER_A, PRODUCT, wing.id, 'Team', 'uses', 'Express', 0.9);
const invalidated = await invalidateTriple(USER_A, PRODUCT, 'Team', 'uses', 'Express');
expect(invalidated).toBe(true);
// Query should not return invalidated triple
const results = await queryEntity(USER_A, PRODUCT, 'Team');
expect(results.length).toBe(0);
});
it('invalidated triples excluded from current queries', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
await addTriple(USER_A, PRODUCT, wing.id, 'API', 'runs_on', 'Express', 0.9);
await addTriple(USER_A, PRODUCT, wing.id, 'API', 'uses', 'REST', 0.8);
await invalidateTriple(USER_A, PRODUCT, 'API', 'runs_on', 'Express');
const results = await queryEntity(USER_A, PRODUCT, 'API');
expect(results.length).toBe(1);
expect(results[0].predicate).toBe('uses');
});
it('queryRelation returns matching triples', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
await addTriple(USER_A, PRODUCT, wing.id, 'Service A', 'depends_on', 'Service B', 0.9);
await addTriple(USER_A, PRODUCT, wing.id, 'Service A', 'depends_on', 'Service C', 0.8);
await addTriple(USER_A, PRODUCT, wing.id, 'Service B', 'depends_on', 'Database', 0.95);
const results = await queryRelation(USER_A, PRODUCT, 'Service A', 'depends_on');
expect(results.length).toBe(2);
});
it('entity timeline returns chronological order', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
// Add triples with slight delay so validFrom differs
await addTriple(USER_A, PRODUCT, wing.id, 'Framework', 'was', 'Express', 0.9);
await addTriple(USER_A, PRODUCT, wing.id, 'Framework', 'migrated_to', 'Fastify', 0.95);
const tl = await entityTimeline(USER_A, PRODUCT, 'Framework');
expect(tl.length).toBe(2);
// First entry should be chronologically earlier or equal
expect(tl[0].validFrom <= tl[1].validFrom).toBe(true);
});
it('contradiction detection finds conflicting current facts', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
await addTriple(USER_A, PRODUCT, wing.id, 'Backend', 'language', 'JavaScript', 0.8);
await addTriple(USER_A, PRODUCT, wing.id, 'Backend', 'language', 'TypeScript', 0.95);
const contradictions = await findKGContradictions(USER_A, PRODUCT, wing.id);
expect(contradictions.length).toBe(1);
expect(contradictions[0].a.object).not.toBe(contradictions[0].b.object);
});
it('no contradictions when facts are different subjects', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
await addTriple(USER_A, PRODUCT, wing.id, 'Frontend', 'language', 'TypeScript', 0.9);
await addTriple(USER_A, PRODUCT, wing.id, 'Backend', 'language', 'TypeScript', 0.9);
const contradictions = await findKGContradictions(USER_A, PRODUCT, wing.id);
expect(contradictions.length).toBe(0);
});
it('cross-user: user A KG never leaks to user B', async () => {
const wingA = await ensureWing(USER_A, PRODUCT, 'ws-a', 'A');
const wingB = await ensureWing(USER_B, PRODUCT, 'ws-b', 'B');
await addTriple(USER_A, PRODUCT, wingA.id, 'SecretProject', 'status', 'active', 0.9);
await addTriple(USER_B, PRODUCT, wingB.id, 'PublicProject', 'status', 'completed', 0.9);
const resultsA = await queryEntity(USER_A, PRODUCT, 'SecretProject');
const resultsB = await queryEntity(USER_B, PRODUCT, 'SecretProject');
const resultsBOwn = await queryEntity(USER_B, PRODUCT, 'PublicProject');
expect(resultsA.length).toBe(1);
expect(resultsB.length).toBe(0);
expect(resultsBOwn.length).toBe(1);
});
it('temporal query with asOf shows triples valid at that point in time', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
await addTriple(USER_A, PRODUCT, wing.id, 'DB', 'type', 'MongoDB', 0.9);
await invalidateTriple(USER_A, PRODUCT, 'DB', 'type', 'MongoDB');
// Query as of now — invalidated triple should be excluded
const current = await queryEntity(USER_A, PRODUCT, 'DB');
expect(current.length).toBe(0);
// Query as of yesterday — the invalidated triple's validTo is ~now,
// which is after yesterday, so it was still valid at that time
const pastDate = new Date(Date.now() - 86400000).toISOString();
const atPast = await queryEntity(USER_A, PRODUCT, 'DB', pastDate);
expect(atPast.length).toBe(1);
expect(atPast[0].object).toBe('MongoDB');
});
});

View File

@ -522,6 +522,163 @@ export async function getPalaceStats(
return { wings, rooms, memories, kgTriples, tunnels, diaries }; return { wings, rooms, memories, kgTriples, tunnels, diaries };
} }
// ── Knowledge Graph (KG) ────────────────────────────────────────
export async function addTriple(
userId: string,
productId: string,
wingId: string,
subject: string,
predicate: string,
object: string,
confidence: number,
sourceMemoryId?: string,
): Promise<PalaceKGTripleDoc> {
const now = new Date().toISOString();
const doc: PalaceKGTripleDoc = {
id: `kg-${crypto.randomUUID()}`,
productId,
userId,
wingId,
subject,
predicate,
object,
confidence,
validFrom: now,
sourceMemoryId,
createdAt: now,
};
return kgCollection().create(doc);
}
export async function invalidateTriple(
userId: string,
productId: string,
subject: string,
predicate: string,
object: string,
): Promise<boolean> {
const all = await kgCollection().findMany({
filter: { userId, productId },
limit: 500,
});
const sNorm = subject.toLowerCase().trim();
const pNorm = predicate.toLowerCase().trim();
const oNorm = object.toLowerCase().trim();
for (const t of all) {
if (
t.subject.toLowerCase().trim() === sNorm &&
t.predicate.toLowerCase().trim() === pNorm &&
t.object.toLowerCase().trim() === oNorm &&
!t.validTo
) {
await kgCollection().update(t.id, userId, {
validTo: new Date().toISOString(),
} as Partial<PalaceKGTripleDoc>);
return true;
}
}
return false;
}
export async function queryEntity(
userId: string,
productId: string,
entity: string,
asOf?: string,
): Promise<PalaceKGTripleDoc[]> {
const all = await kgCollection().findMany({
filter: { userId, productId },
limit: 500,
});
const norm = entity.toLowerCase().trim();
const ref = asOf ? new Date(asOf) : new Date();
return all.filter(t => {
const matchesEntity =
t.subject.toLowerCase().trim() === norm ||
t.object.toLowerCase().trim() === norm;
if (!matchesEntity) return false;
// Current at asOf
if (t.validTo && new Date(t.validTo).getTime() <= ref.getTime()) return false;
return true;
});
}
export async function queryRelation(
userId: string,
productId: string,
subject: string,
predicate: string,
): Promise<PalaceKGTripleDoc[]> {
const all = await kgCollection().findMany({
filter: { userId, productId },
limit: 500,
});
const sNorm = subject.toLowerCase().trim();
const pNorm = predicate.toLowerCase().trim();
return all.filter(t =>
t.subject.toLowerCase().trim() === sNorm &&
t.predicate.toLowerCase().trim() === pNorm &&
!t.validTo,
);
}
export async function entityTimeline(
userId: string,
productId: string,
entity: string,
): Promise<PalaceKGTripleDoc[]> {
const all = await kgCollection().findMany({
filter: { userId, productId },
limit: 500,
});
const norm = entity.toLowerCase().trim();
return all
.filter(t =>
t.subject.toLowerCase().trim() === norm ||
t.object.toLowerCase().trim() === norm,
)
.sort((a, b) => a.validFrom.localeCompare(b.validFrom));
}
export async function findKGContradictions(
userId: string,
productId: string,
wingId?: string,
): Promise<Array<{ a: PalaceKGTripleDoc; b: PalaceKGTripleDoc }>> {
const filter: FilterMap = { userId, productId };
if (wingId) filter.wingId = wingId;
const all = await kgCollection().findMany({ filter, limit: 500 });
const current = all.filter(t => !t.validTo);
const contradictions: Array<{ a: PalaceKGTripleDoc; b: PalaceKGTripleDoc }> = [];
for (let i = 0; i < current.length; i++) {
for (let j = i + 1; j < current.length; j++) {
const a = current[i];
const b = current[j];
if (
a.subject.toLowerCase().trim() === b.subject.toLowerCase().trim() &&
a.predicate.toLowerCase().trim() === b.predicate.toLowerCase().trim() &&
a.object.toLowerCase().trim() !== b.object.toLowerCase().trim()
) {
contradictions.push({ a, b });
}
}
}
return contradictions;
}
export async function healthCheck(): Promise<{ cosmos: boolean; llm: boolean }> { export async function healthCheck(): Promise<{ cosmos: boolean; llm: boolean }> {
let cosmos = false; let cosmos = false;
let llm = false; let llm = false;