/** * Knowledge graph helpers for palace triple management. * * Triples are (subject, predicate, object) with temporal validity. * This module provides pure functions for contradiction detection, * merging, and currency checking. */ /** * A lightweight triple for comparison (does not require full doc fields). */ export interface TripleInput { subject: string; predicate: string; object: string; validFrom: string; validTo?: string; confidence?: number; } /** * Find contradictions between existing triples and incoming ones. * * A contradiction exists when: * - Same subject + predicate, different object * - Both are currently valid (no validTo or validTo in the future) * * @returns Array of { existing, incoming } contradiction pairs */ export function findContradictions( existing: TripleInput[], incoming: TripleInput[], asOf: Date = new Date() ): Array<{ existing: TripleInput; incoming: TripleInput }> { const contradictions: Array<{ existing: TripleInput; incoming: TripleInput }> = []; for (const inc of incoming) { for (const ext of existing) { if ( normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && normalizeEntity(ext.object) !== normalizeEntity(inc.object) && isTripleCurrent(ext, asOf) && isTripleCurrent(inc, asOf) ) { contradictions.push({ existing: ext, incoming: inc }); } } } return contradictions; } /** * Merge incoming triples into existing set. * * - If an incoming triple contradicts an existing one, the existing triple * is invalidated (validTo set) and the incoming one is kept. * - If an incoming triple is a duplicate (same S/P/O), it is skipped. * - Otherwise, the incoming triple is added. * * @returns { merged, invalidated, added, skipped } counts */ export function mergeTriples( existing: TripleInput[], incoming: TripleInput[], asOf: Date = new Date() ): { merged: TripleInput[]; invalidated: TripleInput[]; added: TripleInput[]; skipped: TripleInput[]; } { const invalidated: TripleInput[] = []; const added: TripleInput[] = []; const skipped: TripleInput[] = []; const merged = [...existing]; for (const inc of incoming) { // Check for exact duplicate const isDuplicate = merged.some( ext => normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && normalizeEntity(ext.object) === normalizeEntity(inc.object) && isTripleCurrent(ext, asOf) ); if (isDuplicate) { skipped.push(inc); continue; } // Check for contradiction const contradictIdx = merged.findIndex( ext => normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && normalizeEntity(ext.object) !== normalizeEntity(inc.object) && isTripleCurrent(ext, asOf) ); if (contradictIdx >= 0) { // Invalidate the old triple const old = merged[contradictIdx]; merged[contradictIdx] = { ...old, validTo: asOf.toISOString() }; invalidated.push(old); } merged.push(inc); added.push(inc); } return { merged, invalidated, added, skipped }; } /** * Check if a triple is currently valid. * * @param triple - The triple to check * @param asOf - Reference time (default: now) * @returns true if the triple has no validTo or validTo is in the future */ export function isTripleCurrent( triple: Pick, asOf: Date = new Date() ): boolean { if (!triple.validTo) return true; return new Date(triple.validTo).getTime() > asOf.getTime(); } /** * Normalize an entity string for comparison (lowercase, trim, collapse whitespace). */ function normalizeEntity(s: string): string { return s.toLowerCase().trim().replace(/\s+/g, ' '); }