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