Falsify / IMPLEMENTATION_PLAN.md
Aaryan Kumar
deploy to hugging face
1605cbb
|
Raw
History Blame Contribute Delete
39.3 kB
# 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).*