Spaces:
Sleeping
Sleeping
| # FALSIFY — Tier 2 Implementation & Deployment Plan | |
| > Written incrementally, one section at a time. | |
| > **Section 1 of ~7 below.** Confirm to continue to Section 2. | |
| --- | |
| ## Section 1 — Strategy & Scope (win-ROI under a 24h deadline) | |
| **Deadline reality:** Today is July 4; the hackathon closes July 5. We have ~24 hours. | |
| Every decision below is ranked by *what wins*, not by engineering completeness. | |
| **Two prize tracks — we go for both:** | |
| - **Best Use of Open Source** (MacBook) — the deployed, self-hosted FALSIFY demo. | |
| - **Best Use of Cognee Cloud** (iPhone 17) — the same pipeline, toggled to run against a | |
| Cognee Cloud tenant via `cognee.serve()`. One flag makes us eligible for a *second* prize. | |
| ### Final scope (in priority order) | |
| | # | Feature | Why it wins | Status vs. old plan | | |
| |---|---------|-------------|---------------------| | |
| | **F** | **Live animated web UI** (chat + living graph + scoreboard) | The judges' primary visual; the whole story in one screen | **Centerpiece — keep, elevate** | | |
| | **G** | **Cognee Cloud toggle** (`cognee.serve()`) | Unlocks the *second* prize track for ~1h work | **NEW — was missing entirely** | | |
| | **S** | **Scoreboard as a permanent panel** (FALSIFY vs plain RAG) | Surfaces the core thesis instead of burying it in a chat reply | **NEW — was hidden** | | |
| | **L** | **Revision log / "why" narration** | Makes the animation *legible* — judge understands the reasoning | **NEW — explainability** | | |
| | **U** | **Document upload → `add` + `cognify`** | Turns a fixed demo into a real product; showcases core Cognee | **NEW — from your original vision** | | |
| | **P** | **Persistence proof, integrated** (reopen memory → still revised) | Same "survives restart" punch, but *visible* in the UI | **Replaces old Feature D subprocess** | | |
| | **E** | **Diamond scenario** (partial survival → full collapse) | Depth for technical judges | **Demoted: a UI scenario button, not a CLI mode** | | |
| ### What we cut / change and why | |
| - **CUT the `verify_persistence.py` subprocess (old Feature D).** A subprocess printing JSON | |
| to a terminal is invisible to judges. We get the same proof *visibly* inside the UI: state | |
| lives on disk (Kuzu + LanceDB), so a "Reopen memory" action re-reads from cold storage and | |
| the graph is still revised. | |
| - **DEMOTE the diamond (old Feature E) from a separate `--diamond` CLI mode to one scenario | |
| button in the web UI.** The engine work is cheap (zero algorithm change) and stays; only the | |
| surfacing moves into F. | |
| - **SWITCH the real-time transport from WebSocket → Server-Sent Events (SSE).** Verified today: | |
| our event stream is one-directional (server→browser), and WebSockets are fragile on exactly | |
| the platforms in play — Vercel serverless can't host them, and Render supports them only on | |
| paid plans (its free tier's 15-min spin-down severs them regardless). SSE is plain streaming | |
| HTTP: it works on the HF Spaces single-port monolith and survives the Vercel+Render split. | |
| ### Deployment stance | |
| - **Primary: Hugging Face Spaces (Docker monolith)** — one container, FastAPI serves the built | |
| frontend + API + SSE on port 7860. Keyless demo mode runs out of the box. **Try this first.** | |
| - **Fallback: Vercel (frontend) + Render (backend)** — documented with its caveats (Render free | |
| tier cold start; warm before judging). Detailed in a later section. | |
| ### The keyless-demo guarantee | |
| In **demo mode** the contradiction is pinned, `fastembed` does embeddings locally, and the | |
| scoreboard falls back to graph traversal — **no API key needed**, so the deployed demo works | |
| for any judge instantly. Live mode, upload+cognify, and `recall` completion need a key (set as | |
| an HF secret / Render env var) and degrade gracefully when absent. | |
| --- | |
| *End of Section 1.* | |
| --- | |
| ## Section 2 — Architecture | |
| > **Section 2 of ~7.** Confirm to continue to Section 3. | |
| ### 2.1 Shape at a glance | |
| ``` | |
| ┌─────────────────────────── Browser (built React app) ───────────────────────────┐ | |
| │ ChatPanel │ GraphCanvas (living graph) │ Scoreboard │ | |
| │ + upload │ force-directed, animated state │ FALSIFY vs RAG │ | |
| │ + quick actions │ transitions (flash/cascade/dissolve) │ RevisionLog feed │ | |
| └──────┬─────────────┴───────────────┬────────────────────────┴─────────┬──────────┘ | |
| │ POST /api/chat, /upload, │ GET /api/graph (snapshot) │ EventSource | |
| │ /scenario, /reset, /mode │ GET /api/scoreboard │ GET /api/events (SSE) | |
| ▼ ▼ ▼ | |
| ┌───────────────────────────────── server.py (FastAPI) ───────────────────────────┐ | |
| │ REST endpoints ──► call falsify.falsify (build_graph / revise / scoreboard) │ | |
| │ SSE broadcaster ──► fans events to all subscribed browsers │ | |
| └───────────────────────────────┬─────────────────────────────────────────────────┘ | |
| │ falsify.events.emit(...) (no-op in CLI mode) | |
| ▼ | |
| ┌──────────────── falsify/graph_ops.py (the single chokepoint) ────────────────────┐ | |
| │ set_state() ──► emit_state_change(id, state, epoch) │ | |
| │ delete_from_both_stores() ──► emit_forgotten(id) │ | |
| │ (Cognee graph engine: Kuzu/Ladybug + LanceDB, file-backed) │ | |
| └──────────────────────────────────────────────────────────────────────────────────┘ | |
| ``` | |
| The insight from the research pass still holds: **every** belief mutation flows through | |
| exactly two functions in `graph_ops.py`, so ~4 lines of hooks capture the whole pipeline | |
| without touching any task code. | |
| ### 2.2 Backend (FastAPI) — endpoint contract | |
| | Method + path | Purpose | Calls | | |
| |---|---|---| | |
| | `GET /` | Serve the built frontend (`static/index.html`) | — | | |
| | `GET /api/graph` | Current nodes+edges+truth-state+colors (snapshot) | `graph_ops.load_graph` + `get_truth` | | |
| | `GET /api/events` | **SSE stream** of live mutation events | subscribes to event bus | | |
| | `POST /api/chat` | Classify input → fact (`revise`) or question (`scoreboard`); events fire mid-pipeline | `revise` / `scoreboard` | | |
| | `POST /api/upload` | Ingest a dropped doc → new nodes appear in the graph | `cognee.add` + `cognee.cognify` | | |
| | `POST /api/scenario` | Build `simple` or `diamond` graph | `build_graph` / `build_diamond_graph` | | |
| | `POST /api/reset` | Rebuild the seed investigation | `build_graph` | | |
| | `GET /api/scoreboard?q=` | FALSIFY answer vs stale-RAG answer | `scoreboard` | | |
| | `GET /api/verify` | **Persistence proof**: re-read truth-state from the on-disk graph store (truth-alignment lives in Kuzu, not process memory) | `get_belief_summary` | | |
| | `POST /api/mode` | Toggle `opensource` ↔ `cloud` (`cognee.serve(url, key)`) | backend switch (Feature G) | | |
| ### 2.3 The SSE event bus (`falsify/events.py`, new — leaf module) | |
| - A module-level **set of subscriber `asyncio.Queue`s**. Each open `GET /api/events` | |
| connection registers its own queue; `emit()` fans the event to all of them. | |
| - `emit()` is a **no-op when there are no subscribers** — so `python main.py --demo` (CLI) is | |
| completely unaffected; the 4 new lines in `graph_ops.py` cost nothing. | |
| - Event shapes (JSON): `node_state_changed {id, state, epoch}`, `node_forgotten {id}`, | |
| `graph_reset {}`, `pipeline_step {step, detail}`. | |
| - SSE framing: `StreamingResponse(media_type="text/event-stream")`, each event as | |
| `data: {json}\n\n`, a `: keepalive\n\n` comment every ~15 s, and header | |
| `X-Accel-Buffering: no` so proxies (HF/Render) don't buffer the stream. | |
| - **Leaf-module rule:** `events.py` imports nothing from `falsify` (avoids the circular | |
| import `graph_ops → events`). | |
| ### 2.4 Frontend (Vite + React + TypeScript + Tailwind) | |
| - **Graph:** `react-force-graph-2d` (the same lib Cognee's own frontend uses — we already read | |
| its `nodeCanvasObject` pattern). Custom canvas rendering for glow, dashed-refuted borders, | |
| flash rings, shrink-to-dissolve. | |
| - **Panels/motion:** `framer-motion` for the revision-log slide-ins and scoreboard transitions. | |
| - **Live updates:** a `useEventStream` hook wraps the browser-native `EventSource` against | |
| `/api/events`, with auto-reconnect. On each event it patches the in-memory graph data and | |
| triggers the matching animation. | |
| - **Env-aware API base:** `VITE_API_BASE` (empty for the HF monolith / same-origin; the Render | |
| URL for the Vercel split). SSE and fetch both resolve through it. | |
| - **Built to static** (`vite build → dist/`) — one artifact that either the FastAPI monolith | |
| serves (HF) or Vercel serves (split). Same code, both deploy targets. | |
| ### 2.5 Keyless-demo wiring | |
| - **Demo mode** (default for the deployed Space): contradiction pinned, `fastembed` local | |
| embeddings, scoreboard graph-traversal fallback → **zero API calls, zero key**. | |
| - **Live mode / upload / recall-completion:** need `LLM_API_KEY`; read from env (HF secret or | |
| Render var). When absent, the UI keeps working in demo mode and shows a subtle "add a key for | |
| Live mode" hint instead of erroring. | |
| ### 2.6 Directory layout (target) | |
| ``` | |
| cognee-hackathon-project/ | |
| ├── server.py # NEW — FastAPI app (endpoints + SSE broadcaster) | |
| ├── falsify/ | |
| │ ├── events.py # NEW — SSE event bus (leaf module) | |
| │ ├── graph_ops.py # +4 lines of emit hooks; + flush_and_release() | |
| │ ├── seed.py # + NEW_FACT_2, build_diamond_investigation() (Feature E) | |
| │ └── falsify.py # + build_diamond_graph(); + cloud backend switch (Feature G) | |
| ├── frontend/ # NEW — Vite React app | |
| │ ├── src/{App,components,hooks,lib}... | |
| │ └── dist/ # build output → served as static | |
| ├── Dockerfile # NEW — multi-stage (build frontend → serve with backend) | |
| ├── requirements.txt # + fastapi, uvicorn[standard], sse (stdlib), python-multipart | |
| └── IMPLEMENTATION_PLAN.md | |
| ``` | |
| --- | |
| *End of Section 2.* | |
| --- | |
| ## Section 3 — Backend implementation | |
| > **Section 3 of ~7.** Confirm to continue to Section 4. | |
| All code below matches the **verified** signatures in `graph_ops.py`: | |
| `set_state(node_id, state, epoch)` and `delete_from_both_stores(node_ids, collections)`. | |
| ### 3.1 `falsify/events.py` (NEW — leaf module, imports nothing from falsify) | |
| ```python | |
| """FALSIFY real-time event bus for the live web UI. | |
| A set of per-connection asyncio.Queues. graph_ops hooks call emit() after each | |
| state mutation; each open SSE connection drains its own queue. emit() is a no-op | |
| when there are no subscribers, so the CLI (`python main.py`) is unaffected. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| from typing import Any, Dict, Set | |
| _subscribers: Set[asyncio.Queue] = set() | |
| def subscribe() -> asyncio.Queue: | |
| q: asyncio.Queue = asyncio.Queue(maxsize=1000) | |
| _subscribers.add(q) | |
| return q | |
| def unsubscribe(q: asyncio.Queue) -> None: | |
| _subscribers.discard(q) | |
| async def emit(event: Dict[str, Any]) -> None: | |
| for q in list(_subscribers): | |
| try: | |
| q.put_nowait(event) | |
| except asyncio.QueueFull: | |
| pass # slow client: drop rather than block the pipeline | |
| async def emit_state_change(node_id: str, state: str, epoch: int) -> None: | |
| await emit({"type": "node_state_changed", "id": str(node_id), | |
| "state": state, "epoch": int(epoch)}) | |
| async def emit_forgotten(node_id: str) -> None: | |
| await emit({"type": "node_forgotten", "id": str(node_id)}) | |
| async def emit_graph_reset() -> None: | |
| await emit({"type": "graph_reset"}) | |
| async def emit_step(step: str, detail: str = "") -> None: | |
| await emit({"type": "pipeline_step", "step": step, "detail": detail}) | |
| ``` | |
| ### 3.2 `falsify/graph_ops.py` — the 4-line hook + `flush_and_release()` | |
| Add near the top (after the existing imports): | |
| ```python | |
| from falsify import events | |
| ``` | |
| In `set_state()`, immediately after the successful `set_node_truth_state` (line ~106): | |
| ```python | |
| await events.emit_state_change(str(node_id), value, int(epoch)) | |
| ``` | |
| In `delete_from_both_stores()`, after `await ge.delete_nodes(ids)` succeeds (line ~139): | |
| ```python | |
| for nid in ids: | |
| await events.emit_forgotten(nid) | |
| ``` | |
| Append the persistence helper (used by `GET /api/verify`): | |
| ```python | |
| async def flush_and_release() -> None: | |
| """Checkpoint the graph WAL and evict the engine so the next read is a | |
| genuine cold read from disk (basis of the visible persistence proof).""" | |
| ge = await get_graph_engine() | |
| try: | |
| if hasattr(ge, "checkpoint"): | |
| await ge.checkpoint() | |
| except Exception as exc: | |
| logger.debug("checkpoint best-effort skip: %s", exc) | |
| try: | |
| from cognee.infrastructure.databases.graph.get_graph_engine import evict_graph_engine | |
| from cognee.infrastructure.databases.graph.config import get_graph_config | |
| evict_graph_engine(**get_graph_config().to_hashable_dict()) | |
| except Exception as exc: | |
| logger.debug("evict best-effort skip: %s", exc) | |
| ``` | |
| ### 3.3 Feature E — diamond scenario (engine work stays, surfaced via UI) | |
| `falsify/seed.py` — add after `NEW_FACT`: | |
| ```python | |
| NEW_FACT_2 = ( | |
| "A forensic analysis of email headers shows the January 2021 supplier email " | |
| "was fabricated: the sender domain was not registered until April 2021, and " | |
| "the DKIM signature is invalid." | |
| ) | |
| REFUTED_EVIDENCE_KEY_2 = "E_email" | |
| ``` | |
| Add `build_diamond_investigation()` — identical to `build_investigation()` plus a Conclusion | |
| `K2` that critically depends on **both** `E_qa` and `E_email`: | |
| ```python | |
| conclusion_k2 = Conclusion( | |
| statement="Multiple independent sources confirm Company X had pre-recall " | |
| "knowledge of the defect.", | |
| confidence=0.85, | |
| depends_on_ids=[str(ev_qa.id), str(ev_email.id)], | |
| source_id="analyst", | |
| ) | |
| # ... add K2 to the nodes list, persist, then two critical legs: | |
| await graph_ops.add_edge(str(conclusion_k2.id), str(ev_qa.id), DEPENDS_ON, {"critical": True}) | |
| await graph_ops.add_edge(str(conclusion_k2.id), str(ev_email.id), DEPENDS_ON, {"critical": True}) | |
| # ... include "K2" in seeded.ids / seeded.labels | |
| ``` | |
| `falsify/falsify.py` — add the orchestrator: | |
| ```python | |
| async def build_diamond_graph() -> SeededGraph: | |
| import cognee | |
| from cognee.low_level import setup | |
| from falsify.seed import build_diamond_investigation | |
| await cognee.forget(everything=True) | |
| await setup() | |
| return await build_diamond_investigation() | |
| ``` | |
| Two-phase story (driven by the UI scenario button, both targets pinned so it's deterministic): | |
| Phase 1 refutes `E_qa` → **K dies, K2 survives** (E_email still grounds it). Phase 2 refutes | |
| `E_email` → **K2 collapses** (both legs gone). No algorithm change — the grounded least-fixpoint | |
| already does this; we're just showing it. | |
| ### 3.4 Feature G — Cognee Cloud toggle (the second prize track) | |
| `falsify/falsify.py` — a backend switch used by `--cloud` and `POST /api/mode`: | |
| ```python | |
| async def use_backend(mode: str, url: str | None = None, api_key: str | None = None) -> None: | |
| """Route Cognee ops to the cloud tenant (mode='cloud') or self-hosted (default).""" | |
| import cognee | |
| if mode == "cloud" and url and api_key: | |
| await cognee.serve(url=url, api_key=api_key) # all ops now hit Cognee Cloud | |
| logger.info("FALSIFY backend -> Cognee Cloud (%s)", url) | |
| else: | |
| logger.info("FALSIFY backend -> self-hosted (open source)") | |
| ``` | |
| The **same** `build_graph` / `revise` / `scoreboard` pipeline then runs against whichever | |
| backend is active — so one toggle makes us demonstrable on both tracks. (Caveat: cloud mode | |
| needs a real tenant URL + key; the primary demo stays open-source and keyless.) | |
| ### 3.5 `server.py` (NEW) — FastAPI app, skeleton of the moving parts | |
| ```python | |
| import asyncio, json | |
| from fastapi import FastAPI, UploadFile, File | |
| from fastapi.responses import StreamingResponse, FileResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| import falsify # sets Cognee env defaults on import | |
| from falsify import events, graph_ops | |
| from falsify.falsify import build_graph, build_diamond_graph, revise, scoreboard, use_backend | |
| from falsify.seed import NEW_FACT, NEW_FACT_2, QUESTION_TEXT | |
| from falsify.utils import get_belief_summary | |
| app = FastAPI(title="FALSIFY Live") | |
| app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) | |
| _seeded = None # server-held SeededGraph | |
| _lock = asyncio.Lock() # serialize revise() (single-user demo safety) | |
| class Chat(BaseModel): | |
| message: str | |
| demo: bool = True | |
| @app.get("/api/events") | |
| async def sse(): | |
| q = events.subscribe() | |
| async def gen(): | |
| try: | |
| while True: | |
| try: | |
| ev = await asyncio.wait_for(q.get(), timeout=15) | |
| yield f"data: {json.dumps(ev)}\n\n" | |
| except asyncio.TimeoutError: | |
| yield ": keepalive\n\n" | |
| finally: | |
| events.unsubscribe(q) | |
| return StreamingResponse(gen(), media_type="text/event-stream", | |
| headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"}) | |
| @app.get("/api/graph") | |
| async def api_graph(): | |
| return await _graph_json() # load_graph + get_truth → {nodes, edges} with colors | |
| @app.post("/api/chat") | |
| async def api_chat(c: Chat): | |
| async with _lock: | |
| if c.message.strip().endswith("?"): | |
| board = await scoreboard(c.message, _seeded) | |
| return {"type": "answer", "data": board.__dict__} | |
| pinned = _seeded.refuted_target_id if (c.demo and _seeded) else None | |
| report = await revise(c.message, pinned_target_id=pinned) | |
| return {"type": "revision", "data": _report_json(report)} | |
| @app.post("/api/scenario") | |
| async def api_scenario(kind: str = "simple"): | |
| global _seeded | |
| _seeded = await (build_diamond_graph() if kind == "diamond" else build_graph()) | |
| await events.emit_graph_reset() | |
| return await _graph_json() | |
| @app.post("/api/upload") | |
| async def api_upload(file: UploadFile = File(...)): | |
| import cognee | |
| text = (await file.read()).decode("utf-8", "ignore") | |
| await cognee.add(text); await cognee.cognify() | |
| await events.emit_graph_reset() | |
| return await _graph_json() | |
| @app.get("/api/verify") | |
| async def api_verify(): | |
| await graph_ops.flush_and_release() # cold-read from disk | |
| return {"cold_read": True, "summary": await get_belief_summary()} | |
| # GET /api/scoreboard, POST /api/reset, POST /api/mode … analogous | |
| # GET / and /assets → serve frontend/dist (mounted last so /api/* wins) | |
| app.mount("/", StaticFiles(directory="static", html=True), name="static") | |
| @app.on_event("startup") | |
| async def _startup(): | |
| global _seeded | |
| _seeded = await build_graph() | |
| ``` | |
| *(Helpers `_graph_json()` and `_report_json()` reuse the color map from | |
| `utils._STATE_STYLE` and `_infer_type` — no new color logic.)* | |
| ### 3.6 `requirements.txt` additions | |
| ``` | |
| # — web UI (Feature F) — | |
| fastapi>=0.104.0 | |
| uvicorn[standard]>=0.24.0 | |
| python-multipart>=0.0.6 # multipart form parsing for /api/upload | |
| ``` | |
| *(SSE needs no extra package — it's plain `StreamingResponse`. No `websockets` dependency.)* | |
| --- | |
| *End of Section 3.* | |
| --- | |
| ## Section 4 — The "brilliant" frontend | |
| > **Section 4 of ~7.** Confirm to continue to Section 5. | |
| The UI has to do one job in the first 10 seconds a judge looks at it: **make "revised, not | |
| forgotten" viscerally obvious.** Everything below serves that. A pretty graph is table stakes; | |
| the win is *legibility of reasoning* — the judge should see a belief die and understand *why*. | |
| ### 4.1 Layout — three zones, one screen | |
| ``` | |
| ┌────────────────────────────────────────────────────────────────────────────────┐ | |
| │ ⬦ FALSIFY belief-revision copilot [Open source ▸ Cloud] [demo ●] │ top bar | |
| ├───────────────┬──────────────────────────────────────────┬───────────────────────┤ | |
| │ CHAT │ LIVING GRAPH │ SCOREBOARD │ | |
| │ (380px) │ (fluid, center stage) │ (360px) │ | |
| │ │ │ │ | |
| │ history │ ●─────● force-directed │ FALSIFY ✅ Jan 2021 │ | |
| │ bubbles │ │ │ nodes colored by │ (via alive evidence) │ | |
| │ │ ●──────● truth-state │ │ | |
| │ ┌─────────┐ │ cascade animates on revise │ plain RAG ⚠ Mar 2021 │ | |
| │ │ upload │ │ │ (cites REFUTED node) │ | |
| │ └─────────┘ │ │ ───────────────────── │ | |
| │ [ input … ] │ [Reset] [Run demo] [Diamond] [Verify] │ REVISION LOG ▸ │ | |
| │ ( send ) │ │ ● E_qa refuted 0.92 │ | |
| │ │ │ ● K invalidated │ | |
| │ │ │ ● A superseded → B │ | |
| └───────────────┴──────────────────────────────────────────┴───────────────────────┘ | |
| ``` | |
| On narrow screens the three zones stack (chat → graph → scoreboard) and the graph gets a fixed | |
| tall canvas. Judges will use a laptop, so the 3-column is the one we polish. | |
| ### 4.2 Design system (dark, "forensic lab" aesthetic) | |
| - **Palette (reuses the existing `_STATE_STYLE` so CLI, HTML export, and web all match):** | |
| bg `#0b1020`, panel `#111827`, hairline `#1f2937`, text `#e5e7eb`, dim `#9ca3af`. | |
| Truth-state = the *semantic* colors: alive `#22c55e`, refuted `#ef4444`, | |
| invalidated `#9ca3af`, superseded `#f59e0b`, forgotten `#4b5563`. | |
| - **Accent:** a single electric mint `#34d399` for interactive affordances (send, active tab). | |
| - **Type:** Inter for UI; a mono (JetBrains Mono / ui-monospace) for node ids, weights, epochs — | |
| the "instrument readout" feel. | |
| - **Depth:** soft outer shadows + 1px inner hairlines; nodes get a faint colored glow (their | |
| state color at low alpha) so the canvas looks alive, not like a diagram. | |
| - **Motion budget:** everything ≤ 400ms, `ease-out`; nothing loops forever (looping motion reads | |
| as "loading," not "alive"). `prefers-reduced-motion` collapses animations to instant state | |
| swaps. | |
| ### 4.3 Component tree | |
| ``` | |
| App | |
| ├─ TopBar (brand, BackendToggle [Open source|Cloud], ModeBadge [demo|live]) | |
| ├─ ChatPanel | |
| │ ├─ MessageList (user / system bubbles; system messages can embed a mini result card) | |
| │ ├─ UploadDrop (drag-drop or click → POST /api/upload; shows "cognifying…" then diff) | |
| │ └─ Composer (textarea + Send; Enter=send, Shift+Enter=newline) | |
| ├─ GraphCanvas (react-force-graph-2d; owns node/link rendering + animation state) | |
| │ └─ GraphControls (Reset · Run demo · Diamond · Verify · Legend) | |
| └─ InsightColumn | |
| ├─ Scoreboard (FALSIFY vs plain-RAG, side by side, with the "stale" warning) | |
| └─ RevisionLog (reverse-chronological feed of what changed and why) | |
| ``` | |
| ### 4.4 State & data flow | |
| - **`useEventStream()`** — wraps `EventSource('/api/events')`; exponential-backoff reconnect; | |
| exposes the latest event + a subscribe callback. Single source of live truth. | |
| - **`useGraphStore()`** (small Zustand or `useReducer`) — holds `nodes`/`links` plus per-node | |
| *animation fields* (`flash`, `flashColor`, `shrink`) that the canvas reads each frame. | |
| - `graph_reset` → refetch `GET /api/graph`, diff against current, animate *added* nodes in. | |
| - `node_state_changed` → patch color + set `flash=1.0` in the state's color. | |
| - `node_forgotten` → set `shrink=1.0`; when it hits 0, drop the node + its links. | |
| - **Optimistic chat:** user bubble appears instantly; the graph animates from SSE (not from the | |
| POST response), so the *cause* (chat) and *effect* (cascade) are visually linked in real time. | |
| ### 4.5 The signature animation vocabulary (this is the memorable part) | |
| Four named motions, each mapped to a belief event. Implemented in `nodeCanvasObject` via a | |
| per-node timer decremented on every `requestAnimationFrame`: | |
| 1. **Refute — "the strike."** Target flashes to red, a hard ring pulses outward once, and its | |
| border switches to **dashed red**. Sharp and fast (250ms). This is the moment of doubt. | |
| 2. **Cascade — "the sweep."** Invalidation doesn't happen all at once — it *travels*. Each | |
| downstream node greys out with a ~120ms stagger along `depends_on` edges, and the traversed | |
| edge briefly lights up. The judge literally watches consequence flow through the graph. (We | |
| already have `pipeline_step` events + edge data to order this.) | |
| 3. **Forget — "the dissolve."** An orphaned node shrinks to zero radius while fading alpha over | |
| 300ms, then is removed. Its edges retract with it. "Forgotten" should *feel* like deletion — | |
| but note in the log that provenance was retained. | |
| 4. **Promote — "the rise."** The newly-winning hypothesis (B) pulses green, scales up ~1.15× | |
| and settles, with a soft green glow that lingers a beat longer than the others. The graph | |
| ends on a calm, green, *correct* resting state — the emotional payoff. | |
| Refuted nodes keep the dashed-red border after their flash, so the end-state is self-documenting | |
| even after motion stops (and in screenshots / the GIF). | |
| ### 4.6 Scoreboard — the thesis, made unmissable | |
| Two stacked cards, always visible (not hidden in chat): | |
| - **FALSIFY** ✅ — the alive-evidence answer ("Jan 2021, via supplier email"), green check, | |
| a one-line "supported by: E_email (alive)". | |
| - **plain RAG** ⚠ — the naive vector answer ("Mar 2021, per QA report"), amber warning, and the | |
| killer subtitle: **"still cites E_qa — a node FALSIFY refuted."** When `board.stale` is true, | |
| the card gets a subtle red pulse the first time it renders. | |
| That contrast card is the single screenshot that should end up in the submission. Design it to | |
| be beautiful *standalone*. | |
| ### 4.7 Revision log — "why," in plain language | |
| A framer-motion feed; each entry slides in as its SSE event arrives, newest on top: | |
| - `● E_qa refuted` · `conf 0.92` · *"contradicted by forensic back-dating finding"* | |
| - `● K invalidated` · *"its only critical support (E_qa) died"* | |
| - `● A superseded → B promoted` · *"A's evidence collapsed; B still stands"* | |
| - `● K forgotten` · *"orphaned — no live consumer (provenance kept)"* | |
| Each row links to its node (hover → highlight in graph). This is what converts "cool animation" | |
| into "I understand exactly what the system decided and why." | |
| ### 4.8 Upload flow (turns a fixed demo into a product) | |
| Drag a `.txt`/`.md` onto the chat → optimistic "Ingesting…" bubble → `POST /api/upload` | |
| (`cognee.add` + `cognify`) → `graph_reset` fires → new evidence nodes animate in with the same | |
| "rise" motion. A judge dropping *their own* file and watching it become graph is the answer to | |
| "why would anyone use this." | |
| ### 4.9 Empty / error / no-key states (polish that judges notice) | |
| - **First load:** graph pre-seeded (startup builds it), a one-line coach-mark: *"Type a | |
| contradicting fact, or hit Run demo."* | |
| - **No LLM key:** Live toggle shows a tooltip *"demo mode — add a key for Live/Upload"*; nothing | |
| errors, everything still runs pinned+local. | |
| - **SSE drop:** a tiny amber dot in the top bar ("reconnecting…"); auto-recovers. | |
| - **Cloud unreachable:** toggle snaps back to Open source with a toast, demo continues. | |
| ### 4.10 Frontend dependencies | |
| ``` | |
| react, react-dom, typescript, vite | |
| tailwindcss, postcss, autoprefixer | |
| react-force-graph-2d # canvas force graph (same lib family as Cognee's UI) | |
| framer-motion # log + scoreboard transitions | |
| zustand # tiny graph/animation store (or useReducer to avoid a dep) | |
| ``` | |
| --- | |
| *End of Section 4.* | |
| --- | |
| ## Section 5 — Deployment Plan A: Hugging Face Spaces (PRIMARY) | |
| > **Section 5 of ~7.** Confirm to continue to Section 6. | |
| **Why this is the primary target:** one Docker container serves the built frontend **and** the | |
| API **and** the SSE stream on a single port. No cross-origin config, SSE works natively, secrets | |
| are one settings tab, and the keyless demo means a judge just clicks the Space and it runs. This | |
| is the simplest path to a live URL — **build and ship this first.** | |
| *(All specifics below verified against HF's current Docker Spaces docs, July 2026.)* | |
| ### 5.1 The three facts that shape the Dockerfile | |
| 1. **Port 7860.** HF proxies all external traffic to one port; default is 7860. Bind uvicorn to | |
| `0.0.0.0:7860` and declare `app_port: 7860` in the README frontmatter. | |
| 2. **SSE/WebSocket both ride that single port.** Because everything is same-origin behind HF's | |
| proxy, our `EventSource('/api/events')` just works — no extra config. (This is the payoff of | |
| choosing SSE in Section 1.) | |
| 3. **Non-root, UID 1000.** Spaces run the container as uid 1000; create that user and set | |
| `HOME`/`PATH` accordingly or writes to cache/model dirs fail. | |
| ### 5.2 `README.md` frontmatter (the Space config lives here) | |
| The Space's `README.md` must start with this YAML block: | |
| ```yaml | |
| --- | |
| title: FALSIFY — Belief-Revision Copilot | |
| emoji: ⬦ | |
| colorFrom: indigo | |
| colorTo: green | |
| sdk: docker | |
| app_port: 7860 | |
| pinned: false | |
| --- | |
| ``` | |
| ### 5.3 Multi-stage `Dockerfile` (build frontend → serve with backend) | |
| ```dockerfile | |
| # ── Stage 1: build the React frontend ──────────────────────────────── | |
| FROM node:20-slim AS web | |
| WORKDIR /web | |
| COPY frontend/package*.json ./ | |
| RUN npm ci | |
| COPY frontend/ ./ | |
| RUN npm run build # emits /web/dist | |
| # ── Stage 2: python backend + serve the built frontend ─────────────── | |
| FROM python:3.11-slim | |
| RUN useradd -m -u 1000 user # Spaces run as uid 1000 | |
| WORKDIR /home/user/app | |
| # deps first (layer cache) | |
| COPY --chown=user requirements.txt . | |
| RUN pip install --no-cache-dir --upgrade pip && \ | |
| pip install --no-cache-dir -r requirements.txt | |
| # pre-warm fastembed so the FIRST request isn't a model download | |
| RUN python -c "from fastembed import TextEmbedding; TextEmbedding()" || true | |
| COPY --chown=user . . | |
| COPY --from=web --chown=user /web/dist ./static # server.py mounts ./static | |
| USER user | |
| ENV HOME=/home/user \ | |
| PATH=/home/user/.local/bin:$PATH \ | |
| HF_HOME=/home/user/app/.cache | |
| EXPOSE 7860 | |
| CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"] | |
| ``` | |
| ### 5.4 Secrets (only for Live mode / upload — demo needs none) | |
| In **Space → Settings → Variables & secrets**, add as needed: | |
| - `LLM_API_KEY` (and `LLM_MODEL`, e.g. `gpt-4o-mini`) — enables Live judging, `recall` | |
| completion, and `cognify` on upload. | |
| - For a non-OpenAI endpoint: `LLM_PROVIDER=custom`, `LLM_ENDPOINT=…`. | |
| - These arrive as env vars at runtime (`os.environ.get(...)`), which `falsify/__init__.py` | |
| already reads. **Absent key ⇒ the app stays in keyless demo mode; nothing crashes.** | |
| ### 5.5 Persistent storage (optional, and we don't need it) | |
| - Default disk is **ephemeral** — it resets on rebuild/restart. That's *fine*: the startup hook | |
| seeds the graph fresh every boot, and within a session the on-disk Kuzu/LanceDB persistence is | |
| what powers the `GET /api/verify` cold-read proof. | |
| - If we ever want the graph to survive restarts, enable **persistent storage** and point | |
| `HF_HOME` (and Cognee's system dir) at `/data`. Not required for the demo — skip it. | |
| ### 5.6 Ship it (two ways) | |
| **Option 1 — Git push to the Space remote:** | |
| ```bash | |
| # after creating the Space (SDK: Docker) in the HF UI | |
| git remote add space https://huggingface.co/spaces/<user>/falsify | |
| git push space main # HF builds the Dockerfile and deploys | |
| ``` | |
| **Option 2 — `huggingface_hub` CLI:** `pip install huggingface_hub`, `huggingface-cli login`, | |
| then `huggingface-cli upload <user>/falsify . --repo-type space`. | |
| Watch the **Container** tab for build logs; first build is slow (Cognee pulls Kuzu/LanceDB/ | |
| litellm — several minutes). Once green, the Space URL is the live demo link for the submission. | |
| ### 5.7 HF-specific risks & mitigations | |
| | Risk | Mitigation | | |
| |---|---| | |
| | Cognee install is heavy → long/failed build | Pin versions in `requirements.txt`; the deps layer is cached across rebuilds; accept the first slow build | | |
| | First embed downloads a model → slow first request | Pre-warm `fastembed` in the Dockerfile (line above) | | |
| | Proxy buffers SSE → events arrive in a clump | `X-Accel-Buffering: no` + `Cache-Control: no-cache` already set on the stream (Section 3.5) | | |
| | Write to a non-writable dir as uid 1000 | `HF_HOME` under `/home/user/app/.cache`; all app writes under `$HOME` | | |
| | Build OOM on large frontend deps | `npm ci` in an isolated stage; only `dist/` is copied forward | | |
| --- | |
| *End of Section 5.* | |
| --- | |
| ## Section 6 — Deployment Plan B: Vercel + Render (FALLBACK) | |
| > **Section 6 of ~7.** Confirm to continue to Section 7. | |
| Use this **only if HF Spaces gives trouble.** It splits the app: **Vercel** serves the static | |
| React build, **Render** runs the FastAPI backend (API + SSE). More moving parts (two deploys, | |
| CORS, a cold-start caveat) — but it's a clean production shape and a solid Plan B. | |
| ### 6.1 The load-bearing insight: SSE, not WebSockets, is what makes this split viable | |
| Verified today (July 2026): | |
| - **Vercel serverless cannot host long-lived connections** — WebSockets or otherwise. Functions | |
| are pinned to a max duration (~300s) and future connections aren't guaranteed the same | |
| instance. So the realtime stream **cannot live on Vercel.** | |
| - **Render supports WebSockets only on paid plans**, and its free tier spins down after 15 min | |
| idle (30–60s cold start) which would sever a socket anyway. | |
| Our Section-1 choice of **SSE** sidesteps both cleanly: the stream lives on **Render** (plain | |
| streaming HTTP, no paid-WS requirement), and Vercel only ever serves static files + the browser | |
| connects `EventSource` **directly to the Render origin.** Vercel never has to hold the stream. | |
| ``` | |
| Browser ──static──► Vercel (frontend/dist) | |
| │ | |
| └── fetch + EventSource ──► Render (FastAPI: /api/*, /api/events SSE) ──► Cognee (Kuzu/LanceDB) | |
| ``` | |
| ### 6.2 Backend on Render | |
| 1. **New → Web Service**, connect the GitHub repo. | |
| 2. **Build:** `pip install -r requirements.txt` | |
| 3. **Start:** `uvicorn server:app --host 0.0.0.0 --port $PORT` | |
| *(Render injects `$PORT`; do not hardcode 7860 here.)* | |
| 4. **Env vars:** `LLM_API_KEY`, `LLM_MODEL`, and `FRONTEND_ORIGIN=https://<app>.vercel.app` | |
| (used by CORS below). Demo mode still needs no key. | |
| 5. **Instance:** Free works for a quick demo **but cold-starts 30–60s** after 15 min idle. For | |
| judging, either (a) hit the URL to warm it right before, or (b) use **Starter ($7/mo)** to | |
| keep it always-on. Recommend Starter if the budget allows — a judge won't wait 45s. | |
| `server.py` needs CORS scoped to the Vercel origin (wildcard also fine for a demo): | |
| ```python | |
| import os | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=[os.environ.get("FRONTEND_ORIGIN", "*")], | |
| allow_methods=["*"], allow_headers=["*"], | |
| ) | |
| ``` | |
| SSE already sets `X-Accel-Buffering: no` / `Cache-Control: no-cache` (Section 3.5), which also | |
| keeps Render's proxy from buffering the stream. | |
| ### 6.3 Frontend on Vercel | |
| 1. **Import Project** → point at the `frontend/` directory (set it as the project root). | |
| 2. **Framework preset:** Vite. **Build:** `npm run build`. **Output:** `dist`. | |
| 3. **Env var:** `VITE_API_BASE=https://<app>.onrender.com` — the frontend resolves *all* fetches | |
| and the `EventSource` URL through this base (Section 2.4). | |
| 4. Deploy → Vercel returns `https://<app>.vercel.app`. Put that value into Render's | |
| `FRONTEND_ORIGIN` and redeploy the backend so CORS matches. | |
| `frontend/src/lib/api.ts` resolves the base so the *same* build works on HF (same-origin, empty | |
| base) and on Vercel (cross-origin Render base): | |
| ```ts | |
| export const API = import.meta.env.VITE_API_BASE ?? ""; // "" ⇒ same-origin (HF monolith) | |
| export const sse = () => new EventSource(`${API}/api/events`); | |
| export const api = (p: string, o?: RequestInit) => fetch(`${API}${p}`, o); | |
| ``` | |
| ### 6.4 Order of operations (avoids a CORS chicken-and-egg) | |
| 1. Deploy **Render** first → get the `onrender.com` URL. | |
| 2. Deploy **Vercel** with `VITE_API_BASE` = that URL → get the `vercel.app` URL. | |
| 3. Set Render's `FRONTEND_ORIGIN` = the Vercel URL → redeploy backend. | |
| 4. Warm the Render service, then open the Vercel URL. | |
| ### 6.5 Plan A vs Plan B — pick quickly | |
| | | **HF Spaces (A)** | **Vercel + Render (B)** | | |
| |---|---|---| | |
| | Deploys | 1 | 2 | | |
| | Cross-origin / CORS | none | required | | |
| | Realtime (SSE) | same-port, trivial | Render origin, works (not WS-gated) | | |
| | Cold start | container sleep on free tier, but single service | Render free 30–60s; $7 to remove | | |
| | Secrets | one settings tab | Render env vars | | |
| | Best when | **default — do this** | HF build won't cooperate | | |
| **Decision rule:** ship A. If the HF build fails twice for reasons you can't fix fast, cut to B | |
| — Render backend first, Vercel frontend second, warm, done. | |
| --- | |
| *End of Section 6. Reply "next" for **Section 7 — Build order, 24h timebox & demo script** | |
| (the hour-by-hour sequence, what to cut if time runs short, and the 2-minute submission-video | |
| beat sheet).* | |