From 31bdb0ada98fdbdba1ec279bb912c7b4a11738cb Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 10 Apr 2026 01:30:02 -0700 Subject: [PATCH] feat(palace): knowledge graph triple CRUD with temporal queries, contradiction detection (N3) --- backend/src/modules/palace/kg.test.ts | 144 +++++++++++++++++++++ backend/src/modules/palace/repository.ts | 157 +++++++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 backend/src/modules/palace/kg.test.ts diff --git a/backend/src/modules/palace/kg.test.ts b/backend/src/modules/palace/kg.test.ts new file mode 100644 index 0000000..0cdd67c --- /dev/null +++ b/backend/src/modules/palace/kg.test.ts @@ -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'); + }); +}); diff --git a/backend/src/modules/palace/repository.ts b/backend/src/modules/palace/repository.ts index c6701fe..672b2f6 100644 --- a/backend/src/modules/palace/repository.ts +++ b/backend/src/modules/palace/repository.ts @@ -522,6 +522,163 @@ export async function getPalaceStats( 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 { + 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 { + 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); + return true; + } + } + return false; +} + +export async function queryEntity( + userId: string, + productId: string, + entity: string, + asOf?: string, +): Promise { + 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 { + 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 { + 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> { + 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 }> { let cosmos = false; let llm = false;