Spaces:
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.pysubprocess (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
--diamondCLI 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.Queues. Each openGET /api/eventsconnection registers its own queue;emit()fans the event to all of them. emit()is a no-op when there are no subscribers — sopython main.py --demo(CLI) is completely unaffected; the 4 new lines ingraph_ops.pycost 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 asdata: {json}\n\n, a: keepalive\n\ncomment every ~15 s, and headerX-Accel-Buffering: noso proxies (HF/Render) don't buffer the stream. - Leaf-module rule:
events.pyimports nothing fromfalsify(avoids the circular importgraph_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 itsnodeCanvasObjectpattern). Custom canvas rendering for glow, dashed-refuted borders, flash rings, shrink-to-dissolve. - Panels/motion:
framer-motionfor the revision-log slide-ins and scoreboard transitions. - Live updates: a
useEventStreamhook wraps the browser-nativeEventSourceagainst/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,
fastembedlocal 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)
"""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):
from falsify import events
In set_state(), immediately after the successful set_node_truth_state (line ~106):
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):
for nid in ids:
await events.emit_forgotten(nid)
Append the persistence helper (used by GET /api/verify):
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:
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:
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:
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:
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
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_STYLEso 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
#34d399for 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-motioncollapses 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()— wrapsEventSource('/api/events'); exponential-backoff reconnect; exposes the latest event + a subscribe callback. Single source of live truth.useGraphStore()(small Zustand oruseReducer) — holdsnodes/linksplus per-node animation fields (flash,flashColor,shrink) that the canvas reads each frame.graph_reset→ refetchGET /api/graph, diff against current, animate added nodes in.node_state_changed→ patch color + setflash=1.0in the state's color.node_forgotten→ setshrink=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:
- 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.
- Cascade — "the sweep." Invalidation doesn't happen all at once — it travels. Each
downstream node greys out with a ~120ms stagger along
depends_onedges, and the traversed edge briefly lights up. The judge literally watches consequence flow through the graph. (We already havepipeline_stepevents + edge data to order this.) - 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.
- 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.staleis 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
- Port 7860. HF proxies all external traffic to one port; default is 7860. Bind uvicorn to
0.0.0.0:7860and declareapp_port: 7860in the README frontmatter. - 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.) - Non-root, UID 1000. Spaces run the container as uid 1000; create that user and set
HOME/PATHaccordingly 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(andLLM_MODEL, e.g.gpt-4o-mini) — enables Live judging,recallcompletion, andcognifyon upload.- For a non-OpenAI endpoint:
LLM_PROVIDER=custom,LLM_ENDPOINT=…. - These arrive as env vars at runtime (
os.environ.get(...)), whichfalsify/__init__.pyalready 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/verifycold-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:
# 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
- New → Web Service, connect the GitHub repo.
- Build:
pip install -r requirements.txt - Start:
uvicorn server:app --host 0.0.0.0 --port $PORT(Render injects$PORT; do not hardcode 7860 here.) - Env vars:
LLM_API_KEY,LLM_MODEL, andFRONTEND_ORIGIN=https://<app>.vercel.app(used by CORS below). Demo mode still needs no key. - 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):
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
- Import Project → point at the
frontend/directory (set it as the project root). - Framework preset: Vite. Build:
npm run build. Output:dist. - Env var:
VITE_API_BASE=https://<app>.onrender.com— the frontend resolves all fetches and theEventSourceURL through this base (Section 2.4). - Deploy → Vercel returns
https://<app>.vercel.app. Put that value into Render'sFRONTEND_ORIGINand 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):
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)
- Deploy Render first → get the
onrender.comURL. - Deploy Vercel with
VITE_API_BASE= that URL → get thevercel.appURL. - Set Render's
FRONTEND_ORIGIN= the Vercel URL → redeploy backend. - 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).