import { jaccard, tokenize, type EntityType, type ObservedEntity, type RelationType } from "@src/shared"; /** * Social World Model (§23): an in-memory graph of everything observed in this session. * Guides navigation (visited set, novelty) and becomes the seed of the persisted graph. */ export interface WorldNode { fingerprint: string; type: EntityType; name?: string; url?: string; platform_id?: string; first_seen_step: number; last_seen_step: number; seen_count: number; visited: boolean; // we navigated to it surfaces: Set; tokens: Set; } export interface WorldEdge { from: string; to: string; type: RelationType; step: number; } export class WorldModel { readonly nodes = new Map(); readonly edges: WorldEdge[] = []; readonly visitedUrls = new Set(); private edgeKeys = new Set(); /** Register observed entities; returns those never seen before. */ observe(entities: ObservedEntity[], step: number, discoveredFrom?: string): ObservedEntity[] { const fresh: ObservedEntity[] = []; for (const e of entities) { const n = this.nodes.get(e.fingerprint); if (n) { n.last_seen_step = step; n.seen_count++; if (!n.name && e.name) n.name = e.name; if (!n.url && e.url) n.url = e.url; for (const p of e.provenance) n.surfaces.add(p.surface); if (e.name || e.text) n.tokens = tokenize(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`); } else { this.nodes.set(e.fingerprint, { fingerprint: e.fingerprint, type: e.type, name: e.name, url: e.url, platform_id: e.platform_id, first_seen_step: step, last_seen_step: step, seen_count: 1, visited: false, surfaces: new Set(e.provenance.map((p) => p.surface)), tokens: tokenize(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`), }); fresh.push(e); if (discoveredFrom) this.relate(discoveredFrom, e.fingerprint, "DISCOVERED_FROM", step); } if (e.author && e.author_url) { // author edge when we know the author's page this.relate(`author:${e.author_url}`, e.fingerprint, "AUTHORED", step); } } return fresh; } relate(from: string, to: string, type: RelationType, step: number): void { const k = `${from}→${to}:${type}`; if (this.edgeKeys.has(k)) return; this.edgeKeys.add(k); this.edges.push({ from, to, type, step }); } markVisited(fingerprint: string | undefined, url: string | undefined): void { if (fingerprint) { const n = this.nodes.get(fingerprint); if (n) n.visited = true; } if (url) this.visitedUrls.add(url); } isVisited(e: ObservedEntity): boolean { return (this.nodes.get(e.fingerprint)?.visited ?? false) || (!!e.url && this.visitedUrls.has(e.url)); } /** Lexical novelty: 1 − max Jaccard similarity to what we already know (embedding-ready seam, §27). */ novelty(e: ObservedEntity): number { const t = tokenize(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`); if (t.size === 0) return 0.5; let max = 0; let checked = 0; for (const n of this.nodes.values()) { if (n.fingerprint === e.fingerprint) continue; if (n.tokens.size === 0) continue; const s = jaccard(t, n.tokens); if (s > max) max = s; if (++checked > 2000) break; } return 1 - max; } stats() { const byType: Record = {}; let visited = 0; let multi = 0; for (const n of this.nodes.values()) { byType[n.type] = (byType[n.type] ?? 0) + 1; if (n.visited) visited++; if (n.surfaces.size > 1) multi++; } return { nodes: this.nodes.size, edges: this.edges.length, visited, multi_surface: multi, by_type: byType }; } toJSON() { return { nodes: [...this.nodes.values()].map((n) => ({ ...n, surfaces: [...n.surfaces], tokens: undefined })), edges: this.edges, }; } }