# 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//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 /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://.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://.onrender.com` — the frontend resolves *all* fetches and the `EventSource` URL through this base (Section 2.4). 4. Deploy → Vercel returns `https://.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).*