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 opensourcecloud (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 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)

"""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_qaK dies, K2 survives (E_email still grounds it). Phase 2 refutes E_emailK2 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_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:

# 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):

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):

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).