Spaces:
Sleeping
Sleeping
File size: 39,318 Bytes
1605cbb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 | # 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).*
|