/* ============================================================================ FALSIFY live UI — force-graph rendering, SSE stream, and the four signature animations that make "revised, not forgotten" visible: • strike — a refuted node flashes red and gains a dashed-red border • sweep — invalidation greys downstream nodes with a staggered delay • dissolve — a forgotten node shrinks to nothing, then is removed • rise — a promoted / newly-added node pulses green and settles State is never carried by color alone (green↔red are close under deuteranopia): every node draws its truth-state color AND a text label AND — when refuted — a dashed border, and every change is narrated in the revision log. ========================================================================== */ const API = window.__API_BASE__ || ""; // "" = same-origin (HF monolith) const $ = (id) => document.getElementById(id); const STATE = { alive: { color: "#22c55e", tag: "alive" }, refuted: { color: "#ef4444", tag: "refuted" }, invalidated: { color: "#9ca3af", tag: "invalidated" }, superseded: { color: "#f59e0b", tag: "superseded" }, forgotten: { color: "#4b5563", tag: "forgotten" }, }; const colorOf = (s) => (STATE[s] || STATE.alive).color; /* ------------------------------------------------------------------ graph state */ let Graph; // the force-graph instance let nodes = []; // {id,label,type,state,color, _flash,_flashColor,_shrink,_born} let links = []; // {source,target,relation, _pulse} const byId = () => Object.fromEntries(nodes.map((n) => [n.id, n])); /* animation timers tick every frame; the renderer reads them */ function tickAnimations() { let alive = false; for (const n of nodes) { if (n._flash > 0) { n._flash = Math.max(0, n._flash - 0.018); alive = true; } if (n._shrink > 0) { n._shrink = Math.min(1, n._shrink + 0.04); alive = true; } if (n._born > 0) { n._born = Math.max(0, n._born - 0.02); alive = true; } } for (const l of links) { if (l._pulse > 0) { l._pulse = Math.max(0, l._pulse - 0.03); alive = true; } } // drop fully-dissolved nodes const gone = nodes.filter((n) => n._shrink >= 1).map((n) => n.id); if (gone.length) removeNodes(gone); if (alive && Graph) Graph.nodeRelSize(Graph.nodeRelSize()); // nudge a redraw requestAnimationFrame(tickAnimations); } /* ------------------------------------------------------------------ rendering */ function drawNode(node, ctx, scale) { const base = 6; const grow = node._born > 0 ? 1 + node._born * 0.4 : 1; // "rise" scale-in const shrink = node._shrink > 0 ? 1 - node._shrink : 1; // "dissolve" scale-out const r = base * grow * shrink; if (r <= 0.2) return; const isRefuted = node.state === "refuted"; const alpha = shrink; ctx.globalAlpha = alpha; // soft state glow so the canvas feels alive ctx.beginPath(); ctx.arc(node.x, node.y, r + 5, 0, 2 * Math.PI); ctx.fillStyle = hexA(node.color, 0.12); ctx.fill(); // node body ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, 2 * Math.PI); ctx.fillStyle = node.color; ctx.fill(); // refuted = dashed red ring (the second channel, so it's not color-only) if (isRefuted) { ctx.setLineDash([3, 2]); ctx.strokeStyle = "#ef4444"; ctx.lineWidth = 1.6 / scale; ctx.stroke(); ctx.setLineDash([]); } else { ctx.strokeStyle = "#0b1020"; ctx.lineWidth = 1.4 / scale; ctx.stroke(); } // flash ring (strike / state-change / rise) if (node._flash > 0) { ctx.beginPath(); ctx.arc(node.x, node.y, r + 4 + (1 - node._flash) * 8, 0, 2 * Math.PI); ctx.strokeStyle = hexA(node._flashColor || node.color, node._flash); ctx.lineWidth = 2.4 / scale; ctx.stroke(); } // label — always present (identity never color-alone) const label = node.label.length > 34 ? node.label.slice(0, 31) + "…" : node.label; const fs = Math.max(3.2, 11 / scale); ctx.font = `${fs}px Inter, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillStyle = "#cbd5e1"; ctx.globalAlpha = alpha * 0.9; ctx.fillText(label, node.x, node.y + r + 2); ctx.globalAlpha = 1; } function drawLink(link, ctx, scale) { const s = link.source, t = link.target; if (!s || !t || typeof s !== "object") return; ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(t.x, t.y); if (link._pulse > 0) { ctx.strokeStyle = hexA("#34d399", link._pulse); ctx.lineWidth = 2.4 / scale; } else { ctx.strokeStyle = "rgba(148,163,184,.28)"; ctx.lineWidth = 1 / scale; } ctx.stroke(); } function initGraph() { Graph = ForceGraph()($("graph")) .backgroundColor("rgba(0,0,0,0)") .nodeRelSize(6) .nodeCanvasObject(drawNode) .nodePointerAreaPaint((node, color, ctx) => { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(node.x, node.y, 9, 0, 2 * Math.PI); ctx.fill(); }) .linkCanvasObject(drawLink) .linkDirectionalArrowLength(2.5) .onNodeHover((n) => ($("graph").style.cursor = n ? "pointer" : "default")) .onNodeClick((n) => addMessage("system", nodeCard(n))) .cooldownTicks(120) .d3VelocityDecay(0.28); sizeGraph(); window.addEventListener("resize", sizeGraph); } function sizeGraph() { const el = $("graph-panel"); Graph.width(el.clientWidth).height(el.clientHeight); } /* ------------------------------------------------------------------ graph data ops */ function setGraph(g) { const prev = byId(); nodes = g.nodes.map((n) => ({ ...n, _flash: prev[n.id] ? 0 : 0.9, // new nodes "rise" _flashColor: "#22c55e", _born: prev[n.id] ? 0 : 1, _shrink: 0, x: prev[n.id]?.x, y: prev[n.id]?.y, // keep positions stable across refetches })); const idset = new Set(nodes.map((n) => n.id)); links = g.edges .filter((e) => idset.has(e.source) && idset.has(e.target)) .map((e) => ({ source: e.source, target: e.target, relation: e.relation, _pulse: 0 })); Graph.graphData({ nodes, links }); updateBackendUI(g.backend, g.scenario); } function removeNodes(ids) { const set = new Set(ids); nodes = nodes.filter((n) => !set.has(n.id)); links = links.filter((l) => { const s = typeof l.source === "object" ? l.source.id : l.source; const t = typeof l.target === "object" ? l.target.id : l.target; return !set.has(s) && !set.has(t); }); Graph.graphData({ nodes, links }); } async function refetchGraph() { const g = await fetch(`${API}/api/graph`).then((r) => r.json()); setGraph(g); } /* ------------------------------------------------------------------ SSE events */ function connectSSE() { const es = new EventSource(`${API}/api/events`); es.onopen = () => setConn("live", "live"); es.onerror = () => { setConn("reconnecting", "reconnecting…"); }; es.onmessage = (e) => { let ev; try { ev = JSON.parse(e.data); } catch { return; } handleEvent(ev); }; } function handleEvent(ev) { const map = byId(); if (ev.type === "graph_reset") { refetchGraph(); } else if (ev.type === "node_state_changed") { const n = map[ev.id]; if (!n) return; n.state = ev.state; n.color = colorOf(ev.state); n._flash = 1.0; n._flashColor = colorOf(ev.state); // light up edges INTO this node so the cascade visibly travels for (const l of links) { const t = typeof l.target === "object" ? l.target.id : l.target; if (t === ev.id) l._pulse = 1.0; } logEntry(ev.state, n.label, ev.id, whyFor(ev.state)); } else if (ev.type === "node_forgotten") { const n = map[ev.id]; if (n) { n._shrink = 0.01; logEntry("forgotten", n.label, ev.id, whyFor("forgotten")); } } else if (ev.type === "pipeline_step") { addMessage("system", `
${escapeHtml(ev.step)}
${escapeHtml(ev.detail || "")}`); } } const WHY = { refuted: "contradicted by the incoming fact", invalidated: "its critical support died", superseded: "its evidence collapsed; a rival now leads", forgotten: "orphaned — no live consumer (provenance kept)", alive: "promoted — still standing", }; const whyFor = (s) => WHY[s] || ""; /* ------------------------------------------------------------------ panels */ function addMessage(kind, html, warn) { const div = document.createElement("div"); div.className = `msg ${kind}${warn ? " warn" : ""}`; div.innerHTML = html; $("messages").appendChild(div); $("messages").scrollTop = $("messages").scrollHeight; $("msg-count").textContent = $("messages").querySelectorAll(".msg").length + " msgs"; clearCoach(); } function clearCoach() { const c = document.querySelector(".coach"); if (c) c.remove(); } function nodeCard(n) { return `
${escapeHtml(n.label)}
${n.type} · ${STATE[n.state]?.tag || n.state} · ${n.id.slice(0,8)}
`; } function logEntry(state, label, id, why) { const wrap = $("log"); const row = document.createElement("div"); row.className = "log-entry"; row.innerHTML = `
${escapeHtml(shortLabel(label))} ${state}
${escapeHtml(why)}
`; wrap.prepend(row); $("log-count").textContent = wrap.querySelectorAll(".log-entry").length + " events"; } function renderScoreboard(sb) { if (!sb) return; $("sb-q").textContent = sb.question || ""; $("falsify-answer").textContent = sb.falsify_answer || "—"; $("falsify-support").textContent = (sb.falsify_support || []).length ? "supported by: " + sb.falsify_support.join(", ") : ""; $("rag-answer").textContent = sb.rag_answer || "—"; const ragCard = $("rag-card"); if (sb.stale) { $("rag-note").textContent = "⚠ still cites a node FALSIFY refuted — a stale answer."; ragCard.classList.add("stale"); ragCard.classList.remove("stale-flash"); void ragCard.offsetWidth; ragCard.classList.add("stale-flash"); } else { $("rag-note").textContent = sb.rag_citations?.length ? "cites: " + sb.rag_citations[0] : ""; ragCard.classList.remove("stale"); } } /* ------------------------------------------------------------------ actions */ async function sendMessage() { const input = $("msg-input"); const msg = input.value.trim(); if (!msg) return; addMessage("user", escapeHtml(msg)); input.value = ""; autoGrow(); $("send-btn").disabled = true; try { const res = await fetch(`${API}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: msg, demo: true }), }).then((r) => r.json()); if (res.type === "answer") { renderScoreboard(res.data); addMessage("system", "Answered from the alive belief graph — see the scoreboard."); } else if (res.type === "revision") { summarizeRevision(res.data); await afterRevision(); } } catch (e) { addMessage("system", "Request failed: " + escapeHtml(String(e)), true); } $("send-btn").disabled = false; } function summarizeRevision(d) { if (!d.revised) { addMessage("system", "No contradiction found — the graph is unchanged."); return; } const pill = (t, c) => `${t}`; addMessage("system", `
Belief revised
${pill(d.refuted.length + " refuted", "#ef4444")} ${pill(d.invalidated.length + " invalidated", "#9ca3af")} ${pill(d.forgotten.length + " forgotten", "#4b5563")}
epoch ${d.epoch} · provenance retained: ${d.retained_provenance.length}
`); } async function afterRevision() { const sb = await fetch(`${API}/api/scoreboard`).then((r) => r.json()); renderScoreboard(sb); } async function runDemo() { clearCoach(); addMessage("system", `
Running investigation…
watch the graph revise in real time.`); const res = await fetch(`${API}/api/demo`, { method: "POST" }).then((r) => r.json()); summarizeRevision(res.report); renderScoreboard(res.scoreboard); } async function runDiamond() { clearCoach(); addMessage("system", `
Diamond scenario
K2 rests on two supports — watch it survive one blow, then fall to the second.`); const res = await fetch(`${API}/api/diamond`, { method: "POST" }).then((r) => r.json()); addMessage("system", "Phase 1 done — K died, K2 survived. Phase 2 done — K2 collapsed."); renderScoreboard(res.scoreboard); } async function setScenario(kind) { const g = await fetch(`${API}/api/scenario`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind }), }).then((r) => r.json()); setGraph(g); addMessage("system", `Loaded the ${kind} investigation — ${g.nodes.length} beliefs.`); } async function resetGraph() { const g = await fetch(`${API}/api/reset`, { method: "POST" }).then((r) => r.json()); setGraph(g); addMessage("system", "Graph reset."); } async function verifyPersistence() { addMessage("system", "Re-reading belief state from the on-disk graph store…"); const res = await fetch(`${API}/api/verify`).then((r) => r.json()); if (!res.persisted) { addMessage("system", "Could not read persisted state: " + escapeHtml(res.error || ""), true); return; } const lines = Object.entries(res.summary || {}) .map(([t, c]) => `${t}: ${Object.entries(c).map(([s, n]) => `${n} ${s}`).join(", ")}`).join("
"); addMessage("system", `
✓ Beliefs are persisted on the graph
${lines}
truth-state lives in storage, not in process memory.
`); } async function uploadFile(file) { addMessage("user", `📄 ${escapeHtml(file.name)}`); addMessage("system", "Ingesting into memory (add + cognify)…"); const fd = new FormData(); fd.append("file", file); const res = await fetch(`${API}/api/upload`, { method: "POST", body: fd }).then((r) => r.json()); if (res.ok) { setGraph(res.graph); addMessage("system", "Ingested — new evidence is now in the graph."); } else { addMessage("system", (res.error || "upload failed") + (res.hint ? `
${res.hint}
` : ""), true); } } async function setBackend(mode) { const res = await fetch(`${API}/api/mode`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }), }).then((r) => r.json()); updateBackendUI(res.backend); addMessage("system", res.backend === "cloud" ? "Backend → Cognee Cloud. All memory ops now run on the tenant." : "Backend → self-hosted (open source)."); } /* ------------------------------------------------------------------ ui helpers */ function updateBackendUI(backend, scenario) { if (backend) { document.querySelectorAll("#backend-toggle button").forEach((b) => b.classList.toggle("on", b.dataset.mode === backend)); } } function setConn(cls, label) { const el = $("conn"); el.className = "status-dot " + cls; $("conn-label").textContent = label; } function autoGrow() { const t = $("msg-input"); t.style.height = "auto"; t.style.height = Math.min(120, t.scrollHeight) + "px"; } function shortLabel(s) { return s.length > 40 ? s.slice(0, 37) + "…" : s; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function hexA(hex, a) { const h = hex.replace("#", ""); const n = parseInt(h.length === 3 ? h.split("").map((x) => x + x).join("") : h, 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; } /* ------------------------------------------------------------------ wire up */ function wire() { $("send-btn").onclick = sendMessage; $("msg-input").addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); $("msg-input").addEventListener("input", autoGrow); $("btn-run").onclick = runDemo; $("btn-simple").onclick = () => setScenario("simple"); $("btn-diamond").onclick = runDiamond; $("btn-reset").onclick = resetGraph; $("btn-verify").onclick = verifyPersistence; document.querySelectorAll("#backend-toggle button").forEach((b) => (b.onclick = () => setBackend(b.dataset.mode))); const dz = $("dropzone"), fi = $("file-input"); fi.onchange = () => fi.files[0] && uploadFile(fi.files[0]); ["dragover", "dragenter"].forEach((ev) => dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.add("drag"); })); ["dragleave", "drop"].forEach((ev) => dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.remove("drag"); })); dz.addEventListener("drop", (e) => { const f = e.dataTransfer.files[0]; if (f) uploadFile(f); }); // coach mark $("messages").innerHTML = `
Hit ▶ Run investigation to watch a belief die — or drop a contradicting fact of your own.
`; } async function boot() { wire(); initGraph(); requestAnimationFrame(tickAnimations); connectSSE(); try { await refetchGraph(); } catch { addMessage("system", "Backend not reachable yet — retrying via events.", true); } } boot();