Aaryan Kumar commited on
Commit
1605cbb
·
0 Parent(s):

deploy to hugging face

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +29 -0
  2. .env.template +84 -0
  3. .gitignore +8 -0
  4. DEPLOYMENT.md +133 -0
  5. Dockerfile +46 -0
  6. IMPLEMENTATION_PLAN.md +764 -0
  7. LICENSE +21 -0
  8. README.md +301 -0
  9. REQUIREMENTS.md +393 -0
  10. cognee-hackathon-project-main/.env.template +84 -0
  11. cognee-hackathon-project-main/.gitignore +9 -0
  12. cognee-hackathon-project-main/LICENSE +21 -0
  13. cognee-hackathon-project-main/README.md +289 -0
  14. cognee-hackathon-project-main/REQUIREMENTS.md +393 -0
  15. cognee-hackathon-project-main/demo_video_script.md +62 -0
  16. cognee-hackathon-project-main/falsify/__init__.py +57 -0
  17. cognee-hackathon-project-main/falsify/edges.py +70 -0
  18. cognee-hackathon-project-main/falsify/falsify.py +240 -0
  19. cognee-hackathon-project-main/falsify/graph_ops.py +185 -0
  20. cognee-hackathon-project-main/falsify/models.py +202 -0
  21. cognee-hackathon-project-main/falsify/seed.py +197 -0
  22. cognee-hackathon-project-main/falsify/tasks/__init__.py +36 -0
  23. cognee-hackathon-project-main/falsify/tasks/cascade_forget.py +159 -0
  24. cognee-hackathon-project-main/falsify/tasks/detect_contradictions.py +199 -0
  25. cognee-hackathon-project-main/falsify/tasks/propagate_refutation.py +281 -0
  26. cognee-hackathon-project-main/falsify/utils.py +250 -0
  27. cognee-hackathon-project-main/main.py +190 -0
  28. cognee-hackathon-project-main/requirements.txt +13 -0
  29. cognee-hackathon-project-main/tests/__init__.py +1 -0
  30. cognee-hackathon-project-main/tests/conftest.py +97 -0
  31. cognee-hackathon-project-main/tests/test_detect.py +93 -0
  32. cognee-hackathon-project-main/tests/test_falsify.py +163 -0
  33. cognee-hackathon-project-main/tools/make_demo_gif.py +285 -0
  34. demo_video_script.md +62 -0
  35. falsify/__init__.py +57 -0
  36. falsify/edges.py +70 -0
  37. falsify/events.py +86 -0
  38. falsify/falsify.py +463 -0
  39. falsify/graph_ops.py +219 -0
  40. falsify/models.py +202 -0
  41. falsify/seed.py +317 -0
  42. falsify/tasks/__init__.py +36 -0
  43. falsify/tasks/cascade_forget.py +159 -0
  44. falsify/tasks/detect_contradictions.py +199 -0
  45. falsify/tasks/propagate_refutation.py +281 -0
  46. falsify/utils.py +250 -0
  47. main.py +193 -0
  48. render.yaml +28 -0
  49. requirements.txt +18 -0
  50. server.py +355 -0
.dockerignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Keep the Docker build context small and reproducible.
2
+ .git
3
+ .gitignore
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .venv/
11
+ venv/
12
+ env/
13
+ .env
14
+ .env.*
15
+ !.env.template
16
+
17
+ # local caches / model downloads (rebuilt in the image)
18
+ .cache/
19
+ output/
20
+ *.log
21
+
22
+ # reference clone used only for local research
23
+ /tmp/cognee-ref/
24
+
25
+ # docs & dev-only artifacts not needed at runtime
26
+ IMPLEMENTATION_PLAN.md
27
+ bridge.py
28
+ demo.gif
29
+ *.ipynb
.env.template ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # FALSIFY — environment configuration
3
+ # Copy this file to `.env` and fill in your key: cp .env.template .env
4
+ # ============================================================================
5
+
6
+ # ----------------------------------------------------------------------------
7
+ # LLM provider (OpenAI-compatible)
8
+ # ----------------------------------------------------------------------------
9
+ # FALSIFY works with OpenAI or ANY OpenAI-compatible endpoint (OpenRouter,
10
+ # vLLM, LM Studio, Azure, local proxies, ...). Pick ONE of the blocks below.
11
+
12
+ # --- Default: OpenAI ---------------------------------------------------------
13
+ LLM_PROVIDER="openai"
14
+ LLM_API_KEY="sk-your-openai-key-here"
15
+ LLM_MODEL="openai/gpt-5-mini"
16
+ # LLM_ENDPOINT is not needed for the default OpenAI provider.
17
+
18
+ # --- Alternative: custom / OpenAI-compatible endpoint (e.g. OpenRouter) ------
19
+ # Uncomment this block and comment out the OpenAI block above to use it.
20
+ # LLM_PROVIDER="custom"
21
+ # LLM_API_KEY="your_api_key"
22
+ # LLM_MODEL="openrouter/google/gemini-2.0-flash-lite-preview-02-05:free"
23
+ # LLM_ENDPOINT="https://openrouter.ai/api/v1"
24
+
25
+ # --- Alternative: Alibaba DashScope (Qwen), OpenAI-compatible ----------------
26
+ # Note the "openai/" prefix on the model — litellm needs it to route a raw
27
+ # OpenAI-compatible endpoint. Verified working with FALSIFY.
28
+ # LLM_PROVIDER="custom"
29
+ # LLM_API_KEY="sk-..."
30
+ # LLM_MODEL="openai/qwen-plus"
31
+ # LLM_ENDPOINT="https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
32
+
33
+ # ----------------------------------------------------------------------------
34
+ # Embeddings — fastembed (local, CPU-only, zero-cost) is FALSIFY's default
35
+ # ----------------------------------------------------------------------------
36
+ # FALSIFY sets these automatically at import so the pipeline (and
37
+ # `python main.py --demo`) runs with NO external embedding key. This keeps your
38
+ # LLM key (above) LLM-only and avoids the classic "LLM provider != embedding
39
+ # provider" mismatch. Any explicit value here overrides the default.
40
+ EMBEDDING_PROVIDER="fastembed"
41
+ EMBEDDING_MODEL="BAAI/bge-small-en-v1.5"
42
+ EMBEDDING_DIMENSIONS="384"
43
+ EMBEDDING_MAX_TOKENS="512"
44
+ # To use OpenAI embeddings instead, set:
45
+ # EMBEDDING_PROVIDER="openai"
46
+ # EMBEDDING_MODEL="openai/text-embedding-3-large"
47
+ # EMBEDDING_API_KEY="sk-your-openai-key-here"
48
+
49
+ # ----------------------------------------------------------------------------
50
+ # Session memory / caching
51
+ # ----------------------------------------------------------------------------
52
+ # Cross-session belief persistence uses Cognee's session cache. Default backend
53
+ # is local SQLite — zero external services, survives process restarts.
54
+ # Backends: sqlite (default), postgres, redis, fs, tapes
55
+ CACHING="true"
56
+ CACHE_BACKEND="sqlite"
57
+ # Optional explicit SQLAlchemy URL (only for sqlite/postgres backends):
58
+ # CACHE_DB_URL="postgresql+asyncpg://cognee:cognee@localhost:5432/cognee_db"
59
+
60
+ # ----------------------------------------------------------------------------
61
+ # Databases (self-hosted defaults — no setup required)
62
+ # ----------------------------------------------------------------------------
63
+ # FALSIFY ships with fully local, zero-dependency stores:
64
+ # Vector -> LanceDB
65
+ # Graph -> Ladybug
66
+ # Relational -> SQLite
67
+ # These are the defaults; the lines below are shown only for clarity.
68
+ # VECTOR_DB_PROVIDER="lancedb"
69
+ # GRAPH_DATABASE_PROVIDER="ladybug"
70
+ # DB_PROVIDER="sqlite"
71
+
72
+ # ----------------------------------------------------------------------------
73
+ # FALSIFY demo behavior
74
+ # ----------------------------------------------------------------------------
75
+ # DEMO_MODE=1 (or `python main.py --demo`) pins the refuted evidence id so the
76
+ # refutation cascade + surgical forget run on real graph/vector APIs even if the
77
+ # LLM contradiction judge stalls or the key is rate-limited. Presentation safety net.
78
+ DEMO_MODE="0"
79
+
80
+ # ----------------------------------------------------------------------------
81
+ # Optional: quieter logs / telemetry off
82
+ # ----------------------------------------------------------------------------
83
+ # LITELLM_LOG="ERROR"
84
+ # TELEMETRY_DISABLED="1"
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ .venv/
5
+ output/
6
+ *.lance
7
+ .cognee_system/
8
+ .data_storage/
DEPLOYMENT.md ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FALSIFY — Deployment Guide
2
+
3
+ Two ways to put FALSIFY online. **Do Plan A first** — it's one container, one URL,
4
+ and the realtime stream "just works." Fall back to Plan B only if the HF build
5
+ won't cooperate.
6
+
7
+ | | **Plan A — Hugging Face Spaces** | **Plan B — Vercel + Render** |
8
+ |---|---|---|
9
+ | Pieces | 1 container (frontend + API + SSE) | Vercel (frontend) + Render (API + SSE) |
10
+ | CORS | none (same origin) | required (handled in `server.py`) |
11
+ | Realtime | SSE on port 7860, native | SSE lives on Render; Vercel serves static only |
12
+ | Cost | free | free (Render cold-starts) / $7 always-on |
13
+ | Best when | **default — start here** | HF Docker build fails twice |
14
+
15
+ The **same Docker image** runs on both (it binds `$PORT`, default 7860). Demo mode is
16
+ **keyless** — a judge can open the URL and hit ▶ Run investigation with no setup.
17
+
18
+ ---
19
+
20
+ ## Plan A — Hugging Face Spaces (PRIMARY)
21
+
22
+ ### What's already in the repo
23
+ - `Dockerfile` — single-stage, non-root uid 1000, pre-warms `fastembed`, binds `$PORT`.
24
+ - `README.md` frontmatter — `sdk: docker`, `app_port: 7860` (HF reads this).
25
+ - `.dockerignore` — trims the build context.
26
+ - `static/` — the frontend, served by `server.py`.
27
+
28
+ ### Steps
29
+ 1. **Create the Space.** huggingface.co → *New* → *Space*. Name it `falsify`,
30
+ **SDK: Docker**, **Blank** template, visibility **Public**.
31
+ 2. **Push the repo to the Space remote:**
32
+ ```bash
33
+ git remote add space https://huggingface.co/spaces/<your-username>/falsify
34
+ git push space main
35
+ ```
36
+ *(If your local branch is `main` and the Space expects `main`, this is enough.
37
+ HF authenticates with a write token — when prompted for a password, paste a
38
+ token from huggingface.co/settings/tokens.)*
39
+ 3. **Watch the build.** Open the Space's **Container** / **Logs** tab. First build is
40
+ slow (Cognee pulls Kuzu/LanceDB/litellm — several minutes). Wait for **Running**.
41
+ 4. **Open the Space URL.** The graph should load pre-seeded. Hit **▶ Run
42
+ investigation** and watch the cascade. That URL is your submission link.
43
+
44
+ ### Secrets (OPTIONAL — demo needs none)
45
+ Space → **Settings** → **Variables and secrets**. Add only if you want Live mode /
46
+ upload / the recall completion:
47
+ - `LLM_API_KEY` = your key · `LLM_MODEL` = `gpt-4o-mini`
48
+ - non-OpenAI endpoint: `LLM_PROVIDER=custom`, `LLM_ENDPOINT=https://…/v1`
49
+ - Cognee Cloud toggle: `COGNEE_CLOUD_URL`, `COGNEE_CLOUD_API_KEY`
50
+
51
+ Absent a key, the app **stays in keyless demo mode** — nothing errors.
52
+
53
+ ### Alternative push (CLI)
54
+ ```bash
55
+ pip install -U huggingface_hub
56
+ huggingface-cli login # paste a write token
57
+ huggingface-cli upload <user>/falsify . --repo-type space
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Plan B — Vercel (frontend) + Render (backend)
63
+
64
+ Use this if HF won't build. **Deploy Render first**, then Vercel, then wire CORS.
65
+
66
+ ### Why the split looks the way it does
67
+ - **Vercel can't host the SSE stream** — its serverless functions can't hold a
68
+ long-lived connection. So Vercel serves **only** the static `static/` files.
69
+ - **Render runs the whole backend** (API + SSE) from the same Dockerfile. The
70
+ browser opens `EventSource` **directly against Render**, not Vercel.
71
+
72
+ ### Step 1 — Backend on Render
73
+ 1. render.com → **New +** → **Web Service** → connect the GitHub repo.
74
+ 2. **Runtime: Docker** (it finds `./Dockerfile`). *(Or use **Blueprint** and pick
75
+ `render.yaml`.)*
76
+ 3. **Instance type:** Free to try; **Starter ($7/mo)** to avoid the 15-min-idle
77
+ cold start during judging. (Free spins down; first hit after idle takes 30–60s.)
78
+ 4. **Env vars** (Settings → Environment):
79
+ - `FRONTEND_ORIGIN` = your Vercel URL (fill in after Step 2, then redeploy)
80
+ - optional: `LLM_API_KEY`, `LLM_MODEL`, `COGNEE_CLOUD_URL`, `COGNEE_CLOUD_API_KEY`
81
+ 5. Deploy → note the URL, e.g. `https://falsify-backend.onrender.com`.
82
+ Verify: `curl https://falsify-backend.onrender.com/api/health` → `{"ok": true, …}`.
83
+
84
+ ### Step 2 — Frontend on Vercel
85
+ 1. **Point the frontend at Render.** Edit `static/config.js`:
86
+ ```js
87
+ window.__API_BASE__ = "https://falsify-backend.onrender.com"; // no trailing slash
88
+ ```
89
+ Commit + push.
90
+ 2. vercel.com → **Add New** → **Project** → import the repo.
91
+ 3. Framework preset: **Other**. `vercel.json` already sets output dir = `static`
92
+ and no build. Deploy.
93
+ 4. Note the URL, e.g. `https://falsify.vercel.app`.
94
+
95
+ ### Step 3 — Close the CORS loop
96
+ 1. Back in Render → set `FRONTEND_ORIGIN` = `https://falsify.vercel.app` → **redeploy**.
97
+ 2. Open the Vercel URL. If it was a free Render instance, hit it once to **warm it**
98
+ (~30–60s), then reload. ▶ Run investigation should stream live.
99
+
100
+ ---
101
+
102
+ ## Pre-demo checklist (do this right before judging)
103
+ - [ ] Open the live URL in a fresh incognito window (no cache).
104
+ - [ ] Graph loads pre-seeded; connection dot (top bar) shows **live**.
105
+ - [ ] **▶ Run investigation** → E_qa strikes red, K dissolves, B rises green.
106
+ - [ ] Scoreboard shows **FALSIFY ✅ Jan 2021** vs **RAG ⚠ still cites the refuted March report**.
107
+ - [ ] **Diamond** → K2 survives phase 1, collapses phase 2.
108
+ - [ ] **Verify persistence** → reads truth-state from the on-disk graph store and confirms refuted/superseded nodes are there.
109
+ - [ ] (Plan B) Render warmed within the last 10 min so there's no cold-start pause.
110
+
111
+ ---
112
+
113
+ ## Troubleshooting
114
+
115
+ | Symptom | Cause → Fix |
116
+ |---|---|
117
+ | HF build fails on a heavy wheel | Transient hub/network. Re-run the build (Space → *Factory rebuild*). The deps layer caches after the first success. |
118
+ | First request hangs ~20s | `fastembed` model download. The Dockerfile pre-warms it; if skipped, the first embed pays it once. |
119
+ | SSE events arrive in a clump, not live | A proxy is buffering. We already send `X-Accel-Buffering: no` + `Cache-Control: no-cache`; confirm no extra CDN sits in front. |
120
+ | Graph loads but ▶ does nothing | Open devtools → Network. If `/api/*` calls 404/blocked, check `window.__API_BASE__` (empty on HF, Render URL on Vercel). |
121
+ | Vercel UI can't reach backend (CORS error) | `FRONTEND_ORIGIN` on Render ≠ the Vercel origin. Set it exactly (scheme + host, no path) and redeploy. |
122
+ | Render cold start every time | Free tier idle spin-down. Warm it before judging or use Starter. |
123
+ | Upload says "Set LLM_API_KEY" | `cognify` needs an LLM key. Add it as a secret/env var, or just use the keyless demo/diamond. |
124
+ | Permission error writing cache on HF | Container runs as uid 1000; `HF_HOME` is under `$HOME`. Don't write outside `/home/user/app`. |
125
+
126
+ ---
127
+
128
+ ## Local sanity check (optional, before deploying)
129
+ ```bash
130
+ pip install -r requirements.txt
131
+ uvicorn server:app --port 8000
132
+ # open http://localhost:8000 → same UI the deploy serves
133
+ ```
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # FALSIFY — live belief-revision copilot
3
+ #
4
+ # Single-stage image. The frontend is dependency-free static files (no React
5
+ # build), so there is NO Node stage — one fewer thing to break in CI. FastAPI
6
+ # serves the API, the SSE stream, AND the static UI from one port.
7
+ #
8
+ # Portable across hosts: binds to $PORT (Render injects it) and falls back to
9
+ # 7860 (Hugging Face Spaces' default). Same image runs on both.
10
+ # ─────────────────────────────────────────────────────────────────────────────
11
+ FROM python:3.11-slim
12
+
13
+ # Hugging Face Spaces run containers as uid 1000; create that user so writes
14
+ # to cache/model dirs don't hit permission errors.
15
+ RUN useradd -m -u 1000 user
16
+ WORKDIR /home/user/app
17
+
18
+ # System deps some wheels (lancedb/kuzu/onnxruntime) expect at runtime.
19
+ RUN apt-get update && apt-get install -y --no-install-recommends \
20
+ libgomp1 curl \
21
+ && rm -rf /var/lib/apt/lists/*
22
+
23
+ # Dependencies first for layer caching (Cognee is heavy — cache it across rebuilds).
24
+ COPY --chown=user requirements.txt .
25
+ RUN pip install --no-cache-dir --upgrade pip && \
26
+ pip install --no-cache-dir -r requirements.txt && \
27
+ chown -R user:user /usr/local/lib/python3.11/site-packages/cognee
28
+
29
+ # Pre-warm the local fastembed model so the FIRST request isn't a model download.
30
+ # Best-effort: never fail the build if the hub is briefly unreachable.
31
+ RUN python -c "from fastembed import TextEmbedding; TextEmbedding()" || true
32
+
33
+ # App code (includes ./static — the built-in frontend).
34
+ COPY --chown=user . .
35
+
36
+ USER user
37
+ ENV HOME=/home/user \
38
+ PATH=/home/user/.local/bin:$PATH \
39
+ HF_HOME=/home/user/app/.cache \
40
+ PORT=7860 \
41
+ PYTHONUNBUFFERED=1
42
+
43
+ EXPOSE 7860
44
+
45
+ # Shell form so $PORT expands (HF=7860, Render=its injected value).
46
+ CMD uvicorn server:app --host 0.0.0.0 --port ${PORT:-7860}
IMPLEMENTATION_PLAN.md ADDED
@@ -0,0 +1,764 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FALSIFY — Tier 2 Implementation & Deployment Plan
2
+
3
+ > Written incrementally, one section at a time.
4
+ > **Section 1 of ~7 below.** Confirm to continue to Section 2.
5
+
6
+ ---
7
+
8
+ ## Section 1 — Strategy & Scope (win-ROI under a 24h deadline)
9
+
10
+ **Deadline reality:** Today is July 4; the hackathon closes July 5. We have ~24 hours.
11
+ Every decision below is ranked by *what wins*, not by engineering completeness.
12
+
13
+ **Two prize tracks — we go for both:**
14
+ - **Best Use of Open Source** (MacBook) — the deployed, self-hosted FALSIFY demo.
15
+ - **Best Use of Cognee Cloud** (iPhone 17) — the same pipeline, toggled to run against a
16
+ Cognee Cloud tenant via `cognee.serve()`. One flag makes us eligible for a *second* prize.
17
+
18
+ ### Final scope (in priority order)
19
+
20
+ | # | Feature | Why it wins | Status vs. old plan |
21
+ |---|---------|-------------|---------------------|
22
+ | **F** | **Live animated web UI** (chat + living graph + scoreboard) | The judges' primary visual; the whole story in one screen | **Centerpiece — keep, elevate** |
23
+ | **G** | **Cognee Cloud toggle** (`cognee.serve()`) | Unlocks the *second* prize track for ~1h work | **NEW — was missing entirely** |
24
+ | **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** |
25
+ | **L** | **Revision log / "why" narration** | Makes the animation *legible* — judge understands the reasoning | **NEW — explainability** |
26
+ | **U** | **Document upload → `add` + `cognify`** | Turns a fixed demo into a real product; showcases core Cognee | **NEW — from your original vision** |
27
+ | **P** | **Persistence proof, integrated** (reopen memory → still revised) | Same "survives restart" punch, but *visible* in the UI | **Replaces old Feature D subprocess** |
28
+ | **E** | **Diamond scenario** (partial survival → full collapse) | Depth for technical judges | **Demoted: a UI scenario button, not a CLI mode** |
29
+
30
+ ### What we cut / change and why
31
+ - **CUT the `verify_persistence.py` subprocess (old Feature D).** A subprocess printing JSON
32
+ to a terminal is invisible to judges. We get the same proof *visibly* inside the UI: state
33
+ lives on disk (Kuzu + LanceDB), so a "Reopen memory" action re-reads from cold storage and
34
+ the graph is still revised.
35
+ - **DEMOTE the diamond (old Feature E) from a separate `--diamond` CLI mode to one scenario
36
+ button in the web UI.** The engine work is cheap (zero algorithm change) and stays; only the
37
+ surfacing moves into F.
38
+ - **SWITCH the real-time transport from WebSocket → Server-Sent Events (SSE).** Verified today:
39
+ our event stream is one-directional (server→browser), and WebSockets are fragile on exactly
40
+ the platforms in play — Vercel serverless can't host them, and Render supports them only on
41
+ paid plans (its free tier's 15-min spin-down severs them regardless). SSE is plain streaming
42
+ HTTP: it works on the HF Spaces single-port monolith and survives the Vercel+Render split.
43
+
44
+ ### Deployment stance
45
+ - **Primary: Hugging Face Spaces (Docker monolith)** — one container, FastAPI serves the built
46
+ frontend + API + SSE on port 7860. Keyless demo mode runs out of the box. **Try this first.**
47
+ - **Fallback: Vercel (frontend) + Render (backend)** — documented with its caveats (Render free
48
+ tier cold start; warm before judging). Detailed in a later section.
49
+
50
+ ### The keyless-demo guarantee
51
+ In **demo mode** the contradiction is pinned, `fastembed` does embeddings locally, and the
52
+ scoreboard falls back to graph traversal — **no API key needed**, so the deployed demo works
53
+ for any judge instantly. Live mode, upload+cognify, and `recall` completion need a key (set as
54
+ an HF secret / Render env var) and degrade gracefully when absent.
55
+
56
+ ---
57
+
58
+ *End of Section 1.*
59
+
60
+ ---
61
+
62
+ ## Section 2 — Architecture
63
+
64
+ > **Section 2 of ~7.** Confirm to continue to Section 3.
65
+
66
+ ### 2.1 Shape at a glance
67
+
68
+ ```
69
+ ┌─────────────────────────── Browser (built React app) ───────────────────────────┐
70
+ │ ChatPanel │ GraphCanvas (living graph) │ Scoreboard │
71
+ │ + upload │ force-directed, animated state │ FALSIFY vs RAG │
72
+ │ + quick actions │ transitions (flash/cascade/dissolve) │ RevisionLog feed │
73
+ └──────┬─────────────┴───────────────┬────────────────────────┴─────────┬──────────┘
74
+ │ POST /api/chat, /upload, │ GET /api/graph (snapshot) │ EventSource
75
+ │ /scenario, /reset, /mode │ GET /api/scoreboard │ GET /api/events (SSE)
76
+ ▼ ▼ ▼
77
+ ┌───────────────────────────────── server.py (FastAPI) ───────────────────────────┐
78
+ │ REST endpoints ──► call falsify.falsify (build_graph / revise / scoreboard) │
79
+ │ SSE broadcaster ──► fans events to all subscribed browsers │
80
+ └───────────────────────────────┬─────────────────────────────────────────────────┘
81
+ │ falsify.events.emit(...) (no-op in CLI mode)
82
+
83
+ ┌──────────────── falsify/graph_ops.py (the single chokepoint) ────────────────────┐
84
+ │ set_state() ──► emit_state_change(id, state, epoch) │
85
+ │ delete_from_both_stores() ──► emit_forgotten(id) │
86
+ │ (Cognee graph engine: Kuzu/Ladybug + LanceDB, file-backed) │
87
+ └──────────────────────────────────────────────────────────────────────────────────┘
88
+ ```
89
+
90
+ The insight from the research pass still holds: **every** belief mutation flows through
91
+ exactly two functions in `graph_ops.py`, so ~4 lines of hooks capture the whole pipeline
92
+ without touching any task code.
93
+
94
+ ### 2.2 Backend (FastAPI) — endpoint contract
95
+
96
+ | Method + path | Purpose | Calls |
97
+ |---|---|---|
98
+ | `GET /` | Serve the built frontend (`static/index.html`) | — |
99
+ | `GET /api/graph` | Current nodes+edges+truth-state+colors (snapshot) | `graph_ops.load_graph` + `get_truth` |
100
+ | `GET /api/events` | **SSE stream** of live mutation events | subscribes to event bus |
101
+ | `POST /api/chat` | Classify input → fact (`revise`) or question (`scoreboard`); events fire mid-pipeline | `revise` / `scoreboard` |
102
+ | `POST /api/upload` | Ingest a dropped doc → new nodes appear in the graph | `cognee.add` + `cognee.cognify` |
103
+ | `POST /api/scenario` | Build `simple` or `diamond` graph | `build_graph` / `build_diamond_graph` |
104
+ | `POST /api/reset` | Rebuild the seed investigation | `build_graph` |
105
+ | `GET /api/scoreboard?q=` | FALSIFY answer vs stale-RAG answer | `scoreboard` |
106
+ | `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` |
107
+ | `POST /api/mode` | Toggle `opensource` ↔ `cloud` (`cognee.serve(url, key)`) | backend switch (Feature G) |
108
+
109
+ ### 2.3 The SSE event bus (`falsify/events.py`, new — leaf module)
110
+
111
+ - A module-level **set of subscriber `asyncio.Queue`s**. Each open `GET /api/events`
112
+ connection registers its own queue; `emit()` fans the event to all of them.
113
+ - `emit()` is a **no-op when there are no subscribers** — so `python main.py --demo` (CLI) is
114
+ completely unaffected; the 4 new lines in `graph_ops.py` cost nothing.
115
+ - Event shapes (JSON): `node_state_changed {id, state, epoch}`, `node_forgotten {id}`,
116
+ `graph_reset {}`, `pipeline_step {step, detail}`.
117
+ - SSE framing: `StreamingResponse(media_type="text/event-stream")`, each event as
118
+ `data: {json}\n\n`, a `: keepalive\n\n` comment every ~15 s, and header
119
+ `X-Accel-Buffering: no` so proxies (HF/Render) don't buffer the stream.
120
+ - **Leaf-module rule:** `events.py` imports nothing from `falsify` (avoids the circular
121
+ import `graph_ops → events`).
122
+
123
+ ### 2.4 Frontend (Vite + React + TypeScript + Tailwind)
124
+
125
+ - **Graph:** `react-force-graph-2d` (the same lib Cognee's own frontend uses — we already read
126
+ its `nodeCanvasObject` pattern). Custom canvas rendering for glow, dashed-refuted borders,
127
+ flash rings, shrink-to-dissolve.
128
+ - **Panels/motion:** `framer-motion` for the revision-log slide-ins and scoreboard transitions.
129
+ - **Live updates:** a `useEventStream` hook wraps the browser-native `EventSource` against
130
+ `/api/events`, with auto-reconnect. On each event it patches the in-memory graph data and
131
+ triggers the matching animation.
132
+ - **Env-aware API base:** `VITE_API_BASE` (empty for the HF monolith / same-origin; the Render
133
+ URL for the Vercel split). SSE and fetch both resolve through it.
134
+ - **Built to static** (`vite build → dist/`) — one artifact that either the FastAPI monolith
135
+ serves (HF) or Vercel serves (split). Same code, both deploy targets.
136
+
137
+ ### 2.5 Keyless-demo wiring
138
+ - **Demo mode** (default for the deployed Space): contradiction pinned, `fastembed` local
139
+ embeddings, scoreboard graph-traversal fallback → **zero API calls, zero key**.
140
+ - **Live mode / upload / recall-completion:** need `LLM_API_KEY`; read from env (HF secret or
141
+ Render var). When absent, the UI keeps working in demo mode and shows a subtle "add a key for
142
+ Live mode" hint instead of erroring.
143
+
144
+ ### 2.6 Directory layout (target)
145
+
146
+ ```
147
+ cognee-hackathon-project/
148
+ ├─�� server.py # NEW — FastAPI app (endpoints + SSE broadcaster)
149
+ ├── falsify/
150
+ │ ├── events.py # NEW — SSE event bus (leaf module)
151
+ │ ├── graph_ops.py # +4 lines of emit hooks; + flush_and_release()
152
+ │ ├── seed.py # + NEW_FACT_2, build_diamond_investigation() (Feature E)
153
+ │ └── falsify.py # + build_diamond_graph(); + cloud backend switch (Feature G)
154
+ ├── frontend/ # NEW — Vite React app
155
+ │ ├── src/{App,components,hooks,lib}...
156
+ │ └── dist/ # build output → served as static
157
+ ├── Dockerfile # NEW — multi-stage (build frontend → serve with backend)
158
+ ├── requirements.txt # + fastapi, uvicorn[standard], sse (stdlib), python-multipart
159
+ └── IMPLEMENTATION_PLAN.md
160
+ ```
161
+
162
+ ---
163
+
164
+ *End of Section 2.*
165
+
166
+ ---
167
+
168
+ ## Section 3 — Backend implementation
169
+
170
+ > **Section 3 of ~7.** Confirm to continue to Section 4.
171
+
172
+ All code below matches the **verified** signatures in `graph_ops.py`:
173
+ `set_state(node_id, state, epoch)` and `delete_from_both_stores(node_ids, collections)`.
174
+
175
+ ### 3.1 `falsify/events.py` (NEW — leaf module, imports nothing from falsify)
176
+
177
+ ```python
178
+ """FALSIFY real-time event bus for the live web UI.
179
+
180
+ A set of per-connection asyncio.Queues. graph_ops hooks call emit() after each
181
+ state mutation; each open SSE connection drains its own queue. emit() is a no-op
182
+ when there are no subscribers, so the CLI (`python main.py`) is unaffected.
183
+ """
184
+ from __future__ import annotations
185
+ import asyncio
186
+ from typing import Any, Dict, Set
187
+
188
+ _subscribers: Set[asyncio.Queue] = set()
189
+
190
+ def subscribe() -> asyncio.Queue:
191
+ q: asyncio.Queue = asyncio.Queue(maxsize=1000)
192
+ _subscribers.add(q)
193
+ return q
194
+
195
+ def unsubscribe(q: asyncio.Queue) -> None:
196
+ _subscribers.discard(q)
197
+
198
+ async def emit(event: Dict[str, Any]) -> None:
199
+ for q in list(_subscribers):
200
+ try:
201
+ q.put_nowait(event)
202
+ except asyncio.QueueFull:
203
+ pass # slow client: drop rather than block the pipeline
204
+
205
+ async def emit_state_change(node_id: str, state: str, epoch: int) -> None:
206
+ await emit({"type": "node_state_changed", "id": str(node_id),
207
+ "state": state, "epoch": int(epoch)})
208
+
209
+ async def emit_forgotten(node_id: str) -> None:
210
+ await emit({"type": "node_forgotten", "id": str(node_id)})
211
+
212
+ async def emit_graph_reset() -> None:
213
+ await emit({"type": "graph_reset"})
214
+
215
+ async def emit_step(step: str, detail: str = "") -> None:
216
+ await emit({"type": "pipeline_step", "step": step, "detail": detail})
217
+ ```
218
+
219
+ ### 3.2 `falsify/graph_ops.py` — the 4-line hook + `flush_and_release()`
220
+
221
+ Add near the top (after the existing imports):
222
+ ```python
223
+ from falsify import events
224
+ ```
225
+ In `set_state()`, immediately after the successful `set_node_truth_state` (line ~106):
226
+ ```python
227
+ await events.emit_state_change(str(node_id), value, int(epoch))
228
+ ```
229
+ In `delete_from_both_stores()`, after `await ge.delete_nodes(ids)` succeeds (line ~139):
230
+ ```python
231
+ for nid in ids:
232
+ await events.emit_forgotten(nid)
233
+ ```
234
+ Append the persistence helper (used by `GET /api/verify`):
235
+ ```python
236
+ async def flush_and_release() -> None:
237
+ """Checkpoint the graph WAL and evict the engine so the next read is a
238
+ genuine cold read from disk (basis of the visible persistence proof)."""
239
+ ge = await get_graph_engine()
240
+ try:
241
+ if hasattr(ge, "checkpoint"):
242
+ await ge.checkpoint()
243
+ except Exception as exc:
244
+ logger.debug("checkpoint best-effort skip: %s", exc)
245
+ try:
246
+ from cognee.infrastructure.databases.graph.get_graph_engine import evict_graph_engine
247
+ from cognee.infrastructure.databases.graph.config import get_graph_config
248
+ evict_graph_engine(**get_graph_config().to_hashable_dict())
249
+ except Exception as exc:
250
+ logger.debug("evict best-effort skip: %s", exc)
251
+ ```
252
+
253
+ ### 3.3 Feature E — diamond scenario (engine work stays, surfaced via UI)
254
+
255
+ `falsify/seed.py` — add after `NEW_FACT`:
256
+ ```python
257
+ NEW_FACT_2 = (
258
+ "A forensic analysis of email headers shows the January 2021 supplier email "
259
+ "was fabricated: the sender domain was not registered until April 2021, and "
260
+ "the DKIM signature is invalid."
261
+ )
262
+ REFUTED_EVIDENCE_KEY_2 = "E_email"
263
+ ```
264
+ Add `build_diamond_investigation()` — identical to `build_investigation()` plus a Conclusion
265
+ `K2` that critically depends on **both** `E_qa` and `E_email`:
266
+ ```python
267
+ conclusion_k2 = Conclusion(
268
+ statement="Multiple independent sources confirm Company X had pre-recall "
269
+ "knowledge of the defect.",
270
+ confidence=0.85,
271
+ depends_on_ids=[str(ev_qa.id), str(ev_email.id)],
272
+ source_id="analyst",
273
+ )
274
+ # ... add K2 to the nodes list, persist, then two critical legs:
275
+ await graph_ops.add_edge(str(conclusion_k2.id), str(ev_qa.id), DEPENDS_ON, {"critical": True})
276
+ await graph_ops.add_edge(str(conclusion_k2.id), str(ev_email.id), DEPENDS_ON, {"critical": True})
277
+ # ... include "K2" in seeded.ids / seeded.labels
278
+ ```
279
+ `falsify/falsify.py` — add the orchestrator:
280
+ ```python
281
+ async def build_diamond_graph() -> SeededGraph:
282
+ import cognee
283
+ from cognee.low_level import setup
284
+ from falsify.seed import build_diamond_investigation
285
+ await cognee.forget(everything=True)
286
+ await setup()
287
+ return await build_diamond_investigation()
288
+ ```
289
+ Two-phase story (driven by the UI scenario button, both targets pinned so it's deterministic):
290
+ Phase 1 refutes `E_qa` → **K dies, K2 survives** (E_email still grounds it). Phase 2 refutes
291
+ `E_email` → **K2 collapses** (both legs gone). No algorithm change — the grounded least-fixpoint
292
+ already does this; we're just showing it.
293
+
294
+ ### 3.4 Feature G — Cognee Cloud toggle (the second prize track)
295
+
296
+ `falsify/falsify.py` — a backend switch used by `--cloud` and `POST /api/mode`:
297
+ ```python
298
+ async def use_backend(mode: str, url: str | None = None, api_key: str | None = None) -> None:
299
+ """Route Cognee ops to the cloud tenant (mode='cloud') or self-hosted (default)."""
300
+ import cognee
301
+ if mode == "cloud" and url and api_key:
302
+ await cognee.serve(url=url, api_key=api_key) # all ops now hit Cognee Cloud
303
+ logger.info("FALSIFY backend -> Cognee Cloud (%s)", url)
304
+ else:
305
+ logger.info("FALSIFY backend -> self-hosted (open source)")
306
+ ```
307
+ The **same** `build_graph` / `revise` / `scoreboard` pipeline then runs against whichever
308
+ backend is active — so one toggle makes us demonstrable on both tracks. (Caveat: cloud mode
309
+ needs a real tenant URL + key; the primary demo stays open-source and keyless.)
310
+
311
+ ### 3.5 `server.py` (NEW) — FastAPI app, skeleton of the moving parts
312
+
313
+ ```python
314
+ import asyncio, json
315
+ from fastapi import FastAPI, UploadFile, File
316
+ from fastapi.responses import StreamingResponse, FileResponse
317
+ from fastapi.staticfiles import StaticFiles
318
+ from fastapi.middleware.cors import CORSMiddleware
319
+ from pydantic import BaseModel
320
+
321
+ import falsify # sets Cognee env defaults on import
322
+ from falsify import events, graph_ops
323
+ from falsify.falsify import build_graph, build_diamond_graph, revise, scoreboard, use_backend
324
+ from falsify.seed import NEW_FACT, NEW_FACT_2, QUESTION_TEXT
325
+ from falsify.utils import get_belief_summary
326
+
327
+ app = FastAPI(title="FALSIFY Live")
328
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
329
+
330
+ _seeded = None # server-held SeededGraph
331
+ _lock = asyncio.Lock() # serialize revise() (single-user demo safety)
332
+
333
+ class Chat(BaseModel):
334
+ message: str
335
+ demo: bool = True
336
+
337
+ @app.get("/api/events")
338
+ async def sse():
339
+ q = events.subscribe()
340
+ async def gen():
341
+ try:
342
+ while True:
343
+ try:
344
+ ev = await asyncio.wait_for(q.get(), timeout=15)
345
+ yield f"data: {json.dumps(ev)}\n\n"
346
+ except asyncio.TimeoutError:
347
+ yield ": keepalive\n\n"
348
+ finally:
349
+ events.unsubscribe(q)
350
+ return StreamingResponse(gen(), media_type="text/event-stream",
351
+ headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"})
352
+
353
+ @app.get("/api/graph")
354
+ async def api_graph():
355
+ return await _graph_json() # load_graph + get_truth → {nodes, edges} with colors
356
+
357
+ @app.post("/api/chat")
358
+ async def api_chat(c: Chat):
359
+ async with _lock:
360
+ if c.message.strip().endswith("?"):
361
+ board = await scoreboard(c.message, _seeded)
362
+ return {"type": "answer", "data": board.__dict__}
363
+ pinned = _seeded.refuted_target_id if (c.demo and _seeded) else None
364
+ report = await revise(c.message, pinned_target_id=pinned)
365
+ return {"type": "revision", "data": _report_json(report)}
366
+
367
+ @app.post("/api/scenario")
368
+ async def api_scenario(kind: str = "simple"):
369
+ global _seeded
370
+ _seeded = await (build_diamond_graph() if kind == "diamond" else build_graph())
371
+ await events.emit_graph_reset()
372
+ return await _graph_json()
373
+
374
+ @app.post("/api/upload")
375
+ async def api_upload(file: UploadFile = File(...)):
376
+ import cognee
377
+ text = (await file.read()).decode("utf-8", "ignore")
378
+ await cognee.add(text); await cognee.cognify()
379
+ await events.emit_graph_reset()
380
+ return await _graph_json()
381
+
382
+ @app.get("/api/verify")
383
+ async def api_verify():
384
+ await graph_ops.flush_and_release() # cold-read from disk
385
+ return {"cold_read": True, "summary": await get_belief_summary()}
386
+
387
+ # GET /api/scoreboard, POST /api/reset, POST /api/mode … analogous
388
+ # GET / and /assets → serve frontend/dist (mounted last so /api/* wins)
389
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
390
+
391
+ @app.on_event("startup")
392
+ async def _startup():
393
+ global _seeded
394
+ _seeded = await build_graph()
395
+ ```
396
+
397
+ *(Helpers `_graph_json()` and `_report_json()` reuse the color map from
398
+ `utils._STATE_STYLE` and `_infer_type` — no new color logic.)*
399
+
400
+ ### 3.6 `requirements.txt` additions
401
+ ```
402
+ # — web UI (Feature F) —
403
+ fastapi>=0.104.0
404
+ uvicorn[standard]>=0.24.0
405
+ python-multipart>=0.0.6 # multipart form parsing for /api/upload
406
+ ```
407
+ *(SSE needs no extra package — it's plain `StreamingResponse`. No `websockets` dependency.)*
408
+
409
+ ---
410
+
411
+ *End of Section 3.*
412
+
413
+ ---
414
+
415
+ ## Section 4 — The "brilliant" frontend
416
+
417
+ > **Section 4 of ~7.** Confirm to continue to Section 5.
418
+
419
+ The UI has to do one job in the first 10 seconds a judge looks at it: **make "revised, not
420
+ forgotten" viscerally obvious.** Everything below serves that. A pretty graph is table stakes;
421
+ the win is *legibility of reasoning* — the judge should see a belief die and understand *why*.
422
+
423
+ ### 4.1 Layout — three zones, one screen
424
+
425
+ ```
426
+ ┌────────────────────────────────────────────────────────────────────────────────┐
427
+ │ ⬦ FALSIFY belief-revision copilot [Open source ▸ Cloud] [demo ●] │ top bar
428
+ ├───────────────┬──────────────────────────────────────────┬───────────────────────┤
429
+ │ CHAT │ LIVING GRAPH │ SCOREBOARD │
430
+ │ (380px) │ (fluid, center stage) │ (360px) │
431
+ │ │ │ │
432
+ │ history │ ●─────● force-directed │ FALSIFY ✅ Jan 2021 │
433
+ │ bubbles │ │ │ nodes colored by │ (via alive evidence) │
434
+ │ │ ●──────● truth-state │ │
435
+ │ ┌─────────┐ │ cascade animates on revise │ plain RAG ⚠ Mar 2021 │
436
+ │ │ upload │ │ │ (cites REFUTED node) │
437
+ │ └─────────┘ │ │ ───────────────────── │
438
+ │ [ input … ] │ [Reset] [Run demo] [Diamond] [Verify] │ REVISION LOG ▸ │
439
+ │ ( send ) │ │ ● E_qa refuted 0.92 │
440
+ │ │ │ ● K invalidated │
441
+ │ │ │ ● A superseded → B │
442
+ └───────────────┴──────────────────────────────────────────┴───────────────────────┘
443
+ ```
444
+
445
+ On narrow screens the three zones stack (chat → graph → scoreboard) and the graph gets a fixed
446
+ tall canvas. Judges will use a laptop, so the 3-column is the one we polish.
447
+
448
+ ### 4.2 Design system (dark, "forensic lab" aesthetic)
449
+
450
+ - **Palette (reuses the existing `_STATE_STYLE` so CLI, HTML export, and web all match):**
451
+ bg `#0b1020`, panel `#111827`, hairline `#1f2937`, text `#e5e7eb`, dim `#9ca3af`.
452
+ Truth-state = the *semantic* colors: alive `#22c55e`, refuted `#ef4444`,
453
+ invalidated `#9ca3af`, superseded `#f59e0b`, forgotten `#4b5563`.
454
+ - **Accent:** a single electric mint `#34d399` for interactive affordances (send, active tab).
455
+ - **Type:** Inter for UI; a mono (JetBrains Mono / ui-monospace) for node ids, weights, epochs —
456
+ the "instrument readout" feel.
457
+ - **Depth:** soft outer shadows + 1px inner hairlines; nodes get a faint colored glow (their
458
+ state color at low alpha) so the canvas looks alive, not like a diagram.
459
+ - **Motion budget:** everything ≤ 400ms, `ease-out`; nothing loops forever (looping motion reads
460
+ as "loading," not "alive"). `prefers-reduced-motion` collapses animations to instant state
461
+ swaps.
462
+
463
+ ### 4.3 Component tree
464
+
465
+ ```
466
+ App
467
+ ├─ TopBar (brand, BackendToggle [Open source|Cloud], ModeBadge [demo|live])
468
+ ├─ ChatPanel
469
+ │ ├─ MessageList (user / system bubbles; system messages can embed a mini result card)
470
+ │ ├─ UploadDrop (drag-drop or click → POST /api/upload; shows "cognifying…" then diff)
471
+ │ └─ Composer (textarea + Send; Enter=send, Shift+Enter=newline)
472
+ ├─ GraphCanvas (react-force-graph-2d; owns node/link rendering + animation state)
473
+ │ └─ GraphControls (Reset · Run demo · Diamond · Verify · Legend)
474
+ └─ InsightColumn
475
+ ├─ Scoreboard (FALSIFY vs plain-RAG, side by side, with the "stale" warning)
476
+ └─ RevisionLog (reverse-chronological feed of what changed and why)
477
+ ```
478
+
479
+ ### 4.4 State & data flow
480
+
481
+ - **`useEventStream()`** — wraps `EventSource('/api/events')`; exponential-backoff reconnect;
482
+ exposes the latest event + a subscribe callback. Single source of live truth.
483
+ - **`useGraphStore()`** (small Zustand or `useReducer`) — holds `nodes`/`links` plus per-node
484
+ *animation fields* (`flash`, `flashColor`, `shrink`) that the canvas reads each frame.
485
+ - `graph_reset` → refetch `GET /api/graph`, diff against current, animate *added* nodes in.
486
+ - `node_state_changed` → patch color + set `flash=1.0` in the state's color.
487
+ - `node_forgotten` → set `shrink=1.0`; when it hits 0, drop the node + its links.
488
+ - **Optimistic chat:** user bubble appears instantly; the graph animates from SSE (not from the
489
+ POST response), so the *cause* (chat) and *effect* (cascade) are visually linked in real time.
490
+
491
+ ### 4.5 The signature animation vocabulary (this is the memorable part)
492
+
493
+ Four named motions, each mapped to a belief event. Implemented in `nodeCanvasObject` via a
494
+ per-node timer decremented on every `requestAnimationFrame`:
495
+
496
+ 1. **Refute — "the strike."** Target flashes to red, a hard ring pulses outward once, and its
497
+ border switches to **dashed red**. Sharp and fast (250ms). This is the moment of doubt.
498
+ 2. **Cascade — "the sweep."** Invalidation doesn't happen all at once — it *travels*. Each
499
+ downstream node greys out with a ~120ms stagger along `depends_on` edges, and the traversed
500
+ edge briefly lights up. The judge literally watches consequence flow through the graph. (We
501
+ already have `pipeline_step` events + edge data to order this.)
502
+ 3. **Forget — "the dissolve."** An orphaned node shrinks to zero radius while fading alpha over
503
+ 300ms, then is removed. Its edges retract with it. "Forgotten" should *feel* like deletion —
504
+ but note in the log that provenance was retained.
505
+ 4. **Promote — "the rise."** The newly-winning hypothesis (B) pulses green, scales up ~1.15×
506
+ and settles, with a soft green glow that lingers a beat longer than the others. The graph
507
+ ends on a calm, green, *correct* resting state — the emotional payoff.
508
+
509
+ Refuted nodes keep the dashed-red border after their flash, so the end-state is self-documenting
510
+ even after motion stops (and in screenshots / the GIF).
511
+
512
+ ### 4.6 Scoreboard — the thesis, made unmissable
513
+
514
+ Two stacked cards, always visible (not hidden in chat):
515
+ - **FALSIFY** ✅ — the alive-evidence answer ("Jan 2021, via supplier email"), green check,
516
+ a one-line "supported by: E_email (alive)".
517
+ - **plain RAG** ⚠ — the naive vector answer ("Mar 2021, per QA report"), amber warning, and the
518
+ killer subtitle: **"still cites E_qa — a node FALSIFY refuted."** When `board.stale` is true,
519
+ the card gets a subtle red pulse the first time it renders.
520
+
521
+ That contrast card is the single screenshot that should end up in the submission. Design it to
522
+ be beautiful *standalone*.
523
+
524
+ ### 4.7 Revision log — "why," in plain language
525
+
526
+ A framer-motion feed; each entry slides in as its SSE event arrives, newest on top:
527
+ - `● E_qa refuted` · `conf 0.92` · *"contradicted by forensic back-dating finding"*
528
+ - `● K invalidated` · *"its only critical support (E_qa) died"*
529
+ - `● A superseded → B promoted` · *"A's evidence collapsed; B still stands"*
530
+ - `● K forgotten` · *"orphaned — no live consumer (provenance kept)"*
531
+
532
+ Each row links to its node (hover → highlight in graph). This is what converts "cool animation"
533
+ into "I understand exactly what the system decided and why."
534
+
535
+ ### 4.8 Upload flow (turns a fixed demo into a product)
536
+
537
+ Drag a `.txt`/`.md` onto the chat → optimistic "Ingesting…" bubble → `POST /api/upload`
538
+ (`cognee.add` + `cognify`) → `graph_reset` fires → new evidence nodes animate in with the same
539
+ "rise" motion. A judge dropping *their own* file and watching it become graph is the answer to
540
+ "why would anyone use this."
541
+
542
+ ### 4.9 Empty / error / no-key states (polish that judges notice)
543
+ - **First load:** graph pre-seeded (startup builds it), a one-line coach-mark: *"Type a
544
+ contradicting fact, or hit Run demo."*
545
+ - **No LLM key:** Live toggle shows a tooltip *"demo mode — add a key for Live/Upload"*; nothing
546
+ errors, everything still runs pinned+local.
547
+ - **SSE drop:** a tiny amber dot in the top bar ("reconnecting…"); auto-recovers.
548
+ - **Cloud unreachable:** toggle snaps back to Open source with a toast, demo continues.
549
+
550
+ ### 4.10 Frontend dependencies
551
+ ```
552
+ react, react-dom, typescript, vite
553
+ tailwindcss, postcss, autoprefixer
554
+ react-force-graph-2d # canvas force graph (same lib family as Cognee's UI)
555
+ framer-motion # log + scoreboard transitions
556
+ zustand # tiny graph/animation store (or useReducer to avoid a dep)
557
+ ```
558
+
559
+ ---
560
+
561
+ *End of Section 4.*
562
+
563
+ ---
564
+
565
+ ## Section 5 — Deployment Plan A: Hugging Face Spaces (PRIMARY)
566
+
567
+ > **Section 5 of ~7.** Confirm to continue to Section 6.
568
+
569
+ **Why this is the primary target:** one Docker container serves the built frontend **and** the
570
+ API **and** the SSE stream on a single port. No cross-origin config, SSE works natively, secrets
571
+ are one settings tab, and the keyless demo means a judge just clicks the Space and it runs. This
572
+ is the simplest path to a live URL — **build and ship this first.**
573
+
574
+ *(All specifics below verified against HF's current Docker Spaces docs, July 2026.)*
575
+
576
+ ### 5.1 The three facts that shape the Dockerfile
577
+ 1. **Port 7860.** HF proxies all external traffic to one port; default is 7860. Bind uvicorn to
578
+ `0.0.0.0:7860` and declare `app_port: 7860` in the README frontmatter.
579
+ 2. **SSE/WebSocket both ride that single port.** Because everything is same-origin behind HF's
580
+ proxy, our `EventSource('/api/events')` just works — no extra config. (This is the payoff of
581
+ choosing SSE in Section 1.)
582
+ 3. **Non-root, UID 1000.** Spaces run the container as uid 1000; create that user and set
583
+ `HOME`/`PATH` accordingly or writes to cache/model dirs fail.
584
+
585
+ ### 5.2 `README.md` frontmatter (the Space config lives here)
586
+ The Space's `README.md` must start with this YAML block:
587
+ ```yaml
588
+ ---
589
+ title: FALSIFY — Belief-Revision Copilot
590
+ emoji: ⬦
591
+ colorFrom: indigo
592
+ colorTo: green
593
+ sdk: docker
594
+ app_port: 7860
595
+ pinned: false
596
+ ---
597
+ ```
598
+
599
+ ### 5.3 Multi-stage `Dockerfile` (build frontend → serve with backend)
600
+ ```dockerfile
601
+ # ── Stage 1: build the React frontend ────────────────────────────────
602
+ FROM node:20-slim AS web
603
+ WORKDIR /web
604
+ COPY frontend/package*.json ./
605
+ RUN npm ci
606
+ COPY frontend/ ./
607
+ RUN npm run build # emits /web/dist
608
+
609
+ # ── Stage 2: python backend + serve the built frontend ───────────────
610
+ FROM python:3.11-slim
611
+ RUN useradd -m -u 1000 user # Spaces run as uid 1000
612
+ WORKDIR /home/user/app
613
+
614
+ # deps first (layer cache)
615
+ COPY --chown=user requirements.txt .
616
+ RUN pip install --no-cache-dir --upgrade pip && \
617
+ pip install --no-cache-dir -r requirements.txt
618
+
619
+ # pre-warm fastembed so the FIRST request isn't a model download
620
+ RUN python -c "from fastembed import TextEmbedding; TextEmbedding()" || true
621
+
622
+ COPY --chown=user . .
623
+ COPY --from=web --chown=user /web/dist ./static # server.py mounts ./static
624
+
625
+ USER user
626
+ ENV HOME=/home/user \
627
+ PATH=/home/user/.local/bin:$PATH \
628
+ HF_HOME=/home/user/app/.cache
629
+ EXPOSE 7860
630
+ CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
631
+ ```
632
+
633
+ ### 5.4 Secrets (only for Live mode / upload — demo needs none)
634
+ In **Space → Settings → Variables & secrets**, add as needed:
635
+ - `LLM_API_KEY` (and `LLM_MODEL`, e.g. `gpt-4o-mini`) — enables Live judging, `recall`
636
+ completion, and `cognify` on upload.
637
+ - For a non-OpenAI endpoint: `LLM_PROVIDER=custom`, `LLM_ENDPOINT=…`.
638
+ - These arrive as env vars at runtime (`os.environ.get(...)`), which `falsify/__init__.py`
639
+ already reads. **Absent key ⇒ the app stays in keyless demo mode; nothing crashes.**
640
+
641
+ ### 5.5 Persistent storage (optional, and we don't need it)
642
+ - Default disk is **ephemeral** — it resets on rebuild/restart. That's *fine*: the startup hook
643
+ seeds the graph fresh every boot, and within a session the on-disk Kuzu/LanceDB persistence is
644
+ what powers the `GET /api/verify` cold-read proof.
645
+ - If we ever want the graph to survive restarts, enable **persistent storage** and point
646
+ `HF_HOME` (and Cognee's system dir) at `/data`. Not required for the demo — skip it.
647
+
648
+ ### 5.6 Ship it (two ways)
649
+ **Option 1 — Git push to the Space remote:**
650
+ ```bash
651
+ # after creating the Space (SDK: Docker) in the HF UI
652
+ git remote add space https://huggingface.co/spaces/<user>/falsify
653
+ git push space main # HF builds the Dockerfile and deploys
654
+ ```
655
+ **Option 2 — `huggingface_hub` CLI:** `pip install huggingface_hub`, `huggingface-cli login`,
656
+ then `huggingface-cli upload <user>/falsify . --repo-type space`.
657
+
658
+ Watch the **Container** tab for build logs; first build is slow (Cognee pulls Kuzu/LanceDB/
659
+ litellm — several minutes). Once green, the Space URL is the live demo link for the submission.
660
+
661
+ ### 5.7 HF-specific risks & mitigations
662
+ | Risk | Mitigation |
663
+ |---|---|
664
+ | Cognee install is heavy → long/failed build | Pin versions in `requirements.txt`; the deps layer is cached across rebuilds; accept the first slow build |
665
+ | First embed downloads a model → slow first request | Pre-warm `fastembed` in the Dockerfile (line above) |
666
+ | Proxy buffers SSE → events arrive in a clump | `X-Accel-Buffering: no` + `Cache-Control: no-cache` already set on the stream (Section 3.5) |
667
+ | Write to a non-writable dir as uid 1000 | `HF_HOME` under `/home/user/app/.cache`; all app writes under `$HOME` |
668
+ | Build OOM on large frontend deps | `npm ci` in an isolated stage; only `dist/` is copied forward |
669
+
670
+ ---
671
+
672
+ *End of Section 5.*
673
+
674
+ ---
675
+
676
+ ## Section 6 — Deployment Plan B: Vercel + Render (FALLBACK)
677
+
678
+ > **Section 6 of ~7.** Confirm to continue to Section 7.
679
+
680
+ Use this **only if HF Spaces gives trouble.** It splits the app: **Vercel** serves the static
681
+ React build, **Render** runs the FastAPI backend (API + SSE). More moving parts (two deploys,
682
+ CORS, a cold-start caveat) — but it's a clean production shape and a solid Plan B.
683
+
684
+ ### 6.1 The load-bearing insight: SSE, not WebSockets, is what makes this split viable
685
+ Verified today (July 2026):
686
+ - **Vercel serverless cannot host long-lived connections** — WebSockets or otherwise. Functions
687
+ are pinned to a max duration (~300s) and future connections aren't guaranteed the same
688
+ instance. So the realtime stream **cannot live on Vercel.**
689
+ - **Render supports WebSockets only on paid plans**, and its free tier spins down after 15 min
690
+ idle (30–60s cold start) which would sever a socket anyway.
691
+
692
+ Our Section-1 choice of **SSE** sidesteps both cleanly: the stream lives on **Render** (plain
693
+ streaming HTTP, no paid-WS requirement), and Vercel only ever serves static files + the browser
694
+ connects `EventSource` **directly to the Render origin.** Vercel never has to hold the stream.
695
+
696
+ ```
697
+ Browser ──static──► Vercel (frontend/dist)
698
+
699
+ └── fetch + EventSource ──► Render (FastAPI: /api/*, /api/events SSE) ──► Cognee (Kuzu/LanceDB)
700
+ ```
701
+
702
+ ### 6.2 Backend on Render
703
+ 1. **New → Web Service**, connect the GitHub repo.
704
+ 2. **Build:** `pip install -r requirements.txt`
705
+ 3. **Start:** `uvicorn server:app --host 0.0.0.0 --port $PORT`
706
+ *(Render injects `$PORT`; do not hardcode 7860 here.)*
707
+ 4. **Env vars:** `LLM_API_KEY`, `LLM_MODEL`, and `FRONTEND_ORIGIN=https://<app>.vercel.app`
708
+ (used by CORS below). Demo mode still needs no key.
709
+ 5. **Instance:** Free works for a quick demo **but cold-starts 30–60s** after 15 min idle. For
710
+ judging, either (a) hit the URL to warm it right before, or (b) use **Starter ($7/mo)** to
711
+ keep it always-on. Recommend Starter if the budget allows — a judge won't wait 45s.
712
+
713
+ `server.py` needs CORS scoped to the Vercel origin (wildcard also fine for a demo):
714
+ ```python
715
+ import os
716
+ app.add_middleware(
717
+ CORSMiddleware,
718
+ allow_origins=[os.environ.get("FRONTEND_ORIGIN", "*")],
719
+ allow_methods=["*"], allow_headers=["*"],
720
+ )
721
+ ```
722
+ SSE already sets `X-Accel-Buffering: no` / `Cache-Control: no-cache` (Section 3.5), which also
723
+ keeps Render's proxy from buffering the stream.
724
+
725
+ ### 6.3 Frontend on Vercel
726
+ 1. **Import Project** → point at the `frontend/` directory (set it as the project root).
727
+ 2. **Framework preset:** Vite. **Build:** `npm run build`. **Output:** `dist`.
728
+ 3. **Env var:** `VITE_API_BASE=https://<app>.onrender.com` — the frontend resolves *all* fetches
729
+ and the `EventSource` URL through this base (Section 2.4).
730
+ 4. Deploy → Vercel returns `https://<app>.vercel.app`. Put that value into Render's
731
+ `FRONTEND_ORIGIN` and redeploy the backend so CORS matches.
732
+
733
+ `frontend/src/lib/api.ts` resolves the base so the *same* build works on HF (same-origin, empty
734
+ base) and on Vercel (cross-origin Render base):
735
+ ```ts
736
+ export const API = import.meta.env.VITE_API_BASE ?? ""; // "" ⇒ same-origin (HF monolith)
737
+ export const sse = () => new EventSource(`${API}/api/events`);
738
+ export const api = (p: string, o?: RequestInit) => fetch(`${API}${p}`, o);
739
+ ```
740
+
741
+ ### 6.4 Order of operations (avoids a CORS chicken-and-egg)
742
+ 1. Deploy **Render** first → get the `onrender.com` URL.
743
+ 2. Deploy **Vercel** with `VITE_API_BASE` = that URL → get the `vercel.app` URL.
744
+ 3. Set Render's `FRONTEND_ORIGIN` = the Vercel URL → redeploy backend.
745
+ 4. Warm the Render service, then open the Vercel URL.
746
+
747
+ ### 6.5 Plan A vs Plan B — pick quickly
748
+ | | **HF Spaces (A)** | **Vercel + Render (B)** |
749
+ |---|---|---|
750
+ | Deploys | 1 | 2 |
751
+ | Cross-origin / CORS | none | required |
752
+ | Realtime (SSE) | same-port, trivial | Render origin, works (not WS-gated) |
753
+ | Cold start | container sleep on free tier, but single service | Render free 30–60s; $7 to remove |
754
+ | Secrets | one settings tab | Render env vars |
755
+ | Best when | **default — do this** | HF build won't cooperate |
756
+
757
+ **Decision rule:** ship A. If the HF build fails twice for reasons you can't fix fast, cut to B
758
+ — Render backend first, Vercel frontend second, warm, done.
759
+
760
+ ---
761
+
762
+ *End of Section 6. Reply "next" for **Section 7 — Build order, 24h timebox & demo script**
763
+ (the hour-by-hour sequence, what to cut if time runs short, and the 2-minute submission-video
764
+ beat sheet).*
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arpit Kumar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: FALSIFY — Belief-Revision Copilot
3
+ emoji: 🧠
4
+ colorFrom: indigo
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ short_description: The AI that revises, not forgets — on Cognee.
11
+ ---
12
+
13
+ <div align="center">
14
+
15
+ # FALSIFY
16
+
17
+ ### the AI that revises, not forgets
18
+
19
+ **Drop one contradicting fact. Watch dependent conclusions die, a losing hypothesis rise — permanently, across sessions.**
20
+
21
+ [![Track: Best Use of Open Source](https://img.shields.io/badge/Track-Best_Use_of_Open_Source-blue)](#-hackathon-track--theme)
22
+ [![Theme: Research & Knowledge Copilot](https://img.shields.io/badge/Theme-Research_&_Knowledge_Copilot-8A2BE2)](#-hackathon-track--theme)
23
+ [![Built on Cognee](https://img.shields.io/badge/Built_on-Cognee-00C48C)](https://github.com/topoteretes/cognee)
24
+ [![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-3776AB)](https://www.python.org/)
25
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow)](LICENSE)
26
+
27
+ </div>
28
+
29
+ ---
30
+
31
+ ## The Problem: AI Remembers the *Wrong* Fact
32
+
33
+ Every memory layer bolted onto an LLM today is an **append-only pile of facts**. It can remember. It cannot *un-believe*.
34
+
35
+ When new evidence contradicts something the AI already "knows," today's systems do one of two bad things:
36
+
37
+ - **RAG / vector memory** keeps citing the stale fact forever — it has no notion that a belief can *die*.
38
+ - **Naive "delete the memory"** throws away the fact *and* every conclusion built on top of it, with no record of *why* — a lobotomy, not a revision.
39
+
40
+ This is **belief-level amnesia**. The AI doesn't just forget *where* it put the context (this hackathon's theme) — it confidently remembers a fact that has since been proven false, and every downstream conclusion inherits the lie.
41
+
42
+ > A forensic audit reveals the "March QA report" was **back-dated**. A human analyst instantly revises: *"Then the March timeline is dead — the January supplier email is now our best evidence."* Today's AI memory keeps answering **"March 2021."**
43
+
44
+ ---
45
+
46
+ ## The Solution: A Living Belief Graph
47
+
48
+ FALSIFY treats a research inquiry not as a chat log but as a **living belief graph** of typed, stateful nodes — `Hypothesis`, `Evidence`, `Conclusion` — wired together by dependency edges.
49
+
50
+ When a new fact **contradicts** an existing piece of evidence, FALSIFY performs **belief revision** on the graph itself:
51
+
52
+ 1. **Refute** — the contradicted `Evidence` flips to `truth_state = REFUTED`.
53
+ 2. **Propagate forward** — the refutation cascades *along `depends_on` edges* to every `Conclusion` that critically rested on it → `INVALIDATED`.
54
+ 3. **Re-ignite** — the losing `Hypothesis` is demoted to `SUPERSEDED`; the strongest surviving rival is promoted as the new frontier.
55
+ 4. **Surgically forget** — orphaned dead-ends (no surviving consumer) are hard-deleted from **both** the graph and the vector store. Provenance nodes (the refuted fact, the superseding fact) are **kept** so the graph always explains *why* it changed.
56
+ 5. **Persist** — truth-state is written *on the node*, so the **next session's `recall()` skips the dead branches** — while a plain-RAG baseline still cites the stale fact.
57
+
58
+ The result is memory that **revises instead of forgets**: it changes its mind, keeps the receipts, and never loses the thread.
59
+
60
+ ---
61
+
62
+ ## Demo
63
+
64
+ <div align="center">
65
+
66
+ ![FALSIFY demo — a refutation cascade collapsing Hypothesis A and igniting Hypothesis B](output/falsify-demo.gif)
67
+
68
+ *A **live** run (the LLM judges the contradiction): drop one back-dating fact → `E_qa` turns **red (refuted)** → Conclusion **K is forgotten** (deleted from graph + vector) → Hypothesis **A** dims to **amber (superseded)** → Hypothesis **B ignites** as the new frontier. Caption: **"AI revised, not forgot."***
69
+
70
+ </div>
71
+
72
+ The money shot is the **scoreboard** printed on every run:
73
+
74
+ ```text
75
+ [SCOREBOARD]
76
+ FALSIFY recall : X knew by Jan 2021 (supplier email) [revised]
77
+ Plain-RAG : X knew by Mar 2021 (QA report) [STALE]
78
+ ```
79
+
80
+ Same underlying store. Same query. FALSIFY revised its belief; the baseline did not.
81
+
82
+ ---
83
+
84
+ ## Key Features
85
+
86
+ | | Feature | What it does |
87
+ |---|---|---|
88
+ | 🧠 | **Belief graph, not a fact pile** | Nodes are *stateful beliefs* (`alive` / `refuted` / `superseded` / `invalidated`), not immutable rows. |
89
+ | ⚡ | **Forward refutation propagation** | One contradiction cascades through `depends_on` edges and invalidates every dependent conclusion in ~3s. |
90
+ | 🎯 | **Two-gate contradiction detection** | Cheap deterministic vector prefilter (`cosine < 0.35`) → skeptical LLM adjudication (`confidence ≥ 0.6`). No hallucinated refutations. |
91
+ | ✂️ | **Surgical forget** | Orphaned dead-ends are hard-deleted from graph **and** vector; provenance is retained. Not a lobotomy — a revision. |
92
+ | 🔁 | **Cross-session persistence** | Disbelief lives on the node. Restart the process and `recall()` still skips the dead branches. |
93
+ | 📊 | **Live A/B scoreboard** | FALSIFY (revised) vs. plain-RAG (stale) side-by-side, every run — the differentiator made visible. |
94
+ | 🕸️ | **Force-graph visualization** | Nodes colored by truth-state; forgotten nodes red-flash then ripple out of the sim. |
95
+ | 🔌 | **Zero external services** | Self-hosted Cognee defaults — LanceDB (vector) + Ladybug (graph) + SQLite. OpenAI-compatible; bring any endpoint. |
96
+
97
+ ---
98
+
99
+ ## How It Works
100
+
101
+ FALSIFY drives Cognee's v1.0 memory API (`remember` / `recall` / `improve` / `forget`) plus a set of **custom `memify` tasks** that operate directly on the graph engine's truth-state.
102
+
103
+ ```mermaid
104
+ flowchart TD
105
+ subgraph S1["Session 1 — build the belief graph"]
106
+ Q["InvestigationQuestion<br/>Did Company X know before the recall?"]
107
+ HA["Hypothesis A<br/>knew via QA report, Mar 2021"]
108
+ HB["Hypothesis B<br/>knew via supplier email, Jan 2021"]
109
+ HC["Hypothesis C<br/>didn't know"]
110
+ Eqa["Evidence E_qa<br/>March QA report"]
111
+ Eem["Evidence E_email<br/>January supplier email"]
112
+ K["Conclusion K<br/>X knew by March 2021"]
113
+
114
+ Q --- HA & HB & HC
115
+ Eqa -- supports --> HA
116
+ Eem -- supports --> HB
117
+ K -- "depends_on (critical)" --> Eqa
118
+ end
119
+
120
+ NF["🆕 New fact (Session 2)<br/>Forensic audit: March QA report was back-dated"]
121
+
122
+ subgraph REV["Belief revision — custom memify tasks"]
123
+ direction TB
124
+ D["1. Detect contradiction<br/>vector prefilter < 0.35 → LLM judge ≥ 0.6"]
125
+ R["2. E_qa → REFUTED"]
126
+ P["3. Forward BFS on depends_on<br/>K → INVALIDATED"]
127
+ G["4. A → SUPERSEDED &nbsp;•&nbsp; B ignites (promoted)"]
128
+ F["5. Forget orphan K<br/>delete from graph + vector<br/>keep E_qa as refuted provenance"]
129
+ D --> R --> P --> G --> F
130
+ end
131
+
132
+ NF --> D
133
+ Eqa -.-> D
134
+
135
+ subgraph SCORE["Scoreboard"]
136
+ FA["FALSIFY recall → B (Jan 2021) ✅ revised"]
137
+ RA["Plain-RAG → March 2021 QA report ❌ stale"]
138
+ end
139
+
140
+ F --> FA
141
+ F --> RA
142
+ ```
143
+
144
+ **The mechanism in one paragraph:** a `Conclusion --depends_on--> Evidence` edge is the propagation rail. Refutation seeds at an `Evidence` node; a conclusion stays justified only if it has a **grounded** critical support chain that bottoms out in a still-alive node. FALSIFY computes this as a **least-fixpoint** over the dependency graph, so one formulation correctly handles chains, **diamonds** (a conclusion survives while any critical alternative is grounded), non-critical dependencies, **and cycles** (a self-supporting loop with no grounded base collapses — and the fixpoint always terminates). Hypotheses are re-scored via their `supports` edges. Truth-state (`truth_alignment` + `truth_epoch`) is written on-node via `set_node_truth_state`, so it survives a restart and `recall()` filters on it. See [`falsify/tasks/propagate_refutation.py`](falsify/tasks/propagate_refutation.py) and [REQUIREMENTS.md](REQUIREMENTS.md) for the full algorithm, edge vocabulary, and edge-case handling.
145
+
146
+ ---
147
+
148
+ ## Install & Setup
149
+
150
+ ### Prerequisites
151
+ - Python **3.10 – 3.14**
152
+ - An OpenAI **or any OpenAI-compatible** API key (OpenRouter, vLLM, LM Studio, Azure, …)
153
+
154
+ ### 1. Clone & create an environment
155
+
156
+ ```bash
157
+ git clone https://github.com/ArpitKumar8649/cognee-hackathon-project.git
158
+ cd cognee-hackathon-project
159
+
160
+ # uv (recommended)
161
+ uv venv && source .venv/bin/activate
162
+ uv pip install -r requirements.txt
163
+
164
+ # …or plain pip
165
+ python -m venv .venv && source .venv/bin/activate
166
+ pip install -r requirements.txt
167
+ ```
168
+
169
+ ### 2. Configure your key
170
+
171
+ ```bash
172
+ cp .env.template .env
173
+ # then edit .env and set LLM_API_KEY
174
+ ```
175
+
176
+ Minimal `.env` (OpenAI):
177
+
178
+ ```bash
179
+ LLM_PROVIDER="openai"
180
+ LLM_API_KEY="sk-..."
181
+ LLM_MODEL="openai/gpt-5-mini"
182
+ ```
183
+
184
+ Any OpenAI-compatible endpoint (OpenRouter shown):
185
+
186
+ ```bash
187
+ LLM_PROVIDER="custom"
188
+ LLM_API_KEY="your_api_key"
189
+ LLM_MODEL="openrouter/google/gemini-2.0-flash-lite-preview-02-05:free"
190
+ LLM_ENDPOINT="https://openrouter.ai/api/v1"
191
+ ```
192
+
193
+ > **Heads-up:** if you configure *only* the LLM or *only* embeddings, Cognee defaults the other to OpenAI. Either configure both or keep a valid OpenAI key handy. All databases default to **local, self-hosted** stores — no external services required.
194
+
195
+ ---
196
+
197
+ ## Usage
198
+
199
+ ```bash
200
+ # Full run: build the belief graph, drop the contradicting fact,
201
+ # print the FALSIFY-vs-RAG scoreboard. Judges start here (~2 min).
202
+ python main.py
203
+
204
+ # Deterministic demo mode — pins the refuted evidence id so the
205
+ # cascade + forget run on real graph/vector APIs even if the LLM
206
+ # judge is flaky. This is the presentation safety net.
207
+ python main.py --demo
208
+
209
+ # Run the test suite — 11 tests, NO API key required
210
+ # (FakeGraph + mocked LLM): propagation, diamond, cycle-safety,
211
+ # surgical forget, and the two detector gates.
212
+ pytest -q
213
+ pytest -q tests/test_falsify.py # core belief-revision cascade
214
+ pytest -q tests/test_detect.py # two-gate contradiction detector
215
+ ```
216
+
217
+ **No API key?** `main.py` prints a clear message explaining how to set `LLM_API_KEY` and exits with code **0** — it never crashes in front of a judge.
218
+
219
+ ### Cross-session persistence
220
+
221
+ Truth-state is written *on the graph node*, so it survives a process restart. `main.py` re-reads the belief state fresh at the end of the run to prove the refuted branch never comes back. Run `python main.py --keep` to build on top of existing memory instead of pruning first.
222
+
223
+ ### Visualization
224
+
225
+ Every run writes a self-contained interactive graph to **`output/graph.html`** — just open it in a browser (no server needed). Nodes are colored by truth-state: **alive = green**, **refuted = red (dashed)**, **invalidated = grey**, **superseded = amber**.
226
+
227
+ ---
228
+
229
+ ## Architecture
230
+
231
+ Full design — verified Cognee API surface, node/edge vocabulary, the exact forward-propagation algorithm, truth-state lifecycle, and every handled edge case — lives in the build contract:
232
+
233
+ - **[REQUIREMENTS.md](REQUIREMENTS.md)** — grep-verified Cognee API references, truth-state lifecycle, and edge-case matrix.
234
+
235
+ ```text
236
+ main.py # entry point — seed + revise + scoreboard; graceful no-key exit 0
237
+ falsify/
238
+ models.py # DataPoint subclasses (Hypothesis/Evidence/Conclusion) + TruthState
239
+ edges.py # edge-name constants: DEPENDS_ON, SUPPORTS, REFUTES, SUPERSEDES
240
+ graph_ops.py # verified wrapper over Cognee's graph + vector engines
241
+ seed.py # demo corpus: the Company-X recall investigation
242
+ tasks/
243
+ detect_contradictions.py # two-gate detector (vector prefilter + skeptical-LLM judge)
244
+ propagate_refutation.py # grounded-fixpoint refutation cascade + hypothesis promotion
245
+ cascade_forget.py # surgical orphan delete (graph + vector), keeps provenance
246
+ falsify.py # orchestration: build_graph(), revise(new_fact), scoreboard()
247
+ utils.py # interactive HTML viz + BEFORE/AFTER console state
248
+ tests/
249
+ test_falsify.py # cascade / diamond / cycle-safety / surgical forget / promote
250
+ test_detect.py # detector: pinned demo path + Gate-1 filter + Gate-2 thresholds
251
+ conftest.py # FakeGraph fixture — key-free, DB-free in-memory engine stand-in
252
+ ```
253
+
254
+ ---
255
+
256
+ ## How FALSIFY Maps to the Judging Criteria
257
+
258
+ | # | Criterion | How FALSIFY nails it |
259
+ |---|---|---|
260
+ | 1 | **Potential Impact** | Solves *belief-level amnesia* — AI confidently remembering facts that have been proven false. Every research, legal, medical, or intelligence copilot needs memory that can be *revised*, not just appended. |
261
+ | 2 | **Creativity / Originality** | Reframes graph nodes as **stateful beliefs** (alive / refuted / superseded / invalidated) and treats "changing your mind" as a first-class graph operation — not chat history, not RAG. |
262
+ | 3 | **Technical Excellence** | Custom `memify` extraction + enrichment tasks; deterministic-first **two-gate** contradiction detection; forward BFS propagation with cycle/diamond-safe `visited` sets; dual-store surgical delete; on-node persistent truth-state. |
263
+ | 4 | **Best Use of Cognee** | Drives the v1.0 memory API end-to-end — `remember(session_id)` → `recall()` → `improve()` → surgical `forget()` — plus custom `memify` tasks operating directly on `set_node_truth_state` / `get_neighborhood` / `delete_nodes`. |
264
+ | 5 | **UX / Presentation** | One-screen, 30-second beat: paste one fact → watch A collapse and B ignite → read the FALSIFY-vs-RAG scoreboard. Force-graph colored by belief state. |
265
+ | 6 | **Documentation & Reproducibility** | `python main.py` runs in ~2 min with zero external services; graceful no-key exit; full README + REQUIREMENTS + 11 key-free tests + demo script. |
266
+
267
+ ---
268
+
269
+ ## Hackathon Track & Theme
270
+
271
+ - **Event:** *The Hangover Part AI: Where's My Context?*
272
+ - **Track:** 🏆 **Best Use of Open Source** — built entirely on open-source Cognee with self-hosted, zero-dependency defaults (LanceDB + Ladybug + SQLite).
273
+ - **Category / Theme:** 🔬 **Research & Knowledge Copilot** — a research assistant whose memory revises its beliefs as new evidence arrives.
274
+
275
+ The theme asks *"Where's my context?"* FALSIFY's answer: the context isn't lost — it was **wrong**, and the AI should *revise* it, not blindly recall it.
276
+
277
+ ---
278
+
279
+ ## Future Work
280
+
281
+ - **Confidence-weighted partial refutation** — decay a Conclusion's confidence continuously instead of a binary alive/invalidated flip.
282
+ - **Multi-hop evidence provenance UI** — click any node to trace the full chain of *why it lives or died*.
283
+ - **Automated evidence ingestion** — stream documents in and let the two-gate detector surface contradictions proactively.
284
+ - **Human-in-the-loop review** — queue borderline LLM verdicts (0.4–0.6 confidence) for analyst confirmation before cascading.
285
+ - **Belief-diff export** — a git-style diff of the belief graph between any two epochs.
286
+ - **Neo4j / Postgres backends** — swap the graph engine for a distributed store with no code change (Cognee adapter interface).
287
+
288
+ ---
289
+
290
+ ## Acknowledgments
291
+
292
+ - **[Cognee](https://github.com/topoteretes/cognee)** — the open-source AI memory platform FALSIFY is built on. Its truth-state graph APIs, custom `memify` pipeline, and self-hosted defaults made belief revision possible without a single external service.
293
+ - **[WeMakeDevs](https://wemakedevs.org/)** — for hosting *The Hangover Part AI* hackathon and championing open-source builders.
294
+
295
+ ---
296
+
297
+ <div align="center">
298
+
299
+ **FALSIFY — the AI that revises, not forgets.**
300
+
301
+ </div>
REQUIREMENTS.md ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FALSIFY — Requirements & Build Contract
2
+
3
+ > **Tagline:** Drop one contradicting fact; watch dependent conclusions die and a losing hypothesis rise — permanently, across sessions.
4
+
5
+ FALSIFY is a belief-revision research copilot. It treats a research inquiry as a **living belief graph** (not a chat log). When new evidence contradicts an existing evidence node, FALSIFY performs **belief revision**: it flips the contradicted evidence to `refuted`, propagates the refutation **forward** through the dependency chain, marks every dependent conclusion invalid, promotes the best surviving hypothesis, and **surgically forgets** the orphaned dead-ends. The refutation is written to graph node truth-state so the **next session's `recall()` skips killed branches** — while a plain-RAG baseline still cites the stale fact.
6
+
7
+ This document is the **contract** for all downstream build agents. Every API named here has been verified against the read-only reference at `/workspaces/cognee`. Line references are cited where load-bearing.
8
+
9
+ ---
10
+
11
+ ## 0. Verified Cognee API Surface (grep-confirmed)
12
+
13
+ | API | Exact import / call | Location (reference, read-only) |
14
+ |---|---|---|
15
+ | `Task` | `from cognee.modules.pipelines.tasks.task import Task` **(lowercase module `task`)** | `cognee/modules/pipelines/tasks/task.py` |
16
+ | `memify` | `import cognee; await cognee.memify(extraction_tasks=[...], enrichment_tasks=[...], data=..., node_type=NodeSet, node_name=[...])` | `cognee/modules/memify/memify.py:26` |
17
+ | `remember` | `await cognee.remember(data, dataset_name=..., session_id=...)` | `cognee/api/v1/remember/remember.py:623` |
18
+ | `recall` | `await cognee.recall(query_text, query_type=..., session_id=..., top_k=...)` | `cognee/api/v1/recall/recall.py:361` |
19
+ | `improve` | `await cognee.improve(...)` | `cognee/api/v1/improve/improve.py:36` |
20
+ | `forget` | `await cognee.forget(...)` | `cognee/api/v1/forget/forget.py:16` |
21
+ | `get_graph_engine` | `from cognee.infrastructure.databases.graph import get_graph_engine` → `ge = await get_graph_engine()` **(async)** | `cognee/infrastructure/databases/graph/get_graph_engine.py:110` |
22
+ | `get_vector_engine` | `from cognee.infrastructure.databases.vector import get_vector_engine` → `ve = get_vector_engine()` **(sync)** | `cognee/infrastructure/databases/vector/get_vector_engine.py:44` |
23
+ | `DataPoint`, `Embeddable`, `Dedup`, `LLMContext` | `from cognee.infrastructure.engine import DataPoint, Embeddable, LLMContext, Dedup` | `cognee/infrastructure/engine/models/DataPoint.py:27` |
24
+ | `LLMGateway` | `from cognee.infrastructure.llm.LLMGateway import LLMGateway` | `cognee/infrastructure/llm/LLMGateway.py:52` |
25
+
26
+ ### Graph engine methods (verified in `graph_db_interface.py` + `ladybug/adapter.py`)
27
+
28
+ ```python
29
+ ge = await get_graph_engine()
30
+
31
+ # Forward traversal — THE propagation rail. Returns (nodes, edges).
32
+ nodes, edges = await ge.get_neighborhood(node_ids: List[str], depth: int = 1, edge_types: Optional[List[str]] = None)
33
+ # graph_db_interface.py:303 ; ladybug/adapter.py:2068
34
+
35
+ # Immediate neighbors of ONE node. Returns List[Tuple[source_props, edge_props, target_props]].
36
+ conns = await ge.get_connections(node_id: Union[str, UUID])
37
+ # graph_db_interface.py:289 ; ladybug/adapter.py:1859
38
+
39
+ # Truth-state: the alive/dead flag recall() filters on. PERSISTS across restart.
40
+ await ge.set_node_truth_state({node_id: {"truth_alignment": ["refuted"], "truth_epoch": N}})
41
+ state = await ge.get_node_truth_state(node_ids: List[str])
42
+ # graph_db_interface.py:357,364 ; ladybug impl 1687,1712 ; writes props "truth_alignment" (list) + "truth_epoch" (int) — adapter.py:1576-1578
43
+
44
+ # Confidence / health signal.
45
+ await ge.set_node_feedback_weights({node_id: 0.9}) # graph_db_interface.py:348
46
+ w = await ge.get_node_feedback_weights(node_ids) # graph_db_interface.py:341
47
+
48
+ # Edges.
49
+ await ge.add_edge(from_node: str, to_node: str, relationship_name: str, edge_properties: Dict = {}) # ladybug/adapter.py:1222
50
+
51
+ # Surgical delete.
52
+ await ge.delete_nodes(node_ids: List[str]) # graph_db_interface.py:102 ; ladybug 1042
53
+ ```
54
+
55
+ ### Vector engine methods (verified in `LanceDBAdapter.py`)
56
+
57
+ ```python
58
+ ve = get_vector_engine()
59
+ hits = await ve.search(collection_name: str, query_text: str = None, limit: int = 15, include_payload: bool = False, ...)
60
+ # LanceDBAdapter.py:992 — each hit exposes `.id`, `.score` (COSINE DISTANCE, lower = closer), `.payload`
61
+ await ve.delete_data_points(collection_name: str, data_point_ids: List[UUID])
62
+ # vector_db_interface.py:160 ; LanceDBAdapter.py:1106
63
+ ```
64
+
65
+ ### LLMGateway (verified `LLMGateway.py:59`)
66
+
67
+ ```python
68
+ # STATIC method — do NOT instantiate. Returns a coroutine; await it.
69
+ result = await LLMGateway.acreate_structured_output(
70
+ text_input="A(...): <claim>\nB(...): <claim>",
71
+ system_prompt="skeptical analyst ...",
72
+ response_model=ContradictionJudgement, # a pydantic BaseModel
73
+ )
74
+ ```
75
+
76
+ ### DataPoint subclass pattern (verified `Tool.py`)
77
+
78
+ ```python
79
+ from typing import Annotated
80
+ from cognee.infrastructure.engine import DataPoint, Embeddable, Dedup
81
+
82
+ class Hypothesis(DataPoint):
83
+ statement: Annotated[str, Embeddable(), Dedup()]
84
+ question_id: str
85
+ # identity_fields in metadata dedups nodes across sessions (see §2.4)
86
+ metadata: dict = {"index_fields": ["statement"], "identity_fields": ["question_id", "statement"]}
87
+ ```
88
+
89
+ > **NOTE for implementers:** The FALSIFY spec screenshot wrote `Task(...)` imported from `pipelines.tasks.Task`. The **correct verified path is lowercase** `cognee.modules.pipelines.tasks.task` (class name `Task`). Use that. `get_graph_engine` is `await`-ed; `get_vector_engine` is **not**.
90
+
91
+ ---
92
+
93
+ ## 1. Core Mechanism
94
+
95
+ ### 1.1 Node & edge vocabulary (see §2 for full model)
96
+
97
+ - Nodes: `InvestigationQuestion` (root), `Hypothesis`, `Evidence`, `Conclusion`.
98
+ - Edges (relationship_name string on `add_edge`):
99
+ - `depends_on` : **Conclusion → Evidence** — *THE forward-propagation rail.* A Conclusion `depends_on` the Evidence it rests on. `edge_properties = {"critical": bool}`.
100
+ - `supports` : **Evidence → Hypothesis** — evidence corroborates a hypothesis. `edge_properties = {"weight": float}`.
101
+ - `refutes` : **Evidence → Hypothesis** — evidence contradicts a hypothesis. `edge_properties = {"weight": float}`.
102
+ - `supersedes` : **Evidence(new) → Evidence(old)** — written when the new fact back-dates / overrides an old evidence node. `edge_properties = {"confidence": float}`.
103
+
104
+ ### 1.2 Truth-state lifecycle
105
+
106
+ `truth_alignment` is a list written to the node via `set_node_truth_state`. FALSIFY uses these canonical single-element states:
107
+
108
+ ```
109
+ alive — default; node participates in recall context
110
+ refuted — Evidence directly contradicted by a newer fact (entry point of a cascade)
111
+ superseded — Evidence/Hypothesis replaced by a newer competing node (still exists, demoted)
112
+ invalidated — Conclusion whose supporting Evidence chain was refuted (forward-cascade victim)
113
+ forgotten — orphaned dead-end scheduled for surgical delete (transient; node then removed)
114
+ ```
115
+
116
+ Transition rules:
117
+
118
+ ```
119
+ alive --(new fact contradicts this Evidence, LLM-confirmed)--> refuted
120
+ alive --(a competing Evidence supersedes it)---------------> superseded
121
+ Conclusion.alive --(any critical depends_on Evidence is refuted)--> invalidated
122
+ {refuted|invalidated} node with NO surviving alive dependent/consumer --> forgotten --> delete_nodes + delete_data_points
123
+ Hypothesis.alive --(its only supporting Evidence became refuted AND a rival Hypothesis has surviving support)--> superseded
124
+ losing Hypothesis' rival --(gains the strongest surviving support)--> stays alive, feedback_weight promoted
125
+ ```
126
+
127
+ Every state write also bumps `truth_epoch` to a monotonically increasing integer (epoch = the memify run counter). Recall filters on `truth_alignment` containing `"alive"`.
128
+
129
+ ### 1.3 Contradiction detection (two-gate, deterministic-first)
130
+
131
+ The detector runs inside the `propagate_refutation` extraction/enrichment task. **Two gates** to kill nondeterminism:
132
+
133
+ 1. **Vector prefilter (cheap, deterministic).** `hits = await ve.search("Evidence_claim", query_text=NEW_FACT, limit=5, include_payload=True)`. Candidates = hits with **cosine distance `score < 0.35`** (topically related — same subject). This narrows the LLM to plausibly-conflicting evidence only. Contradictory claims sometimes embed far apart, so claims MUST be normalized to `subject + predicate` phrasing at ingest, and `limit` kept ≥5.
134
+ 2. **LLM adjudication (semantic).** For each candidate, call `LLMGateway.acreate_structured_output` with a **skeptical-analyst** system prompt and `response_model=ContradictionJudgement` (below). Only a verdict of `contradicts` (or `supersedes`) with `confidence ≥ 0.6` triggers refutation. This distinguishes *genuine contradiction* ("report was back-dated") from *topical overlap* ("also mentions the report").
135
+
136
+ ```python
137
+ class ContradictionJudgement(BaseModel):
138
+ relation: Literal["contradicts", "supersedes", "supports", "unrelated"]
139
+ confidence: float # 0..1
140
+ rationale: str
141
+ ```
142
+
143
+ **`--demo` / `DEMO_MODE` override:** when set, the seeded contradiction's target evidence id is pinned (`REFUTED_ID` env / seed constant), so the cascade runs on real graph APIs even if the LLM judge stalls or the key is flaky. This is the non-negotiable demo safety net.
144
+
145
+ ### 1.4 Forward refutation propagation (exact algorithm)
146
+
147
+ Entry: an Evidence node `E` confirmed `refuted` (§1.3).
148
+
149
+ ```
150
+ 1. set_node_truth_state({E.id: {"truth_alignment": ["refuted"], "truth_epoch": epoch}})
151
+ set_node_feedback_weights({E.id: 0.0})
152
+
153
+ 2. FORWARD CLOSURE — reverse-BFS along depends_on (Conclusion --depends_on--> Evidence).
154
+ Seeds = [E.id]. Traverse edges of type ["depends_on", "supersedes"] via:
155
+ nodes, edges = await ge.get_neighborhood([E.id], depth=4, edge_types=["depends_on", "supports"])
156
+ Collect every Conclusion C where a `depends_on` edge points from C into the refuted set
157
+ (transitively, up to depth 4). Because depends_on is Conclusion→Evidence, the "dependents"
158
+ are the SOURCES of those edges. A Conclusion is invalidated iff at least one of its
159
+ `critical: true` depends_on edges targets a refuted/invalidated node.
160
+
161
+ 3. For each invalidated Conclusion C:
162
+ set_node_truth_state({C.id: {"truth_alignment": ["invalidated"], "truth_epoch": epoch}})
163
+ set_node_feedback_weights({C.id: 0.0})
164
+
165
+ 4. PROMOTE competing hypothesis (promote_competing_hypothesis task):
166
+ - Find the Hypothesis H_dead whose only supporting Evidence is now refuted.
167
+ set_node_truth_state({H_dead.id: {"truth_alignment": ["superseded"], "truth_epoch": epoch}})
168
+ - Among rival Hypotheses still holding ≥1 alive `supports` Evidence, pick the one with the
169
+ highest summed support weight (read edge {"weight"}). Promote it:
170
+ set_node_feedback_weights({H_win.id: <boosted>}) # stays alive; becomes new frontier
171
+
172
+ 5. RECORD new fact + supersedes edge (add_data_points dual-write):
173
+ - Materialize NEW_FACT as an Evidence DataPoint, add via add_data_points (writes graph + vector).
174
+ - add_edge(new_evidence.id, E.id, "supersedes", {"confidence": verdict.confidence})
175
+ ```
176
+
177
+ **Direction summary:** refutation flows *from Evidence up to the Conclusions that depend on it* by walking `depends_on` edges backward (Conclusion is the edge source). Hypotheses are re-scored via their `supports`/`refutes` edges. No forward walk ever crosses a `supersedes` edge into an already-superseded node (prevents loops — see §4).
178
+
179
+ ### 1.5 forget() orphan conditions (surgical delete)
180
+
181
+ `forget_orphan_deadends()` runs after propagation and grows a **death set** by traversal:
182
+
183
+ ```
184
+ A node is FORGOTTEN (hard-deleted from graph + vector) iff ALL hold:
185
+ (a) its truth_alignment is refuted OR invalidated (never alive/superseded — superseded nodes
186
+ are kept as provenance), AND
187
+ (b) it has NO surviving consumer: no alive node reaches it via depends_on/supports
188
+ (checked with get_connections — _has_alive_alternative == False), AND
189
+ (c) it is not itself the target of a supersedes edge FROM an alive node (that node is the
190
+ provenance anchor of the new truth and must be retained).
191
+
192
+ Nodes that STILL feed a live node are retained even if refuted (partial refutation, §4).
193
+ ```
194
+
195
+ Delete implementation (one shot per node batch):
196
+ ```python
197
+ await ge.delete_nodes([str(id) for id in death_set])
198
+ await ve.delete_data_points("Evidence_claim", [evidence_ids_in_death_set])
199
+ await ve.delete_data_points("Conclusion_statement", [conclusion_ids_in_death_set])
200
+ ```
201
+
202
+ > Provenance is kept: only *truly orphaned* dead-ends are hard-deleted. `refuted`/`superseded` nodes that still explain *why* the graph changed remain, carrying their state flag.
203
+
204
+ ### 1.6 Cross-session persistence (the proof)
205
+
206
+ Truth-state is stored **on the node** in the graph DB (Ladybug/SQLite-backed), so it survives process restart. Session 2's `recall()` reads only `truth_alignment == alive` context. A parallel **plain-RAG baseline** (`recall(query_type=SearchType.RAG_COMPLETION)` or direct `ve.search`) does NOT read truth-state and re-cites the deleted/stale fact — this A/B is the scoreboard.
207
+
208
+ ---
209
+
210
+ ## 2. Data Model
211
+
212
+ ### 2.1 Node DataPoint subclasses (`memory_core/models.py`)
213
+
214
+ | Class | Embeddable field | Other fields | Collection (auto) |
215
+ |---|---|---|---|
216
+ | `InvestigationQuestion` | `question: Annotated[str, Embeddable()]` | — | `InvestigationQuestion_question` |
217
+ | `Hypothesis` | `statement: Annotated[str, Embeddable(), Dedup()]` | `question_id: str`, `status: str="alive"`, `prior: float` | `Hypothesis_statement` |
218
+ | `Evidence` | `claim: Annotated[str, Embeddable(), Dedup()]` | `source_id: str`, `quote: str`, `stance: str` (`supports`/`refutes`), `asserted_at: str` (ISO date) | `Evidence_claim` (**the refutation entry point / vector prefilter target**) |
219
+ | `Conclusion` | `statement: Annotated[str, Embeddable()]` | `confidence: float`, `depends_on_ids: list[str]` | `Conclusion_statement` |
220
+
221
+ The collection name is `"{ClassName}_{embeddable_field}"` — auto-created on write. `Evidence_claim` is the collection the contradiction prefilter searches.
222
+
223
+ ### 2.2 Edge types (already listed §1.1)
224
+
225
+ `depends_on` (Conclusion→Evidence, `{critical:bool}`), `supports`/`refutes` (Evidence→Hypothesis, `{weight:float}`), `supersedes` (Evidence→Evidence, `{confidence:float}`).
226
+
227
+ ### 2.3 Node-level metadata / state fields (written via engine APIs, not model fields)
228
+
229
+ | Field | Written by | Meaning |
230
+ |---|---|---|
231
+ | `truth_alignment: list[str]` | `set_node_truth_state` | lifecycle state (§1.2). Recall filter key. |
232
+ | `truth_epoch: int` | `set_node_truth_state` | monotonically increasing revision epoch. |
233
+ | `feedback_weight: float` | `set_node_feedback_weights` | confidence/health (0.0 = dead, promoted hypotheses boosted). |
234
+ | `asserted_at` (model field on Evidence) | ingest | timestamp used for supersede tie-breaks (`newer = max(asserted_at)`). |
235
+ | `source_id` (model field) | ingest | provenance handle for the source document. |
236
+
237
+ ### 2.4 Dedup across sessions
238
+
239
+ `Hypothesis` and `Evidence` set `identity_fields` in `metadata` (e.g. `["question_id","statement"]`). DataPoint generates a stable identity id from these (`DataPoint.py:76-81`), so re-adding the same belief in a later session updates the existing node instead of duplicating it — essential for cross-session refutation to land on the right node.
240
+
241
+ ---
242
+
243
+ ## 3. User / Demo Flow
244
+
245
+ ### 3.1 End-to-end story (money shot)
246
+
247
+ > **Question:** *"Did Company X know about the defect before the recall?"*
248
+ >
249
+ > **Session 1** builds:
250
+ > - Hypothesis **A**: "X knew via QA report, Mar 2021" (supported by Evidence `E_qa`)
251
+ > - Hypothesis **B**: "X knew via supplier email, Jan 2021" (supported by Evidence `E_email`)
252
+ > - Hypothesis **C**: "X didn't know" (unsupported)
253
+ > - Conclusion **K**: "X knew by March 2021" — `depends_on(K → E_qa, critical=True)`
254
+ >
255
+ > Saved graph persists (survives restart).
256
+ >
257
+ > **Session 2** (reopened next day): analyst pastes ONE line — *"Forensic audit: the March QA report was back-dated."*
258
+ > 1. `Evidence_claim` vector prefilter finds `E_qa` (distance < 0.35).
259
+ > 2. LLM judge: `contradicts`, confidence 0.9.
260
+ > 3. `E_qa → refuted`; **forward BFS** over `depends_on` finds **K** (critical dep) → `K invalidated`.
261
+ > 4. **A** loses its only support → `A superseded`; **B** ignites as the new frontier (feedback_weight promoted) — in ~3s.
262
+ > 5. `forget_orphan_deadends`: K is orphaned (no alive consumer) → hard-deleted from graph + vector. `E_qa` kept as `refuted` provenance (target of new fact's `supersedes` edge).
263
+ > 6. **Scoreboard:** FALSIFY recall now answers via **B (Jan 2021)**; the **plain-RAG baseline still cites the back-dated March QA report.**
264
+
265
+ ### 3.2 Screen beat (≤30s, one screen)
266
+
267
+ Force-graph shows A/B/C + K. Analyst pastes the fact → **E_qa flashes red → K crosses out and vanishes → A dims (superseded) → B glows green as new frontier.** A live scoreboard panel: `FALSIFY: "B — Jan 2021 (supplier email)"` vs `Plain RAG: "March 2021 QA report"` — captioned *"AI revised, not forgot."*
268
+
269
+ ### 3.3 Cross-session proof
270
+
271
+ Restart the process (or run `main.py` a second time with `--session 2`). Because truth-state is on-node persisted, session 2's `recall()` never sees K or A-as-truth. Show the two recalls side by side.
272
+
273
+ ---
274
+
275
+ ## 4. Success Criteria
276
+
277
+ ### 4.1 Unit-test assertions (pytest)
278
+
279
+ ```
280
+ test_direct_refutation:
281
+ after propagate_refutation(new_fact contradicting E_qa):
282
+ assert get_node_truth_state([E_qa])[E_qa]["truth_alignment"] == ["refuted"]
283
+
284
+ test_forward_cascade_invalidates_conclusion:
285
+ assert get_node_truth_state([K])[K]["truth_alignment"] == ["invalidated"]
286
+
287
+ test_competing_hypothesis_promoted:
288
+ assert A.truth_alignment == ["superseded"]
289
+ assert "alive" in B.truth_alignment
290
+ assert feedback_weight(B) > feedback_weight(A)
291
+
292
+ test_orphan_forgotten_from_both_stores:
293
+ assert K.id NOT in (await ge has node) # gone from graph
294
+ assert K.id NOT in ve.search("Conclusion_statement", ...) # gone from vector
295
+
296
+ test_provenance_kept:
297
+ assert E_qa still exists with truth_alignment == ["refuted"] # NOT deleted (supersede anchor)
298
+
299
+ test_cross_session_recall_skips_dead:
300
+ recall("did X know?") context contains B, does NOT contain K or A-as-truth
301
+
302
+ test_baseline_still_stale:
303
+ RAG_COMPLETION / raw ve.search STILL returns the March QA claim # proves the differentiator
304
+ ```
305
+
306
+ ### 4.2 Expected `python main.py` output (judges, ~2 min)
307
+
308
+ ```
309
+ [FALSIFY] Session 1: built belief graph for "Did Company X know...?"
310
+ Hypotheses: A(QA Mar'21) B(email Jan'21) C(didn't know)
311
+ Conclusion K depends_on E_qa
312
+ [FALSIFY] Session 2: new fact -> "March QA report was back-dated"
313
+ contradiction: E_qa (judge=contradicts conf=0.90)
314
+ forward cascade: K invalidated
315
+ A superseded -> B ignites (new frontier)
316
+ forgot 1 orphan (K) from graph + vector
317
+ [SCOREBOARD]
318
+ FALSIFY recall : X knew by Jan 2021 (supplier email) [revised]
319
+ Plain-RAG : X knew by Mar 2021 (QA report) [STALE]
320
+ ```
321
+
322
+ Graceful exit if no API key: print a clear message + how to set `LLM_API_KEY`, exit code **0**.
323
+
324
+ ### 4.3 Edge cases (must be handled)
325
+
326
+ | Case | Required behavior |
327
+ |---|---|
328
+ | **Cycle** (A depends_on B depends_on A) | BFS tracks a `visited` set; never revisit. `supersedes` edges never traversed into already-superseded nodes. Termination guaranteed. |
329
+ | **Multiple contradictions** (new fact refutes 2 Evidence nodes) | Each refuted independently; union of their dependent Conclusions invalidated; single forget pass over the merged death set. |
330
+ | **Partial refutation** (Conclusion depends_on E_qa AND E_alt, both critical) | Conclusion invalidated only if a *critical* dep is refuted AND no alive critical alternative remains (`_has_alive_alternative`). If E_alt still alive & critical satisfied, Conclusion stays alive; E_qa refuted but **retained** (still feeds a live node → not orphaned). |
331
+ | **Diamond dependency** (K depends_on E1,E2; E1,E2 both depends-chain to refuted E0) | Deduplicate via visited set so K is invalidated once, not twice; forget counts each node once. Explicit unit test required. |
332
+ | **Non-critical dep refuted** | Conclusion's `confidence` decays but stays `alive` (only `critical:true` deps invalidate). |
333
+ | **Contradiction judge false-positive risk** | Two-gate (vector `<0.35` + LLM `≥0.6`) + `--demo` pin. |
334
+
335
+ ---
336
+
337
+ ## 5. Tech Stack & Module Layout
338
+
339
+ ### 5.1 Cognee APIs used (all §0-verified)
340
+
341
+ - Persistence: `cognee.remember(session_id=...)`, `cognee.recall(session_id=..., query_type=...)`.
342
+ - Enrichment pipeline: `cognee.memify(extraction_tasks=[Task(collect_belief_subgraph)], enrichment_tasks=[Task(propagate_refutation), Task(promote_competing_hypothesis), Task(forget_orphan_deadends), Task(add_data_points)], data=[{}], node_type=NodeSet, node_name=[question_id])`.
343
+ - Graph: `get_graph_engine()` → `get_neighborhood` (depth=4, edge_types=["depends_on","supports"]), `get_connections`, `set_node_truth_state`, `get_node_truth_state`, `set_node_feedback_weights`, `add_edge`, `delete_nodes`.
344
+ - Vector: `get_vector_engine()` → `search("Evidence_claim", ...)`, `delete_data_points`.
345
+ - LLM: `LLMGateway.acreate_structured_output(text_input, system_prompt, response_model=ContradictionJudgement)`.
346
+ - Storage: `add_data_points` task (`cognee/tasks/storage/add_data_points.py:31`) for dual graph+vector write of the new fact.
347
+ - Baseline: `SearchType.RAG_COMPLETION` (from `cognee/modules/search/types/SearchType.py`).
348
+
349
+ ### 5.2 Module layout (write under `/workspaces/hackathon-app/`)
350
+
351
+ ```
352
+ /workspaces/hackathon-app/
353
+ main.py # `python main.py` entry — runs seed + demo + scoreboard; graceful no-key exit 0
354
+ requirements.txt / pyproject # deps: cognee (editable ref) + minimal
355
+ .env.template # LLM_API_KEY, LLM_PROVIDER=openai, LLM_ENDPOINT (custom endpoint), LLM_MODEL, DEMO_MODE
356
+ memory_core/
357
+ __init__.py
358
+ models.py # DataPoint subclasses (§2.1) + ContradictionJudgement
359
+ edges.py # edge-name constants: DEPENDS_ON, SUPPORTS, REFUTES, SUPERSEDES
360
+ tasks.py # collect_belief_subgraph, propagate_refutation,
361
+ # promote_competing_hypothesis, forget_orphan_deadends
362
+ falsify.py # orchestration: build_graph(), revise(new_fact), scoreboard()
363
+ seed.py # demo corpus: Company-X recall investigation (A/B/C + K)
364
+ ui/
365
+ graph.html # react-force-graph (color by truth_state, red-flash-then-remove ripple ≤3s)
366
+ server.py # tiny static+JSON server feeding get_neighborhood snapshots
367
+ tests/
368
+ test_propagation.py # §4.1 assertions
369
+ test_edge_cases.py # cycle / diamond / partial / multi
370
+ ```
371
+
372
+ ### 5.3 Visualization approach
373
+
374
+ - **react-force-graph** (CDN, single `graph.html`) reads a JSON snapshot built from `ge.get_neighborhood([question_id], depth=4)`.
375
+ - Node color keyed on `truth_alignment`: alive=green, refuted=red, invalidated=grey-strikethrough, superseded=dim-amber. Forgotten nodes: **red-flash animation then removed** from the sim (≤3s ripple).
376
+ - Scoreboard panel overlays FALSIFY-vs-RAG answers. No external services — server is stdlib/`http.server` or FastAPI already in cognee.
377
+
378
+ ### 5.4 Config / runtime constraints
379
+
380
+ - OpenAI-compatible: honor `LLM_PROVIDER` (`openai` or `custom`), `LLM_ENDPOINT`, `LLM_MODEL`, `LLM_API_KEY`. Defaults: LanceDB (vector) + Ladybug (graph) + SQLite (relational) — **zero external services**.
381
+ - `DEMO_MODE=1` (or `--demo`) pins `REFUTED_ID` so the cascade+forget run on real APIs regardless of LLM flakiness.
382
+ - Never modify `/workspaces/cognee`. All writes under `/workspaces/hackathon-app/` (absolute paths).
383
+
384
+ ---
385
+
386
+ ## 6. Non-negotiables (contract invariants)
387
+
388
+ 1. Forward propagation uses `get_neighborhood` + `set_node_truth_state` (on-node, persistent). No in-memory-only state.
389
+ 2. Surgical forget deletes from **both** graph (`delete_nodes`) and vector (`delete_data_points`); provenance (`refuted`/`superseded`) nodes are retained.
390
+ 3. Cross-session persistence of disbelief is demonstrated (restart / session 2 recall skips dead branches).
391
+ 4. The A/B scoreboard (FALSIFY revised vs plain-RAG stale) is shown every run.
392
+ 5. Contradiction detection is two-gate (vector prefilter `<0.35` + LLM judge `≥0.6`) with a `--demo` deterministic override.
393
+ 6. `Task` imported from `cognee.modules.pipelines.tasks.task` (lowercase); `get_graph_engine` awaited, `get_vector_engine` not.
cognee-hackathon-project-main/.env.template ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # FALSIFY — environment configuration
3
+ # Copy this file to `.env` and fill in your key: cp .env.template .env
4
+ # ============================================================================
5
+
6
+ # ----------------------------------------------------------------------------
7
+ # LLM provider (OpenAI-compatible)
8
+ # ----------------------------------------------------------------------------
9
+ # FALSIFY works with OpenAI or ANY OpenAI-compatible endpoint (OpenRouter,
10
+ # vLLM, LM Studio, Azure, local proxies, ...). Pick ONE of the blocks below.
11
+
12
+ # --- Default: OpenAI ---------------------------------------------------------
13
+ LLM_PROVIDER="openai"
14
+ LLM_API_KEY="sk-ws-H.LIEMPD.DpZK.MEUCIA7GLanKjqNWA0UKOWsBVdGLaMhqZYh3BHYqMNL9Z_oGAiEA2u1dfrMN6KjNoVmBrQWrYwbfdlgRtKnRIfpRSym-z5M"
15
+ LLM_MODEL="openai/gpt-5-mini"
16
+ # LLM_ENDPOINT is not needed for the default OpenAI provider.
17
+
18
+ # --- Alternative: custom / OpenAI-compatible endpoint (e.g. OpenRouter) ------
19
+ # Uncomment this block and comment out the OpenAI block above to use it.
20
+ # LLM_PROVIDER="custom"
21
+ # LLM_API_KEY="your_api_key"
22
+ # LLM_MODEL="openrouter/google/gemini-2.0-flash-lite-preview-02-05:free"
23
+ # LLM_ENDPOINT="https://openrouter.ai/api/v1"
24
+
25
+ # --- Alternative: Alibaba DashScope (Qwen), OpenAI-compatible ----------------
26
+ # Note the "openai/" prefix on the model — litellm needs it to route a raw
27
+ # OpenAI-compatible endpoint. Verified working with FALSIFY.
28
+ # LLM_PROVIDER="custom"
29
+ # LLM_API_KEY="sk-..."
30
+ # LLM_MODEL="openai/qwen-plus"
31
+ # LLM_ENDPOINT="https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
32
+
33
+ # ----------------------------------------------------------------------------
34
+ # Embeddings — fastembed (local, CPU-only, zero-cost) is FALSIFY's default
35
+ # ----------------------------------------------------------------------------
36
+ # FALSIFY sets these automatically at import so the pipeline (and
37
+ # `python main.py --demo`) runs with NO external embedding key. This keeps your
38
+ # LLM key (above) LLM-only and avoids the classic "LLM provider != embedding
39
+ # provider" mismatch. Any explicit value here overrides the default.
40
+ EMBEDDING_PROVIDER="fastembed"
41
+ EMBEDDING_MODEL="BAAI/bge-small-en-v1.5"
42
+ EMBEDDING_DIMENSIONS="384"
43
+ EMBEDDING_MAX_TOKENS="512"
44
+ # To use OpenAI embeddings instead, set:
45
+ # EMBEDDING_PROVIDER="openai"
46
+ # EMBEDDING_MODEL="openai/text-embedding-3-large"
47
+ # EMBEDDING_API_KEY="sk-your-openai-key-here"
48
+
49
+ # ----------------------------------------------------------------------------
50
+ # Session memory / caching
51
+ # ----------------------------------------------------------------------------
52
+ # Cross-session belief persistence uses Cognee's session cache. Default backend
53
+ # is local SQLite — zero external services, survives process restarts.
54
+ # Backends: sqlite (default), postgres, redis, fs, tapes
55
+ CACHING="true"
56
+ CACHE_BACKEND="sqlite"
57
+ # Optional explicit SQLAlchemy URL (only for sqlite/postgres backends):
58
+ # CACHE_DB_URL="postgresql+asyncpg://cognee:cognee@localhost:5432/cognee_db"
59
+
60
+ # ----------------------------------------------------------------------------
61
+ # Databases (self-hosted defaults — no setup required)
62
+ # ----------------------------------------------------------------------------
63
+ # FALSIFY ships with fully local, zero-dependency stores:
64
+ # Vector -> LanceDB
65
+ # Graph -> Ladybug
66
+ # Relational -> SQLite
67
+ # These are the defaults; the lines below are shown only for clarity.
68
+ # VECTOR_DB_PROVIDER="lancedb"
69
+ # GRAPH_DATABASE_PROVIDER="ladybug"
70
+ # DB_PROVIDER="sqlite"
71
+
72
+ # ----------------------------------------------------------------------------
73
+ # FALSIFY demo behavior
74
+ # ----------------------------------------------------------------------------
75
+ # DEMO_MODE=1 (or `python main.py --demo`) pins the refuted evidence id so the
76
+ # refutation cascade + surgical forget run on real graph/vector APIs even if the
77
+ # LLM contradiction judge stalls or the key is rate-limited. Presentation safety net.
78
+ DEMO_MODE="0"
79
+
80
+ # ----------------------------------------------------------------------------
81
+ # Optional: quieter logs / telemetry off
82
+ # ----------------------------------------------------------------------------
83
+ # LITELLM_LOG="ERROR"
84
+ # TELEMETRY_DISABLED="1"
cognee-hackathon-project-main/.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ .venv/
5
+ output/
6
+ *.lance
7
+ .cognee_system/
8
+ .data_storage/
9
+ !output/falsify-demo.gif
cognee-hackathon-project-main/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arpit Kumar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
cognee-hackathon-project-main/README.md ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+ # FALSIFY
4
+
5
+ ### the AI that revises, not forgets
6
+
7
+ **Drop one contradicting fact. Watch dependent conclusions die, a losing hypothesis rise — permanently, across sessions.**
8
+
9
+ [![Track: Best Use of Open Source](https://img.shields.io/badge/Track-Best_Use_of_Open_Source-blue)](#-hackathon-track--theme)
10
+ [![Theme: Research & Knowledge Copilot](https://img.shields.io/badge/Theme-Research_&_Knowledge_Copilot-8A2BE2)](#-hackathon-track--theme)
11
+ [![Built on Cognee](https://img.shields.io/badge/Built_on-Cognee-00C48C)](https://github.com/topoteretes/cognee)
12
+ [![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-3776AB)](https://www.python.org/)
13
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow)](LICENSE)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## The Problem: AI Remembers the *Wrong* Fact
20
+
21
+ Every memory layer bolted onto an LLM today is an **append-only pile of facts**. It can remember. It cannot *un-believe*.
22
+
23
+ When new evidence contradicts something the AI already "knows," today's systems do one of two bad things:
24
+
25
+ - **RAG / vector memory** keeps citing the stale fact forever — it has no notion that a belief can *die*.
26
+ - **Naive "delete the memory"** throws away the fact *and* every conclusion built on top of it, with no record of *why* — a lobotomy, not a revision.
27
+
28
+ This is **belief-level amnesia**. The AI doesn't just forget *where* it put the context (this hackathon's theme) — it confidently remembers a fact that has since been proven false, and every downstream conclusion inherits the lie.
29
+
30
+ > A forensic audit reveals the "March QA report" was **back-dated**. A human analyst instantly revises: *"Then the March timeline is dead — the January supplier email is now our best evidence."* Today's AI memory keeps answering **"March 2021."**
31
+
32
+ ---
33
+
34
+ ## The Solution: A Living Belief Graph
35
+
36
+ FALSIFY treats a research inquiry not as a chat log but as a **living belief graph** of typed, stateful nodes — `Hypothesis`, `Evidence`, `Conclusion` — wired together by dependency edges.
37
+
38
+ When a new fact **contradicts** an existing piece of evidence, FALSIFY performs **belief revision** on the graph itself:
39
+
40
+ 1. **Refute** — the contradicted `Evidence` flips to `truth_state = REFUTED`.
41
+ 2. **Propagate forward** — the refutation cascades *along `depends_on` edges* to every `Conclusion` that critically rested on it → `INVALIDATED`.
42
+ 3. **Re-ignite** — the losing `Hypothesis` is demoted to `SUPERSEDED`; the strongest surviving rival is promoted as the new frontier.
43
+ 4. **Surgically forget** — orphaned dead-ends (no surviving consumer) are hard-deleted from **both** the graph and the vector store. Provenance nodes (the refuted fact, the superseding fact) are **kept** so the graph always explains *why* it changed.
44
+ 5. **Persist** — truth-state is written *on the node*, so the **next session's `recall()` skips the dead branches** — while a plain-RAG baseline still cites the stale fact.
45
+
46
+ The result is memory that **revises instead of forgets**: it changes its mind, keeps the receipts, and never loses the thread.
47
+
48
+ ---
49
+
50
+ ## Demo
51
+
52
+ <div align="center">
53
+
54
+ ![FALSIFY demo — a refutation cascade collapsing Hypothesis A and igniting Hypothesis B](output/falsify-demo.gif)
55
+
56
+ *A **live** run (the LLM judges the contradiction): drop one back-dating fact → `E_qa` turns **red (refuted)** → Conclusion **K is forgotten** (deleted from graph + vector) → Hypothesis **A** dims to **amber (superseded)** → Hypothesis **B ignites** as the new frontier. Caption: **"AI revised, not forgot."***
57
+
58
+ </div>
59
+
60
+ The money shot is the **scoreboard** printed on every run:
61
+
62
+ ```text
63
+ [SCOREBOARD]
64
+ FALSIFY recall : X knew by Jan 2021 (supplier email) [revised]
65
+ Plain-RAG : X knew by Mar 2021 (QA report) [STALE]
66
+ ```
67
+
68
+ Same underlying store. Same query. FALSIFY revised its belief; the baseline did not.
69
+
70
+ ---
71
+
72
+ ## Key Features
73
+
74
+ | | Feature | What it does |
75
+ |---|---|---|
76
+ | 🧠 | **Belief graph, not a fact pile** | Nodes are *stateful beliefs* (`alive` / `refuted` / `superseded` / `invalidated`), not immutable rows. |
77
+ | ⚡ | **Forward refutation propagation** | One contradiction cascades through `depends_on` edges and invalidates every dependent conclusion in ~3s. |
78
+ | 🎯 | **Two-gate contradiction detection** | Cheap deterministic vector prefilter (`cosine < 0.35`) → skeptical LLM adjudication (`confidence ≥ 0.6`). No hallucinated refutations. |
79
+ | ✂️ | **Surgical forget** | Orphaned dead-ends are hard-deleted from graph **and** vector; provenance is retained. Not a lobotomy — a revision. |
80
+ | 🔁 | **Cross-session persistence** | Disbelief lives on the node. Restart the process and `recall()` still skips the dead branches. |
81
+ | 📊 | **Live A/B scoreboard** | FALSIFY (revised) vs. plain-RAG (stale) side-by-side, every run — the differentiator made visible. |
82
+ | 🕸️ | **Force-graph visualization** | Nodes colored by truth-state; forgotten nodes red-flash then ripple out of the sim. |
83
+ | 🔌 | **Zero external services** | Self-hosted Cognee defaults — LanceDB (vector) + Ladybug (graph) + SQLite. OpenAI-compatible; bring any endpoint. |
84
+
85
+ ---
86
+
87
+ ## How It Works
88
+
89
+ FALSIFY drives Cognee's v1.0 memory API (`remember` / `recall` / `improve` / `forget`) plus a set of **custom `memify` tasks** that operate directly on the graph engine's truth-state.
90
+
91
+ ```mermaid
92
+ flowchart TD
93
+ subgraph S1["Session 1 — build the belief graph"]
94
+ Q["InvestigationQuestion<br/>Did Company X know before the recall?"]
95
+ HA["Hypothesis A<br/>knew via QA report, Mar 2021"]
96
+ HB["Hypothesis B<br/>knew via supplier email, Jan 2021"]
97
+ HC["Hypothesis C<br/>didn't know"]
98
+ Eqa["Evidence E_qa<br/>March QA report"]
99
+ Eem["Evidence E_email<br/>January supplier email"]
100
+ K["Conclusion K<br/>X knew by March 2021"]
101
+
102
+ Q --- HA & HB & HC
103
+ Eqa -- supports --> HA
104
+ Eem -- supports --> HB
105
+ K -- "depends_on (critical)" --> Eqa
106
+ end
107
+
108
+ NF["🆕 New fact (Session 2)<br/>Forensic audit: March QA report was back-dated"]
109
+
110
+ subgraph REV["Belief revision — custom memify tasks"]
111
+ direction TB
112
+ D["1. Detect contradiction<br/>vector prefilter < 0.35 → LLM judge ≥ 0.6"]
113
+ R["2. E_qa → REFUTED"]
114
+ P["3. Forward BFS on depends_on<br/>K → INVALIDATED"]
115
+ G["4. A → SUPERSEDED &nbsp;•&nbsp; B ignites (promoted)"]
116
+ F["5. Forget orphan K<br/>delete from graph + vector<br/>keep E_qa as refuted provenance"]
117
+ D --> R --> P --> G --> F
118
+ end
119
+
120
+ NF --> D
121
+ Eqa -.-> D
122
+
123
+ subgraph SCORE["Scoreboard"]
124
+ FA["FALSIFY recall → B (Jan 2021) ✅ revised"]
125
+ RA["Plain-RAG → March 2021 QA report ❌ stale"]
126
+ end
127
+
128
+ F --> FA
129
+ F --> RA
130
+ ```
131
+
132
+ **The mechanism in one paragraph:** a `Conclusion --depends_on--> Evidence` edge is the propagation rail. Refutation seeds at an `Evidence` node; a conclusion stays justified only if it has a **grounded** critical support chain that bottoms out in a still-alive node. FALSIFY computes this as a **least-fixpoint** over the dependency graph, so one formulation correctly handles chains, **diamonds** (a conclusion survives while any critical alternative is grounded), non-critical dependencies, **and cycles** (a self-supporting loop with no grounded base collapses — and the fixpoint always terminates). Hypotheses are re-scored via their `supports` edges. Truth-state (`truth_alignment` + `truth_epoch`) is written on-node via `set_node_truth_state`, so it survives a restart and `recall()` filters on it. See [`falsify/tasks/propagate_refutation.py`](falsify/tasks/propagate_refutation.py) and [REQUIREMENTS.md](REQUIREMENTS.md) for the full algorithm, edge vocabulary, and edge-case handling.
133
+
134
+ ---
135
+
136
+ ## Install & Setup
137
+
138
+ ### Prerequisites
139
+ - Python **3.10 – 3.14**
140
+ - An OpenAI **or any OpenAI-compatible** API key (OpenRouter, vLLM, LM Studio, Azure, …)
141
+
142
+ ### 1. Clone & create an environment
143
+
144
+ ```bash
145
+ git clone https://github.com/ArpitKumar8649/cognee-hackathon-project.git
146
+ cd cognee-hackathon-project
147
+
148
+ # uv (recommended)
149
+ uv venv && source .venv/bin/activate
150
+ uv pip install -r requirements.txt
151
+
152
+ # …or plain pip
153
+ python -m venv .venv && source .venv/bin/activate
154
+ pip install -r requirements.txt
155
+ ```
156
+
157
+ ### 2. Configure your key
158
+
159
+ ```bash
160
+ cp .env.template .env
161
+ # then edit .env and set LLM_API_KEY
162
+ ```
163
+
164
+ Minimal `.env` (OpenAI):
165
+
166
+ ```bash
167
+ LLM_PROVIDER="openai"
168
+ LLM_API_KEY="sk-..."
169
+ LLM_MODEL="openai/gpt-5-mini"
170
+ ```
171
+
172
+ Any OpenAI-compatible endpoint (OpenRouter shown):
173
+
174
+ ```bash
175
+ LLM_PROVIDER="custom"
176
+ LLM_API_KEY="your_api_key"
177
+ LLM_MODEL="openrouter/google/gemini-2.0-flash-lite-preview-02-05:free"
178
+ LLM_ENDPOINT="https://openrouter.ai/api/v1"
179
+ ```
180
+
181
+ > **Heads-up:** if you configure *only* the LLM or *only* embeddings, Cognee defaults the other to OpenAI. Either configure both or keep a valid OpenAI key handy. All databases default to **local, self-hosted** stores — no external services required.
182
+
183
+ ---
184
+
185
+ ## Usage
186
+
187
+ ```bash
188
+ # Full run: build the belief graph, drop the contradicting fact,
189
+ # print the FALSIFY-vs-RAG scoreboard. Judges start here (~2 min).
190
+ python main.py
191
+
192
+ # Deterministic demo mode — pins the refuted evidence id so the
193
+ # cascade + forget run on real graph/vector APIs even if the LLM
194
+ # judge is flaky. This is the presentation safety net.
195
+ python main.py --demo
196
+
197
+ # Run the test suite — 11 tests, NO API key required
198
+ # (FakeGraph + mocked LLM): propagation, diamond, cycle-safety,
199
+ # surgical forget, and the two detector gates.
200
+ pytest -q
201
+ pytest -q tests/test_falsify.py # core belief-revision cascade
202
+ pytest -q tests/test_detect.py # two-gate contradiction detector
203
+ ```
204
+
205
+ **No API key?** `main.py` prints a clear message explaining how to set `LLM_API_KEY` and exits with code **0** — it never crashes in front of a judge.
206
+
207
+ ### Cross-session persistence
208
+
209
+ Truth-state is written *on the graph node*, so it survives a process restart. `main.py` re-reads the belief state fresh at the end of the run to prove the refuted branch never comes back. Run `python main.py --keep` to build on top of existing memory instead of pruning first.
210
+
211
+ ### Visualization
212
+
213
+ Every run writes a self-contained interactive graph to **`output/graph.html`** — just open it in a browser (no server needed). Nodes are colored by truth-state: **alive = green**, **refuted = red (dashed)**, **invalidated = grey**, **superseded = amber**.
214
+
215
+ ---
216
+
217
+ ## Architecture
218
+
219
+ Full design — verified Cognee API surface, node/edge vocabulary, the exact forward-propagation algorithm, truth-state lifecycle, and every handled edge case — lives in the build contract:
220
+
221
+ - **[REQUIREMENTS.md](REQUIREMENTS.md)** — grep-verified Cognee API references, truth-state lifecycle, and edge-case matrix.
222
+
223
+ ```text
224
+ main.py # entry point — seed + revise + scoreboard; graceful no-key exit 0
225
+ falsify/
226
+ models.py # DataPoint subclasses (Hypothesis/Evidence/Conclusion) + TruthState
227
+ edges.py # edge-name constants: DEPENDS_ON, SUPPORTS, REFUTES, SUPERSEDES
228
+ graph_ops.py # verified wrapper over Cognee's graph + vector engines
229
+ seed.py # demo corpus: the Company-X recall investigation
230
+ tasks/
231
+ detect_contradictions.py # two-gate detector (vector prefilter + skeptical-LLM judge)
232
+ propagate_refutation.py # grounded-fixpoint refutation cascade + hypothesis promotion
233
+ cascade_forget.py # surgical orphan delete (graph + vector), keeps provenance
234
+ falsify.py # orchestration: build_graph(), revise(new_fact), scoreboard()
235
+ utils.py # interactive HTML viz + BEFORE/AFTER console state
236
+ tests/
237
+ test_falsify.py # cascade / diamond / cycle-safety / surgical forget / promote
238
+ test_detect.py # detector: pinned demo path + Gate-1 filter + Gate-2 thresholds
239
+ conftest.py # FakeGraph fixture — key-free, DB-free in-memory engine stand-in
240
+ ```
241
+
242
+ ---
243
+
244
+ ## How FALSIFY Maps to the Judging Criteria
245
+
246
+ | # | Criterion | How FALSIFY nails it |
247
+ |---|---|---|
248
+ | 1 | **Potential Impact** | Solves *belief-level amnesia* — AI confidently remembering facts that have been proven false. Every research, legal, medical, or intelligence copilot needs memory that can be *revised*, not just appended. |
249
+ | 2 | **Creativity / Originality** | Reframes graph nodes as **stateful beliefs** (alive / refuted / superseded / invalidated) and treats "changing your mind" as a first-class graph operation — not chat history, not RAG. |
250
+ | 3 | **Technical Excellence** | Custom `memify` extraction + enrichment tasks; deterministic-first **two-gate** contradiction detection; forward BFS propagation with cycle/diamond-safe `visited` sets; dual-store surgical delete; on-node persistent truth-state. |
251
+ | 4 | **Best Use of Cognee** | Drives the v1.0 memory API end-to-end — `remember(session_id)` → `recall()` → `improve()` → surgical `forget()` — plus custom `memify` tasks operating directly on `set_node_truth_state` / `get_neighborhood` / `delete_nodes`. |
252
+ | 5 | **UX / Presentation** | One-screen, 30-second beat: paste one fact → watch A collapse and B ignite → read the FALSIFY-vs-RAG scoreboard. Force-graph colored by belief state. |
253
+ | 6 | **Documentation & Reproducibility** | `python main.py` runs in ~2 min with zero external services; graceful no-key exit; full README + REQUIREMENTS + 11 key-free tests + demo script. |
254
+
255
+ ---
256
+
257
+ ## Hackathon Track & Theme
258
+
259
+ - **Event:** *The Hangover Part AI: Where's My Context?*
260
+ - **Track:** 🏆 **Best Use of Open Source** — built entirely on open-source Cognee with self-hosted, zero-dependency defaults (LanceDB + Ladybug + SQLite).
261
+ - **Category / Theme:** 🔬 **Research & Knowledge Copilot** — a research assistant whose memory revises its beliefs as new evidence arrives.
262
+
263
+ The theme asks *"Where's my context?"* FALSIFY's answer: the context isn't lost — it was **wrong**, and the AI should *revise* it, not blindly recall it.
264
+
265
+ ---
266
+
267
+ ## Future Work
268
+
269
+ - **Confidence-weighted partial refutation** — decay a Conclusion's confidence continuously instead of a binary alive/invalidated flip.
270
+ - **Multi-hop evidence provenance UI** — click any node to trace the full chain of *why it lives or died*.
271
+ - **Automated evidence ingestion** — stream documents in and let the two-gate detector surface contradictions proactively.
272
+ - **Human-in-the-loop review** — queue borderline LLM verdicts (0.4–0.6 confidence) for analyst confirmation before cascading.
273
+ - **Belief-diff export** — a git-style diff of the belief graph between any two epochs.
274
+ - **Neo4j / Postgres backends** — swap the graph engine for a distributed store with no code change (Cognee adapter interface).
275
+
276
+ ---
277
+
278
+ ## Acknowledgments
279
+
280
+ - **[Cognee](https://github.com/topoteretes/cognee)** — the open-source AI memory platform FALSIFY is built on. Its truth-state graph APIs, custom `memify` pipeline, and self-hosted defaults made belief revision possible without a single external service.
281
+ - **[WeMakeDevs](https://wemakedevs.org/)** — for hosting *The Hangover Part AI* hackathon and championing open-source builders.
282
+
283
+ ---
284
+
285
+ <div align="center">
286
+
287
+ **FALSIFY — the AI that revises, not forgets.**
288
+
289
+ </div>
cognee-hackathon-project-main/REQUIREMENTS.md ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FALSIFY — Requirements & Build Contract
2
+
3
+ > **Tagline:** Drop one contradicting fact; watch dependent conclusions die and a losing hypothesis rise — permanently, across sessions.
4
+
5
+ FALSIFY is a belief-revision research copilot. It treats a research inquiry as a **living belief graph** (not a chat log). When new evidence contradicts an existing evidence node, FALSIFY performs **belief revision**: it flips the contradicted evidence to `refuted`, propagates the refutation **forward** through the dependency chain, marks every dependent conclusion invalid, promotes the best surviving hypothesis, and **surgically forgets** the orphaned dead-ends. The refutation is written to graph node truth-state so the **next session's `recall()` skips killed branches** — while a plain-RAG baseline still cites the stale fact.
6
+
7
+ This document is the **contract** for all downstream build agents. Every API named here has been verified against the read-only reference at `/workspaces/cognee`. Line references are cited where load-bearing.
8
+
9
+ ---
10
+
11
+ ## 0. Verified Cognee API Surface (grep-confirmed)
12
+
13
+ | API | Exact import / call | Location (reference, read-only) |
14
+ |---|---|---|
15
+ | `Task` | `from cognee.modules.pipelines.tasks.task import Task` **(lowercase module `task`)** | `cognee/modules/pipelines/tasks/task.py` |
16
+ | `memify` | `import cognee; await cognee.memify(extraction_tasks=[...], enrichment_tasks=[...], data=..., node_type=NodeSet, node_name=[...])` | `cognee/modules/memify/memify.py:26` |
17
+ | `remember` | `await cognee.remember(data, dataset_name=..., session_id=...)` | `cognee/api/v1/remember/remember.py:623` |
18
+ | `recall` | `await cognee.recall(query_text, query_type=..., session_id=..., top_k=...)` | `cognee/api/v1/recall/recall.py:361` |
19
+ | `improve` | `await cognee.improve(...)` | `cognee/api/v1/improve/improve.py:36` |
20
+ | `forget` | `await cognee.forget(...)` | `cognee/api/v1/forget/forget.py:16` |
21
+ | `get_graph_engine` | `from cognee.infrastructure.databases.graph import get_graph_engine` → `ge = await get_graph_engine()` **(async)** | `cognee/infrastructure/databases/graph/get_graph_engine.py:110` |
22
+ | `get_vector_engine` | `from cognee.infrastructure.databases.vector import get_vector_engine` → `ve = get_vector_engine()` **(sync)** | `cognee/infrastructure/databases/vector/get_vector_engine.py:44` |
23
+ | `DataPoint`, `Embeddable`, `Dedup`, `LLMContext` | `from cognee.infrastructure.engine import DataPoint, Embeddable, LLMContext, Dedup` | `cognee/infrastructure/engine/models/DataPoint.py:27` |
24
+ | `LLMGateway` | `from cognee.infrastructure.llm.LLMGateway import LLMGateway` | `cognee/infrastructure/llm/LLMGateway.py:52` |
25
+
26
+ ### Graph engine methods (verified in `graph_db_interface.py` + `ladybug/adapter.py`)
27
+
28
+ ```python
29
+ ge = await get_graph_engine()
30
+
31
+ # Forward traversal — THE propagation rail. Returns (nodes, edges).
32
+ nodes, edges = await ge.get_neighborhood(node_ids: List[str], depth: int = 1, edge_types: Optional[List[str]] = None)
33
+ # graph_db_interface.py:303 ; ladybug/adapter.py:2068
34
+
35
+ # Immediate neighbors of ONE node. Returns List[Tuple[source_props, edge_props, target_props]].
36
+ conns = await ge.get_connections(node_id: Union[str, UUID])
37
+ # graph_db_interface.py:289 ; ladybug/adapter.py:1859
38
+
39
+ # Truth-state: the alive/dead flag recall() filters on. PERSISTS across restart.
40
+ await ge.set_node_truth_state({node_id: {"truth_alignment": ["refuted"], "truth_epoch": N}})
41
+ state = await ge.get_node_truth_state(node_ids: List[str])
42
+ # graph_db_interface.py:357,364 ; ladybug impl 1687,1712 ; writes props "truth_alignment" (list) + "truth_epoch" (int) — adapter.py:1576-1578
43
+
44
+ # Confidence / health signal.
45
+ await ge.set_node_feedback_weights({node_id: 0.9}) # graph_db_interface.py:348
46
+ w = await ge.get_node_feedback_weights(node_ids) # graph_db_interface.py:341
47
+
48
+ # Edges.
49
+ await ge.add_edge(from_node: str, to_node: str, relationship_name: str, edge_properties: Dict = {}) # ladybug/adapter.py:1222
50
+
51
+ # Surgical delete.
52
+ await ge.delete_nodes(node_ids: List[str]) # graph_db_interface.py:102 ; ladybug 1042
53
+ ```
54
+
55
+ ### Vector engine methods (verified in `LanceDBAdapter.py`)
56
+
57
+ ```python
58
+ ve = get_vector_engine()
59
+ hits = await ve.search(collection_name: str, query_text: str = None, limit: int = 15, include_payload: bool = False, ...)
60
+ # LanceDBAdapter.py:992 — each hit exposes `.id`, `.score` (COSINE DISTANCE, lower = closer), `.payload`
61
+ await ve.delete_data_points(collection_name: str, data_point_ids: List[UUID])
62
+ # vector_db_interface.py:160 ; LanceDBAdapter.py:1106
63
+ ```
64
+
65
+ ### LLMGateway (verified `LLMGateway.py:59`)
66
+
67
+ ```python
68
+ # STATIC method — do NOT instantiate. Returns a coroutine; await it.
69
+ result = await LLMGateway.acreate_structured_output(
70
+ text_input="A(...): <claim>\nB(...): <claim>",
71
+ system_prompt="skeptical analyst ...",
72
+ response_model=ContradictionJudgement, # a pydantic BaseModel
73
+ )
74
+ ```
75
+
76
+ ### DataPoint subclass pattern (verified `Tool.py`)
77
+
78
+ ```python
79
+ from typing import Annotated
80
+ from cognee.infrastructure.engine import DataPoint, Embeddable, Dedup
81
+
82
+ class Hypothesis(DataPoint):
83
+ statement: Annotated[str, Embeddable(), Dedup()]
84
+ question_id: str
85
+ # identity_fields in metadata dedups nodes across sessions (see §2.4)
86
+ metadata: dict = {"index_fields": ["statement"], "identity_fields": ["question_id", "statement"]}
87
+ ```
88
+
89
+ > **NOTE for implementers:** The FALSIFY spec screenshot wrote `Task(...)` imported from `pipelines.tasks.Task`. The **correct verified path is lowercase** `cognee.modules.pipelines.tasks.task` (class name `Task`). Use that. `get_graph_engine` is `await`-ed; `get_vector_engine` is **not**.
90
+
91
+ ---
92
+
93
+ ## 1. Core Mechanism
94
+
95
+ ### 1.1 Node & edge vocabulary (see §2 for full model)
96
+
97
+ - Nodes: `InvestigationQuestion` (root), `Hypothesis`, `Evidence`, `Conclusion`.
98
+ - Edges (relationship_name string on `add_edge`):
99
+ - `depends_on` : **Conclusion → Evidence** — *THE forward-propagation rail.* A Conclusion `depends_on` the Evidence it rests on. `edge_properties = {"critical": bool}`.
100
+ - `supports` : **Evidence → Hypothesis** — evidence corroborates a hypothesis. `edge_properties = {"weight": float}`.
101
+ - `refutes` : **Evidence → Hypothesis** — evidence contradicts a hypothesis. `edge_properties = {"weight": float}`.
102
+ - `supersedes` : **Evidence(new) → Evidence(old)** — written when the new fact back-dates / overrides an old evidence node. `edge_properties = {"confidence": float}`.
103
+
104
+ ### 1.2 Truth-state lifecycle
105
+
106
+ `truth_alignment` is a list written to the node via `set_node_truth_state`. FALSIFY uses these canonical single-element states:
107
+
108
+ ```
109
+ alive — default; node participates in recall context
110
+ refuted — Evidence directly contradicted by a newer fact (entry point of a cascade)
111
+ superseded — Evidence/Hypothesis replaced by a newer competing node (still exists, demoted)
112
+ invalidated — Conclusion whose supporting Evidence chain was refuted (forward-cascade victim)
113
+ forgotten — orphaned dead-end scheduled for surgical delete (transient; node then removed)
114
+ ```
115
+
116
+ Transition rules:
117
+
118
+ ```
119
+ alive --(new fact contradicts this Evidence, LLM-confirmed)--> refuted
120
+ alive --(a competing Evidence supersedes it)---------------> superseded
121
+ Conclusion.alive --(any critical depends_on Evidence is refuted)--> invalidated
122
+ {refuted|invalidated} node with NO surviving alive dependent/consumer --> forgotten --> delete_nodes + delete_data_points
123
+ Hypothesis.alive --(its only supporting Evidence became refuted AND a rival Hypothesis has surviving support)--> superseded
124
+ losing Hypothesis' rival --(gains the strongest surviving support)--> stays alive, feedback_weight promoted
125
+ ```
126
+
127
+ Every state write also bumps `truth_epoch` to a monotonically increasing integer (epoch = the memify run counter). Recall filters on `truth_alignment` containing `"alive"`.
128
+
129
+ ### 1.3 Contradiction detection (two-gate, deterministic-first)
130
+
131
+ The detector runs inside the `propagate_refutation` extraction/enrichment task. **Two gates** to kill nondeterminism:
132
+
133
+ 1. **Vector prefilter (cheap, deterministic).** `hits = await ve.search("Evidence_claim", query_text=NEW_FACT, limit=5, include_payload=True)`. Candidates = hits with **cosine distance `score < 0.35`** (topically related — same subject). This narrows the LLM to plausibly-conflicting evidence only. Contradictory claims sometimes embed far apart, so claims MUST be normalized to `subject + predicate` phrasing at ingest, and `limit` kept ≥5.
134
+ 2. **LLM adjudication (semantic).** For each candidate, call `LLMGateway.acreate_structured_output` with a **skeptical-analyst** system prompt and `response_model=ContradictionJudgement` (below). Only a verdict of `contradicts` (or `supersedes`) with `confidence ≥ 0.6` triggers refutation. This distinguishes *genuine contradiction* ("report was back-dated") from *topical overlap* ("also mentions the report").
135
+
136
+ ```python
137
+ class ContradictionJudgement(BaseModel):
138
+ relation: Literal["contradicts", "supersedes", "supports", "unrelated"]
139
+ confidence: float # 0..1
140
+ rationale: str
141
+ ```
142
+
143
+ **`--demo` / `DEMO_MODE` override:** when set, the seeded contradiction's target evidence id is pinned (`REFUTED_ID` env / seed constant), so the cascade runs on real graph APIs even if the LLM judge stalls or the key is flaky. This is the non-negotiable demo safety net.
144
+
145
+ ### 1.4 Forward refutation propagation (exact algorithm)
146
+
147
+ Entry: an Evidence node `E` confirmed `refuted` (§1.3).
148
+
149
+ ```
150
+ 1. set_node_truth_state({E.id: {"truth_alignment": ["refuted"], "truth_epoch": epoch}})
151
+ set_node_feedback_weights({E.id: 0.0})
152
+
153
+ 2. FORWARD CLOSURE — reverse-BFS along depends_on (Conclusion --depends_on--> Evidence).
154
+ Seeds = [E.id]. Traverse edges of type ["depends_on", "supersedes"] via:
155
+ nodes, edges = await ge.get_neighborhood([E.id], depth=4, edge_types=["depends_on", "supports"])
156
+ Collect every Conclusion C where a `depends_on` edge points from C into the refuted set
157
+ (transitively, up to depth 4). Because depends_on is Conclusion→Evidence, the "dependents"
158
+ are the SOURCES of those edges. A Conclusion is invalidated iff at least one of its
159
+ `critical: true` depends_on edges targets a refuted/invalidated node.
160
+
161
+ 3. For each invalidated Conclusion C:
162
+ set_node_truth_state({C.id: {"truth_alignment": ["invalidated"], "truth_epoch": epoch}})
163
+ set_node_feedback_weights({C.id: 0.0})
164
+
165
+ 4. PROMOTE competing hypothesis (promote_competing_hypothesis task):
166
+ - Find the Hypothesis H_dead whose only supporting Evidence is now refuted.
167
+ set_node_truth_state({H_dead.id: {"truth_alignment": ["superseded"], "truth_epoch": epoch}})
168
+ - Among rival Hypotheses still holding ≥1 alive `supports` Evidence, pick the one with the
169
+ highest summed support weight (read edge {"weight"}). Promote it:
170
+ set_node_feedback_weights({H_win.id: <boosted>}) # stays alive; becomes new frontier
171
+
172
+ 5. RECORD new fact + supersedes edge (add_data_points dual-write):
173
+ - Materialize NEW_FACT as an Evidence DataPoint, add via add_data_points (writes graph + vector).
174
+ - add_edge(new_evidence.id, E.id, "supersedes", {"confidence": verdict.confidence})
175
+ ```
176
+
177
+ **Direction summary:** refutation flows *from Evidence up to the Conclusions that depend on it* by walking `depends_on` edges backward (Conclusion is the edge source). Hypotheses are re-scored via their `supports`/`refutes` edges. No forward walk ever crosses a `supersedes` edge into an already-superseded node (prevents loops — see §4).
178
+
179
+ ### 1.5 forget() orphan conditions (surgical delete)
180
+
181
+ `forget_orphan_deadends()` runs after propagation and grows a **death set** by traversal:
182
+
183
+ ```
184
+ A node is FORGOTTEN (hard-deleted from graph + vector) iff ALL hold:
185
+ (a) its truth_alignment is refuted OR invalidated (never alive/superseded — superseded nodes
186
+ are kept as provenance), AND
187
+ (b) it has NO surviving consumer: no alive node reaches it via depends_on/supports
188
+ (checked with get_connections — _has_alive_alternative == False), AND
189
+ (c) it is not itself the target of a supersedes edge FROM an alive node (that node is the
190
+ provenance anchor of the new truth and must be retained).
191
+
192
+ Nodes that STILL feed a live node are retained even if refuted (partial refutation, §4).
193
+ ```
194
+
195
+ Delete implementation (one shot per node batch):
196
+ ```python
197
+ await ge.delete_nodes([str(id) for id in death_set])
198
+ await ve.delete_data_points("Evidence_claim", [evidence_ids_in_death_set])
199
+ await ve.delete_data_points("Conclusion_statement", [conclusion_ids_in_death_set])
200
+ ```
201
+
202
+ > Provenance is kept: only *truly orphaned* dead-ends are hard-deleted. `refuted`/`superseded` nodes that still explain *why* the graph changed remain, carrying their state flag.
203
+
204
+ ### 1.6 Cross-session persistence (the proof)
205
+
206
+ Truth-state is stored **on the node** in the graph DB (Ladybug/SQLite-backed), so it survives process restart. Session 2's `recall()` reads only `truth_alignment == alive` context. A parallel **plain-RAG baseline** (`recall(query_type=SearchType.RAG_COMPLETION)` or direct `ve.search`) does NOT read truth-state and re-cites the deleted/stale fact — this A/B is the scoreboard.
207
+
208
+ ---
209
+
210
+ ## 2. Data Model
211
+
212
+ ### 2.1 Node DataPoint subclasses (`memory_core/models.py`)
213
+
214
+ | Class | Embeddable field | Other fields | Collection (auto) |
215
+ |---|---|---|---|
216
+ | `InvestigationQuestion` | `question: Annotated[str, Embeddable()]` | — | `InvestigationQuestion_question` |
217
+ | `Hypothesis` | `statement: Annotated[str, Embeddable(), Dedup()]` | `question_id: str`, `status: str="alive"`, `prior: float` | `Hypothesis_statement` |
218
+ | `Evidence` | `claim: Annotated[str, Embeddable(), Dedup()]` | `source_id: str`, `quote: str`, `stance: str` (`supports`/`refutes`), `asserted_at: str` (ISO date) | `Evidence_claim` (**the refutation entry point / vector prefilter target**) |
219
+ | `Conclusion` | `statement: Annotated[str, Embeddable()]` | `confidence: float`, `depends_on_ids: list[str]` | `Conclusion_statement` |
220
+
221
+ The collection name is `"{ClassName}_{embeddable_field}"` — auto-created on write. `Evidence_claim` is the collection the contradiction prefilter searches.
222
+
223
+ ### 2.2 Edge types (already listed §1.1)
224
+
225
+ `depends_on` (Conclusion→Evidence, `{critical:bool}`), `supports`/`refutes` (Evidence→Hypothesis, `{weight:float}`), `supersedes` (Evidence→Evidence, `{confidence:float}`).
226
+
227
+ ### 2.3 Node-level metadata / state fields (written via engine APIs, not model fields)
228
+
229
+ | Field | Written by | Meaning |
230
+ |---|---|---|
231
+ | `truth_alignment: list[str]` | `set_node_truth_state` | lifecycle state (§1.2). Recall filter key. |
232
+ | `truth_epoch: int` | `set_node_truth_state` | monotonically increasing revision epoch. |
233
+ | `feedback_weight: float` | `set_node_feedback_weights` | confidence/health (0.0 = dead, promoted hypotheses boosted). |
234
+ | `asserted_at` (model field on Evidence) | ingest | timestamp used for supersede tie-breaks (`newer = max(asserted_at)`). |
235
+ | `source_id` (model field) | ingest | provenance handle for the source document. |
236
+
237
+ ### 2.4 Dedup across sessions
238
+
239
+ `Hypothesis` and `Evidence` set `identity_fields` in `metadata` (e.g. `["question_id","statement"]`). DataPoint generates a stable identity id from these (`DataPoint.py:76-81`), so re-adding the same belief in a later session updates the existing node instead of duplicating it — essential for cross-session refutation to land on the right node.
240
+
241
+ ---
242
+
243
+ ## 3. User / Demo Flow
244
+
245
+ ### 3.1 End-to-end story (money shot)
246
+
247
+ > **Question:** *"Did Company X know about the defect before the recall?"*
248
+ >
249
+ > **Session 1** builds:
250
+ > - Hypothesis **A**: "X knew via QA report, Mar 2021" (supported by Evidence `E_qa`)
251
+ > - Hypothesis **B**: "X knew via supplier email, Jan 2021" (supported by Evidence `E_email`)
252
+ > - Hypothesis **C**: "X didn't know" (unsupported)
253
+ > - Conclusion **K**: "X knew by March 2021" — `depends_on(K → E_qa, critical=True)`
254
+ >
255
+ > Saved graph persists (survives restart).
256
+ >
257
+ > **Session 2** (reopened next day): analyst pastes ONE line — *"Forensic audit: the March QA report was back-dated."*
258
+ > 1. `Evidence_claim` vector prefilter finds `E_qa` (distance < 0.35).
259
+ > 2. LLM judge: `contradicts`, confidence 0.9.
260
+ > 3. `E_qa → refuted`; **forward BFS** over `depends_on` finds **K** (critical dep) → `K invalidated`.
261
+ > 4. **A** loses its only support → `A superseded`; **B** ignites as the new frontier (feedback_weight promoted) — in ~3s.
262
+ > 5. `forget_orphan_deadends`: K is orphaned (no alive consumer) → hard-deleted from graph + vector. `E_qa` kept as `refuted` provenance (target of new fact's `supersedes` edge).
263
+ > 6. **Scoreboard:** FALSIFY recall now answers via **B (Jan 2021)**; the **plain-RAG baseline still cites the back-dated March QA report.**
264
+
265
+ ### 3.2 Screen beat (≤30s, one screen)
266
+
267
+ Force-graph shows A/B/C + K. Analyst pastes the fact → **E_qa flashes red → K crosses out and vanishes → A dims (superseded) → B glows green as new frontier.** A live scoreboard panel: `FALSIFY: "B — Jan 2021 (supplier email)"` vs `Plain RAG: "March 2021 QA report"` — captioned *"AI revised, not forgot."*
268
+
269
+ ### 3.3 Cross-session proof
270
+
271
+ Restart the process (or run `main.py` a second time with `--session 2`). Because truth-state is on-node persisted, session 2's `recall()` never sees K or A-as-truth. Show the two recalls side by side.
272
+
273
+ ---
274
+
275
+ ## 4. Success Criteria
276
+
277
+ ### 4.1 Unit-test assertions (pytest)
278
+
279
+ ```
280
+ test_direct_refutation:
281
+ after propagate_refutation(new_fact contradicting E_qa):
282
+ assert get_node_truth_state([E_qa])[E_qa]["truth_alignment"] == ["refuted"]
283
+
284
+ test_forward_cascade_invalidates_conclusion:
285
+ assert get_node_truth_state([K])[K]["truth_alignment"] == ["invalidated"]
286
+
287
+ test_competing_hypothesis_promoted:
288
+ assert A.truth_alignment == ["superseded"]
289
+ assert "alive" in B.truth_alignment
290
+ assert feedback_weight(B) > feedback_weight(A)
291
+
292
+ test_orphan_forgotten_from_both_stores:
293
+ assert K.id NOT in (await ge has node) # gone from graph
294
+ assert K.id NOT in ve.search("Conclusion_statement", ...) # gone from vector
295
+
296
+ test_provenance_kept:
297
+ assert E_qa still exists with truth_alignment == ["refuted"] # NOT deleted (supersede anchor)
298
+
299
+ test_cross_session_recall_skips_dead:
300
+ recall("did X know?") context contains B, does NOT contain K or A-as-truth
301
+
302
+ test_baseline_still_stale:
303
+ RAG_COMPLETION / raw ve.search STILL returns the March QA claim # proves the differentiator
304
+ ```
305
+
306
+ ### 4.2 Expected `python main.py` output (judges, ~2 min)
307
+
308
+ ```
309
+ [FALSIFY] Session 1: built belief graph for "Did Company X know...?"
310
+ Hypotheses: A(QA Mar'21) B(email Jan'21) C(didn't know)
311
+ Conclusion K depends_on E_qa
312
+ [FALSIFY] Session 2: new fact -> "March QA report was back-dated"
313
+ contradiction: E_qa (judge=contradicts conf=0.90)
314
+ forward cascade: K invalidated
315
+ A superseded -> B ignites (new frontier)
316
+ forgot 1 orphan (K) from graph + vector
317
+ [SCOREBOARD]
318
+ FALSIFY recall : X knew by Jan 2021 (supplier email) [revised]
319
+ Plain-RAG : X knew by Mar 2021 (QA report) [STALE]
320
+ ```
321
+
322
+ Graceful exit if no API key: print a clear message + how to set `LLM_API_KEY`, exit code **0**.
323
+
324
+ ### 4.3 Edge cases (must be handled)
325
+
326
+ | Case | Required behavior |
327
+ |---|---|
328
+ | **Cycle** (A depends_on B depends_on A) | BFS tracks a `visited` set; never revisit. `supersedes` edges never traversed into already-superseded nodes. Termination guaranteed. |
329
+ | **Multiple contradictions** (new fact refutes 2 Evidence nodes) | Each refuted independently; union of their dependent Conclusions invalidated; single forget pass over the merged death set. |
330
+ | **Partial refutation** (Conclusion depends_on E_qa AND E_alt, both critical) | Conclusion invalidated only if a *critical* dep is refuted AND no alive critical alternative remains (`_has_alive_alternative`). If E_alt still alive & critical satisfied, Conclusion stays alive; E_qa refuted but **retained** (still feeds a live node → not orphaned). |
331
+ | **Diamond dependency** (K depends_on E1,E2; E1,E2 both depends-chain to refuted E0) | Deduplicate via visited set so K is invalidated once, not twice; forget counts each node once. Explicit unit test required. |
332
+ | **Non-critical dep refuted** | Conclusion's `confidence` decays but stays `alive` (only `critical:true` deps invalidate). |
333
+ | **Contradiction judge false-positive risk** | Two-gate (vector `<0.35` + LLM `≥0.6`) + `--demo` pin. |
334
+
335
+ ---
336
+
337
+ ## 5. Tech Stack & Module Layout
338
+
339
+ ### 5.1 Cognee APIs used (all §0-verified)
340
+
341
+ - Persistence: `cognee.remember(session_id=...)`, `cognee.recall(session_id=..., query_type=...)`.
342
+ - Enrichment pipeline: `cognee.memify(extraction_tasks=[Task(collect_belief_subgraph)], enrichment_tasks=[Task(propagate_refutation), Task(promote_competing_hypothesis), Task(forget_orphan_deadends), Task(add_data_points)], data=[{}], node_type=NodeSet, node_name=[question_id])`.
343
+ - Graph: `get_graph_engine()` → `get_neighborhood` (depth=4, edge_types=["depends_on","supports"]), `get_connections`, `set_node_truth_state`, `get_node_truth_state`, `set_node_feedback_weights`, `add_edge`, `delete_nodes`.
344
+ - Vector: `get_vector_engine()` → `search("Evidence_claim", ...)`, `delete_data_points`.
345
+ - LLM: `LLMGateway.acreate_structured_output(text_input, system_prompt, response_model=ContradictionJudgement)`.
346
+ - Storage: `add_data_points` task (`cognee/tasks/storage/add_data_points.py:31`) for dual graph+vector write of the new fact.
347
+ - Baseline: `SearchType.RAG_COMPLETION` (from `cognee/modules/search/types/SearchType.py`).
348
+
349
+ ### 5.2 Module layout (write under `/workspaces/hackathon-app/`)
350
+
351
+ ```
352
+ /workspaces/hackathon-app/
353
+ main.py # `python main.py` entry — runs seed + demo + scoreboard; graceful no-key exit 0
354
+ requirements.txt / pyproject # deps: cognee (editable ref) + minimal
355
+ .env.template # LLM_API_KEY, LLM_PROVIDER=openai, LLM_ENDPOINT (custom endpoint), LLM_MODEL, DEMO_MODE
356
+ memory_core/
357
+ __init__.py
358
+ models.py # DataPoint subclasses (§2.1) + ContradictionJudgement
359
+ edges.py # edge-name constants: DEPENDS_ON, SUPPORTS, REFUTES, SUPERSEDES
360
+ tasks.py # collect_belief_subgraph, propagate_refutation,
361
+ # promote_competing_hypothesis, forget_orphan_deadends
362
+ falsify.py # orchestration: build_graph(), revise(new_fact), scoreboard()
363
+ seed.py # demo corpus: Company-X recall investigation (A/B/C + K)
364
+ ui/
365
+ graph.html # react-force-graph (color by truth_state, red-flash-then-remove ripple ≤3s)
366
+ server.py # tiny static+JSON server feeding get_neighborhood snapshots
367
+ tests/
368
+ test_propagation.py # §4.1 assertions
369
+ test_edge_cases.py # cycle / diamond / partial / multi
370
+ ```
371
+
372
+ ### 5.3 Visualization approach
373
+
374
+ - **react-force-graph** (CDN, single `graph.html`) reads a JSON snapshot built from `ge.get_neighborhood([question_id], depth=4)`.
375
+ - Node color keyed on `truth_alignment`: alive=green, refuted=red, invalidated=grey-strikethrough, superseded=dim-amber. Forgotten nodes: **red-flash animation then removed** from the sim (≤3s ripple).
376
+ - Scoreboard panel overlays FALSIFY-vs-RAG answers. No external services — server is stdlib/`http.server` or FastAPI already in cognee.
377
+
378
+ ### 5.4 Config / runtime constraints
379
+
380
+ - OpenAI-compatible: honor `LLM_PROVIDER` (`openai` or `custom`), `LLM_ENDPOINT`, `LLM_MODEL`, `LLM_API_KEY`. Defaults: LanceDB (vector) + Ladybug (graph) + SQLite (relational) — **zero external services**.
381
+ - `DEMO_MODE=1` (or `--demo`) pins `REFUTED_ID` so the cascade+forget run on real APIs regardless of LLM flakiness.
382
+ - Never modify `/workspaces/cognee`. All writes under `/workspaces/hackathon-app/` (absolute paths).
383
+
384
+ ---
385
+
386
+ ## 6. Non-negotiables (contract invariants)
387
+
388
+ 1. Forward propagation uses `get_neighborhood` + `set_node_truth_state` (on-node, persistent). No in-memory-only state.
389
+ 2. Surgical forget deletes from **both** graph (`delete_nodes`) and vector (`delete_data_points`); provenance (`refuted`/`superseded`) nodes are retained.
390
+ 3. Cross-session persistence of disbelief is demonstrated (restart / session 2 recall skips dead branches).
391
+ 4. The A/B scoreboard (FALSIFY revised vs plain-RAG stale) is shown every run.
392
+ 5. Contradiction detection is two-gate (vector prefilter `<0.35` + LLM judge `≥0.6`) with a `--demo` deterministic override.
393
+ 6. `Task` imported from `cognee.modules.pipelines.tasks.task` (lowercase); `get_graph_engine` awaited, `get_vector_engine` not.
cognee-hackathon-project-main/demo_video_script.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FALSIFY — 30-second demo script
2
+
3
+ A tight, judge-ready narration. Total runtime ~30s of talking over one `python main.py --demo` run. Capture the terminal + the `output/graph.html` for the GIF.
4
+
5
+ ---
6
+
7
+ ## Setup (before recording)
8
+ - Terminal with a dark theme, font large enough to read on playback.
9
+ - `.env` has a working `LLM_API_KEY` (so embeddings + recall are real).
10
+ - Run once beforehand to warm caches; record the second run.
11
+
12
+ ---
13
+
14
+ ## Beat sheet
15
+
16
+ **[0:00–0:05] The hook**
17
+ > "Every AI memory demo fixes *forgetting*. FALSIFY fixes something worse — an AI that confidently remembers a fact that's been **proven false**."
18
+
19
+ *Run:* `python main.py --demo`
20
+
21
+ **[0:05–0:12] Session 1 — the belief graph**
22
+ > "We're investigating: *did Company X know about the defect before the recall?* Two hypotheses — a March QA report, and a January supplier email. A conclusion rests on that March report."
23
+
24
+ *On screen:* the **BEFORE** panel — every node tagged `ALIVE` in green.
25
+
26
+ **[0:12–0:20] Session 2 — one contradicting fact**
27
+ > "Now one line arrives: *a forensic audit found the March report was back-dated.* Watch."
28
+
29
+ *On screen:* the revision log —
30
+ ```
31
+ ✗ refuted: 1 evidence node
32
+ ✗ invalidated: 1 conclusion
33
+ hypothesis A → ↓ superseded
34
+ hypothesis B → ↑ promoted (new frontier)
35
+ 🗑 forgotten: Company X knew about the defect by March 2021
36
+ ```
37
+
38
+ **[0:20–0:27] The AFTER + scoreboard**
39
+ > "The March evidence is red. The conclusion built on it collapsed and was **surgically deleted** — from the graph *and* the vector store. Hypothesis B ignites as the new answer."
40
+
41
+ *On screen:* the **SCOREBOARD** —
42
+ ```
43
+ FALSIFY : X knew by Jan 2021 (supplier email) ← revised
44
+ RAG : X knew by Mar 2021 (QA report) [STALE] ← still cites the refuted fact
45
+ ```
46
+
47
+ **[0:27–0:30] The close**
48
+ > "Same store, same query. FALSIFY revised its belief and the disbelief persists across sessions. Plain RAG can't. **AI revised, not forgot.**"
49
+
50
+ *On screen:* open `output/graph.html` — the red refuted node, the missing orphan, the green frontier.
51
+
52
+ ---
53
+
54
+ ## The single most important line
55
+ > **"It's not that the AI forgot where the context was — the context was *wrong*, and FALSIFY revised it."**
56
+
57
+ That reframes the hackathon's "Where's My Context?" theme into FALSIFY's exact contribution.
58
+
59
+ ---
60
+
61
+ ## If asked "why can't RAG do this?"
62
+ > "Refutation is graph traversal over typed edges — *this fact grounds that conclusion three hops away.* A vector index has no edges to walk and no truth-state to filter on. This needs a knowledge graph — which is exactly what Cognee gives us."
cognee-hackathon-project-main/falsify/__init__.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY — Belief Revision Research Copilot
3
+
4
+ Exposes core models and edge constants for the belief graph.
5
+
6
+ Import-time environment safety
7
+ ------------------------------
8
+ FALSIFY is a single-user, self-hosted demo. We set a few Cognee defaults *before*
9
+ any Cognee module is imported (importing :mod:`falsify.models` pulls in Cognee), so
10
+ that graph writes go into one shared local graph without per-user access-control
11
+ gymnastics. ``setdefault`` means an explicitly-exported env var always wins.
12
+ """
13
+
14
+ import os as _os
15
+
16
+ # Single-user mode: shared local DBs, auth off. Must precede the first cognee import.
17
+ _os.environ.setdefault("ENABLE_BACKEND_ACCESS_CONTROL", "False")
18
+ # Keep logs quiet unless the user opts in.
19
+ _os.environ.setdefault("LOG_LEVEL", "ERROR")
20
+ _os.environ.setdefault("COGNEE_LOG_FILE", "false")
21
+ # Enable the session cache so remember(session_id=...) / improve() work (cross-session proof).
22
+ _os.environ.setdefault("CACHING", "true")
23
+ _os.environ.setdefault("CACHE_BACKEND", "fs")
24
+ # Default embeddings to fastembed — a fully local, CPU-only, zero-cost embedder, so
25
+ # the whole pipeline (and `python main.py --demo`) runs with NO external API key.
26
+ # Any explicit EMBEDDING_* in the environment / .env overrides these (setdefault).
27
+ _os.environ.setdefault("EMBEDDING_PROVIDER", "fastembed")
28
+ _os.environ.setdefault("EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5")
29
+ _os.environ.setdefault("EMBEDDING_DIMENSIONS", "384")
30
+ _os.environ.setdefault("EMBEDDING_MAX_TOKENS", "512")
31
+
32
+ from falsify.models import (
33
+ Assertion,
34
+ Conclusion,
35
+ Evidence,
36
+ Hypothesis,
37
+ InvestigationQuestion,
38
+ TruthState,
39
+ # Edge relationship constants
40
+ CONTRADICTS,
41
+ DEPENDS_ON,
42
+ SUPPORTS,
43
+ SUPERSEDES,
44
+ )
45
+
46
+ __all__ = [
47
+ "TruthState",
48
+ "InvestigationQuestion",
49
+ "Hypothesis",
50
+ "Evidence",
51
+ "Conclusion",
52
+ "Assertion",
53
+ "DEPENDS_ON",
54
+ "SUPPORTS",
55
+ "CONTRADICTS",
56
+ "SUPERSEDES",
57
+ ]
cognee-hackathon-project-main/falsify/edges.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Edge relationship-name constants for the FALSIFY belief graph.
3
+
4
+ These strings are passed as ``relationship_name`` to ``graph_engine.add_edge(...)``
5
+ and used as ``edge_types`` filters in ``get_neighborhood(...)``. They are re-exported
6
+ from :mod:`falsify.models` as well; this module is the single source of truth.
7
+
8
+ Edge semantics (see REQUIREMENTS.md §1.1)
9
+ -----------------------------------------
10
+ - ``DEPENDS_ON`` : Conclusion -> Evidence. THE forward-propagation rail. A Conclusion
11
+ depends on the Evidence it rests on. ``edge_properties = {"critical": bool}``.
12
+ - ``SUPPORTS`` : Evidence -> Hypothesis. Evidence corroborates a hypothesis.
13
+ ``edge_properties = {"weight": float}``.
14
+ - ``REFUTES`` : Evidence -> Hypothesis. Evidence contradicts a hypothesis.
15
+ ``edge_properties = {"weight": float}``.
16
+ - ``SUPERSEDES`` : Evidence(new) -> Evidence(old). Written when a new fact back-dates /
17
+ overrides an old evidence node. ``edge_properties = {"confidence": float}``.
18
+ """
19
+
20
+ DEPENDS_ON = "depends_on"
21
+ # Non-critical dependency variant. Criticality is encoded in the relationship NAME
22
+ # because some graph backends (e.g. Ladybug) do not round-trip edge *properties* —
23
+ # a name always persists, an edge property may not. So a plain ``depends_on`` edge is
24
+ # treated as critical by default, and ``depends_on_soft`` is the explicit non-critical
25
+ # form. (For in-memory/tests, an explicit ``{"critical": bool}`` property still wins.)
26
+ DEPENDS_ON_SOFT = "depends_on_soft"
27
+ SUPPORTS = "supports"
28
+ REFUTES = "refutes"
29
+ # ``CONTRADICTS`` kept as an alias for the refutes edge (spec used both names).
30
+ CONTRADICTS = REFUTES
31
+ SUPERSEDES = "supersedes"
32
+
33
+ #: All dependency edge relationship names (critical + soft).
34
+ DEPENDENCY_EDGE_TYPES = [DEPENDS_ON, DEPENDS_ON_SOFT]
35
+
36
+ #: Edge types traversed when checking whether a node still feeds a live consumer.
37
+ CONSUMER_EDGE_TYPES = [DEPENDS_ON, DEPENDS_ON_SOFT, SUPPORTS]
38
+
39
+
40
+ def is_critical_dependency(rel: str, props: dict | None = None) -> bool:
41
+ """Return whether a dependency edge is *critical*.
42
+
43
+ An explicit ``critical`` edge property wins when present (used by in-memory tests).
44
+ Otherwise criticality is inferred from the relationship name: ``depends_on`` is
45
+ critical by default, ``depends_on_soft`` is not. This makes correctness independent
46
+ of whether the backend persists edge properties.
47
+ """
48
+ props = props or {}
49
+ if "critical" in props:
50
+ return bool(props["critical"])
51
+ if rel == DEPENDS_ON_SOFT:
52
+ return False
53
+ return rel == DEPENDS_ON # depends_on => critical by default
54
+
55
+
56
+ # Edge types traversed during forward refutation propagation.
57
+ PROPAGATION_EDGE_TYPES = [DEPENDS_ON, DEPENDS_ON_SOFT, SUPPORTS]
58
+
59
+ __all__ = [
60
+ "DEPENDS_ON",
61
+ "DEPENDS_ON_SOFT",
62
+ "SUPPORTS",
63
+ "REFUTES",
64
+ "CONTRADICTS",
65
+ "SUPERSEDES",
66
+ "DEPENDENCY_EDGE_TYPES",
67
+ "CONSUMER_EDGE_TYPES",
68
+ "PROPAGATION_EDGE_TYPES",
69
+ "is_critical_dependency",
70
+ ]
cognee-hackathon-project-main/falsify/falsify.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY orchestration — the belief-revision copilot's public API.
3
+
4
+ Three verbs tie the engine together:
5
+
6
+ build_graph() -> seed the Session-1 investigation graph (clean slate).
7
+ revise(new_fact) -> run the full revision pipeline for an incoming fact:
8
+ detect -> propagate -> promote -> record supersede -> forget.
9
+ scoreboard(question) -> the money shot: FALSIFY's revised answer (reads truth
10
+ state, skips dead branches) vs a plain-RAG baseline
11
+ (raw vector search, no truth filter) that still cites the
12
+ refuted fact.
13
+
14
+ Everything is persisted on the graph (truth-state on nodes), so a fresh process /
15
+ second session sees the revised beliefs — the cross-session guarantee.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from dataclasses import dataclass, field
22
+ from typing import Dict, List, Optional
23
+
24
+ from falsify import graph_ops
25
+ from falsify.edges import SUPERSEDES, SUPPORTS
26
+ from falsify.models import Evidence, TruthState
27
+ from falsify.seed import SeededGraph, build_investigation
28
+ from falsify.tasks import (
29
+ Contradiction,
30
+ cascade_forget,
31
+ detect_contradictions,
32
+ promote_competing_hypothesis,
33
+ propagate_refutation,
34
+ )
35
+
36
+ logger = logging.getLogger("falsify.orchestrator")
37
+
38
+ _ALIVE = TruthState.ALIVE.value
39
+ _EVIDENCE_COLLECTION = "Evidence_claim"
40
+
41
+
42
+ @dataclass
43
+ class RevisionReport:
44
+ """Full record of one :func:`revise` run, for demo output and tests."""
45
+
46
+ new_fact: str
47
+ contradictions: List[Contradiction] = field(default_factory=list)
48
+ refuted: List[str] = field(default_factory=list)
49
+ invalidated: List[str] = field(default_factory=list)
50
+ hypothesis_actions: Dict[str, str] = field(default_factory=dict)
51
+ forgotten: List[str] = field(default_factory=list)
52
+ forgotten_labels: Dict[str, str] = field(default_factory=dict)
53
+ retained_provenance: List[str] = field(default_factory=list)
54
+ new_evidence_id: Optional[str] = None
55
+ epoch: int = 0
56
+
57
+ @property
58
+ def revised(self) -> bool:
59
+ """True if the fact actually triggered a belief change."""
60
+ return bool(self.refuted or self.invalidated)
61
+
62
+
63
+ async def build_graph() -> SeededGraph:
64
+ """Prune everything and build the Session-1 investigation graph.
65
+
66
+ Returns the :class:`SeededGraph` with stable handles to the seeded nodes.
67
+ """
68
+ import cognee
69
+ from cognee.low_level import setup
70
+
71
+ logger.info("build_graph: pruning and seeding fresh investigation")
72
+ await cognee.forget(everything=True)
73
+ await setup() # (re)create relational tables the storage pipeline needs
74
+ seeded = await build_investigation()
75
+ return seeded
76
+
77
+
78
+ async def revise(
79
+ new_fact: str,
80
+ *,
81
+ pinned_target_id: Optional[str] = None,
82
+ source_id: str = "session2_fact",
83
+ ) -> RevisionReport:
84
+ """Run the full belief-revision pipeline for an incoming fact.
85
+
86
+ Pipeline (REQUIREMENTS §1.3-§1.5):
87
+ 1. detect_contradictions -> which evidence does the fact contradict?
88
+ 2. propagate_refutation -> refute it; cascade invalidation forward.
89
+ 3. promote_competing_hypothesis -> demote the losing hypothesis, ignite the rival.
90
+ 4. record the new fact as Evidence + a ``supersedes`` edge to the refuted node
91
+ (so the refuted node is retained as a provenance tombstone).
92
+ 5. cascade_forget -> hard-delete orphaned dead-ends from graph + vector.
93
+
94
+ Args:
95
+ new_fact: the incoming claim.
96
+ pinned_target_id: demo override — refute this evidence id deterministically.
97
+ source_id: provenance id for the materialized new-fact Evidence node.
98
+
99
+ Returns:
100
+ A :class:`RevisionReport` describing everything that changed.
101
+ """
102
+ report = RevisionReport(new_fact=new_fact)
103
+
104
+ # 1) detect
105
+ contradictions = await detect_contradictions(new_fact, pinned_target_id=pinned_target_id)
106
+ report.contradictions = contradictions
107
+ if not contradictions:
108
+ logger.info("revise: no contradiction found; graph unchanged")
109
+ return report
110
+
111
+ target_ids = [c.target_id for c in contradictions]
112
+
113
+ # 2) propagate
114
+ prop = await propagate_refutation(target_ids)
115
+ report.refuted = prop.refuted
116
+ report.invalidated = prop.invalidated
117
+ report.epoch = prop.epoch
118
+
119
+ # 3) promote competing hypothesis
120
+ report.hypothesis_actions = await promote_competing_hypothesis(target_ids, prop.epoch)
121
+
122
+ # 4) record the new fact + supersedes edge (keeps the refuted node as provenance)
123
+ report.new_evidence_id = await _record_new_fact(new_fact, contradictions, source_id)
124
+
125
+ # 5) forget orphaned dead-ends
126
+ forget_res = await cascade_forget(prop.affected)
127
+ report.forgotten = forget_res.forgotten
128
+ report.forgotten_labels = forget_res.labels
129
+ report.retained_provenance = forget_res.retained_provenance
130
+
131
+ logger.info(
132
+ "revise complete: refuted=%d invalidated=%d forgotten=%d",
133
+ len(report.refuted), len(report.invalidated), len(report.forgotten),
134
+ )
135
+ return report
136
+
137
+
138
+ async def _record_new_fact(
139
+ new_fact: str,
140
+ contradictions: List[Contradiction],
141
+ source_id: str,
142
+ ) -> Optional[str]:
143
+ """Materialize the new fact as an Evidence node and link supersedes edges.
144
+
145
+ The new (alive) evidence ``supersedes`` each refuted evidence node. This both
146
+ records provenance and pins the refuted node as a retained tombstone (an alive
147
+ supersedes-source protects its target from forget — REQUIREMENTS §1.5c).
148
+ """
149
+ from cognee.tasks.storage import add_data_points
150
+
151
+ try:
152
+ new_ev = Evidence(
153
+ claim=new_fact,
154
+ source_id=source_id,
155
+ stance="refutes",
156
+ confidence=max((c.confidence for c in contradictions), default=0.9),
157
+ )
158
+ await add_data_points([new_ev])
159
+ for c in contradictions:
160
+ await graph_ops.add_edge(
161
+ str(new_ev.id), str(c.target_id), SUPERSEDES, {"confidence": c.confidence}
162
+ )
163
+ logger.info("recorded new fact %s superseding %d node(s)", new_ev.id, len(contradictions))
164
+ return str(new_ev.id)
165
+ except Exception as exc:
166
+ logger.error("failed to record new fact: %s", exc)
167
+ return None
168
+
169
+
170
+ @dataclass
171
+ class Scoreboard:
172
+ """The FALSIFY-vs-RAG comparison shown every run."""
173
+
174
+ question: str
175
+ falsify_answer: str
176
+ falsify_support: List[str] = field(default_factory=list)
177
+ rag_answer: str = ""
178
+ rag_citations: List[str] = field(default_factory=list)
179
+ stale: bool = False # True if RAG still cites a refuted node FALSIFY dropped
180
+
181
+
182
+ async def scoreboard(question: str, seeded: Optional[SeededGraph] = None) -> Scoreboard:
183
+ """Compare FALSIFY's revised answer against a plain-RAG baseline.
184
+
185
+ FALSIFY answer: derived from the graph, reading truth-state and using only
186
+ hypotheses/evidence still ``alive`` (the promoted frontier hypothesis).
187
+
188
+ RAG baseline: a raw vector search over ``Evidence_claim`` with **no** truth
189
+ filter — so it still returns evidence FALSIFY has refuted, and cites the stale
190
+ fact. This asymmetry is the demo's whole point.
191
+ """
192
+ board = Scoreboard(question=question, falsify_answer="(no surviving hypothesis)")
193
+
194
+ # ---- FALSIFY: alive-filtered graph answer ----
195
+ nodes, edges = await graph_ops.load_graph()
196
+ node_ids = [nid for nid, _p in nodes]
197
+ truth = await graph_ops.get_truth(node_ids)
198
+ props_by_id = {str(nid): (p or {}) for nid, p in nodes}
199
+
200
+ # The winning hypothesis = alive hypothesis with the most alive supporting evidence weight.
201
+ best_hyp, best_score = None, -1.0
202
+ support_edges = [(s, d, p) for (s, d, r, p) in edges if r == SUPPORTS]
203
+ for nid, props in nodes:
204
+ nid = str(nid)
205
+ if "statement" not in props: # crude: hypotheses/conclusions carry 'statement'
206
+ continue
207
+ if _ALIVE not in truth.get(nid, [_ALIVE]):
208
+ continue
209
+ score = 0.0
210
+ alive_support = []
211
+ for (src, dst, ep) in support_edges:
212
+ if str(dst) != nid:
213
+ continue
214
+ if _ALIVE in truth.get(str(src), [_ALIVE]):
215
+ score += float(ep.get("weight", 0.5))
216
+ alive_support.append(graph_ops.node_label(props_by_id.get(str(src), {})))
217
+ if alive_support and score > best_score:
218
+ best_hyp, best_score = nid, score
219
+ board.falsify_answer = graph_ops.node_label(props)
220
+ board.falsify_support = alive_support
221
+
222
+ # ---- RAG baseline: raw vector search, no truth filter ----
223
+ ve = graph_ops.get_vector_engine()
224
+ try:
225
+ hits = await ve.search(_EVIDENCE_COLLECTION, query_text=question, limit=5, include_payload=True)
226
+ except Exception as exc:
227
+ logger.warning("RAG baseline search failed: %s", exc)
228
+ hits = []
229
+
230
+ refuted_ids = {nid for nid in node_ids if TruthState.REFUTED.value in truth.get(str(nid), [])}
231
+ for h in (hits or []):
232
+ payload = getattr(h, "payload", {}) or {}
233
+ text = payload.get("claim") or payload.get("text") or graph_ops.node_label(payload)
234
+ board.rag_citations.append(str(text))
235
+ if str(h.id) in refuted_ids:
236
+ board.stale = True
237
+ board.rag_answer = board.rag_citations[0] if board.rag_citations else "(no vector hits)"
238
+
239
+ logger.info("scoreboard: falsify=%r stale_rag=%s", board.falsify_answer, board.stale)
240
+ return board
cognee-hackathon-project-main/falsify/graph_ops.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Low-level graph operations for FALSIFY.
3
+
4
+ This module is the single, verified surface between FALSIFY's belief logic and
5
+ Cognee's graph/vector engines. Every engine call used elsewhere in the project goes
6
+ through a helper here, so the (few) places that touch Cognee internals are auditable.
7
+
8
+ Verified Cognee API shapes (grep-confirmed against /workspaces/cognee — see REQUIREMENTS.md §0):
9
+
10
+ ge = await get_graph_engine() # ASYNC factory
11
+ nodes, edges = await ge.get_graph_data() # ([(id, props)], [(src, dst, rel, props)])
12
+ nodes, edges = await ge.get_neighborhood(ids, depth=, edge_types=)
13
+ await ge.add_edge(from_node, to_node, relationship_name, edge_properties={})
14
+ await ge.set_node_truth_state({id: {"truth_alignment": [...], "truth_epoch": N}})
15
+ state = await ge.get_node_truth_state([ids]) # {id: {"truth_alignment": [...], ...}}
16
+ await ge.set_node_feedback_weights({id: 0.0})
17
+ await ge.delete_nodes([ids])
18
+
19
+ ve = get_vector_engine() # SYNC factory
20
+ await ve.delete_data_points(collection_name, [uuids])
21
+ hits = await ve.search(collection_name, query_text=, limit=, include_payload=)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from typing import Any, Dict, List, Optional, Set, Tuple
28
+
29
+ from cognee.infrastructure.databases.graph import get_graph_engine
30
+ from cognee.infrastructure.databases.vector import get_vector_engine
31
+
32
+ from falsify.models import TruthState
33
+
34
+ logger = logging.getLogger("falsify.graph_ops")
35
+
36
+ # A parsed edge: (source_id, target_id, relationship_name, properties)
37
+ Edge = Tuple[str, str, str, Dict[str, Any]]
38
+ # A parsed node: (node_id, properties)
39
+ GraphNode = Tuple[str, Dict[str, Any]]
40
+
41
+
42
+ async def load_graph() -> Tuple[List[GraphNode], List[Edge]]:
43
+ """Return the full graph as ``(nodes, edges)``.
44
+
45
+ Nodes are ``(id, props)`` tuples; edges are ``(src, dst, rel, props)`` tuples.
46
+ IDs are normalized to ``str`` so they can be used as dict keys regardless of
47
+ whether the adapter returns ``UUID`` or ``str``.
48
+ """
49
+ ge = await get_graph_engine()
50
+ raw_nodes, raw_edges = await ge.get_graph_data()
51
+
52
+ nodes: List[GraphNode] = [(str(nid), props or {}) for nid, props in raw_nodes]
53
+
54
+ edges: List[Edge] = []
55
+ for e in raw_edges:
56
+ # Adapters return 4-tuples (src, dst, rel, props); be defensive about arity.
57
+ if len(e) >= 4:
58
+ src, dst, rel, props = e[0], e[1], e[2], e[3]
59
+ elif len(e) == 3:
60
+ src, dst, rel, props = e[0], e[1], e[2], {}
61
+ else: # pragma: no cover - unexpected adapter shape
62
+ continue
63
+ edges.append((str(src), str(dst), str(rel), props or {}))
64
+ return nodes, edges
65
+
66
+
67
+ async def get_truth(node_ids: List[str]) -> Dict[str, List[str]]:
68
+ """Return ``{node_id: truth_alignment_list}`` for the given ids.
69
+
70
+ A node with no stored truth state is treated as ``["alive"]`` (the default).
71
+ """
72
+ if not node_ids:
73
+ return {}
74
+ ge = await get_graph_engine()
75
+ ids = [str(n) for n in node_ids]
76
+ try:
77
+ raw = await ge.get_node_truth_state(ids)
78
+ except Exception as exc: # adapter may not have state yet
79
+ logger.debug("get_node_truth_state failed (%s); defaulting to alive", exc)
80
+ raw = {}
81
+ out: Dict[str, List[str]] = {}
82
+ for nid in ids:
83
+ entry = (raw or {}).get(nid) or (raw or {}).get(str(nid))
84
+ alignment = (entry or {}).get("truth_alignment") if entry else None
85
+ out[nid] = list(alignment) if alignment else [TruthState.ALIVE.value]
86
+ return out
87
+
88
+
89
+ async def is_alive(node_id: str) -> bool:
90
+ """True iff the node's truth_alignment currently contains ``alive``."""
91
+ state = await get_truth([str(node_id)])
92
+ return TruthState.ALIVE.value in state.get(str(node_id), [TruthState.ALIVE.value])
93
+
94
+
95
+ async def set_state(node_id: str, state: TruthState, epoch: int) -> None:
96
+ """Persist ``truth_alignment=[state]`` + ``truth_epoch=epoch`` on a node.
97
+
98
+ Truth state is stored ON the graph node, so it survives a process restart — the
99
+ basis of FALSIFY's cross-session persistence guarantee.
100
+ """
101
+ ge = await get_graph_engine()
102
+ value = state.value if isinstance(state, TruthState) else str(state)
103
+ try:
104
+ await ge.set_node_truth_state(
105
+ {str(node_id): {"truth_alignment": [value], "truth_epoch": int(epoch)}}
106
+ )
107
+ except Exception as exc:
108
+ logger.error("set_node_truth_state failed for %s -> %s: %s", node_id, value, exc)
109
+ raise
110
+
111
+
112
+ async def set_weight(node_id: str, weight: float) -> None:
113
+ """Set a node's feedback weight (confidence/health signal). Best-effort."""
114
+ ge = await get_graph_engine()
115
+ try:
116
+ await ge.set_node_feedback_weights({str(node_id): float(weight)})
117
+ except Exception as exc: # non-fatal: weight is a secondary signal
118
+ logger.debug("set_node_feedback_weights failed for %s: %s", node_id, exc)
119
+
120
+
121
+ async def add_edge(src: str, dst: str, rel: str, props: Optional[Dict[str, Any]] = None) -> None:
122
+ """Add a directed edge ``src --rel--> dst`` with optional properties."""
123
+ ge = await get_graph_engine()
124
+ await ge.add_edge(str(src), str(dst), rel, props or {})
125
+
126
+
127
+ async def delete_from_both_stores(node_ids: List[str], collections: List[str]) -> int:
128
+ """Hard-delete nodes from the graph AND their rows from vector collections.
129
+
130
+ Returns the number of node ids deleted. Vector deletion is attempted per
131
+ collection and is best-effort (a node may not live in every collection).
132
+ """
133
+ if not node_ids:
134
+ return 0
135
+ ids = [str(n) for n in node_ids]
136
+
137
+ ge = await get_graph_engine()
138
+ try:
139
+ await ge.delete_nodes(ids)
140
+ except Exception as exc:
141
+ logger.error("delete_nodes failed: %s", exc)
142
+ raise
143
+
144
+ ve = get_vector_engine()
145
+ for collection in collections:
146
+ try:
147
+ await ve.delete_data_points(collection, ids)
148
+ except Exception as exc: # collection may not exist / id not present
149
+ logger.debug("delete_data_points(%s) best-effort skip: %s", collection, exc)
150
+ return len(ids)
151
+
152
+
153
+ # --------------------------------------------------------------------------- #
154
+ # Adjacency helpers (built from a single load_graph() snapshot)
155
+ # --------------------------------------------------------------------------- #
156
+
157
+
158
+ def dependents_of(evidence_id: str, edges: List[Edge], rel: str) -> List[str]:
159
+ """Return source ids of ``rel`` edges pointing INTO ``evidence_id``.
160
+
161
+ For ``depends_on`` (Conclusion -> Evidence) this yields the Conclusions that
162
+ depend on the given Evidence — i.e. the forward-cascade victims.
163
+ """
164
+ tid = str(evidence_id)
165
+ return [src for (src, dst, r, _p) in edges if r == rel and str(dst) == tid]
166
+
167
+
168
+ def incoming(node_id: str, edges: List[Edge], rel: str) -> List[Tuple[str, Dict[str, Any]]]:
169
+ """Return ``[(source_id, props)]`` for ``rel`` edges pointing into ``node_id``."""
170
+ tid = str(node_id)
171
+ return [(src, p) for (src, dst, r, p) in edges if r == rel and str(dst) == tid]
172
+
173
+
174
+ def outgoing(node_id: str, edges: List[Edge], rel: str) -> List[Tuple[str, Dict[str, Any]]]:
175
+ """Return ``[(target_id, props)]`` for ``rel`` edges leaving ``node_id``."""
176
+ sid = str(node_id)
177
+ return [(dst, p) for (src, dst, r, p) in edges if r == rel and str(src) == sid]
178
+
179
+
180
+ def node_label(props: Dict[str, Any]) -> str:
181
+ """Best-effort human label for a node from its properties."""
182
+ for key in ("statement", "claim", "question", "text", "name"):
183
+ if props.get(key):
184
+ return str(props[key])
185
+ return props.get("id", "?")
cognee-hackathon-project-main/falsify/models.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY belief-graph data models.
3
+
4
+ Every node is a Cognee ``DataPoint`` subclass, so the same object serializes into
5
+ the graph DB (Ladybug) and, via its ``Embeddable`` field, into the vector DB
6
+ (LanceDB). The vector collection auto-created for a class is ``"{ClassName}_{field}"``
7
+ (e.g. ``Evidence_claim`` — the collection the contradiction prefilter searches).
8
+
9
+ Lifecycle / truth-state note
10
+ ----------------------------
11
+ The *authoritative, persistent* belief state of a node lives on the graph node as
12
+ ``truth_alignment`` (a list) + ``truth_epoch`` (int), written through the graph
13
+ engine (``set_node_truth_state``), not as pydantic model fields. Those props are
14
+ what ``recall()`` filters on and what survives a process restart.
15
+
16
+ The ``truth_state`` model field below is a convenience mirror of the node's initial
17
+ state at ingest time (defaults to ``ALIVE``). Do not treat it as the source of
18
+ truth after a memify run — always read back with ``get_node_truth_state``.
19
+
20
+ Dedup across sessions
21
+ ---------------------
22
+ ``Hypothesis``, ``Evidence`` and ``Assertion`` mark identity fields with ``Dedup()``.
23
+ DataPoint derives a stable UUID5 id from those fields (``DataPoint.id_for``), so
24
+ re-adding the same belief in a later session updates the existing node instead of
25
+ creating a duplicate — essential for a Session-2 contradiction to land on the exact
26
+ node built in Session 1.
27
+ """
28
+
29
+ from datetime import datetime, timezone
30
+ from enum import Enum
31
+ from typing import Annotated, List, Optional
32
+
33
+ from pydantic import Field
34
+
35
+ from cognee.infrastructure.engine import DataPoint, Dedup, Embeddable, LLMContext
36
+
37
+
38
+ def _now_iso() -> str:
39
+ """Return the current UTC time as an ISO-8601 string (used for node timestamps)."""
40
+ return datetime.now(timezone.utc).isoformat()
41
+
42
+
43
+ class TruthState(str, Enum):
44
+ """Canonical belief-lifecycle states for a FALSIFY graph node.
45
+
46
+ The values are the exact strings written into the node's ``truth_alignment``
47
+ list, so a state can be compared directly against what ``get_node_truth_state``
48
+ returns (e.g. ``get_node_truth_state([n])[n]["truth_alignment"] == [TruthState.REFUTED]``).
49
+
50
+ States:
51
+ - ``ALIVE``: default; the node participates in recall context.
52
+ - ``REFUTED``: Evidence directly contradicted by a newer fact — the entry point
53
+ of a forward cascade.
54
+ - ``SUPERSEDED``: a node replaced/demoted by a newer competing node; retained as
55
+ provenance, excluded from recall.
56
+ - ``INVALIDATED``: a Conclusion whose critical supporting Evidence chain was
57
+ refuted (forward-cascade victim).
58
+ - ``FORGOTTEN``: an orphaned dead-end scheduled for surgical delete (transient;
59
+ the node is then hard-removed from both graph and vector stores).
60
+
61
+ Note: the FALSIFY spec's minimal enum names ALIVE/REFUTED/SUPERSEDED/FORGOTTEN.
62
+ ``INVALIDATED`` is added here because the forward-propagation algorithm
63
+ (REQUIREMENTS §1.2/§1.4) needs a distinct state for cascade-victim Conclusions
64
+ versus directly-refuted Evidence.
65
+ """
66
+
67
+ ALIVE = "alive"
68
+ REFUTED = "refuted"
69
+ SUPERSEDED = "superseded"
70
+ INVALIDATED = "invalidated"
71
+ FORGOTTEN = "forgotten"
72
+
73
+
74
+ # --------------------------------------------------------------------------- #
75
+ # Edge relationship-name constants (passed as ``relationship_name`` to add_edge)
76
+ # --------------------------------------------------------------------------- #
77
+
78
+ # Conclusion -> Evidence. THE forward-propagation rail. edge_properties={"critical": bool}
79
+ DEPENDS_ON = "depends_on"
80
+
81
+ # Evidence -> Hypothesis. Evidence corroborates a hypothesis. edge_properties={"weight": float}
82
+ SUPPORTS = "supports"
83
+
84
+ # Evidence -> Hypothesis. Evidence contradicts a hypothesis. edge_properties={"weight": float}
85
+ # (REQUIREMENTS §1.1 names this edge "refutes"; ``REFUTES`` is provided as an alias.)
86
+ CONTRADICTS = "refutes"
87
+ REFUTES = CONTRADICTS
88
+
89
+ # Evidence(new) -> Evidence(old). New fact overrides an old evidence node.
90
+ # edge_properties={"confidence": float}
91
+ SUPERSEDES = "supersedes"
92
+
93
+
94
+ class InvestigationQuestion(DataPoint):
95
+ """Root node of a belief graph: the research question under investigation.
96
+
97
+ Example: "Did Company X know about the defect before the recall?" Everything
98
+ else (hypotheses, evidence, conclusions) hangs off this question via
99
+ ``question_id``.
100
+ """
101
+
102
+ question: Annotated[str, Embeddable(), LLMContext()]
103
+ truth_state: TruthState = TruthState.ALIVE
104
+ confidence: float = 1.0
105
+ timestamp: str = Field(default_factory=_now_iso)
106
+ source_id: Optional[str] = None
107
+
108
+ metadata: dict = {
109
+ "index_fields": ["question"],
110
+ "identity_fields": ["question"],
111
+ }
112
+
113
+
114
+ class Hypothesis(DataPoint):
115
+ """A candidate explanation competing to answer the InvestigationQuestion.
116
+
117
+ Hypotheses gain/lose standing through ``supports``/``refutes`` Evidence edges.
118
+ When a hypothesis' only supporting Evidence is refuted, it is demoted to
119
+ ``SUPERSEDED`` and the rival with the strongest surviving support is promoted.
120
+ """
121
+
122
+ statement: Annotated[str, Embeddable(), Dedup(), LLMContext()]
123
+ question_id: str
124
+ status: str = "alive"
125
+ prior: float = 0.5
126
+ truth_state: TruthState = TruthState.ALIVE
127
+ confidence: float = 0.5
128
+ timestamp: str = Field(default_factory=_now_iso)
129
+ source_id: Optional[str] = None
130
+
131
+ metadata: dict = {
132
+ "index_fields": ["statement"],
133
+ "identity_fields": ["question_id", "statement"],
134
+ }
135
+
136
+
137
+ class Evidence(DataPoint):
138
+ """A factual claim bearing on one or more hypotheses.
139
+
140
+ Evidence is the contradiction entry point: the ``Evidence_claim`` vector
141
+ collection is what the two-gate detector prefilters, and a ``REFUTED`` Evidence
142
+ node is the seed of every forward cascade. ``asserted_at`` is used as the
143
+ tie-break when deciding which of two competing claims supersedes the other
144
+ (newer wins).
145
+ """
146
+
147
+ claim: Annotated[str, Embeddable(), Dedup(), LLMContext()]
148
+ source_id: str
149
+ quote: str = ""
150
+ stance: str = "supports" # "supports" | "refutes"
151
+ asserted_at: str = Field(default_factory=_now_iso)
152
+ truth_state: TruthState = TruthState.ALIVE
153
+ confidence: float = 0.5
154
+ timestamp: str = Field(default_factory=_now_iso)
155
+
156
+ metadata: dict = {
157
+ "index_fields": ["claim"],
158
+ "identity_fields": ["source_id", "claim"],
159
+ }
160
+
161
+
162
+ class Conclusion(DataPoint):
163
+ """A derived finding that rests on one or more Evidence nodes.
164
+
165
+ A Conclusion ``depends_on`` the Evidence it is built from (edge carries
166
+ ``critical: bool``). When a *critical* dependency is refuted and no alive
167
+ critical alternative remains, the Conclusion is ``INVALIDATED`` by the forward
168
+ cascade; if it then has no surviving consumer it is ``FORGOTTEN`` (hard-deleted).
169
+ """
170
+
171
+ statement: Annotated[str, Embeddable(), LLMContext()]
172
+ confidence: float = 0.5
173
+ depends_on_ids: List[str] = Field(default_factory=list)
174
+ truth_state: TruthState = TruthState.ALIVE
175
+ timestamp: str = Field(default_factory=_now_iso)
176
+ source_id: Optional[str] = None
177
+
178
+ metadata: dict = {
179
+ "index_fields": ["statement"],
180
+ "identity_fields": ["statement"],
181
+ }
182
+
183
+
184
+ class Assertion(DataPoint):
185
+ """A raw, unclassified incoming claim — e.g. the new fact pasted in Session 2.
186
+
187
+ An Assertion is the pre-belief form of an incoming statement before the detector
188
+ decides whether it contradicts/supersedes existing Evidence and materializes a
189
+ proper ``Evidence`` node. It carries the same lifecycle scaffolding as the other
190
+ nodes so it can be reasoned over uniformly.
191
+ """
192
+
193
+ text: Annotated[str, Embeddable(), Dedup(), LLMContext()]
194
+ truth_state: TruthState = TruthState.ALIVE
195
+ confidence: float = 0.5
196
+ timestamp: str = Field(default_factory=_now_iso)
197
+ source_id: Optional[str] = None
198
+
199
+ metadata: dict = {
200
+ "index_fields": ["text"],
201
+ "identity_fields": ["text"],
202
+ }
cognee-hackathon-project-main/falsify/seed.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY demo corpus — the "Company X recall" investigation.
3
+
4
+ This module builds the Session-1 belief graph that the demo revises in Session 2.
5
+ It is intentionally small and hand-authored so the belief-revision mechanics are
6
+ legible on screen in under 30 seconds, and so the cascade result is deterministic
7
+ (the winning demo must not depend on a flaky LLM).
8
+
9
+ The investigation
10
+ -----------------
11
+ Question Q: "Did Company X know about the defect before the recall?"
12
+
13
+ Competing hypotheses:
14
+ A — "X knew via the QA report dated March 2021" (supported by E_qa)
15
+ B — "X knew via a supplier email in January 2021" (supported by E_email)
16
+ C — "X did not know before the recall" (unsupported)
17
+
18
+ Evidence:
19
+ E_qa — the March-2021 QA report (supports A)
20
+ E_email — the January-2021 supplier email (supports B)
21
+
22
+ Conclusion:
23
+ K — "Company X knew about the defect by March 2021"
24
+ depends_on E_qa (critical=True) <-- the propagation rail
25
+
26
+ The Session-2 fact (NEW_FACT) is a forensic finding that the March QA report was
27
+ back-dated. It contradicts E_qa. Refuting E_qa must cascade:
28
+
29
+ E_qa -> refuted
30
+ -> K (depends_on E_qa, critical) -> invalidated
31
+ -> A (only supporter E_qa now dead) -> superseded ; B promoted (new frontier)
32
+ -> K orphaned (no alive consumer) -> forgotten (hard-deleted, graph + vector)
33
+ -> E_qa kept as refuted provenance (it is NEW_FACT's supersedes anchor)
34
+
35
+ The scoreboard then shows FALSIFY answering via B (Jan 2021) while a plain vector
36
+ (RAG) baseline still cites the refuted March QA report.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import logging
42
+ from dataclasses import dataclass, field
43
+ from typing import Dict, List
44
+
45
+ # NOTE: importing falsify.models runs falsify/__init__, which sets the Cognee env
46
+ # defaults (access-control off, session cache on) before Cognee is imported.
47
+ from falsify import graph_ops
48
+ from falsify.edges import DEPENDS_ON, SUPPORTS
49
+ from falsify.models import (
50
+ Conclusion,
51
+ Evidence,
52
+ Hypothesis,
53
+ InvestigationQuestion,
54
+ )
55
+
56
+ logger = logging.getLogger("falsify.seed")
57
+
58
+ # The research question this whole graph hangs off of.
59
+ QUESTION_TEXT = "Did Company X know about the defect before the recall?"
60
+
61
+ # The Session-2 fact that triggers belief revision. It contradicts E_qa.
62
+ NEW_FACT = (
63
+ "A forensic audit found that the March 2021 QA report was back-dated: it was "
64
+ "actually created shortly after the product recall, not before it."
65
+ )
66
+
67
+ # Human-readable stable keys -> used by --demo mode to pin the refutation target
68
+ # deterministically (so the cascade runs on real graph APIs even if the LLM judge
69
+ # is unavailable). These map to the ``key`` field on the seeded nodes below.
70
+ REFUTED_EVIDENCE_KEY = "E_qa"
71
+
72
+
73
+ @dataclass
74
+ class SeededGraph:
75
+ """Handles to the nodes created by :func:`build_investigation`.
76
+
77
+ ``ids`` maps a stable human key (e.g. ``"E_qa"``, ``"A"``, ``"K"``) to the
78
+ string node id in the graph, so the demo/tests can reference specific nodes
79
+ without re-querying. ``labels`` maps the same keys to display strings.
80
+ """
81
+
82
+ question_id: str
83
+ ids: Dict[str, str] = field(default_factory=dict)
84
+ labels: Dict[str, str] = field(default_factory=dict)
85
+
86
+ @property
87
+ def refuted_target_id(self) -> str:
88
+ """Node id of the evidence the Session-2 fact contradicts (E_qa)."""
89
+ return self.ids[REFUTED_EVIDENCE_KEY]
90
+
91
+
92
+ async def build_investigation() -> SeededGraph:
93
+ """Create and persist the Session-1 belief graph. Returns a :class:`SeededGraph`.
94
+
95
+ The caller is responsible for starting from a clean state (e.g. via
96
+ ``cognee.forget(everything=True)`` and ``cognee.low_level.setup()``). This
97
+ function only builds; it does not prune.
98
+
99
+ Nodes are persisted with Cognee's ``add_data_points`` (writing both the graph
100
+ node and the vector row for each ``Embeddable`` field). Typed edges with
101
+ properties are then added explicitly through the graph engine so we control the
102
+ exact relationship names and edge properties (``critical`` / ``weight``).
103
+ """
104
+ # Import here so the module import stays cheap and env defaults are already set.
105
+ from cognee.tasks.storage import add_data_points
106
+
107
+ # ------------------------------------------------------------------ nodes
108
+ question = InvestigationQuestion(question=QUESTION_TEXT, source_id="investigation")
109
+
110
+ hyp_a = Hypothesis(
111
+ statement="Company X knew via the QA report dated March 2021.",
112
+ question_id=str(question.id),
113
+ prior=0.5,
114
+ confidence=0.6,
115
+ source_id="analyst",
116
+ )
117
+ hyp_b = Hypothesis(
118
+ statement="Company X knew via a supplier email in January 2021.",
119
+ question_id=str(question.id),
120
+ prior=0.5,
121
+ confidence=0.55,
122
+ source_id="analyst",
123
+ )
124
+ hyp_c = Hypothesis(
125
+ statement="Company X did not know about the defect before the recall.",
126
+ question_id=str(question.id),
127
+ prior=0.5,
128
+ confidence=0.4,
129
+ source_id="analyst",
130
+ )
131
+
132
+ ev_qa = Evidence(
133
+ claim="A QA report dated March 2021 documented the defect internally.",
134
+ source_id="qa_report_2021_03",
135
+ quote="Internal QA report, dated 2021-03-15, flags the defect.",
136
+ stance="supports",
137
+ asserted_at="2021-03-15",
138
+ confidence=0.8,
139
+ )
140
+ ev_email = Evidence(
141
+ claim="A supplier email in January 2021 warned Company X about the defect.",
142
+ source_id="supplier_email_2021_01",
143
+ quote="Supplier email, 2021-01-20: 'we have observed the defect in test units.'",
144
+ stance="supports",
145
+ asserted_at="2021-01-20",
146
+ confidence=0.7,
147
+ )
148
+
149
+ conclusion_k = Conclusion(
150
+ statement="Company X knew about the defect by March 2021.",
151
+ confidence=0.8,
152
+ depends_on_ids=[str(ev_qa.id)],
153
+ source_id="analyst",
154
+ )
155
+
156
+ nodes: List = [question, hyp_a, hyp_b, hyp_c, ev_qa, ev_email, conclusion_k]
157
+
158
+ logger.info("Persisting %d belief nodes via add_data_points", len(nodes))
159
+ await add_data_points(nodes)
160
+
161
+ # ------------------------------------------------------------------ edges
162
+ # Evidence -> Hypothesis (supports, weighted)
163
+ await graph_ops.add_edge(str(ev_qa.id), str(hyp_a.id), SUPPORTS, {"weight": 0.8})
164
+ await graph_ops.add_edge(str(ev_email.id), str(hyp_b.id), SUPPORTS, {"weight": 0.7})
165
+
166
+ # Conclusion -> Evidence (depends_on, critical) — THE propagation rail
167
+ await graph_ops.add_edge(
168
+ str(conclusion_k.id), str(ev_qa.id), DEPENDS_ON, {"critical": True}
169
+ )
170
+
171
+ # Hypothesis -> Question (answers) — keeps the graph connected for visualization
172
+ for hyp in (hyp_a, hyp_b, hyp_c):
173
+ await graph_ops.add_edge(str(hyp.id), str(question.id), "answers", {})
174
+
175
+ seeded = SeededGraph(
176
+ question_id=str(question.id),
177
+ ids={
178
+ "Q": str(question.id),
179
+ "A": str(hyp_a.id),
180
+ "B": str(hyp_b.id),
181
+ "C": str(hyp_c.id),
182
+ "E_qa": str(ev_qa.id),
183
+ "E_email": str(ev_email.id),
184
+ "K": str(conclusion_k.id),
185
+ },
186
+ labels={
187
+ "Q": QUESTION_TEXT,
188
+ "A": hyp_a.statement,
189
+ "B": hyp_b.statement,
190
+ "C": hyp_c.statement,
191
+ "E_qa": ev_qa.claim,
192
+ "E_email": ev_email.claim,
193
+ "K": conclusion_k.statement,
194
+ },
195
+ )
196
+ logger.info("Seeded investigation graph: %s", seeded.ids)
197
+ return seeded
cognee-hackathon-project-main/falsify/tasks/__init__.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY belief-revision tasks.
3
+
4
+ The three-step revision pipeline, in execution order:
5
+
6
+ 1. :func:`detect_contradictions` — two-gate detector: which existing evidence does a
7
+ new fact contradict/supersede?
8
+ 2. :func:`propagate_refutation` — flip the contradicted evidence to ``refuted`` and
9
+ cascade ``invalidated`` forward through ``depends_on``; then
10
+ :func:`promote_competing_hypothesis` re-scores the rival hypotheses.
11
+ 3. :func:`cascade_forget` — hard-delete truly-orphaned dead-ends from graph + vector,
12
+ keeping provenance tombstones.
13
+ """
14
+
15
+ from falsify.tasks.detect_contradictions import (
16
+ Contradiction,
17
+ ContradictionJudgement,
18
+ detect_contradictions,
19
+ )
20
+ from falsify.tasks.propagate_refutation import (
21
+ PropagationResult,
22
+ promote_competing_hypothesis,
23
+ propagate_refutation,
24
+ )
25
+ from falsify.tasks.cascade_forget import ForgetResult, cascade_forget
26
+
27
+ __all__ = [
28
+ "detect_contradictions",
29
+ "Contradiction",
30
+ "ContradictionJudgement",
31
+ "propagate_refutation",
32
+ "promote_competing_hypothesis",
33
+ "PropagationResult",
34
+ "cascade_forget",
35
+ "ForgetResult",
36
+ ]
cognee-hackathon-project-main/falsify/tasks/cascade_forget.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Surgical forget — prune orphaned dead-ends from graph **and** vector stores.
3
+
4
+ After :mod:`falsify.tasks.propagate_refutation` marks nodes ``refuted`` /
5
+ ``invalidated``, some of those nodes are still useful: they explain *why* a belief
6
+ changed (provenance) or they still feed a node that is alive. Others are pure
7
+ dead-ends with no surviving consumer. FALSIFY hard-deletes only the latter, from
8
+ both the graph (``delete_nodes``) and the vector index (``delete_data_points``), so a
9
+ subsequent ``recall()`` — and even a raw vector search — can never resurface them.
10
+
11
+ Orphan rule (REQUIREMENTS §1.5) — a node is FORGOTTEN iff ALL hold:
12
+ (a) truth state is ``refuted`` or ``invalidated`` (never ``alive``/``superseded``;
13
+ superseded nodes are kept as provenance), AND
14
+ (b) no surviving ALIVE node reaches it via ``depends_on`` or ``supports``
15
+ (it has no live consumer), AND
16
+ (c) it is NOT the target of a ``supersedes`` edge FROM an alive node (such a node
17
+ is the provenance anchor of the new truth and must be retained as a tombstone).
18
+
19
+ The asymmetry is deliberate and is what makes FALSIFY look *surgical*: in the demo the
20
+ orphaned Conclusion K is deleted, while the refuted Evidence E_qa is kept — flagged
21
+ red — because it is the supersedes-anchor of the new fact.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from dataclasses import dataclass, field
28
+ from typing import Dict, List, Set
29
+
30
+ from falsify import graph_ops
31
+ from falsify.edges import CONSUMER_EDGE_TYPES, SUPERSEDES
32
+ from falsify.models import (
33
+ Conclusion,
34
+ Evidence,
35
+ TruthState,
36
+ )
37
+
38
+ logger = logging.getLogger("falsify.forget")
39
+
40
+ _DELETABLE_STATES = {TruthState.REFUTED.value, TruthState.INVALIDATED.value}
41
+ _ALIVE = TruthState.ALIVE.value
42
+
43
+ # Vector collections FALSIFY writes to (``"{ClassName}_{embeddable_field}"``).
44
+ # Deleting an id from a collection it isn't in is a best-effort no-op.
45
+ _VECTOR_COLLECTIONS = [
46
+ "Evidence_claim",
47
+ "Conclusion_statement",
48
+ "Hypothesis_statement",
49
+ "Assertion_text",
50
+ "InvestigationQuestion_question",
51
+ ]
52
+
53
+
54
+ @dataclass
55
+ class ForgetResult:
56
+ """Outcome of a forget pass.
57
+
58
+ Attributes:
59
+ forgotten: node ids hard-deleted from graph + vector.
60
+ retained_provenance: refuted/invalidated ids deliberately kept (a supersedes
61
+ anchor, or still feeding a live node).
62
+ labels: id -> human label for the forgotten nodes (for demo output).
63
+ """
64
+
65
+ forgotten: List[str] = field(default_factory=list)
66
+ retained_provenance: List[str] = field(default_factory=list)
67
+ labels: Dict[str, str] = field(default_factory=dict)
68
+
69
+
70
+ async def _alive_consumer_exists(node_id: str, edges, truth: Dict[str, List[str]]) -> bool:
71
+ """True if some ALIVE node reaches ``node_id`` via depends_on/supports.
72
+
73
+ ``depends_on`` (Conclusion->Evidence) and ``supports`` (Evidence->Hypothesis) both
74
+ point *from consumer to the thing consumed*, so an incoming edge's **source** is a
75
+ consumer of ``node_id``.
76
+ """
77
+ nid = str(node_id)
78
+ for rel in CONSUMER_EDGE_TYPES:
79
+ for src, _props in graph_ops.incoming(nid, edges, rel):
80
+ alignment = truth.get(str(src), [_ALIVE])
81
+ if _ALIVE in alignment:
82
+ return True
83
+ return False
84
+
85
+
86
+ def _is_supersedes_anchor(node_id: str, edges, truth: Dict[str, List[str]]) -> bool:
87
+ """True if ``node_id`` is the target of a ``supersedes`` edge from an ALIVE node.
88
+
89
+ That alive source is the new, current truth; the target is its tombstone and must
90
+ be retained as provenance.
91
+ """
92
+ nid = str(node_id)
93
+ for src, _props in graph_ops.incoming(nid, edges, SUPERSEDES):
94
+ alignment = truth.get(str(src), [_ALIVE])
95
+ if _ALIVE in alignment:
96
+ return True
97
+ return False
98
+
99
+
100
+ async def cascade_forget(candidate_ids: List[str]) -> ForgetResult:
101
+ """Delete truly-orphaned dead-ends among ``candidate_ids``; keep provenance.
102
+
103
+ Args:
104
+ candidate_ids: nodes marked refuted/invalidated by a preceding cascade.
105
+
106
+ Returns:
107
+ A :class:`ForgetResult`. Deleted nodes are removed from the graph and from
108
+ every FALSIFY vector collection, so no retrieval path can resurface them.
109
+ """
110
+ result = ForgetResult()
111
+ candidates = [str(c) for c in dict.fromkeys(candidate_ids) if c]
112
+ if not candidates:
113
+ return result
114
+
115
+ nodes, edges = await graph_ops.load_graph()
116
+ props_by_id = {str(nid): (props or {}) for nid, props in nodes}
117
+
118
+ # Current truth for candidates + their neighbors (consumers/anchors).
119
+ neighbor_ids: Set[str] = set(candidates)
120
+ for cid in candidates:
121
+ for rel in (*CONSUMER_EDGE_TYPES, SUPERSEDES):
122
+ neighbor_ids.update(str(s) for s, _p in graph_ops.incoming(cid, edges, rel))
123
+ truth = await graph_ops.get_truth(list(neighbor_ids))
124
+
125
+ death_set: List[str] = []
126
+ for cid in candidates:
127
+ alignment = truth.get(cid, [_ALIVE])
128
+
129
+ # (a) must be refuted/invalidated
130
+ if not any(state in _DELETABLE_STATES for state in alignment):
131
+ continue
132
+ # (c) keep supersedes anchors (provenance tombstones)
133
+ if _is_supersedes_anchor(cid, edges, truth):
134
+ result.retained_provenance.append(cid)
135
+ logger.info("retained %s as supersedes provenance anchor", cid)
136
+ continue
137
+ # (b) keep nodes that still feed a live consumer
138
+ if await _alive_consumer_exists(cid, edges, truth):
139
+ result.retained_provenance.append(cid)
140
+ logger.info("retained %s (still feeds a live node)", cid)
141
+ continue
142
+
143
+ death_set.append(cid)
144
+ result.labels[cid] = graph_ops.node_label(props_by_id.get(cid, {}))
145
+
146
+ if not death_set:
147
+ logger.info("forget pass: nothing orphaned; %d provenance nodes retained",
148
+ len(result.retained_provenance))
149
+ return result
150
+
151
+ # Hard-delete from graph + all vector collections in one batch.
152
+ deleted = await graph_ops.delete_from_both_stores(death_set, _VECTOR_COLLECTIONS)
153
+ result.forgotten = death_set
154
+ logger.info(
155
+ "forget pass: hard-deleted %d orphan(s) from graph + vector; retained %d provenance",
156
+ deleted,
157
+ len(result.retained_provenance),
158
+ )
159
+ return result
cognee-hackathon-project-main/falsify/tasks/detect_contradictions.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Two-gate contradiction detection — the trigger for belief revision.
3
+
4
+ Deciding that a new fact *contradicts* an existing belief is the one genuinely
5
+ fuzzy step in FALSIFY. A false positive nukes a valid conclusion; a false negative
6
+ lets a stale fact survive. We therefore gate it twice, cheap-deterministic first,
7
+ expensive-semantic second, with a hard deterministic override for the live demo.
8
+
9
+ Gate 1 — vector prefilter (deterministic).
10
+ Embed the new fact and search the ``Evidence_claim`` collection. Only
11
+ evidence within a cosine-distance threshold (``< 0.35`` by default, i.e.
12
+ clearly on-topic) proceeds. This narrows the LLM to plausibly-conflicting
13
+ claims and keeps cost + nondeterminism bounded.
14
+
15
+ Gate 2 — LLM adjudication (semantic).
16
+ For each surviving candidate, ask an LLM acting as a skeptical analyst to
17
+ classify the relation as contradicts / supersedes / supports / unrelated with
18
+ a confidence. Only ``contradicts`` or ``supersedes`` at confidence >= 0.6
19
+ triggers refutation. This separates a genuine contradiction ("the report was
20
+ back-dated") from mere topical overlap ("also mentions the report").
21
+
22
+ Demo override (``pinned_target_id`` / ``DEMO_MODE``).
23
+ When set, gates are bypassed and a fixed high-confidence ``contradicts``
24
+ verdict is returned for the pinned evidence id, so the on-stage cascade runs
25
+ on real graph APIs even if the LLM is slow, rate-limited, or the key is
26
+ absent. This is FALSIFY's demo safety net (REQUIREMENTS §1.3).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ from dataclasses import dataclass, field
33
+ from typing import List, Literal, Optional
34
+
35
+ from pydantic import BaseModel, Field
36
+
37
+ from falsify import graph_ops
38
+
39
+ logger = logging.getLogger("falsify.detect")
40
+
41
+ # Cosine distance below which two claims are "about the same thing" (Gate 1).
42
+ DEFAULT_DISTANCE_THRESHOLD = 0.35
43
+ # Minimum LLM confidence for a contradiction/supersede to count (Gate 2).
44
+ DEFAULT_CONFIDENCE_THRESHOLD = 0.6
45
+ # Vector collection holding Evidence claims.
46
+ _EVIDENCE_COLLECTION = "Evidence_claim"
47
+
48
+ _SYSTEM_PROMPT = (
49
+ "You are a skeptical forensic analyst. You are given an EXISTING evidence claim "
50
+ "and a NEW fact. Decide the logical relation of the NEW fact to the EXISTING "
51
+ "claim. Answer 'contradicts' only if the new fact makes the existing claim false "
52
+ "or untrustworthy (e.g. it was fabricated, back-dated, retracted, or refuted). "
53
+ "Answer 'supersedes' if the new fact replaces the existing claim with a newer, "
54
+ "more authoritative version of the same fact. Answer 'supports' if it corroborates "
55
+ "the claim, and 'unrelated' otherwise. Be conservative: when unsure, prefer "
56
+ "'unrelated'. Provide a calibrated confidence in [0,1] and a one-sentence rationale."
57
+ )
58
+
59
+
60
+ class ContradictionJudgement(BaseModel):
61
+ """Structured verdict returned by the Gate-2 LLM adjudication."""
62
+
63
+ relation: Literal["contradicts", "supersedes", "supports", "unrelated"]
64
+ confidence: float = Field(ge=0.0, le=1.0)
65
+ rationale: str = ""
66
+
67
+
68
+ @dataclass
69
+ class Contradiction:
70
+ """A confirmed conflict between the new fact and an existing evidence node.
71
+
72
+ Attributes:
73
+ target_id: the existing Evidence node id that is contradicted/superseded.
74
+ relation: ``contradicts`` or ``supersedes``.
75
+ confidence: adjudicated confidence.
76
+ rationale: short human explanation (shown in the demo).
77
+ distance: Gate-1 cosine distance (lower = more on-topic).
78
+ """
79
+
80
+ target_id: str
81
+ relation: str
82
+ confidence: float
83
+ rationale: str = ""
84
+ distance: float = 0.0
85
+
86
+
87
+ async def detect_contradictions(
88
+ new_fact: str,
89
+ *,
90
+ pinned_target_id: Optional[str] = None,
91
+ distance_threshold: float = DEFAULT_DISTANCE_THRESHOLD,
92
+ confidence_threshold: float = DEFAULT_CONFIDENCE_THRESHOLD,
93
+ max_candidates: int = 5,
94
+ ) -> List[Contradiction]:
95
+ """Return the existing evidence nodes that ``new_fact`` contradicts or supersedes.
96
+
97
+ Args:
98
+ new_fact: the incoming claim (e.g. the Session-2 forensic finding).
99
+ pinned_target_id: demo/deterministic override; if given, gates are skipped and
100
+ a single high-confidence ``contradicts`` verdict is returned for this id.
101
+ distance_threshold: Gate-1 cosine-distance cutoff (lower = stricter on-topic).
102
+ confidence_threshold: Gate-2 minimum confidence to accept a verdict.
103
+ max_candidates: cap on Gate-1 candidates sent to the LLM.
104
+
105
+ Returns:
106
+ A list of :class:`Contradiction` (possibly empty). Callers feed the target
107
+ ids into :func:`falsify.tasks.propagate_refutation.propagate_refutation`.
108
+ """
109
+ # -------- Demo / deterministic override -------------------------------
110
+ if pinned_target_id:
111
+ logger.info("detect_contradictions: pinned target %s (demo mode)", pinned_target_id)
112
+ return [
113
+ Contradiction(
114
+ target_id=str(pinned_target_id),
115
+ relation="contradicts",
116
+ confidence=0.9,
117
+ rationale="Pinned contradiction (demo mode): new fact invalidates the target evidence.",
118
+ distance=0.0,
119
+ )
120
+ ]
121
+
122
+ # -------- Gate 1: vector prefilter ------------------------------------
123
+ ve = graph_ops.get_vector_engine()
124
+ try:
125
+ hits = await ve.search(
126
+ _EVIDENCE_COLLECTION,
127
+ query_text=new_fact,
128
+ limit=max_candidates,
129
+ include_payload=True,
130
+ )
131
+ except Exception as exc:
132
+ logger.warning("Gate-1 vector search failed (%s); no contradictions detected", exc)
133
+ return []
134
+
135
+ candidates = [(str(h.id), float(getattr(h, "score", 1.0)), getattr(h, "payload", {}) or {})
136
+ for h in (hits or [])]
137
+ on_topic = [c for c in candidates if c[1] < distance_threshold]
138
+ logger.info(
139
+ "Gate-1: %d hit(s), %d within distance %.2f", len(candidates), len(on_topic), distance_threshold
140
+ )
141
+ if not on_topic:
142
+ return []
143
+
144
+ # -------- Gate 2: LLM adjudication ------------------------------------
145
+ from cognee.infrastructure.llm.LLMGateway import LLMGateway
146
+
147
+ confirmed: List[Contradiction] = []
148
+ for target_id, distance, payload in on_topic:
149
+ existing_claim = _payload_text(payload) or "(existing evidence claim)"
150
+ text_input = (
151
+ f"EXISTING claim:\n{existing_claim}\n\nNEW fact:\n{new_fact}\n\n"
152
+ "Classify the relation of the NEW fact to the EXISTING claim."
153
+ )
154
+ try:
155
+ verdict: ContradictionJudgement = await LLMGateway.acreate_structured_output(
156
+ text_input=text_input,
157
+ system_prompt=_SYSTEM_PROMPT,
158
+ response_model=ContradictionJudgement,
159
+ )
160
+ except Exception as exc:
161
+ logger.warning("Gate-2 LLM judge failed for %s (%s); skipping candidate", target_id, exc)
162
+ continue
163
+
164
+ if verdict.relation in ("contradicts", "supersedes") and verdict.confidence >= confidence_threshold:
165
+ confirmed.append(
166
+ Contradiction(
167
+ target_id=target_id,
168
+ relation=verdict.relation,
169
+ confidence=verdict.confidence,
170
+ rationale=verdict.rationale,
171
+ distance=distance,
172
+ )
173
+ )
174
+ logger.info(
175
+ "Gate-2: CONFIRMED %s on %s (conf=%.2f)", verdict.relation, target_id, verdict.confidence
176
+ )
177
+ else:
178
+ logger.info(
179
+ "Gate-2: rejected %s (relation=%s conf=%.2f)",
180
+ target_id, verdict.relation, verdict.confidence,
181
+ )
182
+
183
+ return confirmed
184
+
185
+
186
+ def _payload_text(payload: dict) -> Optional[str]:
187
+ """Extract the human-readable claim text from a vector-hit payload."""
188
+ if not payload:
189
+ return None
190
+ for key in ("claim", "text", "statement", "content"):
191
+ if payload.get(key):
192
+ return str(payload[key])
193
+ # cognee payloads sometimes nest the original properties
194
+ props = payload.get("properties") or payload.get("metadata")
195
+ if isinstance(props, dict):
196
+ for key in ("claim", "text", "statement"):
197
+ if props.get(key):
198
+ return str(props[key])
199
+ return None
cognee-hackathon-project-main/falsify/tasks/propagate_refutation.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Forward refutation propagation — the core of FALSIFY's belief revision.
3
+
4
+ When a piece of Evidence is refuted, the conclusions that *depend on it* can no
5
+ longer stand. This module walks the dependency structure and flips the truth-state
6
+ of every node that transitively rests on the refuted evidence, then re-scores the
7
+ competing hypotheses.
8
+
9
+ Why this can't be done by RAG
10
+ -----------------------------
11
+ Vector similarity has no notion of "this fact supports that conclusion three hops
12
+ away." Refutation propagation is *graph traversal over typed edges* — it is exactly
13
+ the thing a knowledge graph can do and an embedding index cannot. This is FALSIFY's
14
+ differentiator and maps directly to the hackathon's "Best Use of Cognee" criterion.
15
+
16
+ Direction of travel (critical detail)
17
+ -------------------------------------
18
+ The ``depends_on`` edge points **Conclusion -> Evidence** (a conclusion depends on
19
+ the evidence it rests on). So to find what *breaks* when Evidence ``E`` is refuted,
20
+ we look for ``depends_on`` edges whose **target** is ``E``; their **sources** are the
21
+ dependent Conclusions. We then recurse: a newly-invalidated Conclusion may itself be
22
+ the target of further ``depends_on`` edges.
23
+
24
+ Correctness cases handled (REQUIREMENTS §4.3)
25
+ --------------------------------------------
26
+ * **Cycle safety** — a ``visited`` set guarantees termination on cyclic graphs.
27
+ * **Critical vs non-critical** — only a ``critical: true`` dependency can invalidate
28
+ a conclusion. A non-critical dependency being refuted decays confidence but the
29
+ conclusion stays ``alive``.
30
+ * **Diamond / partial refutation** — a conclusion with several critical supporters is
31
+ invalidated only when it loses its **last** alive critical supporter. If an
32
+ alternative critical support is still alive, the conclusion survives (and the
33
+ refuted evidence is *retained*, because it still feeds a live node).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ from dataclasses import dataclass, field
40
+ from typing import Dict, List, Optional, Set
41
+
42
+ from falsify import graph_ops
43
+ from falsify.edges import (
44
+ DEPENDENCY_EDGE_TYPES,
45
+ DEPENDS_ON,
46
+ SUPPORTS,
47
+ is_critical_dependency,
48
+ )
49
+ from falsify.models import TruthState
50
+
51
+ logger = logging.getLogger("falsify.propagate")
52
+
53
+ # Truth states that count as "dead" for the purpose of dependency support.
54
+ _DEAD_STATES = {TruthState.REFUTED.value, TruthState.INVALIDATED.value, TruthState.FORGOTTEN.value}
55
+
56
+
57
+ @dataclass
58
+ class PropagationResult:
59
+ """Outcome of a refutation cascade.
60
+
61
+ Attributes:
62
+ refuted: evidence node ids set to ``refuted`` (the cascade seeds).
63
+ invalidated: conclusion node ids set to ``invalidated`` by the cascade.
64
+ weakened: conclusion ids whose confidence decayed but stayed ``alive``
65
+ (a non-critical dependency was refuted).
66
+ epoch: the revision epoch stamped on every state change in this cascade.
67
+ affected: convenience union of refuted + invalidated ids (the death set
68
+ candidates for :mod:`falsify.tasks.cascade_forget`).
69
+ """
70
+
71
+ refuted: List[str] = field(default_factory=list)
72
+ invalidated: List[str] = field(default_factory=list)
73
+ weakened: List[str] = field(default_factory=list)
74
+ epoch: int = 0
75
+
76
+ @property
77
+ def affected(self) -> List[str]:
78
+ return list(dict.fromkeys(self.refuted + self.invalidated))
79
+
80
+
81
+ async def _next_epoch() -> int:
82
+ """Return a monotonically increasing revision epoch.
83
+
84
+ We derive it from the current maximum ``truth_epoch`` present on any node so the
85
+ counter survives restarts (state is persisted on nodes). Falls back to 1.
86
+ """
87
+ try:
88
+ nodes, _edges = await graph_ops.load_graph()
89
+ max_epoch = 0
90
+ for _nid, props in nodes:
91
+ ep = props.get("truth_epoch")
92
+ if isinstance(ep, int) and ep > max_epoch:
93
+ max_epoch = ep
94
+ return max_epoch + 1
95
+ except Exception as exc: # pragma: no cover - defensive
96
+ logger.debug("epoch derivation failed (%s); defaulting to 1", exc)
97
+ return 1
98
+
99
+
100
+ async def propagate_refutation(
101
+ refuted_evidence_ids: List[str],
102
+ epoch: Optional[int] = None,
103
+ ) -> PropagationResult:
104
+ """Refute the given evidence and cascade the consequence forward.
105
+
106
+ Args:
107
+ refuted_evidence_ids: evidence node ids directly contradicted by a new fact.
108
+ epoch: optional explicit revision epoch; if omitted a fresh one is derived.
109
+
110
+ Returns:
111
+ A :class:`PropagationResult` describing what changed. All state changes are
112
+ persisted on the graph nodes via ``set_node_truth_state`` (so they survive a
113
+ process restart — the basis of cross-session belief revision).
114
+
115
+ Algorithm — grounded least-fixpoint justification
116
+ -------------------------------------------------
117
+ A conclusion is *justified* only if it has a **critical** ``depends_on`` support
118
+ chain that bottoms out in a still-alive node. We therefore:
119
+
120
+ 1. Mark each seed evidence ``refuted``.
121
+ 2. Build the "dead" set = seeds plus anything already refuted / invalidated /
122
+ superseded from prior revisions.
123
+ 3. Compute the GROUNDED set as a least fixpoint: a node is grounded if it is
124
+ not dead and either (a) it has no critical ``depends_on`` edges (a base
125
+ node — evidence, or a conclusion resting only on non-critical support) or
126
+ (b) at least one of its critical dependencies is itself grounded. Iterate
127
+ to convergence.
128
+ 4. Every *conclusion* (a node that is the source of a ``depends_on`` edge)
129
+ that is currently alive but **not** grounded is ``invalidated``.
130
+ 5. A conclusion that survives (stays grounded) yet lost some dependency to the
131
+ dead set is merely ``weakened`` (confidence decayed).
132
+
133
+ This single formulation is correct for chains, diamonds (survives while any
134
+ critical alternative is grounded), partial/non-critical refutation, **and cycles**
135
+ (a mutually-supporting loop with no grounded base is not justified, so it
136
+ collapses) — the fixpoint terminates because ``grounded`` only ever grows.
137
+ """
138
+ result = PropagationResult(epoch=epoch if epoch is not None else await _next_epoch())
139
+ seeds = [str(e) for e in refuted_evidence_ids if e]
140
+ if not seeds:
141
+ logger.info("propagate_refutation called with no seeds; nothing to do")
142
+ return result
143
+
144
+ nodes, edges = await graph_ops.load_graph()
145
+ node_ids = [str(nid) for nid, _p in nodes]
146
+
147
+ # 1) seed refutations (persisted)
148
+ for ev_id in seeds:
149
+ await graph_ops.set_state(ev_id, TruthState.REFUTED, result.epoch)
150
+ await graph_ops.set_weight(ev_id, 0.0)
151
+ result.refuted.append(ev_id)
152
+ logger.info("refuted evidence %s", ev_id)
153
+
154
+ # 2) dead set = seeds + already-dead-from-prior-revisions
155
+ truth = await graph_ops.get_truth(node_ids)
156
+ dead: Set[str] = set(seeds)
157
+ for nid in node_ids:
158
+ alignment = truth.get(nid, [TruthState.ALIVE.value])
159
+ if any(s in _DEAD_STATES or s == TruthState.SUPERSEDED.value for s in alignment):
160
+ dead.add(nid)
161
+
162
+ # Dependency structure: node -> critical / all depends_on targets.
163
+ conclusions: Set[str] = set()
164
+ critical_targets: Dict[str, Set[str]] = {}
165
+ all_targets: Dict[str, Set[str]] = {}
166
+ for src, dst, rel, props in edges:
167
+ if rel not in DEPENDENCY_EDGE_TYPES:
168
+ continue
169
+ s, d = str(src), str(dst)
170
+ conclusions.add(s)
171
+ all_targets.setdefault(s, set()).add(d)
172
+ if is_critical_dependency(rel, props):
173
+ critical_targets.setdefault(s, set()).add(d)
174
+
175
+ def _has_critical(n: str) -> bool:
176
+ return bool(critical_targets.get(n))
177
+
178
+ # 3) grounded least fixpoint
179
+ grounded: Set[str] = {nid for nid in node_ids if nid not in dead and not _has_critical(nid)}
180
+ changed = True
181
+ while changed:
182
+ changed = False
183
+ for c in conclusions:
184
+ if c in grounded or c in dead:
185
+ continue
186
+ if critical_targets.get(c, set()) & grounded:
187
+ grounded.add(c)
188
+ changed = True
189
+
190
+ # 4) invalidate currently-alive conclusions that lost grounding
191
+ for c in conclusions:
192
+ if c in grounded:
193
+ continue
194
+ alignment = truth.get(c, [TruthState.ALIVE.value])
195
+ if TruthState.ALIVE.value not in alignment:
196
+ continue # already dead in a prior revision; don't re-report
197
+ await graph_ops.set_state(c, TruthState.INVALIDATED, result.epoch)
198
+ await graph_ops.set_weight(c, 0.0)
199
+ result.invalidated.append(c)
200
+ logger.info("invalidated conclusion %s (lost grounded critical support)", c)
201
+
202
+ # 5) weaken survivors that lost some dependency to the dead set
203
+ for c in conclusions:
204
+ if c not in grounded:
205
+ continue
206
+ if all_targets.get(c, set()) & dead:
207
+ result.weakened.append(c)
208
+ await graph_ops.set_weight(c, 0.3)
209
+ logger.info("weakened conclusion %s (lost a dependency but stays grounded)", c)
210
+
211
+ logger.info(
212
+ "propagation done: refuted=%d invalidated=%d weakened=%d epoch=%d",
213
+ len(result.refuted),
214
+ len(result.invalidated),
215
+ len(result.weakened),
216
+ result.epoch,
217
+ )
218
+ return result
219
+
220
+
221
+ async def promote_competing_hypothesis(
222
+ refuted_evidence_ids: List[str],
223
+ epoch: int,
224
+ ) -> Dict[str, str]:
225
+ """Demote hypotheses whose support just died; promote the strongest survivor.
226
+
227
+ A hypothesis is ``superseded`` when every ``supports`` Evidence pointing at it is
228
+ now dead. Among the hypotheses still holding at least one alive ``supports`` edge,
229
+ the one with the greatest summed support ``weight`` is promoted (its feedback
230
+ weight is boosted) and becomes the new frontier answer.
231
+
232
+ Returns a dict mapping hypothesis id -> action (``"superseded"`` / ``"promoted"``).
233
+ """
234
+ actions: Dict[str, str] = {}
235
+ _nodes, edges = await graph_ops.load_graph()
236
+
237
+ # Collect hypotheses that are the target of any supports edge.
238
+ supports_edges = [(s, d, p) for (s, d, r, p) in edges if r == SUPPORTS]
239
+ hypothesis_ids = {str(d) for (_s, d, _p) in supports_edges}
240
+ if not hypothesis_ids:
241
+ return actions
242
+
243
+ # Determine current dead evidence set (seeds + anything already refuted/invalidated).
244
+ all_ids = list({str(s) for (s, _d, _p) in supports_edges} | {str(e) for e in refuted_evidence_ids})
245
+ truth = await graph_ops.get_truth(all_ids)
246
+
247
+ def _is_dead(node_id: str) -> bool:
248
+ alignment = truth.get(str(node_id), [TruthState.ALIVE.value])
249
+ return any(state in _DEAD_STATES for state in alignment) or str(node_id) in {
250
+ str(e) for e in refuted_evidence_ids
251
+ }
252
+
253
+ # Score each hypothesis by its surviving support.
254
+ live_support: Dict[str, float] = {}
255
+ for hyp_id in hypothesis_ids:
256
+ total = 0.0
257
+ for (src, dst, props) in supports_edges:
258
+ if str(dst) != hyp_id:
259
+ continue
260
+ if _is_dead(src):
261
+ continue
262
+ total += float(props.get("weight", 0.5))
263
+ live_support[hyp_id] = total
264
+
265
+ # Demote hypotheses with zero surviving support.
266
+ for hyp_id, score in live_support.items():
267
+ if score <= 0.0:
268
+ await graph_ops.set_state(hyp_id, TruthState.SUPERSEDED, epoch)
269
+ await graph_ops.set_weight(hyp_id, 0.0)
270
+ actions[hyp_id] = "superseded"
271
+ logger.info("superseded hypothesis %s (no surviving support)", hyp_id)
272
+
273
+ # Promote the strongest surviving hypothesis, if any.
274
+ survivors = {h: s for h, s in live_support.items() if s > 0.0}
275
+ if survivors:
276
+ winner = max(survivors, key=survivors.get)
277
+ await graph_ops.set_weight(winner, 1.0)
278
+ actions[winner] = "promoted"
279
+ logger.info("promoted hypothesis %s (support=%.2f) as new frontier", winner, survivors[winner])
280
+
281
+ return actions
cognee-hackathon-project-main/falsify/utils.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY presentation helpers — console state + interactive graph visualization.
3
+
4
+ These functions turn the belief graph into the two things a judge actually sees:
5
+
6
+ * :func:`print_graph_state` — the BEFORE/AFTER console beat, each node tagged with a
7
+ colored ``ALIVE`` / ``REFUTED`` / ``INVALIDATED`` / ``SUPERSEDED`` marker.
8
+ * :func:`visualize_belief_graph` — a single self-contained HTML file (vis-network via
9
+ CDN, no build step) that colors nodes by truth-state so the cascade is legible at a
10
+ glance: green = alive, red (dashed) = refuted, grey = invalidated, amber = superseded.
11
+ * :func:`get_belief_summary` — counts by type/state, used in output and tests.
12
+ * :func:`verify_propagation` — a small assertion helper for tests.
13
+
14
+ Everything degrades gracefully on an empty graph and never hard-depends on ``rich``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import html
20
+ import json
21
+ import logging
22
+ import os
23
+ from typing import Dict, List, Optional
24
+
25
+ from falsify import graph_ops
26
+ from falsify.models import TruthState
27
+
28
+ logger = logging.getLogger("falsify.utils")
29
+
30
+ # truth-state -> (console tag, hex color for the graph)
31
+ _STATE_STYLE = {
32
+ TruthState.ALIVE.value: ("ALIVE", "#22c55e"),
33
+ TruthState.REFUTED.value: ("REFUTED", "#ef4444"),
34
+ TruthState.INVALIDATED.value: ("INVALIDATED", "#9ca3af"),
35
+ TruthState.SUPERSEDED.value: ("SUPERSEDED", "#f59e0b"),
36
+ TruthState.FORGOTTEN.value: ("FORGOTTEN", "#4b5563"),
37
+ }
38
+
39
+ # ANSI colors for console tags (fall back to plain text if not a TTY).
40
+ _ANSI = {
41
+ "ALIVE": "\033[92m",
42
+ "REFUTED": "\033[91m",
43
+ "INVALIDATED": "\033[90m",
44
+ "SUPERSEDED": "\033[93m",
45
+ "FORGOTTEN": "\033[90m",
46
+ }
47
+ _RESET = "\033[0m"
48
+
49
+
50
+ def _colored(tag: str) -> str:
51
+ """Return an ANSI-colored tag if stdout is a TTY, else the plain tag."""
52
+ if os.environ.get("NO_COLOR") or not _stdout_is_tty():
53
+ return tag
54
+ return f"{_ANSI.get(tag, '')}{tag}{_RESET}"
55
+
56
+
57
+ def _stdout_is_tty() -> bool:
58
+ try:
59
+ import sys
60
+
61
+ return bool(sys.stdout.isatty())
62
+ except Exception:
63
+ return False
64
+
65
+
66
+ async def _state_of(node_ids: List[str]) -> Dict[str, str]:
67
+ """Return ``{id: single-state-string}`` (first alignment entry, default alive)."""
68
+ truth = await graph_ops.get_truth(node_ids)
69
+ return {nid: (align[0] if align else TruthState.ALIVE.value) for nid, align in truth.items()}
70
+
71
+
72
+ async def get_belief_summary(dataset: Optional[str] = None) -> Dict[str, Dict[str, int]]:
73
+ """Count nodes grouped by node type and truth-state.
74
+
75
+ Returns e.g. ``{"Hypothesis": {"alive": 2, "superseded": 1}, "Conclusion": {...}}``.
76
+ Node type is inferred from the node's ``type`` property, falling back to the
77
+ dominant embeddable field present.
78
+ """
79
+ nodes, _edges = await graph_ops.load_graph()
80
+ if not nodes:
81
+ return {}
82
+ states = await _state_of([str(nid) for nid, _p in nodes])
83
+ summary: Dict[str, Dict[str, int]] = {}
84
+ for nid, props in nodes:
85
+ ntype = _infer_type(props)
86
+ state = states.get(str(nid), TruthState.ALIVE.value)
87
+ summary.setdefault(ntype, {})
88
+ summary[ntype][state] = summary[ntype].get(state, 0) + 1
89
+ return summary
90
+
91
+
92
+ def _infer_type(props: dict) -> str:
93
+ """Best-effort node-type label from properties."""
94
+ if props.get("type"):
95
+ return str(props["type"])
96
+ for field, label in (
97
+ ("question", "InvestigationQuestion"),
98
+ ("claim", "Evidence"),
99
+ ("statement", "Hypothesis/Conclusion"),
100
+ ("text", "Assertion"),
101
+ ):
102
+ if props.get(field):
103
+ return label
104
+ return "Node"
105
+
106
+
107
+ async def print_graph_state(title: str, dataset: Optional[str] = None) -> None:
108
+ """Print every node with a colored truth-state tag (the BEFORE/AFTER beat)."""
109
+ nodes, _edges = await graph_ops.load_graph()
110
+ print(f"\n{'=' * 64}\n {title}\n{'=' * 64}")
111
+ if not nodes:
112
+ print(" (empty graph)")
113
+ return
114
+ states = await _state_of([str(nid) for nid, _p in nodes])
115
+ # Stable, readable ordering: questions, hypotheses, evidence, conclusions.
116
+ order = {"InvestigationQuestion": 0, "Hypothesis/Conclusion": 1, "Evidence": 2, "Assertion": 3}
117
+ rows = []
118
+ for nid, props in nodes:
119
+ ntype = _infer_type(props)
120
+ state = states.get(str(nid), TruthState.ALIVE.value)
121
+ tag = _STATE_STYLE.get(state, ("ALIVE", ""))[0]
122
+ label = graph_ops.node_label(props)
123
+ rows.append((order.get(ntype, 9), ntype, tag, label))
124
+ for _o, ntype, tag, label in sorted(rows, key=lambda r: r[0]):
125
+ clipped = label if len(label) <= 66 else label[:63] + "..."
126
+ print(f" [{_colored(tag):<22}] {ntype:<22} {clipped}")
127
+ print()
128
+
129
+
130
+ async def visualize_belief_graph(
131
+ out_path: str = "output/graph.html",
132
+ dataset: Optional[str] = None,
133
+ title: str = "FALSIFY belief graph",
134
+ ) -> Optional[str]:
135
+ """Write a self-contained interactive HTML visualization of the belief graph.
136
+
137
+ Nodes are colored by truth-state (green/red/grey/amber). Refuted nodes are drawn
138
+ with a dashed red border so the cascade result reads at a glance. Returns the
139
+ written path, or ``None`` if the graph is empty.
140
+ """
141
+ nodes, edges = await graph_ops.load_graph()
142
+ if not nodes:
143
+ logger.info("visualize_belief_graph: empty graph, nothing to draw")
144
+ return None
145
+
146
+ states = await _state_of([str(nid) for nid, _p in nodes])
147
+
148
+ vis_nodes = []
149
+ for nid, props in nodes:
150
+ nid = str(nid)
151
+ state = states.get(nid, TruthState.ALIVE.value)
152
+ tag, color = _STATE_STYLE.get(state, ("ALIVE", "#22c55e"))
153
+ label = graph_ops.node_label(props)
154
+ short = label if len(label) <= 40 else label[:37] + "..."
155
+ vis_nodes.append(
156
+ {
157
+ "id": nid,
158
+ "label": short,
159
+ "title": f"{_infer_type(props)} — {tag}\n{html.escape(label)}",
160
+ "color": {"background": color, "border": "#111827"},
161
+ "shapeProperties": {"borderDashes": state == TruthState.REFUTED.value},
162
+ "borderWidth": 3 if state == TruthState.REFUTED.value else 1,
163
+ "font": {"color": "#0b1020"},
164
+ }
165
+ )
166
+
167
+ vis_edges = []
168
+ for src, dst, rel, props in edges:
169
+ vis_edges.append(
170
+ {
171
+ "from": str(src),
172
+ "to": str(dst),
173
+ "label": rel,
174
+ "arrows": "to",
175
+ "font": {"align": "middle", "size": 10},
176
+ "color": {"color": "#94a3b8"},
177
+ }
178
+ )
179
+
180
+ os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
181
+ doc = _HTML_TEMPLATE.replace("__TITLE__", html.escape(title)) \
182
+ .replace("__NODES__", json.dumps(vis_nodes)) \
183
+ .replace("__EDGES__", json.dumps(vis_edges))
184
+ with open(out_path, "w", encoding="utf-8") as fh:
185
+ fh.write(doc)
186
+ logger.info("wrote visualization to %s (%d nodes, %d edges)", out_path, len(vis_nodes), len(vis_edges))
187
+ return out_path
188
+
189
+
190
+ async def verify_propagation(refuted_id: str, expected_affected: List[str]) -> bool:
191
+ """Test helper: assert every ``expected_affected`` id is now non-alive.
192
+
193
+ Returns True iff the refuted node is refuted and each expected dependent is in a
194
+ dead state (refuted/invalidated/forgotten or absent from the graph).
195
+ """
196
+ ids = [str(refuted_id)] + [str(x) for x in expected_affected]
197
+ truth = await graph_ops.get_truth(ids)
198
+ dead = {TruthState.REFUTED.value, TruthState.INVALIDATED.value, TruthState.FORGOTTEN.value}
199
+ nodes, _edges = await graph_ops.load_graph()
200
+ present = {str(nid) for nid, _p in nodes}
201
+
202
+ r_align = truth.get(str(refuted_id), [])
203
+ if TruthState.REFUTED.value not in r_align:
204
+ return False
205
+ for dep in expected_affected:
206
+ dep = str(dep)
207
+ if dep not in present: # forgotten (deleted) counts as affected
208
+ continue
209
+ if not any(s in dead for s in truth.get(dep, [TruthState.ALIVE.value])):
210
+ return False
211
+ return True
212
+
213
+
214
+ _HTML_TEMPLATE = """<!DOCTYPE html>
215
+ <html lang="en">
216
+ <head>
217
+ <meta charset="utf-8" />
218
+ <title>__TITLE__</title>
219
+ <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
220
+ <style>
221
+ body { margin:0; font-family: ui-sans-serif, system-ui, sans-serif; background:#0b1020; color:#e5e7eb; }
222
+ #hdr { padding:14px 18px; font-size:18px; font-weight:600; border-bottom:1px solid #1f2937; }
223
+ #legend { padding:8px 18px; font-size:13px; color:#9ca3af; }
224
+ .chip { display:inline-block; width:11px; height:11px; border-radius:3px; margin:0 5px 0 14px; vertical-align:middle; }
225
+ #net { width:100%; height:calc(100vh - 92px); }
226
+ </style>
227
+ </head>
228
+ <body>
229
+ <div id="hdr">__TITLE__</div>
230
+ <div id="legend">
231
+ <span class="chip" style="background:#22c55e"></span>alive
232
+ <span class="chip" style="background:#ef4444"></span>refuted
233
+ <span class="chip" style="background:#9ca3af"></span>invalidated
234
+ <span class="chip" style="background:#f59e0b"></span>superseded
235
+ </div>
236
+ <div id="net"></div>
237
+ <script>
238
+ const nodes = new vis.DataSet(__NODES__);
239
+ const edges = new vis.DataSet(__EDGES__);
240
+ const container = document.getElementById('net');
241
+ const options = {
242
+ physics: { stabilization: true, barnesHut: { gravitationalConstant: -8000, springLength: 150 } },
243
+ nodes: { shape: 'box', margin: 10, widthConstraint: { maximum: 200 } },
244
+ edges: { smooth: { type: 'cubicBezier' } }
245
+ };
246
+ new vis.Network(container, { nodes, edges }, options);
247
+ </script>
248
+ </body>
249
+ </html>
250
+ """
cognee-hackathon-project-main/main.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ FALSIFY — the AI research copilot that *revises*, not forgets.
4
+
5
+ Run this to watch belief revision happen on a real Cognee knowledge graph:
6
+
7
+ python main.py # full run (uses your LLM to judge the contradiction)
8
+ python main.py --demo # deterministic: pins the contradiction so the cascade
9
+ # always runs, even without/with a flaky LLM key
10
+ python main.py --keep # don't prune existing memory first (advanced)
11
+
12
+ The story (Company X recall investigation)
13
+ ------------------------------------------
14
+ Session 1 builds a belief graph with two competing hypotheses:
15
+ A — "X knew via the March 2021 QA report" (supported by evidence E_qa)
16
+ B — "X knew via a January 2021 supplier email" (supported by evidence E_email)
17
+ and a Conclusion K that *depends on* E_qa.
18
+
19
+ Session 2 drops ONE contradicting fact: "the March QA report was back-dated."
20
+ FALSIFY refutes E_qa, cascades the refutation forward (K collapses), promotes B as
21
+ the new frontier, and surgically forgets the orphaned conclusion — writing the
22
+ disbelief onto the graph so it survives a restart. A plain-RAG baseline, which has no
23
+ notion of truth-state, keeps citing the refuted March report. That contrast is the
24
+ whole point: *AI revised, not forgot.*
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import asyncio
31
+ import os
32
+ import sys
33
+
34
+ # Load .env before importing cognee/falsify so provider config is in place.
35
+ try:
36
+ from dotenv import load_dotenv
37
+
38
+ load_dotenv()
39
+ except Exception: # python-dotenv is optional; env may be set another way
40
+ pass
41
+
42
+ # Importing falsify sets single-user Cognee env defaults (access control off, cache on).
43
+ import falsify # noqa: F401 (import-time side effects)
44
+ from falsify.seed import NEW_FACT, QUESTION_TEXT
45
+
46
+
47
+ C = {
48
+ "b": "\033[1m", "dim": "\033[2m", "g": "\033[92m", "r": "\033[91m",
49
+ "y": "\033[93m", "c": "\033[96m", "x": "\033[0m",
50
+ }
51
+ if os.environ.get("NO_COLOR") or not sys.stdout.isatty():
52
+ C = {k: "" for k in C}
53
+
54
+
55
+ def banner(text: str) -> None:
56
+ print(f"\n{C['b']}{C['c']}{'━' * 64}{C['x']}")
57
+ print(f"{C['b']}{C['c']} {text}{C['x']}")
58
+ print(f"{C['b']}{C['c']}{'━' * 64}{C['x']}")
59
+
60
+
61
+ def _has_llm_key() -> bool:
62
+ """True if some LLM API key is configured (OpenAI-compatible or otherwise)."""
63
+ for var in ("LLM_API_KEY", "OPENAI_API_KEY"):
64
+ if os.environ.get(var):
65
+ return True
66
+ return False
67
+
68
+
69
+ def _print_no_key_help() -> None:
70
+ print(
71
+ f"""
72
+ {C['y']}{C['b']}No LLM API key found.{C['x']}
73
+
74
+ FALSIFY needs an OpenAI-compatible API key to (a) embed claims for the vector
75
+ prefilter and (b) judge contradictions. Set it up:
76
+
77
+ 1. cp .env.template .env
78
+ 2. edit .env and set:
79
+ LLM_API_KEY="your_key_here"
80
+ LLM_MODEL="gpt-4o-mini" # or any OpenAI-compatible model
81
+ # For a non-OpenAI endpoint (OpenRouter, vLLM, LM Studio, Groq):
82
+ # LLM_PROVIDER="custom"
83
+ # LLM_ENDPOINT="https://your-endpoint/v1"
84
+ 3. re-run: python main.py
85
+
86
+ {C['dim']}Tip: `python main.py --demo` runs FULLY OFFLINE — fastembed handles
87
+ embeddings locally and the contradiction is pinned, so no API key is needed at all.
88
+ A key is only required for LIVE mode, where the LLM judges the contradiction.{C['x']}
89
+ """
90
+ )
91
+
92
+
93
+ async def run(demo: bool, keep: bool) -> int:
94
+ from falsify.falsify import build_graph, revise, scoreboard
95
+ from falsify.utils import get_belief_summary, print_graph_state, visualize_belief_graph
96
+
97
+ banner("FALSIFY — belief-revision research copilot")
98
+ print(f" Research question: {C['b']}{QUESTION_TEXT}{C['x']}")
99
+ print(f" Mode: {'DEMO (deterministic contradiction pin)' if demo else 'LIVE (LLM judge)'}")
100
+
101
+ # ---- Session 1: build the belief graph -------------------------------
102
+ banner("SESSION 1 — build the investigation")
103
+ if keep:
104
+ from cognee.low_level import setup
105
+
106
+ from falsify.seed import build_investigation
107
+
108
+ await setup()
109
+ seeded = await build_investigation()
110
+ else:
111
+ seeded = await build_graph()
112
+ await print_graph_state("BEFORE — both hypotheses stand, Conclusion K rests on E_qa")
113
+
114
+ # ---- Session 2: drop the contradicting fact --------------------------
115
+ banner("SESSION 2 — a new fact arrives")
116
+ print(f" {C['y']}New fact:{C['x']} {NEW_FACT}\n")
117
+
118
+ pinned = seeded.refuted_target_id if demo else None
119
+ report = await revise(NEW_FACT, pinned_target_id=pinned)
120
+
121
+ if not report.revised:
122
+ print(f" {C['y']}No contradiction was confirmed — graph unchanged.{C['x']}")
123
+ print(f" {C['dim']}(Try `python main.py --demo` to force the cascade deterministically.){C['x']}")
124
+ else:
125
+ print(f" {C['r']}✗ refuted:{C['x']} {len(report.refuted)} evidence node(s)")
126
+ print(f" {C['r']}✗ invalidated:{C['x']} {len(report.invalidated)} conclusion(s)")
127
+ for hid, action in report.hypothesis_actions.items():
128
+ if action == "superseded":
129
+ mark = f"{C['r']}↓ superseded{C['x']}"
130
+ else:
131
+ mark = f"{C['g']}↑ promoted (new frontier){C['x']}"
132
+ print(f" hypothesis {hid[:8]} → {mark}")
133
+ for fid in report.forgotten:
134
+ label = report.forgotten_labels.get(fid, fid)
135
+ print(f" {C['dim']}🗑 forgotten (deleted from graph + vector):{C['x']} {label}")
136
+ if report.retained_provenance:
137
+ print(f" {C['dim']}⚑ kept as red provenance:{C['x']} {len(report.retained_provenance)} node(s)")
138
+
139
+ await print_graph_state("AFTER — refutation cascaded, B ignites, orphan forgotten")
140
+
141
+ # ---- The scoreboard: FALSIFY vs plain RAG ----------------------------
142
+ banner("SCOREBOARD — FALSIFY (revised) vs plain RAG (stale)")
143
+ board = await scoreboard(QUESTION_TEXT, seeded)
144
+ print(f" {C['g']}{C['b']}FALSIFY :{C['x']} {board.falsify_answer}")
145
+ if board.falsify_support:
146
+ print(f" {C['dim']}supported by: {', '.join(board.falsify_support)}{C['x']}")
147
+ rag_tag = f"{C['r']}[STALE — still cites a refuted fact]{C['x']}" if board.stale else ""
148
+ print(f" {C['y']}{C['b']}RAG :{C['x']} {board.rag_answer} {rag_tag}")
149
+ print(f"\n {C['b']}→ AI revised, not forgot.{C['x']}")
150
+
151
+ # ---- Cross-session proof: reload belief state fresh ------------------
152
+ banner("CROSS-SESSION PROOF — reopen memory, beliefs stay revised")
153
+ summary = await get_belief_summary()
154
+ print(f" Persisted belief state (re-read from graph): {summary}")
155
+ print(f" {C['dim']}Truth-state lives on the graph nodes, so a brand-new process sees the")
156
+ print(f" revised graph — the refuted branch never comes back.{C['x']}")
157
+
158
+ # ---- Visualization ---------------------------------------------------
159
+ out = await visualize_belief_graph("output/graph.html", title="FALSIFY — Company X investigation")
160
+ if out:
161
+ banner("VISUALIZATION")
162
+ print(f" Interactive belief graph written to: {C['b']}{os.path.abspath(out)}{C['x']}")
163
+ print(f" {C['dim']}Open it in a browser — red = refuted, grey = invalidated, green = alive.{C['x']}")
164
+
165
+ return 0
166
+
167
+
168
+ def main() -> int:
169
+ parser = argparse.ArgumentParser(description="FALSIFY belief-revision demo")
170
+ parser.add_argument("--demo", action="store_true",
171
+ help="pin the contradiction deterministically (LLM-independent cascade)")
172
+ parser.add_argument("--keep", action="store_true",
173
+ help="do not prune existing memory before building")
174
+ args = parser.parse_args()
175
+
176
+ if not _has_llm_key() and not args.demo:
177
+ _print_no_key_help()
178
+ # Live mode needs an LLM key to judge the contradiction. Exit cleanly (0) with
179
+ # setup help rather than a stack trace. (`--demo` runs fully offline below.)
180
+ return 0
181
+
182
+ try:
183
+ return asyncio.run(run(demo=args.demo, keep=args.keep))
184
+ except KeyboardInterrupt:
185
+ print("\ninterrupted.")
186
+ return 130
187
+
188
+
189
+ if __name__ == "__main__":
190
+ raise SystemExit(main())
cognee-hackathon-project-main/requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FALSIFY — dependencies
2
+ #
3
+ # The only hard requirement is Cognee (the self-hosted AI-memory platform this
4
+ # project is built on). Everything else is small tooling. Cognee itself pulls in
5
+ # LanceDB / Ladybug / SQLite / litellm, so there is no separate DB to install.
6
+
7
+ cognee>=0.1.0 # AI-memory platform: graph + vector + memify/forget APIs
8
+ fastembed>=0.3.0 # local, CPU-only embeddings (no API key needed for the demo)
9
+ python-dotenv>=1.0.0 # load .env configuration
10
+
11
+ # ── dev / test (optional: pip install -r requirements.txt already includes these) ──
12
+ pytest>=7.4.0
13
+ pytest-asyncio>=0.21.0
cognee-hackathon-project-main/tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """FALSIFY test suite."""
cognee-hackathon-project-main/tests/conftest.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pytest fixtures for FALSIFY.
3
+
4
+ The core belief-revision logic (refutation propagation, orphan forget, hypothesis
5
+ promotion) is pure graph reasoning: given nodes, typed edges, and truth-states, it
6
+ decides what to flip and what to delete. We test that logic against an in-memory
7
+ :class:`FakeGraph` that stands in for Cognee's graph/vector engines — so the whole
8
+ suite runs with **no API key, no database, no network**.
9
+
10
+ Only the handful of *engine-touching* helpers in :mod:`falsify.graph_ops` are
11
+ monkeypatched (``load_graph``, ``get_truth``, ``set_state``, ``set_weight``,
12
+ ``delete_from_both_stores``, ``get_vector_engine``). The pure adjacency helpers
13
+ (``dependents_of``, ``incoming``, ``outgoing``, ``node_label``) are exercised as-is,
14
+ so the tests cover the real code paths.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+
21
+ import pytest
22
+
23
+ from falsify import graph_ops
24
+ from falsify.models import TruthState
25
+
26
+ Edge = Tuple[str, str, str, Dict[str, Any]]
27
+ Node = Tuple[str, Dict[str, Any]]
28
+
29
+
30
+ class FakeGraph:
31
+ """A minimal in-memory graph mimicking the subset of the engine FALSIFY uses.
32
+
33
+ Nodes are ``{id: props}``; edges are ``(src, dst, rel, props)`` tuples; truth is
34
+ ``{id: {"truth_alignment": [...], "truth_epoch": n}}``. Deletions remove nodes and
35
+ record deleted ids/collections so tests can assert vector cleanup happened.
36
+ """
37
+
38
+ def __init__(self) -> None:
39
+ self.nodes: Dict[str, Dict[str, Any]] = {}
40
+ self.edges: List[Edge] = []
41
+ self.truth: Dict[str, Dict[str, Any]] = {}
42
+ self.weights: Dict[str, float] = {}
43
+ self.deleted: List[str] = []
44
+ self.deleted_from_collections: List[str] = []
45
+
46
+ # -- builders -------------------------------------------------------
47
+ def add_node(self, nid: str, **props: Any) -> str:
48
+ self.nodes[nid] = props
49
+ self.truth.setdefault(nid, {"truth_alignment": [TruthState.ALIVE.value], "truth_epoch": 0})
50
+ return nid
51
+
52
+ def add_edge(self, src: str, dst: str, rel: str, **props: Any) -> None:
53
+ self.edges.append((src, dst, rel, props))
54
+
55
+ # -- engine-shaped async API (monkeypatched onto graph_ops) ---------
56
+ async def load_graph(self) -> Tuple[List[Node], List[Edge]]:
57
+ nodes = [(nid, dict(props)) for nid, props in self.nodes.items()]
58
+ return nodes, list(self.edges)
59
+
60
+ async def get_truth(self, node_ids: List[str]) -> Dict[str, List[str]]:
61
+ out: Dict[str, List[str]] = {}
62
+ for nid in node_ids:
63
+ entry = self.truth.get(str(nid))
64
+ out[str(nid)] = list(entry["truth_alignment"]) if entry else [TruthState.ALIVE.value]
65
+ return out
66
+
67
+ async def set_state(self, node_id: str, state: TruthState, epoch: int) -> None:
68
+ value = state.value if isinstance(state, TruthState) else str(state)
69
+ self.truth[str(node_id)] = {"truth_alignment": [value], "truth_epoch": int(epoch)}
70
+
71
+ async def set_weight(self, node_id: str, weight: float) -> None:
72
+ self.weights[str(node_id)] = float(weight)
73
+
74
+ async def delete_from_both_stores(self, node_ids: List[str], collections: List[str]) -> int:
75
+ for nid in node_ids:
76
+ self.nodes.pop(str(nid), None)
77
+ self.truth.pop(str(nid), None)
78
+ self.deleted.append(str(nid))
79
+ self.edges = [e for e in self.edges if e[0] not in node_ids and e[1] not in node_ids]
80
+ self.deleted_from_collections.extend(collections)
81
+ return len(node_ids)
82
+
83
+
84
+ @pytest.fixture
85
+ def fake_graph(monkeypatch) -> FakeGraph:
86
+ """Install a :class:`FakeGraph` in place of the real engine helpers.
87
+
88
+ Returns the graph so a test can build topology (``add_node``/``add_edge``) and
89
+ later assert on final truth-states and deletions.
90
+ """
91
+ g = FakeGraph()
92
+ monkeypatch.setattr(graph_ops, "load_graph", g.load_graph)
93
+ monkeypatch.setattr(graph_ops, "get_truth", g.get_truth)
94
+ monkeypatch.setattr(graph_ops, "set_state", g.set_state)
95
+ monkeypatch.setattr(graph_ops, "set_weight", g.set_weight)
96
+ monkeypatch.setattr(graph_ops, "delete_from_both_stores", g.delete_from_both_stores)
97
+ return g
cognee-hackathon-project-main/tests/test_detect.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the two-gate contradiction detector — no live LLM required.
3
+
4
+ * The ``--demo`` / ``pinned_target_id`` path bypasses both gates and must return a
5
+ deterministic high-confidence verdict (this is what keeps the live demo reliable).
6
+ * The Gate-2 path is exercised with a monkeypatched ``LLMGateway`` so we test the
7
+ accept/reject thresholding without any API key.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pytest
13
+
14
+ from falsify import graph_ops
15
+ from falsify.tasks import detect_contradictions
16
+ from falsify.tasks.detect_contradictions import ContradictionJudgement
17
+
18
+ pytestmark = pytest.mark.asyncio
19
+
20
+
21
+ async def test_pinned_target_is_deterministic():
22
+ """Pinned mode returns exactly one high-confidence 'contradicts' verdict."""
23
+ out = await detect_contradictions("any new fact", pinned_target_id="E_qa")
24
+ assert len(out) == 1
25
+ assert out[0].target_id == "E_qa"
26
+ assert out[0].relation == "contradicts"
27
+ assert out[0].confidence >= 0.6
28
+
29
+
30
+ class _FakeHit:
31
+ def __init__(self, _id, score, payload):
32
+ self.id = _id
33
+ self.score = score
34
+ self.payload = payload
35
+
36
+
37
+ class _FakeVectorEngine:
38
+ def __init__(self, hits):
39
+ self._hits = hits
40
+
41
+ async def search(self, collection, query_text=None, limit=15, include_payload=False, **kw):
42
+ return self._hits
43
+
44
+
45
+ async def test_gate2_accepts_high_confidence_contradiction(monkeypatch):
46
+ """An on-topic candidate judged 'contradicts' with conf>=0.6 is returned."""
47
+ hits = [_FakeHit("E_qa", 0.10, {"claim": "March QA report documents the defect"})]
48
+ monkeypatch.setattr(graph_ops, "get_vector_engine", lambda: _FakeVectorEngine(hits))
49
+
50
+ async def fake_llm(text_input, system_prompt, response_model, **kw):
51
+ return ContradictionJudgement(relation="contradicts", confidence=0.9, rationale="back-dated")
52
+
53
+ from cognee.infrastructure.llm.LLMGateway import LLMGateway
54
+ monkeypatch.setattr(LLMGateway, "acreate_structured_output", staticmethod(fake_llm))
55
+
56
+ out = await detect_contradictions("the report was back-dated")
57
+ assert len(out) == 1
58
+ assert out[0].target_id == "E_qa"
59
+ assert out[0].relation == "contradicts"
60
+
61
+
62
+ async def test_gate1_filters_off_topic(monkeypatch):
63
+ """A candidate beyond the distance threshold is filtered before the LLM runs."""
64
+ hits = [_FakeHit("E_far", 0.90, {"claim": "unrelated topic"})]
65
+ monkeypatch.setattr(graph_ops, "get_vector_engine", lambda: _FakeVectorEngine(hits))
66
+
67
+ called = {"llm": False}
68
+
69
+ async def fake_llm(text_input, system_prompt, response_model, **kw):
70
+ called["llm"] = True
71
+ return ContradictionJudgement(relation="contradicts", confidence=1.0)
72
+
73
+ from cognee.infrastructure.llm.LLMGateway import LLMGateway
74
+ monkeypatch.setattr(LLMGateway, "acreate_structured_output", staticmethod(fake_llm))
75
+
76
+ out = await detect_contradictions("some fact", distance_threshold=0.35)
77
+ assert out == []
78
+ assert called["llm"] is False # Gate-1 rejected it; LLM never consulted
79
+
80
+
81
+ async def test_gate2_rejects_low_confidence(monkeypatch):
82
+ """On-topic but low-confidence verdicts are not treated as contradictions."""
83
+ hits = [_FakeHit("E_qa", 0.10, {"claim": "March QA report"})]
84
+ monkeypatch.setattr(graph_ops, "get_vector_engine", lambda: _FakeVectorEngine(hits))
85
+
86
+ async def fake_llm(text_input, system_prompt, response_model, **kw):
87
+ return ContradictionJudgement(relation="contradicts", confidence=0.2, rationale="unsure")
88
+
89
+ from cognee.infrastructure.llm.LLMGateway import LLMGateway
90
+ monkeypatch.setattr(LLMGateway, "acreate_structured_output", staticmethod(fake_llm))
91
+
92
+ out = await detect_contradictions("weak signal")
93
+ assert out == []
cognee-hackathon-project-main/tests/test_falsify.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY core-logic tests — belief revision without any API key or database.
3
+
4
+ Each test builds a small graph via the :class:`~tests.conftest.FakeGraph` fixture,
5
+ runs the real cascade/forget code, and asserts on the resulting truth-states. These
6
+ cover the correctness cases enumerated in REQUIREMENTS.md §4.3:
7
+
8
+ * direct refutation (test_direct_refutation)
9
+ * forward cascade invalidates conclusion (test_forward_cascade_invalidates)
10
+ * diamond / partial refutation (test_diamond_partial_refutation)
11
+ * non-critical dependency survives (test_non_critical_dependency_survives)
12
+ * cycle safety (termination) (test_cycle_safety)
13
+ * surgical forget of orphans only (test_forget_orphan_keeps_provenance)
14
+ * hypothesis demote/promote (test_promote_competing_hypothesis)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import pytest
20
+
21
+ from falsify.edges import DEPENDS_ON, SUPERSEDES, SUPPORTS
22
+ from falsify.models import TruthState
23
+ from falsify.tasks.cascade_forget import cascade_forget
24
+ from falsify.tasks.propagate_refutation import (
25
+ promote_competing_hypothesis,
26
+ propagate_refutation,
27
+ )
28
+
29
+ pytestmark = pytest.mark.asyncio
30
+
31
+
32
+ async def _alignment(g, nid: str):
33
+ entry = g.truth.get(str(nid))
34
+ return entry["truth_alignment"] if entry else None
35
+
36
+
37
+ async def test_direct_refutation(fake_graph):
38
+ """Refuting an evidence node sets its truth_alignment to ['refuted']."""
39
+ g = fake_graph
40
+ g.add_node("E", claim="the March QA report documents the defect")
41
+
42
+ res = await propagate_refutation(["E"])
43
+
44
+ assert res.refuted == ["E"]
45
+ assert await _alignment(g, "E") == [TruthState.REFUTED.value]
46
+
47
+
48
+ async def test_forward_cascade_invalidates(fake_graph):
49
+ """A→B→C depends_on chain: refuting the base evidence invalidates the chain.
50
+
51
+ Topology (depends_on points Conclusion→Evidence):
52
+ C --depends_on(critical)--> B --depends_on(critical)--> E(evidence)
53
+ Refuting E must invalidate B, then C.
54
+ """
55
+ g = fake_graph
56
+ g.add_node("E", claim="base evidence")
57
+ g.add_node("B", statement="mid conclusion")
58
+ g.add_node("C", statement="top conclusion")
59
+ g.add_edge("B", "E", DEPENDS_ON, critical=True)
60
+ g.add_edge("C", "B", DEPENDS_ON, critical=True)
61
+
62
+ res = await propagate_refutation(["E"])
63
+
64
+ assert await _alignment(g, "E") == [TruthState.REFUTED.value]
65
+ assert await _alignment(g, "B") == [TruthState.INVALIDATED.value]
66
+ assert await _alignment(g, "C") == [TruthState.INVALIDATED.value]
67
+ assert set(res.invalidated) == {"B", "C"}
68
+
69
+
70
+ async def test_diamond_partial_refutation(fake_graph):
71
+ """A conclusion with two critical supporters survives losing only one.
72
+
73
+ K depends_on E1 (critical) AND E2 (critical). Refuting only E1 must keep K alive
74
+ (E2 still supports it); refuting E2 as well then invalidates K.
75
+ """
76
+ g = fake_graph
77
+ g.add_node("E1", claim="evidence one")
78
+ g.add_node("E2", claim="evidence two")
79
+ g.add_node("K", statement="conclusion on both")
80
+ g.add_edge("K", "E1", DEPENDS_ON, critical=True)
81
+ g.add_edge("K", "E2", DEPENDS_ON, critical=True)
82
+
83
+ # First refutation: K keeps an alive critical supporter (E2) -> stays alive.
84
+ await propagate_refutation(["E1"])
85
+ assert await _alignment(g, "E1") == [TruthState.REFUTED.value]
86
+ assert await _alignment(g, "K") == [TruthState.ALIVE.value]
87
+
88
+ # Second refutation: K loses its last critical supporter -> invalidated.
89
+ await propagate_refutation(["E2"])
90
+ assert await _alignment(g, "K") == [TruthState.INVALIDATED.value]
91
+
92
+
93
+ async def test_non_critical_dependency_survives(fake_graph):
94
+ """Refuting a NON-critical dependency weakens but does not invalidate."""
95
+ g = fake_graph
96
+ g.add_node("E", claim="soft evidence")
97
+ g.add_node("K", statement="conclusion softly resting on E")
98
+ g.add_edge("K", "E", DEPENDS_ON, critical=False)
99
+
100
+ res = await propagate_refutation(["E"])
101
+
102
+ assert await _alignment(g, "K") == [TruthState.ALIVE.value]
103
+ assert "K" in res.weakened
104
+ assert "K" not in res.invalidated
105
+
106
+
107
+ async def test_cycle_safety(fake_graph):
108
+ """A cyclic depends_on graph terminates (visited set) and doesn't hang."""
109
+ g = fake_graph
110
+ g.add_node("E", claim="evidence")
111
+ g.add_node("X", statement="X")
112
+ g.add_node("Y", statement="Y")
113
+ # Cycle among conclusions, all critically resting on E and each other.
114
+ g.add_edge("X", "E", DEPENDS_ON, critical=True)
115
+ g.add_edge("Y", "X", DEPENDS_ON, critical=True)
116
+ g.add_edge("X", "Y", DEPENDS_ON, critical=True)
117
+
118
+ res = await propagate_refutation(["E"]) # must return, not loop forever
119
+
120
+ assert await _alignment(g, "E") == [TruthState.REFUTED.value]
121
+ assert "X" in res.invalidated and "Y" in res.invalidated
122
+
123
+
124
+ async def test_forget_orphan_keeps_provenance(fake_graph):
125
+ """cascade_forget deletes an orphaned invalidated conclusion but keeps the
126
+ refuted evidence that is a supersedes-anchor (provenance tombstone)."""
127
+ g = fake_graph
128
+ g.add_node("E", claim="refuted evidence")
129
+ g.add_node("K", statement="orphaned conclusion")
130
+ g.add_node("NEW", claim="the new fact")
131
+ g.add_edge("K", "E", DEPENDS_ON, critical=True)
132
+ g.add_edge("NEW", "E", SUPERSEDES, confidence=0.9) # NEW (alive) supersedes E
133
+
134
+ # Run the cascade, then forget.
135
+ prop = await propagate_refutation(["E"])
136
+ forget = await cascade_forget(prop.affected)
137
+
138
+ # K is orphaned (no alive consumer) -> deleted from both stores.
139
+ assert "K" in forget.forgotten
140
+ assert "K" in g.deleted
141
+ assert g.deleted_from_collections # vector collections were targeted
142
+ # E is refuted but retained as the supersedes provenance anchor.
143
+ assert "E" in forget.retained_provenance
144
+ assert "E" not in g.deleted
145
+
146
+
147
+ async def test_promote_competing_hypothesis(fake_graph):
148
+ """When A's only support dies, A is superseded and rival B is promoted."""
149
+ g = fake_graph
150
+ g.add_node("E_a", claim="evidence for A")
151
+ g.add_node("E_b", claim="evidence for B")
152
+ g.add_node("A", statement="hypothesis A")
153
+ g.add_node("B", statement="hypothesis B")
154
+ g.add_edge("E_a", "A", SUPPORTS, weight=0.8)
155
+ g.add_edge("E_b", "B", SUPPORTS, weight=0.7)
156
+
157
+ # Refute A's evidence, then re-score hypotheses.
158
+ prop = await propagate_refutation(["E_a"])
159
+ actions = await promote_competing_hypothesis(["E_a"], prop.epoch)
160
+
161
+ assert actions.get("A") == "superseded"
162
+ assert actions.get("B") == "promoted"
163
+ assert await _alignment(g, "A") == [TruthState.SUPERSEDED.value]
cognee-hackathon-project-main/tools/make_demo_gif.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Build the FALSIFY demo GIF from a LIVE run (Qwen judges the contradiction).
4
+
5
+ The belief graph is rendered natively with Pillow (no browser needed) from the real
6
+ node/edge/truth-state data, so the GIF shows the actual graph transforming:
7
+
8
+ BEFORE : every node green (alive)
9
+ AFTER : E_qa red (refuted), K removed (forgotten), A amber (superseded),
10
+ B green (the new frontier)
11
+
12
+ Frames: title -> Session 1 -> BEFORE graph -> Session 2 (the fact) -> AFTER graph
13
+ -> scoreboard -> closing.
14
+
15
+ Run: python tools/make_demo_gif.py # live (needs .env LLM key)
16
+ python tools/make_demo_gif.py --demo # offline (pinned contradiction)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import asyncio
23
+ import os
24
+ import sys
25
+
26
+ from dotenv import load_dotenv
27
+
28
+ load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
29
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
30
+
31
+ import falsify # noqa: E402
32
+ from falsify import graph_ops # noqa: E402
33
+ from falsify.models import TruthState # noqa: E402
34
+
35
+ from PIL import Image, ImageDraw, ImageFont # noqa: E402
36
+
37
+ W, H = 1000, 700
38
+ BG = (11, 16, 32)
39
+ PANEL = (17, 24, 39)
40
+ FG = (229, 231, 235)
41
+ GREEN = (34, 197, 94)
42
+ RED = (239, 68, 68)
43
+ AMBER = (245, 158, 11)
44
+ GREY = (107, 114, 128)
45
+ DIM = (148, 163, 184)
46
+ CYAN = (34, 211, 238)
47
+ EDGE = (71, 85, 105)
48
+
49
+ FONT_DIR = "/usr/share/fonts/truetype/dejavu"
50
+
51
+ STATE_COLOR = {
52
+ TruthState.ALIVE.value: GREEN,
53
+ TruthState.REFUTED.value: RED,
54
+ TruthState.INVALIDATED.value: GREY,
55
+ TruthState.SUPERSEDED.value: AMBER,
56
+ }
57
+
58
+ # Fixed layout for the 7-node Company-X graph (x, y = node center).
59
+ LAYOUT = {
60
+ "Q": (500, 130),
61
+ "A": (215, 300), "B": (500, 300), "C": (785, 300),
62
+ "E_qa": (215, 470), "E_email": (500, 470),
63
+ "K": (215, 615),
64
+ }
65
+ SHORT = {
66
+ "Q": "Q: Did X know\nbefore recall?",
67
+ "A": "Hyp A\nMar QA report",
68
+ "B": "Hyp B\nJan email",
69
+ "C": "Hyp C\ndidn't know",
70
+ "E_qa": "E_qa\nMarch QA report",
71
+ "E_email": "E_email\nJan supplier email",
72
+ "K": "K: knew by\nMarch 2021",
73
+ }
74
+ # edges as (src_key, dst_key, label)
75
+ EDGES = [
76
+ ("A", "Q", "answers"), ("B", "Q", "answers"), ("C", "Q", "answers"),
77
+ ("E_qa", "A", "supports"), ("E_email", "B", "supports"),
78
+ ("K", "E_qa", "depends_on"),
79
+ ]
80
+
81
+
82
+ def font(size, bold=False, mono=False):
83
+ if mono:
84
+ name = "DejaVuSansMono-Bold.ttf" if bold else "DejaVuSansMono.ttf"
85
+ else:
86
+ name = "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf"
87
+ return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
88
+
89
+
90
+ def _center(d, text, f, y, color=FG):
91
+ w = d.textlength(text, font=f)
92
+ d.text(((W - w) / 2, y), text, font=f, fill=color)
93
+
94
+
95
+ def card(lines):
96
+ img = Image.new("RGB", (W, H), BG)
97
+ d = ImageDraw.Draw(img)
98
+ for text, size, bold, color, y in lines:
99
+ _center(d, text, font(size, bold), y, color)
100
+ return img
101
+
102
+
103
+ def title_card():
104
+ return card([
105
+ ("FALSIFY", 92, True, CYAN, 200),
106
+ ("the AI that revises, not forgets", 34, False, FG, 315),
107
+ ("belief revision on a Cognee knowledge graph", 22, False, DIM, 378),
108
+ ])
109
+
110
+
111
+ def session1_card():
112
+ return card([
113
+ ("SESSION 1", 42, True, CYAN, 150),
114
+ ("Build the belief graph", 30, False, FG, 222),
115
+ ("Q: Did Company X know about the defect before the recall?", 21, False, DIM, 300),
116
+ ("A — via March 2021 QA report B — via Jan 2021 email", 20, False, FG, 358),
117
+ ("Conclusion K depends on the March QA report", 19, False, DIM, 408),
118
+ ])
119
+
120
+
121
+ def session2_card():
122
+ return card([
123
+ ("SESSION 2", 42, True, CYAN, 150),
124
+ ("One contradicting fact arrives", 30, False, FG, 224),
125
+ ('"A forensic audit found the March 2021', 25, True, AMBER, 322),
126
+ ('QA report was back-dated."', 25, True, AMBER, 364),
127
+ ])
128
+
129
+
130
+ def scoreboard_card(falsify_ans, stale):
131
+ ans = falsify_ans if len(falsify_ans) <= 58 else falsify_ans[:57] + "…"
132
+ return card([
133
+ ("SCOREBOARD", 42, True, CYAN, 120),
134
+ ("FALSIFY (revised)", 30, True, GREEN, 220),
135
+ (ans, 22, False, FG, 272),
136
+ ("plain RAG (stale)", 30, True, RED, 362),
137
+ ("still anchored to the refuted March QA report", 21, False, DIM, 414),
138
+ ("AI revised, not forgot.", 36, True, FG, 520),
139
+ ])
140
+
141
+
142
+ def closing_card():
143
+ return card([
144
+ ("FALSIFY", 76, True, CYAN, 240),
145
+ ("revise, don't forget.", 30, False, FG, 355),
146
+ ("github.com/ArpitKumar8649/cognee-hackathon-project", 18, False, DIM, 430),
147
+ ])
148
+
149
+
150
+ def _round_rect(d, box, radius, fill, outline, width=2, dashed=False):
151
+ d.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)
152
+
153
+
154
+ def draw_graph(states: dict, deleted: set, caption: str, sub: str) -> Image.Image:
155
+ """Render the belief graph. `states` maps node-key -> truth-state string."""
156
+ img = Image.new("RGB", (W, H), BG)
157
+ d = ImageDraw.Draw(img)
158
+
159
+ # caption banner
160
+ d.rectangle([0, 0, W, 74], fill=PANEL)
161
+ d.text((26, 16), caption, font=font(30, True), fill=CYAN)
162
+ if sub:
163
+ d.text((26, 50), sub, font=font(15), fill=DIM)
164
+
165
+ bw, bh = 168, 62 # node box size
166
+
167
+ def box_of(key):
168
+ cx, cy = LAYOUT[key]
169
+ return [cx - bw // 2, cy - bh // 2, cx + bw // 2, cy + bh // 2]
170
+
171
+ # edges first
172
+ for s, t, label in EDGES:
173
+ if s in deleted or t in deleted:
174
+ continue
175
+ sx, sy = LAYOUT[s]
176
+ tx, ty = LAYOUT[t]
177
+ col = RED if (states.get(s) == TruthState.REFUTED.value or
178
+ states.get(t) == TruthState.REFUTED.value) else EDGE
179
+ d.line([sx, sy, tx, ty], fill=col, width=2)
180
+ mx, my = (sx + tx) // 2, (sy + ty) // 2
181
+ d.text((mx - d.textlength(label, font=font(12)) / 2, my - 16), label,
182
+ font=font(12), fill=DIM)
183
+
184
+ # nodes
185
+ for key, (cx, cy) in LAYOUT.items():
186
+ if key in deleted:
187
+ # ghost outline for a forgotten node
188
+ b = box_of(key)
189
+ d.rounded_rectangle(b, radius=10, outline=(55, 65, 81), width=2)
190
+ _center_box(d, "🗑 forgotten", font(13, True), cx, cy, (75, 85, 99))
191
+ continue
192
+ st = states.get(key, TruthState.ALIVE.value)
193
+ color = STATE_COLOR.get(st, GREEN)
194
+ b = box_of(key)
195
+ dashed = st == TruthState.REFUTED.value
196
+ d.rounded_rectangle(b, radius=10, fill=_tint(color), outline=color,
197
+ width=4 if dashed else 2)
198
+ # label (2 lines)
199
+ lines = SHORT[key].split("\n")
200
+ fnt = font(14, True)
201
+ total_h = len(lines) * 17
202
+ yy = cy - total_h // 2
203
+ for ln in lines:
204
+ _center_box(d, ln, fnt, cx, yy + 8, (12, 16, 32))
205
+ yy += 17
206
+ # state tag
207
+ d.text((b[0] + 6, b[1] + 4), st.upper(), font=font(10, True), fill=(12, 16, 32))
208
+
209
+ return img
210
+
211
+
212
+ def _center_box(d, text, f, cx, cy, color):
213
+ w = d.textlength(text, font=f)
214
+ d.text((cx - w / 2, cy - 8), text, font=f, fill=color)
215
+
216
+
217
+ def _tint(color):
218
+ """Lighten a state color for the node fill."""
219
+ return tuple(min(255, int(c * 0.55 + 120)) for c in color)
220
+
221
+
222
+ def states_from_ids(truth: dict, ids: dict) -> dict:
223
+ out = {}
224
+ for key, nid in ids.items():
225
+ align = truth.get(nid, [TruthState.ALIVE.value])
226
+ out[key] = align[0] if align else TruthState.ALIVE.value
227
+ return out
228
+
229
+
230
+ async def build(demo: bool) -> None:
231
+ from falsify.falsify import build_graph, revise, scoreboard
232
+ from falsify.seed import NEW_FACT, QUESTION_TEXT
233
+
234
+ os.makedirs("output", exist_ok=True)
235
+
236
+ print("[gif] Session 1: building belief graph…")
237
+ seeded = await build_graph()
238
+ t_before = await graph_ops.get_truth(list(seeded.ids.values()))
239
+ before_states = states_from_ids(t_before, seeded.ids)
240
+
241
+ print(f"[gif] Session 2: revising (mode={'demo' if demo else 'live'})…")
242
+ pinned = seeded.refuted_target_id if demo else None
243
+ report = await revise(NEW_FACT, pinned_target_id=pinned)
244
+
245
+ nodes, _ = await graph_ops.load_graph()
246
+ present = {str(n) for n, _ in nodes}
247
+ deleted_keys = {k for k, v in seeded.ids.items() if v not in present}
248
+ t_after = await graph_ops.get_truth([v for v in seeded.ids.values() if v in present])
249
+ after_states = states_from_ids(t_after, {k: v for k, v in seeded.ids.items()
250
+ if v in present})
251
+
252
+ board = await scoreboard(QUESTION_TEXT, seeded)
253
+
254
+ print("[gif] composing frames…")
255
+ frames = [
256
+ (title_card(), 2600),
257
+ (session1_card(), 2500),
258
+ (draw_graph(before_states, set(), "BEFORE — every belief is alive",
259
+ "green = alive"), 3000),
260
+ (session2_card(), 2800),
261
+ (draw_graph(after_states, deleted_keys, "AFTER — refutation cascaded",
262
+ "red = refuted · K forgotten · amber = superseded · B ignites"), 3800),
263
+ (scoreboard_card(board.falsify_answer, board.stale), 3600),
264
+ (closing_card(), 2400),
265
+ ]
266
+ imgs = [f.convert("P", palette=Image.ADAPTIVE, colors=128) for f, _ in frames]
267
+ durations = [dur for _, dur in frames]
268
+ out_path = "output/falsify-demo.gif"
269
+ imgs[0].save(out_path, save_all=True, append_images=imgs[1:],
270
+ duration=durations, loop=0, optimize=False, disposal=2)
271
+ print(f"[gif] wrote {out_path} ({os.path.getsize(out_path)//1024} KB, {len(imgs)} frames)")
272
+ print(f"[gif] refuted={len(report.refuted)} invalidated={len(report.invalidated)} "
273
+ f"forgotten={len(report.forgotten)}")
274
+
275
+
276
+ def main() -> int:
277
+ ap = argparse.ArgumentParser()
278
+ ap.add_argument("--demo", action="store_true")
279
+ args = ap.parse_args()
280
+ asyncio.run(build(demo=args.demo))
281
+ return 0
282
+
283
+
284
+ if __name__ == "__main__":
285
+ raise SystemExit(main())
demo_video_script.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FALSIFY — 30-second demo script
2
+
3
+ A tight, judge-ready narration. Total runtime ~30s of talking over one `python main.py --demo` run. Capture the terminal + the `output/graph.html` for the GIF.
4
+
5
+ ---
6
+
7
+ ## Setup (before recording)
8
+ - Terminal with a dark theme, font large enough to read on playback.
9
+ - `.env` has a working `LLM_API_KEY` (so embeddings + recall are real).
10
+ - Run once beforehand to warm caches; record the second run.
11
+
12
+ ---
13
+
14
+ ## Beat sheet
15
+
16
+ **[0:00–0:05] The hook**
17
+ > "Every AI memory demo fixes *forgetting*. FALSIFY fixes something worse — an AI that confidently remembers a fact that's been **proven false**."
18
+
19
+ *Run:* `python main.py --demo`
20
+
21
+ **[0:05–0:12] Session 1 — the belief graph**
22
+ > "We're investigating: *did Company X know about the defect before the recall?* Two hypotheses — a March QA report, and a January supplier email. A conclusion rests on that March report."
23
+
24
+ *On screen:* the **BEFORE** panel — every node tagged `ALIVE` in green.
25
+
26
+ **[0:12–0:20] Session 2 — one contradicting fact**
27
+ > "Now one line arrives: *a forensic audit found the March report was back-dated.* Watch."
28
+
29
+ *On screen:* the revision log —
30
+ ```
31
+ ✗ refuted: 1 evidence node
32
+ ✗ invalidated: 1 conclusion
33
+ hypothesis A → ↓ superseded
34
+ hypothesis B → ↑ promoted (new frontier)
35
+ 🗑 forgotten: Company X knew about the defect by March 2021
36
+ ```
37
+
38
+ **[0:20–0:27] The AFTER + scoreboard**
39
+ > "The March evidence is red. The conclusion built on it collapsed and was **surgically deleted** — from the graph *and* the vector store. Hypothesis B ignites as the new answer."
40
+
41
+ *On screen:* the **SCOREBOARD** —
42
+ ```
43
+ FALSIFY : X knew by Jan 2021 (supplier email) ← revised
44
+ RAG : X knew by Mar 2021 (QA report) [STALE] ← still cites the refuted fact
45
+ ```
46
+
47
+ **[0:27–0:30] The close**
48
+ > "Same store, same query. FALSIFY revised its belief and the disbelief persists across sessions. Plain RAG can't. **AI revised, not forgot.**"
49
+
50
+ *On screen:* open `output/graph.html` — the red refuted node, the missing orphan, the green frontier.
51
+
52
+ ---
53
+
54
+ ## The single most important line
55
+ > **"It's not that the AI forgot where the context was — the context was *wrong*, and FALSIFY revised it."**
56
+
57
+ That reframes the hackathon's "Where's My Context?" theme into FALSIFY's exact contribution.
58
+
59
+ ---
60
+
61
+ ## If asked "why can't RAG do this?"
62
+ > "Refutation is graph traversal over typed edges — *this fact grounds that conclusion three hops away.* A vector index has no edges to walk and no truth-state to filter on. This needs a knowledge graph — which is exactly what Cognee gives us."
falsify/__init__.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY — Belief Revision Research Copilot
3
+
4
+ Exposes core models and edge constants for the belief graph.
5
+
6
+ Import-time environment safety
7
+ ------------------------------
8
+ FALSIFY is a single-user, self-hosted demo. We set a few Cognee defaults *before*
9
+ any Cognee module is imported (importing :mod:`falsify.models` pulls in Cognee), so
10
+ that graph writes go into one shared local graph without per-user access-control
11
+ gymnastics. ``setdefault`` means an explicitly-exported env var always wins.
12
+ """
13
+
14
+ import os as _os
15
+
16
+ # Single-user mode: shared local DBs, auth off. Must precede the first cognee import.
17
+ _os.environ.setdefault("ENABLE_BACKEND_ACCESS_CONTROL", "False")
18
+ # Keep logs quiet unless the user opts in.
19
+ _os.environ.setdefault("LOG_LEVEL", "ERROR")
20
+ _os.environ.setdefault("COGNEE_LOG_FILE", "false")
21
+ # Enable the session cache so remember(session_id=...) / improve() work (cross-session proof).
22
+ _os.environ.setdefault("CACHING", "true")
23
+ _os.environ.setdefault("CACHE_BACKEND", "fs")
24
+ # Default embeddings to fastembed — a fully local, CPU-only, zero-cost embedder, so
25
+ # the whole pipeline (and `python main.py --demo`) runs with NO external API key.
26
+ # Any explicit EMBEDDING_* in the environment / .env overrides these (setdefault).
27
+ _os.environ.setdefault("EMBEDDING_PROVIDER", "fastembed")
28
+ _os.environ.setdefault("EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5")
29
+ _os.environ.setdefault("EMBEDDING_DIMENSIONS", "384")
30
+ _os.environ.setdefault("EMBEDDING_MAX_TOKENS", "512")
31
+
32
+ from falsify.models import (
33
+ Assertion,
34
+ Conclusion,
35
+ Evidence,
36
+ Hypothesis,
37
+ InvestigationQuestion,
38
+ TruthState,
39
+ # Edge relationship constants
40
+ CONTRADICTS,
41
+ DEPENDS_ON,
42
+ SUPPORTS,
43
+ SUPERSEDES,
44
+ )
45
+
46
+ __all__ = [
47
+ "TruthState",
48
+ "InvestigationQuestion",
49
+ "Hypothesis",
50
+ "Evidence",
51
+ "Conclusion",
52
+ "Assertion",
53
+ "DEPENDS_ON",
54
+ "SUPPORTS",
55
+ "CONTRADICTS",
56
+ "SUPERSEDES",
57
+ ]
falsify/edges.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Edge relationship-name constants for the FALSIFY belief graph.
3
+
4
+ These strings are passed as ``relationship_name`` to ``graph_engine.add_edge(...)``
5
+ and used as ``edge_types`` filters in ``get_neighborhood(...)``. They are re-exported
6
+ from :mod:`falsify.models` as well; this module is the single source of truth.
7
+
8
+ Edge semantics (see REQUIREMENTS.md §1.1)
9
+ -----------------------------------------
10
+ - ``DEPENDS_ON`` : Conclusion -> Evidence. THE forward-propagation rail. A Conclusion
11
+ depends on the Evidence it rests on. ``edge_properties = {"critical": bool}``.
12
+ - ``SUPPORTS`` : Evidence -> Hypothesis. Evidence corroborates a hypothesis.
13
+ ``edge_properties = {"weight": float}``.
14
+ - ``REFUTES`` : Evidence -> Hypothesis. Evidence contradicts a hypothesis.
15
+ ``edge_properties = {"weight": float}``.
16
+ - ``SUPERSEDES`` : Evidence(new) -> Evidence(old). Written when a new fact back-dates /
17
+ overrides an old evidence node. ``edge_properties = {"confidence": float}``.
18
+ """
19
+
20
+ DEPENDS_ON = "depends_on"
21
+ # Non-critical dependency variant. Criticality is encoded in the relationship NAME
22
+ # because some graph backends (e.g. Ladybug) do not round-trip edge *properties* —
23
+ # a name always persists, an edge property may not. So a plain ``depends_on`` edge is
24
+ # treated as critical by default, and ``depends_on_soft`` is the explicit non-critical
25
+ # form. (For in-memory/tests, an explicit ``{"critical": bool}`` property still wins.)
26
+ DEPENDS_ON_SOFT = "depends_on_soft"
27
+ SUPPORTS = "supports"
28
+ REFUTES = "refutes"
29
+ # ``CONTRADICTS`` kept as an alias for the refutes edge (spec used both names).
30
+ CONTRADICTS = REFUTES
31
+ SUPERSEDES = "supersedes"
32
+
33
+ #: All dependency edge relationship names (critical + soft).
34
+ DEPENDENCY_EDGE_TYPES = [DEPENDS_ON, DEPENDS_ON_SOFT]
35
+
36
+ #: Edge types traversed when checking whether a node still feeds a live consumer.
37
+ CONSUMER_EDGE_TYPES = [DEPENDS_ON, DEPENDS_ON_SOFT, SUPPORTS]
38
+
39
+
40
+ def is_critical_dependency(rel: str, props: dict | None = None) -> bool:
41
+ """Return whether a dependency edge is *critical*.
42
+
43
+ An explicit ``critical`` edge property wins when present (used by in-memory tests).
44
+ Otherwise criticality is inferred from the relationship name: ``depends_on`` is
45
+ critical by default, ``depends_on_soft`` is not. This makes correctness independent
46
+ of whether the backend persists edge properties.
47
+ """
48
+ props = props or {}
49
+ if "critical" in props:
50
+ return bool(props["critical"])
51
+ if rel == DEPENDS_ON_SOFT:
52
+ return False
53
+ return rel == DEPENDS_ON # depends_on => critical by default
54
+
55
+
56
+ # Edge types traversed during forward refutation propagation.
57
+ PROPAGATION_EDGE_TYPES = [DEPENDS_ON, DEPENDS_ON_SOFT, SUPPORTS]
58
+
59
+ __all__ = [
60
+ "DEPENDS_ON",
61
+ "DEPENDS_ON_SOFT",
62
+ "SUPPORTS",
63
+ "REFUTES",
64
+ "CONTRADICTS",
65
+ "SUPERSEDES",
66
+ "DEPENDENCY_EDGE_TYPES",
67
+ "CONSUMER_EDGE_TYPES",
68
+ "PROPAGATION_EDGE_TYPES",
69
+ "is_critical_dependency",
70
+ ]
falsify/events.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY real-time event bus — the seam between the belief pipeline and the live UI.
3
+
4
+ The whole point of the web demo is that a judge *watches* a belief die: the refute
5
+ flash, the cascade sweep, the forget-dissolve. Those animations are driven by events
6
+ that this module fans out to every connected browser over Server-Sent Events.
7
+
8
+ Design constraints that shaped this module:
9
+
10
+ * **Leaf module.** It imports nothing from ``falsify`` so that ``graph_ops`` can import
11
+ it without a cycle (``graph_ops -> events`` only, never the reverse).
12
+ * **No-op when nobody's watching.** ``emit`` iterates an empty subscriber set under the
13
+ CLI (``python main.py``), so the two hooks in ``graph_ops`` cost effectively nothing
14
+ and the offline demo behaves exactly as before.
15
+ * **Never block the pipeline.** A slow browser must not stall belief revision, so we
16
+ ``put_nowait`` and drop on a full queue rather than awaiting backpressure. The client
17
+ re-syncs via ``GET /api/graph`` on reconnect, so a dropped frame is cosmetic.
18
+
19
+ Each open ``GET /api/events`` connection owns one queue (``subscribe`` / ``unsubscribe``);
20
+ ``server.py`` drains it and serializes each event as an SSE ``data:`` frame.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from asyncio import Queue, QueueFull
27
+ from typing import Any, Dict, Set
28
+
29
+ logger = logging.getLogger("falsify.events")
30
+
31
+ # One queue per live SSE connection. Empty set == CLI / offline == emit is a no-op.
32
+ _subscribers: Set[Queue] = set()
33
+
34
+ # Per-connection buffer. Large enough that a whole cascade never overflows a healthy
35
+ # client; a client slow enough to fill 1000 frames is already gone.
36
+ _QUEUE_MAXSIZE = 1000
37
+
38
+
39
+ def subscribe() -> Queue:
40
+ """Register a new SSE connection and return its private event queue."""
41
+ q: Queue = Queue(maxsize=_QUEUE_MAXSIZE)
42
+ _subscribers.add(q)
43
+ logger.debug("SSE subscriber added (now %d)", len(_subscribers))
44
+ return q
45
+
46
+
47
+ def unsubscribe(q: Queue) -> None:
48
+ """Drop a disconnected SSE connection's queue (idempotent)."""
49
+ _subscribers.discard(q)
50
+ logger.debug("SSE subscriber removed (now %d)", len(_subscribers))
51
+
52
+
53
+ def has_subscribers() -> bool:
54
+ """True if at least one browser is listening — lets the server pace animations
55
+ (small inter-step sleeps) only when someone is actually watching."""
56
+ return bool(_subscribers)
57
+
58
+
59
+ async def emit(event: Dict[str, Any]) -> None:
60
+ """Fan one event out to every live connection. No-op when none. Never blocks."""
61
+ for q in list(_subscribers):
62
+ try:
63
+ q.put_nowait(event)
64
+ except QueueFull: # slow client: drop the frame, don't stall the pipeline
65
+ logger.debug("dropping event for a full subscriber queue")
66
+
67
+
68
+ async def emit_state_change(node_id: str, state: str, epoch: int) -> None:
69
+ """A node's truth-state changed (refuted / invalidated / superseded / alive)."""
70
+ await emit({"type": "node_state_changed", "id": str(node_id),
71
+ "state": str(state), "epoch": int(epoch)})
72
+
73
+
74
+ async def emit_forgotten(node_id: str) -> None:
75
+ """A node was hard-deleted from the graph (the forget-dissolve animation)."""
76
+ await emit({"type": "node_forgotten", "id": str(node_id)})
77
+
78
+
79
+ async def emit_graph_reset() -> None:
80
+ """The graph was rebuilt/reseeded — clients should refetch the full snapshot."""
81
+ await emit({"type": "graph_reset"})
82
+
83
+
84
+ async def emit_step(step: str, detail: str = "") -> None:
85
+ """A human-readable pipeline milestone, for the revision-log feed."""
86
+ await emit({"type": "pipeline_step", "step": str(step), "detail": str(detail)})
falsify/falsify.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY orchestration — the belief-revision copilot's public API.
3
+
4
+ Three verbs tie the engine together:
5
+
6
+ build_graph() -> seed the Session-1 investigation graph (clean slate).
7
+ revise(new_fact) -> run the full revision pipeline for an incoming fact:
8
+ detect -> propagate -> promote -> record supersede -> forget.
9
+ scoreboard(question) -> the money shot: FALSIFY's revised answer (reads truth
10
+ state, skips dead branches) vs a plain-RAG baseline
11
+ (raw vector search, no truth filter) that still cites the
12
+ refuted fact.
13
+
14
+ Everything is persisted on the graph (truth-state on nodes), so a fresh process /
15
+ second session sees the revised beliefs — the cross-session guarantee.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ from falsify import graph_ops
25
+ from falsify.edges import SUPERSEDES, SUPPORTS
26
+ from falsify.models import Evidence, TruthState
27
+ from falsify.seed import SeededGraph, build_diamond_investigation, build_investigation
28
+ from falsify.tasks import (
29
+ Contradiction,
30
+ cascade_forget,
31
+ detect_contradictions,
32
+ promote_competing_hypothesis,
33
+ propagate_refutation,
34
+ )
35
+
36
+ logger = logging.getLogger("falsify.orchestrator")
37
+
38
+ _ALIVE = TruthState.ALIVE.value
39
+ _EVIDENCE_COLLECTION = "Evidence_claim"
40
+
41
+
42
+ @dataclass
43
+ class RevisionReport:
44
+ """Full record of one :func:`revise` run, for demo output and tests."""
45
+
46
+ new_fact: str
47
+ contradictions: List[Contradiction] = field(default_factory=list)
48
+ refuted: List[str] = field(default_factory=list)
49
+ invalidated: List[str] = field(default_factory=list)
50
+ hypothesis_actions: Dict[str, str] = field(default_factory=dict)
51
+ forgotten: List[str] = field(default_factory=list)
52
+ forgotten_labels: Dict[str, str] = field(default_factory=dict)
53
+ retained_provenance: List[str] = field(default_factory=list)
54
+ new_evidence_id: Optional[str] = None
55
+ epoch: int = 0
56
+ rag_snapshot: Optional[List[Dict]] = None
57
+
58
+ @property
59
+ def revised(self) -> bool:
60
+ """True if the fact actually triggered a belief change."""
61
+ return bool(self.refuted or self.invalidated)
62
+
63
+
64
+ async def build_graph() -> SeededGraph:
65
+ """Prune everything and build the Session-1 investigation graph.
66
+
67
+ Returns the :class:`SeededGraph` with stable handles to the seeded nodes.
68
+ """
69
+ import cognee
70
+ from cognee.low_level import setup
71
+
72
+ logger.info("build_graph: pruning and seeding fresh investigation")
73
+ await cognee.forget(everything=True)
74
+ await setup() # (re)create relational tables the storage pipeline needs
75
+ seeded = await build_investigation()
76
+ return seeded
77
+
78
+
79
+ async def build_diamond_graph() -> SeededGraph:
80
+ """Prune everything and build the diamond-dependency investigation graph.
81
+
82
+ Same clean-slate sequence as :func:`build_graph`, but seeds the extended graph
83
+ with Conclusion K2 (critically dependent on both E_qa and E_email). Drives the
84
+ two-phase "survive then collapse" demo; see
85
+ :func:`falsify.seed.build_diamond_investigation`.
86
+ """
87
+ import cognee
88
+ from cognee.low_level import setup
89
+
90
+ logger.info("build_diamond_graph: pruning and seeding diamond investigation")
91
+ await cognee.forget(everything=True)
92
+ await setup()
93
+ return await build_diamond_investigation()
94
+
95
+
96
+ async def use_backend(
97
+ mode: str = "opensource",
98
+ *,
99
+ url: Optional[str] = None,
100
+ api_key: Optional[str] = None,
101
+ ) -> str:
102
+ """Route Cognee operations to a backend and return the active mode.
103
+
104
+ ``mode="cloud"`` (with a tenant ``url`` + ``api_key``) points every subsequent
105
+ ``remember`` / ``recall`` / ``memify`` / ``forget`` call at a Cognee Cloud tenant
106
+ via :func:`cognee.serve` — making the *same* FALSIFY pipeline demonstrable on the
107
+ Cognee Cloud track without changing any belief logic. Anything else keeps the
108
+ self-hosted (open-source) engines. Best-effort: if the cloud handshake fails we
109
+ log and stay open-source so the demo never hard-fails.
110
+ """
111
+ import cognee
112
+
113
+ if mode == "cloud" and url and api_key:
114
+ try:
115
+ await cognee.serve(url=url, api_key=api_key)
116
+ logger.info("FALSIFY backend -> Cognee Cloud (%s)", url)
117
+ return "cloud"
118
+ except Exception as exc:
119
+ logger.warning("cognee.serve failed (%s); staying open-source", exc)
120
+ return "opensource"
121
+ logger.info("FALSIFY backend -> self-hosted (open source)")
122
+ return "opensource"
123
+
124
+
125
+ async def revise(
126
+ new_fact: str,
127
+ *,
128
+ pinned_target_id: Optional[str] = None,
129
+ source_id: str = "session2_fact",
130
+ ) -> RevisionReport:
131
+ """Run the full belief-revision pipeline for an incoming fact.
132
+
133
+ Pipeline (REQUIREMENTS §1.3-§1.5):
134
+ 1. detect_contradictions -> which evidence does the fact contradict?
135
+ 2. propagate_refutation -> refute it; cascade invalidation forward.
136
+ 3. promote_competing_hypothesis -> demote the losing hypothesis, ignite the rival.
137
+ 4. record the new fact as Evidence + a ``supersedes`` edge to the refuted node
138
+ (so the refuted node is retained as a provenance tombstone).
139
+ 5. cascade_forget -> hard-delete orphaned dead-ends from graph + vector.
140
+
141
+ Args:
142
+ new_fact: the incoming claim.
143
+ pinned_target_id: demo override — refute this evidence id deterministically.
144
+ source_id: provenance id for the materialized new-fact Evidence node.
145
+
146
+ Returns:
147
+ A :class:`RevisionReport` describing everything that changed.
148
+ """
149
+ report = RevisionReport(new_fact=new_fact)
150
+
151
+ # Route through cognee.memify() pipeline for deep Cognee API integration.
152
+ # Falls back to direct calls if memify is unavailable.
153
+ try:
154
+ report = await revise_via_memify(
155
+ new_fact, pinned_target_id=pinned_target_id, source_id=source_id,
156
+ )
157
+ if not report.revised:
158
+ logger.info("revise: no contradiction found; graph unchanged")
159
+ else:
160
+ logger.info(
161
+ "revise complete (via memify): refuted=%d invalidated=%d forgotten=%d",
162
+ len(report.refuted), len(report.invalidated), len(report.forgotten),
163
+ )
164
+ return report
165
+ except Exception as exc:
166
+ logger.warning("memify pipeline failed (%s); falling back to direct calls", exc)
167
+
168
+ # Fallback: direct task calls (same logic, no pipeline wrapper)
169
+ contradictions = await detect_contradictions(new_fact, pinned_target_id=pinned_target_id)
170
+ report.contradictions = contradictions
171
+ if not contradictions:
172
+ logger.info("revise: no contradiction found; graph unchanged")
173
+ return report
174
+
175
+ target_ids = [c.target_id for c in contradictions]
176
+
177
+ prop = await propagate_refutation(target_ids)
178
+ report.refuted = prop.refuted
179
+ report.invalidated = prop.invalidated
180
+ report.epoch = prop.epoch
181
+
182
+ report.hypothesis_actions = await promote_competing_hypothesis(target_ids, prop.epoch)
183
+
184
+ report.new_evidence_id = await _record_new_fact(new_fact, contradictions, source_id)
185
+
186
+ report.rag_snapshot = await _snapshot_rag(new_fact)
187
+
188
+ forget_res = await cascade_forget(prop.affected)
189
+ report.forgotten = forget_res.forgotten
190
+ report.forgotten_labels = forget_res.labels
191
+ report.retained_provenance = forget_res.retained_provenance
192
+
193
+ logger.info(
194
+ "revise complete (direct): refuted=%d invalidated=%d forgotten=%d",
195
+ len(report.refuted), len(report.invalidated), len(report.forgotten),
196
+ )
197
+ return report
198
+
199
+
200
+ async def _record_new_fact(
201
+ new_fact: str,
202
+ contradictions: List[Contradiction],
203
+ source_id: str,
204
+ ) -> Optional[str]:
205
+ """Materialize the new fact as an Evidence node and link supersedes edges.
206
+
207
+ The new (alive) evidence ``supersedes`` each refuted evidence node. This both
208
+ records provenance and pins the refuted node as a retained tombstone (an alive
209
+ supersedes-source protects its target from forget — REQUIREMENTS §1.5c).
210
+ """
211
+ from cognee.tasks.storage import add_data_points
212
+
213
+ try:
214
+ new_ev = Evidence(
215
+ claim=new_fact,
216
+ source_id=source_id,
217
+ stance="refutes",
218
+ confidence=max((c.confidence for c in contradictions), default=0.9),
219
+ )
220
+ await add_data_points([new_ev])
221
+ for c in contradictions:
222
+ await graph_ops.add_edge(
223
+ str(new_ev.id), str(c.target_id), SUPERSEDES, {"confidence": c.confidence}
224
+ )
225
+ logger.info("recorded new fact %s superseding %d node(s)", new_ev.id, len(contradictions))
226
+ return str(new_ev.id)
227
+ except Exception as exc:
228
+ logger.error("failed to record new fact: %s", exc)
229
+ return None
230
+
231
+
232
+ async def _snapshot_rag(query: str) -> List[Dict]:
233
+ """Capture RAG vector hits before cascade_forget deletes them."""
234
+ ve = graph_ops.get_vector_engine()
235
+ try:
236
+ hits = await ve.search(
237
+ _EVIDENCE_COLLECTION, query_text=query, limit=5, include_payload=True,
238
+ )
239
+ except Exception:
240
+ return []
241
+ results = []
242
+ for h in (hits or []):
243
+ payload = getattr(h, "payload", {}) or {}
244
+ results.append({"id": str(h.id), "payload": payload})
245
+ return results
246
+
247
+
248
+ # --------------------------------------------------------------------------- #
249
+ # memify adapter — wraps FALSIFY tasks as a cognee.memify() pipeline
250
+ # --------------------------------------------------------------------------- #
251
+
252
+
253
+ async def _task_detect(data: List[Dict[str, Any]], **kwargs) -> List[Dict[str, Any]]:
254
+ """memify extraction task: detect contradictions."""
255
+ c = data[0] if data else {}
256
+ contradictions = await detect_contradictions(
257
+ c["new_fact"], pinned_target_id=c.get("pinned_target_id"),
258
+ )
259
+ c["contradictions"] = contradictions
260
+ return [c]
261
+
262
+
263
+ async def _task_propagate(data: List[Dict[str, Any]], **kwargs) -> List[Dict[str, Any]]:
264
+ """memify enrichment task 1: propagate refutation + promote hypotheses."""
265
+ c = data[0] if data else {}
266
+ contradictions = c.get("contradictions", [])
267
+ if not contradictions:
268
+ return [c]
269
+
270
+ target_ids = [con.target_id for con in contradictions]
271
+ prop = await propagate_refutation(target_ids)
272
+ c["propagation"] = prop
273
+ c["hypothesis_actions"] = await promote_competing_hypothesis(target_ids, prop.epoch)
274
+ return [c]
275
+
276
+
277
+ async def _task_record_and_forget(data: List[Dict[str, Any]], **kwargs) -> List[Dict[str, Any]]:
278
+ """memify enrichment task 2: record new fact, snapshot RAG, cascade forget."""
279
+ c = data[0] if data else {}
280
+ contradictions = c.get("contradictions", [])
281
+ if not contradictions:
282
+ return [c]
283
+
284
+ new_evidence_id = await _record_new_fact(
285
+ c["new_fact"], contradictions, c.get("source_id", "session2_fact"),
286
+ )
287
+ c["new_evidence_id"] = new_evidence_id
288
+ c["rag_snapshot"] = await _snapshot_rag(c["new_fact"])
289
+
290
+ prop = c.get("propagation")
291
+ if prop:
292
+ forget_res = await cascade_forget(prop.affected)
293
+ c["forget_result"] = forget_res
294
+
295
+ return [c]
296
+
297
+
298
+ async def revise_via_memify(
299
+ new_fact: str,
300
+ *,
301
+ pinned_target_id: Optional[str] = None,
302
+ source_id: str = "session2_fact",
303
+ ) -> RevisionReport:
304
+ """Run the belief-revision pipeline through cognee.memify().
305
+
306
+ Functionally identical to the direct-call path, but routes through Cognee's
307
+ memify pipeline runner so the revision tasks appear as first-class Cognee
308
+ pipeline stages — demonstrating deep API integration.
309
+ """
310
+ import cognee
311
+ from cognee.modules.pipelines.tasks.task import Task
312
+
313
+ pipeline_input = [{
314
+ "new_fact": new_fact,
315
+ "pinned_target_id": pinned_target_id,
316
+ "source_id": source_id,
317
+ }]
318
+
319
+ await cognee.memify(
320
+ extraction_tasks=[Task(_task_detect)],
321
+ enrichment_tasks=[
322
+ Task(_task_propagate),
323
+ Task(_task_record_and_forget),
324
+ ],
325
+ data=pipeline_input,
326
+ )
327
+
328
+ # Build the report from the mutated context dict
329
+ ctx = pipeline_input[0]
330
+ report = RevisionReport(new_fact=new_fact)
331
+ report.contradictions = ctx.get("contradictions", [])
332
+
333
+ prop = ctx.get("propagation")
334
+ if prop:
335
+ report.refuted = prop.refuted
336
+ report.invalidated = prop.invalidated
337
+ report.epoch = prop.epoch
338
+
339
+ report.hypothesis_actions = ctx.get("hypothesis_actions", {})
340
+ report.new_evidence_id = ctx.get("new_evidence_id")
341
+ report.rag_snapshot = ctx.get("rag_snapshot")
342
+
343
+ forget_res = ctx.get("forget_result")
344
+ if forget_res:
345
+ report.forgotten = forget_res.forgotten
346
+ report.forgotten_labels = forget_res.labels
347
+ report.retained_provenance = forget_res.retained_provenance
348
+
349
+ return report
350
+
351
+
352
+ @dataclass
353
+ class Scoreboard:
354
+ """The FALSIFY-vs-RAG comparison shown every run."""
355
+
356
+ question: str
357
+ falsify_answer: str
358
+ falsify_support: List[str] = field(default_factory=list)
359
+ rag_answer: str = ""
360
+ rag_citations: List[str] = field(default_factory=list)
361
+ stale: bool = False # True if RAG still cites a refuted node FALSIFY dropped
362
+
363
+
364
+ async def scoreboard(
365
+ question: str,
366
+ seeded: Optional[SeededGraph] = None,
367
+ rag_snapshot: Optional[List[Dict]] = None,
368
+ ) -> Scoreboard:
369
+ """Compare FALSIFY's revised answer against a plain-RAG baseline.
370
+
371
+ FALSIFY answer: derived from the graph, reading truth-state and using only
372
+ hypotheses/evidence still ``alive`` (the promoted frontier hypothesis).
373
+
374
+ RAG baseline: a raw vector search over ``Evidence_claim`` with **no** truth
375
+ filter — so it still returns evidence FALSIFY has refuted, and cites the stale
376
+ fact. This asymmetry is the demo's whole point.
377
+ """
378
+ board = Scoreboard(question=question, falsify_answer="(no surviving hypothesis)")
379
+
380
+ # ---- FALSIFY: try cognee.recall() first, fall back to graph traversal ----
381
+ recall_succeeded = False
382
+ try:
383
+ import cognee
384
+ from cognee.modules.search.types.SearchType import SearchType
385
+
386
+ recall_results = await cognee.recall(
387
+ query_text=question,
388
+ query_type=SearchType.GRAPH_COMPLETION,
389
+ top_k=3,
390
+ )
391
+ if recall_results:
392
+ best = recall_results[0]
393
+ answer_text = getattr(best, "text", None) or str(best)
394
+ board.falsify_answer = answer_text
395
+ board.falsify_support = ["(via cognee.recall GRAPH_COMPLETION)"]
396
+ recall_succeeded = True
397
+ logger.info("scoreboard: used cognee.recall() for FALSIFY answer")
398
+ except Exception as exc:
399
+ logger.info("cognee.recall() unavailable (%s); falling back to graph traversal", exc)
400
+
401
+ # Fall back to manual graph traversal (always works, including --demo offline mode)
402
+ nodes, edges = await graph_ops.load_graph()
403
+ node_ids = [nid for nid, _p in nodes]
404
+ truth = await graph_ops.get_truth(node_ids)
405
+ props_by_id = {str(nid): (p or {}) for nid, p in nodes}
406
+
407
+ if not recall_succeeded:
408
+ best_hyp, best_score = None, -1.0
409
+ support_edges = [(s, d, p) for (s, d, r, p) in edges if r == SUPPORTS]
410
+ for nid, props in nodes:
411
+ nid = str(nid)
412
+ if "statement" not in props:
413
+ continue
414
+ if _ALIVE not in truth.get(nid, [_ALIVE]):
415
+ continue
416
+ score = 0.0
417
+ alive_support = []
418
+ for (src, dst, ep) in support_edges:
419
+ if str(dst) != nid:
420
+ continue
421
+ if _ALIVE in truth.get(str(src), [_ALIVE]):
422
+ score += float(ep.get("weight", 0.5))
423
+ alive_support.append(graph_ops.node_label(props_by_id.get(str(src), {})))
424
+ if alive_support and score > best_score:
425
+ best_hyp, best_score = nid, score
426
+ board.falsify_answer = graph_ops.node_label(props)
427
+ board.falsify_support = alive_support
428
+
429
+ # ---- RAG baseline: use pre-forget snapshot if available, else live search ----
430
+ refuted_ids = {str(nid) for nid in node_ids
431
+ if TruthState.REFUTED.value in truth.get(str(nid), [])}
432
+
433
+ if rag_snapshot is not None:
434
+ for entry in rag_snapshot:
435
+ payload = entry.get("payload", {})
436
+ text = (payload.get("claim") or payload.get("text")
437
+ or graph_ops.node_label(payload))
438
+ board.rag_citations.append(str(text))
439
+ if entry["id"] in refuted_ids:
440
+ board.stale = True
441
+ else:
442
+ ve = graph_ops.get_vector_engine()
443
+ try:
444
+ hits = await ve.search(
445
+ _EVIDENCE_COLLECTION, query_text=question, limit=5,
446
+ include_payload=True,
447
+ )
448
+ except Exception as exc:
449
+ logger.warning("RAG baseline search failed: %s", exc)
450
+ hits = []
451
+ for h in (hits or []):
452
+ payload = getattr(h, "payload", {}) or {}
453
+ text = (payload.get("claim") or payload.get("text")
454
+ or graph_ops.node_label(payload))
455
+ board.rag_citations.append(str(text))
456
+ if str(h.id) in refuted_ids:
457
+ board.stale = True
458
+
459
+ board.rag_answer = (board.rag_citations[0] if board.rag_citations
460
+ else "(no vector hits)")
461
+
462
+ logger.info("scoreboard: falsify=%r stale_rag=%s", board.falsify_answer, board.stale)
463
+ return board
falsify/graph_ops.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Low-level graph operations for FALSIFY.
3
+
4
+ This module is the single, verified surface between FALSIFY's belief logic and
5
+ Cognee's graph/vector engines. Every engine call used elsewhere in the project goes
6
+ through a helper here, so the (few) places that touch Cognee internals are auditable.
7
+
8
+ Verified Cognee API shapes (grep-confirmed against /workspaces/cognee — see REQUIREMENTS.md §0):
9
+
10
+ ge = await get_graph_engine() # ASYNC factory
11
+ nodes, edges = await ge.get_graph_data() # ([(id, props)], [(src, dst, rel, props)])
12
+ nodes, edges = await ge.get_neighborhood(ids, depth=, edge_types=)
13
+ await ge.add_edge(from_node, to_node, relationship_name, edge_properties={})
14
+ await ge.set_node_truth_state({id: {"truth_alignment": [...], "truth_epoch": N}})
15
+ state = await ge.get_node_truth_state([ids]) # {id: {"truth_alignment": [...], ...}}
16
+ await ge.set_node_feedback_weights({id: 0.0})
17
+ await ge.delete_nodes([ids])
18
+
19
+ ve = get_vector_engine() # SYNC factory
20
+ await ve.delete_data_points(collection_name, [uuids])
21
+ hits = await ve.search(collection_name, query_text=, limit=, include_payload=)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from typing import Any, Dict, List, Optional, Set, Tuple
28
+
29
+ from cognee.infrastructure.databases.graph import get_graph_engine
30
+ from cognee.infrastructure.databases.vector import get_vector_engine
31
+
32
+ from falsify import events
33
+ from falsify.models import TruthState
34
+
35
+ logger = logging.getLogger("falsify.graph_ops")
36
+
37
+ # A parsed edge: (source_id, target_id, relationship_name, properties)
38
+ Edge = Tuple[str, str, str, Dict[str, Any]]
39
+ # A parsed node: (node_id, properties)
40
+ GraphNode = Tuple[str, Dict[str, Any]]
41
+
42
+
43
+ async def load_graph() -> Tuple[List[GraphNode], List[Edge]]:
44
+ """Return the full graph as ``(nodes, edges)``.
45
+
46
+ Nodes are ``(id, props)`` tuples; edges are ``(src, dst, rel, props)`` tuples.
47
+ IDs are normalized to ``str`` so they can be used as dict keys regardless of
48
+ whether the adapter returns ``UUID`` or ``str``.
49
+ """
50
+ ge = await get_graph_engine()
51
+ raw_nodes, raw_edges = await ge.get_graph_data()
52
+
53
+ nodes: List[GraphNode] = [(str(nid), props or {}) for nid, props in raw_nodes]
54
+
55
+ edges: List[Edge] = []
56
+ for e in raw_edges:
57
+ # Adapters return 4-tuples (src, dst, rel, props); be defensive about arity.
58
+ if len(e) >= 4:
59
+ src, dst, rel, props = e[0], e[1], e[2], e[3]
60
+ elif len(e) == 3:
61
+ src, dst, rel, props = e[0], e[1], e[2], {}
62
+ else: # pragma: no cover - unexpected adapter shape
63
+ continue
64
+ edges.append((str(src), str(dst), str(rel), props or {}))
65
+ return nodes, edges
66
+
67
+
68
+ async def get_truth(node_ids: List[str]) -> Dict[str, List[str]]:
69
+ """Return ``{node_id: truth_alignment_list}`` for the given ids.
70
+
71
+ A node with no stored truth state is treated as ``["alive"]`` (the default).
72
+ """
73
+ if not node_ids:
74
+ return {}
75
+ ge = await get_graph_engine()
76
+ ids = [str(n) for n in node_ids]
77
+ try:
78
+ raw = await ge.get_node_truth_state(ids)
79
+ except Exception as exc: # adapter may not have state yet
80
+ logger.debug("get_node_truth_state failed (%s); defaulting to alive", exc)
81
+ raw = {}
82
+ out: Dict[str, List[str]] = {}
83
+ for nid in ids:
84
+ entry = (raw or {}).get(nid) or (raw or {}).get(str(nid))
85
+ alignment = (entry or {}).get("truth_alignment") if entry else None
86
+ out[nid] = list(alignment) if alignment else [TruthState.ALIVE.value]
87
+ return out
88
+
89
+
90
+ async def is_alive(node_id: str) -> bool:
91
+ """True iff the node's truth_alignment currently contains ``alive``."""
92
+ state = await get_truth([str(node_id)])
93
+ return TruthState.ALIVE.value in state.get(str(node_id), [TruthState.ALIVE.value])
94
+
95
+
96
+ async def set_state(node_id: str, state: TruthState, epoch: int) -> None:
97
+ """Persist ``truth_alignment=[state]`` + ``truth_epoch=epoch`` on a node.
98
+
99
+ Truth state is stored ON the graph node, so it survives a process restart — the
100
+ basis of FALSIFY's cross-session persistence guarantee.
101
+ """
102
+ ge = await get_graph_engine()
103
+ value = state.value if isinstance(state, TruthState) else str(state)
104
+ try:
105
+ await ge.set_node_truth_state(
106
+ {str(node_id): {"truth_alignment": [value], "truth_epoch": int(epoch)}}
107
+ )
108
+ except Exception as exc:
109
+ logger.error("set_node_truth_state failed for %s -> %s: %s", node_id, value, exc)
110
+ raise
111
+ # Reaching here means the write succeeded (the except re-raises). Tell any live
112
+ # UI so the node can animate its state change; no-op under the CLI.
113
+ await events.emit_state_change(str(node_id), value, int(epoch))
114
+
115
+
116
+ async def set_weight(node_id: str, weight: float) -> None:
117
+ """Set a node's feedback weight (confidence/health signal). Best-effort."""
118
+ ge = await get_graph_engine()
119
+ try:
120
+ await ge.set_node_feedback_weights({str(node_id): float(weight)})
121
+ except Exception as exc: # non-fatal: weight is a secondary signal
122
+ logger.debug("set_node_feedback_weights failed for %s: %s", node_id, exc)
123
+
124
+
125
+ async def add_edge(src: str, dst: str, rel: str, props: Optional[Dict[str, Any]] = None) -> None:
126
+ """Add a directed edge ``src --rel--> dst`` with optional properties."""
127
+ ge = await get_graph_engine()
128
+ await ge.add_edge(str(src), str(dst), rel, props or {})
129
+
130
+
131
+ async def delete_from_both_stores(node_ids: List[str], collections: List[str]) -> int:
132
+ """Hard-delete nodes from the graph AND their rows from vector collections.
133
+
134
+ Returns the number of node ids deleted. Vector deletion is attempted per
135
+ collection and is best-effort (a node may not live in every collection).
136
+ """
137
+ if not node_ids:
138
+ return 0
139
+ ids = [str(n) for n in node_ids]
140
+
141
+ ge = await get_graph_engine()
142
+ try:
143
+ await ge.delete_nodes(ids)
144
+ except Exception as exc:
145
+ logger.error("delete_nodes failed: %s", exc)
146
+ raise
147
+ # Graph delete succeeded — animate each node's forget-dissolve in any live UI.
148
+ for nid in ids:
149
+ await events.emit_forgotten(nid)
150
+
151
+ ve = get_vector_engine()
152
+ for collection in collections:
153
+ try:
154
+ await ve.delete_data_points(collection, ids)
155
+ except Exception as exc: # collection may not exist / id not present
156
+ logger.debug("delete_data_points(%s) best-effort skip: %s", collection, exc)
157
+ return len(ids)
158
+
159
+
160
+ # --------------------------------------------------------------------------- #
161
+ # Adjacency helpers (built from a single load_graph() snapshot)
162
+ # --------------------------------------------------------------------------- #
163
+
164
+
165
+ def dependents_of(evidence_id: str, edges: List[Edge], rel: str) -> List[str]:
166
+ """Return source ids of ``rel`` edges pointing INTO ``evidence_id``.
167
+
168
+ For ``depends_on`` (Conclusion -> Evidence) this yields the Conclusions that
169
+ depend on the given Evidence — i.e. the forward-cascade victims.
170
+ """
171
+ tid = str(evidence_id)
172
+ return [src for (src, dst, r, _p) in edges if r == rel and str(dst) == tid]
173
+
174
+
175
+ def incoming(node_id: str, edges: List[Edge], rel: str) -> List[Tuple[str, Dict[str, Any]]]:
176
+ """Return ``[(source_id, props)]`` for ``rel`` edges pointing into ``node_id``."""
177
+ tid = str(node_id)
178
+ return [(src, p) for (src, dst, r, p) in edges if r == rel and str(dst) == tid]
179
+
180
+
181
+ def outgoing(node_id: str, edges: List[Edge], rel: str) -> List[Tuple[str, Dict[str, Any]]]:
182
+ """Return ``[(target_id, props)]`` for ``rel`` edges leaving ``node_id``."""
183
+ sid = str(node_id)
184
+ return [(dst, p) for (src, dst, r, p) in edges if r == rel and str(src) == sid]
185
+
186
+
187
+ def node_label(props: Dict[str, Any]) -> str:
188
+ """Best-effort human label for a node from its properties."""
189
+ for key in ("statement", "claim", "question", "text", "name"):
190
+ if props.get(key):
191
+ return str(props[key])
192
+ return props.get("id", "?")
193
+
194
+
195
+ async def flush_and_release() -> None:
196
+ """Checkpoint the graph WAL and evict the engine from Cognee's cache.
197
+
198
+ After this call, the next graph operation re-opens the on-disk store from
199
+ scratch — so a subsequent read is a genuine *cold* read, which is what makes
200
+ the web UI's persistence proof (``GET /api/verify``) honest rather than a
201
+ reflection of a warm in-memory handle. Every step is best-effort: on an
202
+ adapter that lacks ``checkpoint`` (e.g. the test FakeGraph), or if Cognee's
203
+ eviction internals move, we log and continue rather than break the demo.
204
+ """
205
+ ge = await get_graph_engine()
206
+ try:
207
+ if hasattr(ge, "checkpoint"):
208
+ await ge.checkpoint()
209
+ except Exception as exc:
210
+ logger.debug("checkpoint best-effort skip: %s", exc)
211
+ try:
212
+ from cognee.infrastructure.databases.graph.get_graph_engine import (
213
+ evict_graph_engine,
214
+ )
215
+ from cognee.infrastructure.databases.graph.config import get_graph_config
216
+
217
+ evict_graph_engine(**get_graph_config().to_hashable_dict())
218
+ except Exception as exc:
219
+ logger.debug("evict_graph_engine best-effort skip: %s", exc)
falsify/models.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY belief-graph data models.
3
+
4
+ Every node is a Cognee ``DataPoint`` subclass, so the same object serializes into
5
+ the graph DB (Ladybug) and, via its ``Embeddable`` field, into the vector DB
6
+ (LanceDB). The vector collection auto-created for a class is ``"{ClassName}_{field}"``
7
+ (e.g. ``Evidence_claim`` — the collection the contradiction prefilter searches).
8
+
9
+ Lifecycle / truth-state note
10
+ ----------------------------
11
+ The *authoritative, persistent* belief state of a node lives on the graph node as
12
+ ``truth_alignment`` (a list) + ``truth_epoch`` (int), written through the graph
13
+ engine (``set_node_truth_state``), not as pydantic model fields. Those props are
14
+ what ``recall()`` filters on and what survives a process restart.
15
+
16
+ The ``truth_state`` model field below is a convenience mirror of the node's initial
17
+ state at ingest time (defaults to ``ALIVE``). Do not treat it as the source of
18
+ truth after a memify run — always read back with ``get_node_truth_state``.
19
+
20
+ Dedup across sessions
21
+ ---------------------
22
+ ``Hypothesis``, ``Evidence`` and ``Assertion`` mark identity fields with ``Dedup()``.
23
+ DataPoint derives a stable UUID5 id from those fields (``DataPoint.id_for``), so
24
+ re-adding the same belief in a later session updates the existing node instead of
25
+ creating a duplicate — essential for a Session-2 contradiction to land on the exact
26
+ node built in Session 1.
27
+ """
28
+
29
+ from datetime import datetime, timezone
30
+ from enum import Enum
31
+ from typing import Annotated, List, Optional
32
+
33
+ from pydantic import Field
34
+
35
+ from cognee.infrastructure.engine import DataPoint, Dedup, Embeddable, LLMContext
36
+
37
+
38
+ def _now_iso() -> str:
39
+ """Return the current UTC time as an ISO-8601 string (used for node timestamps)."""
40
+ return datetime.now(timezone.utc).isoformat()
41
+
42
+
43
+ class TruthState(str, Enum):
44
+ """Canonical belief-lifecycle states for a FALSIFY graph node.
45
+
46
+ The values are the exact strings written into the node's ``truth_alignment``
47
+ list, so a state can be compared directly against what ``get_node_truth_state``
48
+ returns (e.g. ``get_node_truth_state([n])[n]["truth_alignment"] == [TruthState.REFUTED]``).
49
+
50
+ States:
51
+ - ``ALIVE``: default; the node participates in recall context.
52
+ - ``REFUTED``: Evidence directly contradicted by a newer fact — the entry point
53
+ of a forward cascade.
54
+ - ``SUPERSEDED``: a node replaced/demoted by a newer competing node; retained as
55
+ provenance, excluded from recall.
56
+ - ``INVALIDATED``: a Conclusion whose critical supporting Evidence chain was
57
+ refuted (forward-cascade victim).
58
+ - ``FORGOTTEN``: an orphaned dead-end scheduled for surgical delete (transient;
59
+ the node is then hard-removed from both graph and vector stores).
60
+
61
+ Note: the FALSIFY spec's minimal enum names ALIVE/REFUTED/SUPERSEDED/FORGOTTEN.
62
+ ``INVALIDATED`` is added here because the forward-propagation algorithm
63
+ (REQUIREMENTS §1.2/§1.4) needs a distinct state for cascade-victim Conclusions
64
+ versus directly-refuted Evidence.
65
+ """
66
+
67
+ ALIVE = "alive"
68
+ REFUTED = "refuted"
69
+ SUPERSEDED = "superseded"
70
+ INVALIDATED = "invalidated"
71
+ FORGOTTEN = "forgotten"
72
+
73
+
74
+ # --------------------------------------------------------------------------- #
75
+ # Edge relationship-name constants (passed as ``relationship_name`` to add_edge)
76
+ # --------------------------------------------------------------------------- #
77
+
78
+ # Conclusion -> Evidence. THE forward-propagation rail. edge_properties={"critical": bool}
79
+ DEPENDS_ON = "depends_on"
80
+
81
+ # Evidence -> Hypothesis. Evidence corroborates a hypothesis. edge_properties={"weight": float}
82
+ SUPPORTS = "supports"
83
+
84
+ # Evidence -> Hypothesis. Evidence contradicts a hypothesis. edge_properties={"weight": float}
85
+ # (REQUIREMENTS §1.1 names this edge "refutes"; ``REFUTES`` is provided as an alias.)
86
+ CONTRADICTS = "refutes"
87
+ REFUTES = CONTRADICTS
88
+
89
+ # Evidence(new) -> Evidence(old). New fact overrides an old evidence node.
90
+ # edge_properties={"confidence": float}
91
+ SUPERSEDES = "supersedes"
92
+
93
+
94
+ class InvestigationQuestion(DataPoint):
95
+ """Root node of a belief graph: the research question under investigation.
96
+
97
+ Example: "Did Company X know about the defect before the recall?" Everything
98
+ else (hypotheses, evidence, conclusions) hangs off this question via
99
+ ``question_id``.
100
+ """
101
+
102
+ question: Annotated[str, Embeddable(), LLMContext()]
103
+ truth_state: TruthState = TruthState.ALIVE
104
+ confidence: float = 1.0
105
+ timestamp: str = Field(default_factory=_now_iso)
106
+ source_id: Optional[str] = None
107
+
108
+ metadata: dict = {
109
+ "index_fields": ["question"],
110
+ "identity_fields": ["question"],
111
+ }
112
+
113
+
114
+ class Hypothesis(DataPoint):
115
+ """A candidate explanation competing to answer the InvestigationQuestion.
116
+
117
+ Hypotheses gain/lose standing through ``supports``/``refutes`` Evidence edges.
118
+ When a hypothesis' only supporting Evidence is refuted, it is demoted to
119
+ ``SUPERSEDED`` and the rival with the strongest surviving support is promoted.
120
+ """
121
+
122
+ statement: Annotated[str, Embeddable(), Dedup(), LLMContext()]
123
+ question_id: str
124
+ status: str = "alive"
125
+ prior: float = 0.5
126
+ truth_state: TruthState = TruthState.ALIVE
127
+ confidence: float = 0.5
128
+ timestamp: str = Field(default_factory=_now_iso)
129
+ source_id: Optional[str] = None
130
+
131
+ metadata: dict = {
132
+ "index_fields": ["statement"],
133
+ "identity_fields": ["question_id", "statement"],
134
+ }
135
+
136
+
137
+ class Evidence(DataPoint):
138
+ """A factual claim bearing on one or more hypotheses.
139
+
140
+ Evidence is the contradiction entry point: the ``Evidence_claim`` vector
141
+ collection is what the two-gate detector prefilters, and a ``REFUTED`` Evidence
142
+ node is the seed of every forward cascade. ``asserted_at`` is used as the
143
+ tie-break when deciding which of two competing claims supersedes the other
144
+ (newer wins).
145
+ """
146
+
147
+ claim: Annotated[str, Embeddable(), Dedup(), LLMContext()]
148
+ source_id: str
149
+ quote: str = ""
150
+ stance: str = "supports" # "supports" | "refutes"
151
+ asserted_at: str = Field(default_factory=_now_iso)
152
+ truth_state: TruthState = TruthState.ALIVE
153
+ confidence: float = 0.5
154
+ timestamp: str = Field(default_factory=_now_iso)
155
+
156
+ metadata: dict = {
157
+ "index_fields": ["claim"],
158
+ "identity_fields": ["source_id", "claim"],
159
+ }
160
+
161
+
162
+ class Conclusion(DataPoint):
163
+ """A derived finding that rests on one or more Evidence nodes.
164
+
165
+ A Conclusion ``depends_on`` the Evidence it is built from (edge carries
166
+ ``critical: bool``). When a *critical* dependency is refuted and no alive
167
+ critical alternative remains, the Conclusion is ``INVALIDATED`` by the forward
168
+ cascade; if it then has no surviving consumer it is ``FORGOTTEN`` (hard-deleted).
169
+ """
170
+
171
+ statement: Annotated[str, Embeddable(), LLMContext()]
172
+ confidence: float = 0.5
173
+ depends_on_ids: List[str] = Field(default_factory=list)
174
+ truth_state: TruthState = TruthState.ALIVE
175
+ timestamp: str = Field(default_factory=_now_iso)
176
+ source_id: Optional[str] = None
177
+
178
+ metadata: dict = {
179
+ "index_fields": ["statement"],
180
+ "identity_fields": ["statement"],
181
+ }
182
+
183
+
184
+ class Assertion(DataPoint):
185
+ """A raw, unclassified incoming claim — e.g. the new fact pasted in Session 2.
186
+
187
+ An Assertion is the pre-belief form of an incoming statement before the detector
188
+ decides whether it contradicts/supersedes existing Evidence and materializes a
189
+ proper ``Evidence`` node. It carries the same lifecycle scaffolding as the other
190
+ nodes so it can be reasoned over uniformly.
191
+ """
192
+
193
+ text: Annotated[str, Embeddable(), Dedup(), LLMContext()]
194
+ truth_state: TruthState = TruthState.ALIVE
195
+ confidence: float = 0.5
196
+ timestamp: str = Field(default_factory=_now_iso)
197
+ source_id: Optional[str] = None
198
+
199
+ metadata: dict = {
200
+ "index_fields": ["text"],
201
+ "identity_fields": ["text"],
202
+ }
falsify/seed.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY demo corpus — the "Company X recall" investigation.
3
+
4
+ This module builds the Session-1 belief graph that the demo revises in Session 2.
5
+ It is intentionally small and hand-authored so the belief-revision mechanics are
6
+ legible on screen in under 30 seconds, and so the cascade result is deterministic
7
+ (the winning demo must not depend on a flaky LLM).
8
+
9
+ The investigation
10
+ -----------------
11
+ Question Q: "Did Company X know about the defect before the recall?"
12
+
13
+ Competing hypotheses:
14
+ A — "X knew via the QA report dated March 2021" (supported by E_qa)
15
+ B — "X knew via a supplier email in January 2021" (supported by E_email)
16
+ C — "X did not know before the recall" (unsupported)
17
+
18
+ Evidence:
19
+ E_qa — the March-2021 QA report (supports A)
20
+ E_email — the January-2021 supplier email (supports B)
21
+
22
+ Conclusion:
23
+ K — "Company X knew about the defect by March 2021"
24
+ depends_on E_qa (critical=True) <-- the propagation rail
25
+
26
+ The Session-2 fact (NEW_FACT) is a forensic finding that the March QA report was
27
+ back-dated. It contradicts E_qa. Refuting E_qa must cascade:
28
+
29
+ E_qa -> refuted
30
+ -> K (depends_on E_qa, critical) -> invalidated
31
+ -> A (only supporter E_qa now dead) -> superseded ; B promoted (new frontier)
32
+ -> K orphaned (no alive consumer) -> forgotten (hard-deleted, graph + vector)
33
+ -> E_qa kept as refuted provenance (it is NEW_FACT's supersedes anchor)
34
+
35
+ The scoreboard then shows FALSIFY answering via B (Jan 2021) while a plain vector
36
+ (RAG) baseline still cites the refuted March QA report.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import logging
42
+ from dataclasses import dataclass, field
43
+ from typing import Dict, List
44
+
45
+ # NOTE: importing falsify.models runs falsify/__init__, which sets the Cognee env
46
+ # defaults (access-control off, session cache on) before Cognee is imported.
47
+ from falsify import graph_ops
48
+ from falsify.edges import DEPENDS_ON, SUPPORTS
49
+ from falsify.models import (
50
+ Conclusion,
51
+ Evidence,
52
+ Hypothesis,
53
+ InvestigationQuestion,
54
+ )
55
+
56
+ logger = logging.getLogger("falsify.seed")
57
+
58
+ # The research question this whole graph hangs off of.
59
+ QUESTION_TEXT = "Did Company X know about the defect before the recall?"
60
+
61
+ # The Session-2 fact that triggers belief revision. It contradicts E_qa.
62
+ NEW_FACT = (
63
+ "A forensic audit found that the March 2021 QA report was back-dated: it was "
64
+ "actually created shortly after the product recall, not before it."
65
+ )
66
+
67
+ # The SECOND contradiction, used only by the diamond scenario. It contradicts
68
+ # E_email. Dropped after NEW_FACT to demonstrate iterative revision: once BOTH
69
+ # legs of a multi-source conclusion are refuted, the conclusion finally collapses.
70
+ NEW_FACT_2 = (
71
+ "A forensic analysis of email headers shows the January 2021 supplier email was "
72
+ "fabricated: the sender domain was not registered until April 2021, and the DKIM "
73
+ "signature is invalid."
74
+ )
75
+
76
+ # Human-readable stable keys -> used by --demo mode to pin the refutation target
77
+ # deterministically (so the cascade runs on real graph APIs even if the LLM judge
78
+ # is unavailable). These map to the ``key`` field on the seeded nodes below.
79
+ REFUTED_EVIDENCE_KEY = "E_qa"
80
+ REFUTED_EVIDENCE_KEY_2 = "E_email" # second-phase target for the diamond scenario
81
+
82
+
83
+ @dataclass
84
+ class SeededGraph:
85
+ """Handles to the nodes created by :func:`build_investigation`.
86
+
87
+ ``ids`` maps a stable human key (e.g. ``"E_qa"``, ``"A"``, ``"K"``) to the
88
+ string node id in the graph, so the demo/tests can reference specific nodes
89
+ without re-querying. ``labels`` maps the same keys to display strings.
90
+ """
91
+
92
+ question_id: str
93
+ ids: Dict[str, str] = field(default_factory=dict)
94
+ labels: Dict[str, str] = field(default_factory=dict)
95
+
96
+ @property
97
+ def refuted_target_id(self) -> str:
98
+ """Node id of the evidence the Session-2 fact contradicts (E_qa)."""
99
+ return self.ids[REFUTED_EVIDENCE_KEY]
100
+
101
+
102
+ async def build_investigation() -> SeededGraph:
103
+ """Create and persist the Session-1 belief graph. Returns a :class:`SeededGraph`.
104
+
105
+ The caller is responsible for starting from a clean state (e.g. via
106
+ ``cognee.forget(everything=True)`` and ``cognee.low_level.setup()``). This
107
+ function only builds; it does not prune.
108
+
109
+ Nodes are persisted with Cognee's ``add_data_points`` (writing both the graph
110
+ node and the vector row for each ``Embeddable`` field). Typed edges with
111
+ properties are then added explicitly through the graph engine so we control the
112
+ exact relationship names and edge properties (``critical`` / ``weight``).
113
+ """
114
+ # Import here so the module import stays cheap and env defaults are already set.
115
+ from cognee.tasks.storage import add_data_points
116
+
117
+ # ------------------------------------------------------------------ nodes
118
+ question = InvestigationQuestion(question=QUESTION_TEXT, source_id="investigation")
119
+
120
+ hyp_a = Hypothesis(
121
+ statement="Company X knew via the QA report dated March 2021.",
122
+ question_id=str(question.id),
123
+ prior=0.5,
124
+ confidence=0.6,
125
+ source_id="analyst",
126
+ )
127
+ hyp_b = Hypothesis(
128
+ statement="Company X knew via a supplier email in January 2021.",
129
+ question_id=str(question.id),
130
+ prior=0.5,
131
+ confidence=0.55,
132
+ source_id="analyst",
133
+ )
134
+ hyp_c = Hypothesis(
135
+ statement="Company X did not know about the defect before the recall.",
136
+ question_id=str(question.id),
137
+ prior=0.5,
138
+ confidence=0.4,
139
+ source_id="analyst",
140
+ )
141
+
142
+ ev_qa = Evidence(
143
+ claim="A QA report dated March 2021 documented the defect internally.",
144
+ source_id="qa_report_2021_03",
145
+ quote="Internal QA report, dated 2021-03-15, flags the defect.",
146
+ stance="supports",
147
+ asserted_at="2021-03-15",
148
+ confidence=0.8,
149
+ )
150
+ ev_email = Evidence(
151
+ claim="A supplier email in January 2021 warned Company X about the defect.",
152
+ source_id="supplier_email_2021_01",
153
+ quote="Supplier email, 2021-01-20: 'we have observed the defect in test units.'",
154
+ stance="supports",
155
+ asserted_at="2021-01-20",
156
+ confidence=0.7,
157
+ )
158
+
159
+ conclusion_k = Conclusion(
160
+ statement="Company X knew about the defect by March 2021.",
161
+ confidence=0.8,
162
+ depends_on_ids=[str(ev_qa.id)],
163
+ source_id="analyst",
164
+ )
165
+
166
+ nodes: List = [question, hyp_a, hyp_b, hyp_c, ev_qa, ev_email, conclusion_k]
167
+
168
+ logger.info("Persisting %d belief nodes via add_data_points", len(nodes))
169
+ await add_data_points(nodes)
170
+
171
+ # ------------------------------------------------------------------ edges
172
+ # Evidence -> Hypothesis (supports, weighted)
173
+ await graph_ops.add_edge(str(ev_qa.id), str(hyp_a.id), SUPPORTS, {"weight": 0.8})
174
+ await graph_ops.add_edge(str(ev_email.id), str(hyp_b.id), SUPPORTS, {"weight": 0.7})
175
+
176
+ # Conclusion -> Evidence (depends_on, critical) — THE propagation rail
177
+ await graph_ops.add_edge(
178
+ str(conclusion_k.id), str(ev_qa.id), DEPENDS_ON, {"critical": True}
179
+ )
180
+
181
+ # Hypothesis -> Question (answers) — keeps the graph connected for visualization
182
+ for hyp in (hyp_a, hyp_b, hyp_c):
183
+ await graph_ops.add_edge(str(hyp.id), str(question.id), "answers", {})
184
+
185
+ seeded = SeededGraph(
186
+ question_id=str(question.id),
187
+ ids={
188
+ "Q": str(question.id),
189
+ "A": str(hyp_a.id),
190
+ "B": str(hyp_b.id),
191
+ "C": str(hyp_c.id),
192
+ "E_qa": str(ev_qa.id),
193
+ "E_email": str(ev_email.id),
194
+ "K": str(conclusion_k.id),
195
+ },
196
+ labels={
197
+ "Q": QUESTION_TEXT,
198
+ "A": hyp_a.statement,
199
+ "B": hyp_b.statement,
200
+ "C": hyp_c.statement,
201
+ "E_qa": ev_qa.claim,
202
+ "E_email": ev_email.claim,
203
+ "K": conclusion_k.statement,
204
+ },
205
+ )
206
+ logger.info("Seeded investigation graph: %s", seeded.ids)
207
+ return seeded
208
+
209
+
210
+ async def build_diamond_investigation() -> SeededGraph:
211
+ """Build the Session-1 graph WITH a diamond dependency (Conclusion K2).
212
+
213
+ This is the same investigation as :func:`build_investigation`, plus one extra
214
+ Conclusion K2 that *critically depends on BOTH* E_qa and E_email. It exists to
215
+ demonstrate that FALSIFY's propagation is a grounded least-fixpoint, not a naive
216
+ cascade: a conclusion with two critical supports survives losing one of them, and
217
+ only collapses when the LAST support dies.
218
+
219
+ Two-phase story the demo drives on top of this graph:
220
+
221
+ Phase 1 — refute E_qa (NEW_FACT):
222
+ E_qa -> refuted ; K (single dep) -> invalidated -> forgotten
223
+ K2 -> STILL ALIVE (E_email keeps it grounded) ; A superseded, B promoted
224
+ E_qa -> retained (K2 alive still depends on it, and it anchors NEW_FACT)
225
+
226
+ Phase 2 — refute E_email (NEW_FACT_2):
227
+ E_email -> refuted ; K2 (last dep now dead) -> invalidated -> forgotten
228
+ B -> superseded ; only C ("did not know") may remain
229
+
230
+ The base nodes are re-created here (rather than shared with build_investigation)
231
+ so the proven single-contradiction demo path stays untouched.
232
+ """
233
+ from cognee.tasks.storage import add_data_points
234
+
235
+ # ------------------------------------------------------------------ nodes
236
+ question = InvestigationQuestion(question=QUESTION_TEXT, source_id="investigation")
237
+
238
+ hyp_a = Hypothesis(
239
+ statement="Company X knew via the QA report dated March 2021.",
240
+ question_id=str(question.id), prior=0.5, confidence=0.6, source_id="analyst",
241
+ )
242
+ hyp_b = Hypothesis(
243
+ statement="Company X knew via a supplier email in January 2021.",
244
+ question_id=str(question.id), prior=0.5, confidence=0.55, source_id="analyst",
245
+ )
246
+ hyp_c = Hypothesis(
247
+ statement="Company X did not know about the defect before the recall.",
248
+ question_id=str(question.id), prior=0.5, confidence=0.4, source_id="analyst",
249
+ )
250
+
251
+ ev_qa = Evidence(
252
+ claim="A QA report dated March 2021 documented the defect internally.",
253
+ source_id="qa_report_2021_03",
254
+ quote="Internal QA report, dated 2021-03-15, flags the defect.",
255
+ stance="supports", asserted_at="2021-03-15", confidence=0.8,
256
+ )
257
+ ev_email = Evidence(
258
+ claim="A supplier email in January 2021 warned Company X about the defect.",
259
+ source_id="supplier_email_2021_01",
260
+ quote="Supplier email, 2021-01-20: 'we have observed the defect in test units.'",
261
+ stance="supports", asserted_at="2021-01-20", confidence=0.7,
262
+ )
263
+
264
+ conclusion_k = Conclusion(
265
+ statement="Company X knew about the defect by March 2021.",
266
+ confidence=0.8, depends_on_ids=[str(ev_qa.id)], source_id="analyst",
267
+ )
268
+ # THE DIAMOND: K2 rests on two independent critical supports.
269
+ conclusion_k2 = Conclusion(
270
+ statement="Multiple independent sources confirm Company X had pre-recall "
271
+ "knowledge of the defect.",
272
+ confidence=0.85,
273
+ depends_on_ids=[str(ev_qa.id), str(ev_email.id)],
274
+ source_id="analyst",
275
+ )
276
+
277
+ nodes: List = [question, hyp_a, hyp_b, hyp_c, ev_qa, ev_email, conclusion_k, conclusion_k2]
278
+
279
+ logger.info("Persisting %d belief nodes (diamond) via add_data_points", len(nodes))
280
+ await add_data_points(nodes)
281
+
282
+ # ------------------------------------------------------------------ edges
283
+ await graph_ops.add_edge(str(ev_qa.id), str(hyp_a.id), SUPPORTS, {"weight": 0.8})
284
+ await graph_ops.add_edge(str(ev_email.id), str(hyp_b.id), SUPPORTS, {"weight": 0.7})
285
+
286
+ # K depends on E_qa only (single-leg — dies in phase 1).
287
+ await graph_ops.add_edge(
288
+ str(conclusion_k.id), str(ev_qa.id), DEPENDS_ON, {"critical": True}
289
+ )
290
+ # K2's two critical legs — the diamond. Survives phase 1, collapses in phase 2.
291
+ await graph_ops.add_edge(
292
+ str(conclusion_k2.id), str(ev_qa.id), DEPENDS_ON, {"critical": True}
293
+ )
294
+ await graph_ops.add_edge(
295
+ str(conclusion_k2.id), str(ev_email.id), DEPENDS_ON, {"critical": True}
296
+ )
297
+
298
+ for hyp in (hyp_a, hyp_b, hyp_c):
299
+ await graph_ops.add_edge(str(hyp.id), str(question.id), "answers", {})
300
+
301
+ seeded = SeededGraph(
302
+ question_id=str(question.id),
303
+ ids={
304
+ "Q": str(question.id),
305
+ "A": str(hyp_a.id), "B": str(hyp_b.id), "C": str(hyp_c.id),
306
+ "E_qa": str(ev_qa.id), "E_email": str(ev_email.id),
307
+ "K": str(conclusion_k.id), "K2": str(conclusion_k2.id),
308
+ },
309
+ labels={
310
+ "Q": QUESTION_TEXT,
311
+ "A": hyp_a.statement, "B": hyp_b.statement, "C": hyp_c.statement,
312
+ "E_qa": ev_qa.claim, "E_email": ev_email.claim,
313
+ "K": conclusion_k.statement, "K2": conclusion_k2.statement,
314
+ },
315
+ )
316
+ logger.info("Seeded diamond investigation graph: %s", seeded.ids)
317
+ return seeded
falsify/tasks/__init__.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY belief-revision tasks.
3
+
4
+ The three-step revision pipeline, in execution order:
5
+
6
+ 1. :func:`detect_contradictions` — two-gate detector: which existing evidence does a
7
+ new fact contradict/supersede?
8
+ 2. :func:`propagate_refutation` — flip the contradicted evidence to ``refuted`` and
9
+ cascade ``invalidated`` forward through ``depends_on``; then
10
+ :func:`promote_competing_hypothesis` re-scores the rival hypotheses.
11
+ 3. :func:`cascade_forget` — hard-delete truly-orphaned dead-ends from graph + vector,
12
+ keeping provenance tombstones.
13
+ """
14
+
15
+ from falsify.tasks.detect_contradictions import (
16
+ Contradiction,
17
+ ContradictionJudgement,
18
+ detect_contradictions,
19
+ )
20
+ from falsify.tasks.propagate_refutation import (
21
+ PropagationResult,
22
+ promote_competing_hypothesis,
23
+ propagate_refutation,
24
+ )
25
+ from falsify.tasks.cascade_forget import ForgetResult, cascade_forget
26
+
27
+ __all__ = [
28
+ "detect_contradictions",
29
+ "Contradiction",
30
+ "ContradictionJudgement",
31
+ "propagate_refutation",
32
+ "promote_competing_hypothesis",
33
+ "PropagationResult",
34
+ "cascade_forget",
35
+ "ForgetResult",
36
+ ]
falsify/tasks/cascade_forget.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Surgical forget — prune orphaned dead-ends from graph **and** vector stores.
3
+
4
+ After :mod:`falsify.tasks.propagate_refutation` marks nodes ``refuted`` /
5
+ ``invalidated``, some of those nodes are still useful: they explain *why* a belief
6
+ changed (provenance) or they still feed a node that is alive. Others are pure
7
+ dead-ends with no surviving consumer. FALSIFY hard-deletes only the latter, from
8
+ both the graph (``delete_nodes``) and the vector index (``delete_data_points``), so a
9
+ subsequent ``recall()`` — and even a raw vector search — can never resurface them.
10
+
11
+ Orphan rule (REQUIREMENTS §1.5) — a node is FORGOTTEN iff ALL hold:
12
+ (a) truth state is ``refuted`` or ``invalidated`` (never ``alive``/``superseded``;
13
+ superseded nodes are kept as provenance), AND
14
+ (b) no surviving ALIVE node reaches it via ``depends_on`` or ``supports``
15
+ (it has no live consumer), AND
16
+ (c) it is NOT the target of a ``supersedes`` edge FROM an alive node (such a node
17
+ is the provenance anchor of the new truth and must be retained as a tombstone).
18
+
19
+ The asymmetry is deliberate and is what makes FALSIFY look *surgical*: in the demo the
20
+ orphaned Conclusion K is deleted, while the refuted Evidence E_qa is kept — flagged
21
+ red — because it is the supersedes-anchor of the new fact.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from dataclasses import dataclass, field
28
+ from typing import Dict, List, Set
29
+
30
+ from falsify import graph_ops
31
+ from falsify.edges import CONSUMER_EDGE_TYPES, SUPERSEDES
32
+ from falsify.models import (
33
+ Conclusion,
34
+ Evidence,
35
+ TruthState,
36
+ )
37
+
38
+ logger = logging.getLogger("falsify.forget")
39
+
40
+ _DELETABLE_STATES = {TruthState.REFUTED.value, TruthState.INVALIDATED.value}
41
+ _ALIVE = TruthState.ALIVE.value
42
+
43
+ # Vector collections FALSIFY writes to (``"{ClassName}_{embeddable_field}"``).
44
+ # Deleting an id from a collection it isn't in is a best-effort no-op.
45
+ _VECTOR_COLLECTIONS = [
46
+ "Evidence_claim",
47
+ "Conclusion_statement",
48
+ "Hypothesis_statement",
49
+ "Assertion_text",
50
+ "InvestigationQuestion_question",
51
+ ]
52
+
53
+
54
+ @dataclass
55
+ class ForgetResult:
56
+ """Outcome of a forget pass.
57
+
58
+ Attributes:
59
+ forgotten: node ids hard-deleted from graph + vector.
60
+ retained_provenance: refuted/invalidated ids deliberately kept (a supersedes
61
+ anchor, or still feeding a live node).
62
+ labels: id -> human label for the forgotten nodes (for demo output).
63
+ """
64
+
65
+ forgotten: List[str] = field(default_factory=list)
66
+ retained_provenance: List[str] = field(default_factory=list)
67
+ labels: Dict[str, str] = field(default_factory=dict)
68
+
69
+
70
+ async def _alive_consumer_exists(node_id: str, edges, truth: Dict[str, List[str]]) -> bool:
71
+ """True if some ALIVE node reaches ``node_id`` via depends_on/supports.
72
+
73
+ ``depends_on`` (Conclusion->Evidence) and ``supports`` (Evidence->Hypothesis) both
74
+ point *from consumer to the thing consumed*, so an incoming edge's **source** is a
75
+ consumer of ``node_id``.
76
+ """
77
+ nid = str(node_id)
78
+ for rel in CONSUMER_EDGE_TYPES:
79
+ for src, _props in graph_ops.incoming(nid, edges, rel):
80
+ alignment = truth.get(str(src), [_ALIVE])
81
+ if _ALIVE in alignment:
82
+ return True
83
+ return False
84
+
85
+
86
+ def _is_supersedes_anchor(node_id: str, edges, truth: Dict[str, List[str]]) -> bool:
87
+ """True if ``node_id`` is the target of a ``supersedes`` edge from an ALIVE node.
88
+
89
+ That alive source is the new, current truth; the target is its tombstone and must
90
+ be retained as provenance.
91
+ """
92
+ nid = str(node_id)
93
+ for src, _props in graph_ops.incoming(nid, edges, SUPERSEDES):
94
+ alignment = truth.get(str(src), [_ALIVE])
95
+ if _ALIVE in alignment:
96
+ return True
97
+ return False
98
+
99
+
100
+ async def cascade_forget(candidate_ids: List[str]) -> ForgetResult:
101
+ """Delete truly-orphaned dead-ends among ``candidate_ids``; keep provenance.
102
+
103
+ Args:
104
+ candidate_ids: nodes marked refuted/invalidated by a preceding cascade.
105
+
106
+ Returns:
107
+ A :class:`ForgetResult`. Deleted nodes are removed from the graph and from
108
+ every FALSIFY vector collection, so no retrieval path can resurface them.
109
+ """
110
+ result = ForgetResult()
111
+ candidates = [str(c) for c in dict.fromkeys(candidate_ids) if c]
112
+ if not candidates:
113
+ return result
114
+
115
+ nodes, edges = await graph_ops.load_graph()
116
+ props_by_id = {str(nid): (props or {}) for nid, props in nodes}
117
+
118
+ # Current truth for candidates + their neighbors (consumers/anchors).
119
+ neighbor_ids: Set[str] = set(candidates)
120
+ for cid in candidates:
121
+ for rel in (*CONSUMER_EDGE_TYPES, SUPERSEDES):
122
+ neighbor_ids.update(str(s) for s, _p in graph_ops.incoming(cid, edges, rel))
123
+ truth = await graph_ops.get_truth(list(neighbor_ids))
124
+
125
+ death_set: List[str] = []
126
+ for cid in candidates:
127
+ alignment = truth.get(cid, [_ALIVE])
128
+
129
+ # (a) must be refuted/invalidated
130
+ if not any(state in _DELETABLE_STATES for state in alignment):
131
+ continue
132
+ # (c) keep supersedes anchors (provenance tombstones)
133
+ if _is_supersedes_anchor(cid, edges, truth):
134
+ result.retained_provenance.append(cid)
135
+ logger.info("retained %s as supersedes provenance anchor", cid)
136
+ continue
137
+ # (b) keep nodes that still feed a live consumer
138
+ if await _alive_consumer_exists(cid, edges, truth):
139
+ result.retained_provenance.append(cid)
140
+ logger.info("retained %s (still feeds a live node)", cid)
141
+ continue
142
+
143
+ death_set.append(cid)
144
+ result.labels[cid] = graph_ops.node_label(props_by_id.get(cid, {}))
145
+
146
+ if not death_set:
147
+ logger.info("forget pass: nothing orphaned; %d provenance nodes retained",
148
+ len(result.retained_provenance))
149
+ return result
150
+
151
+ # Hard-delete from graph + all vector collections in one batch.
152
+ deleted = await graph_ops.delete_from_both_stores(death_set, _VECTOR_COLLECTIONS)
153
+ result.forgotten = death_set
154
+ logger.info(
155
+ "forget pass: hard-deleted %d orphan(s) from graph + vector; retained %d provenance",
156
+ deleted,
157
+ len(result.retained_provenance),
158
+ )
159
+ return result
falsify/tasks/detect_contradictions.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Two-gate contradiction detection — the trigger for belief revision.
3
+
4
+ Deciding that a new fact *contradicts* an existing belief is the one genuinely
5
+ fuzzy step in FALSIFY. A false positive nukes a valid conclusion; a false negative
6
+ lets a stale fact survive. We therefore gate it twice, cheap-deterministic first,
7
+ expensive-semantic second, with a hard deterministic override for the live demo.
8
+
9
+ Gate 1 — vector prefilter (deterministic).
10
+ Embed the new fact and search the ``Evidence_claim`` collection. Only
11
+ evidence within a cosine-distance threshold (``< 0.35`` by default, i.e.
12
+ clearly on-topic) proceeds. This narrows the LLM to plausibly-conflicting
13
+ claims and keeps cost + nondeterminism bounded.
14
+
15
+ Gate 2 — LLM adjudication (semantic).
16
+ For each surviving candidate, ask an LLM acting as a skeptical analyst to
17
+ classify the relation as contradicts / supersedes / supports / unrelated with
18
+ a confidence. Only ``contradicts`` or ``supersedes`` at confidence >= 0.6
19
+ triggers refutation. This separates a genuine contradiction ("the report was
20
+ back-dated") from mere topical overlap ("also mentions the report").
21
+
22
+ Demo override (``pinned_target_id`` / ``DEMO_MODE``).
23
+ When set, gates are bypassed and a fixed high-confidence ``contradicts``
24
+ verdict is returned for the pinned evidence id, so the on-stage cascade runs
25
+ on real graph APIs even if the LLM is slow, rate-limited, or the key is
26
+ absent. This is FALSIFY's demo safety net (REQUIREMENTS §1.3).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ from dataclasses import dataclass, field
33
+ from typing import List, Literal, Optional
34
+
35
+ from pydantic import BaseModel, Field
36
+
37
+ from falsify import graph_ops
38
+
39
+ logger = logging.getLogger("falsify.detect")
40
+
41
+ # Cosine distance below which two claims are "about the same thing" (Gate 1).
42
+ DEFAULT_DISTANCE_THRESHOLD = 0.35
43
+ # Minimum LLM confidence for a contradiction/supersede to count (Gate 2).
44
+ DEFAULT_CONFIDENCE_THRESHOLD = 0.6
45
+ # Vector collection holding Evidence claims.
46
+ _EVIDENCE_COLLECTION = "Evidence_claim"
47
+
48
+ _SYSTEM_PROMPT = (
49
+ "You are a skeptical forensic analyst. You are given an EXISTING evidence claim "
50
+ "and a NEW fact. Decide the logical relation of the NEW fact to the EXISTING "
51
+ "claim. Answer 'contradicts' only if the new fact makes the existing claim false "
52
+ "or untrustworthy (e.g. it was fabricated, back-dated, retracted, or refuted). "
53
+ "Answer 'supersedes' if the new fact replaces the existing claim with a newer, "
54
+ "more authoritative version of the same fact. Answer 'supports' if it corroborates "
55
+ "the claim, and 'unrelated' otherwise. Be conservative: when unsure, prefer "
56
+ "'unrelated'. Provide a calibrated confidence in [0,1] and a one-sentence rationale."
57
+ )
58
+
59
+
60
+ class ContradictionJudgement(BaseModel):
61
+ """Structured verdict returned by the Gate-2 LLM adjudication."""
62
+
63
+ relation: Literal["contradicts", "supersedes", "supports", "unrelated"]
64
+ confidence: float = Field(ge=0.0, le=1.0)
65
+ rationale: str = ""
66
+
67
+
68
+ @dataclass
69
+ class Contradiction:
70
+ """A confirmed conflict between the new fact and an existing evidence node.
71
+
72
+ Attributes:
73
+ target_id: the existing Evidence node id that is contradicted/superseded.
74
+ relation: ``contradicts`` or ``supersedes``.
75
+ confidence: adjudicated confidence.
76
+ rationale: short human explanation (shown in the demo).
77
+ distance: Gate-1 cosine distance (lower = more on-topic).
78
+ """
79
+
80
+ target_id: str
81
+ relation: str
82
+ confidence: float
83
+ rationale: str = ""
84
+ distance: float = 0.0
85
+
86
+
87
+ async def detect_contradictions(
88
+ new_fact: str,
89
+ *,
90
+ pinned_target_id: Optional[str] = None,
91
+ distance_threshold: float = DEFAULT_DISTANCE_THRESHOLD,
92
+ confidence_threshold: float = DEFAULT_CONFIDENCE_THRESHOLD,
93
+ max_candidates: int = 5,
94
+ ) -> List[Contradiction]:
95
+ """Return the existing evidence nodes that ``new_fact`` contradicts or supersedes.
96
+
97
+ Args:
98
+ new_fact: the incoming claim (e.g. the Session-2 forensic finding).
99
+ pinned_target_id: demo/deterministic override; if given, gates are skipped and
100
+ a single high-confidence ``contradicts`` verdict is returned for this id.
101
+ distance_threshold: Gate-1 cosine-distance cutoff (lower = stricter on-topic).
102
+ confidence_threshold: Gate-2 minimum confidence to accept a verdict.
103
+ max_candidates: cap on Gate-1 candidates sent to the LLM.
104
+
105
+ Returns:
106
+ A list of :class:`Contradiction` (possibly empty). Callers feed the target
107
+ ids into :func:`falsify.tasks.propagate_refutation.propagate_refutation`.
108
+ """
109
+ # -------- Demo / deterministic override -------------------------------
110
+ if pinned_target_id:
111
+ logger.info("detect_contradictions: pinned target %s (demo mode)", pinned_target_id)
112
+ return [
113
+ Contradiction(
114
+ target_id=str(pinned_target_id),
115
+ relation="contradicts",
116
+ confidence=0.9,
117
+ rationale="Pinned contradiction (demo mode): new fact invalidates the target evidence.",
118
+ distance=0.0,
119
+ )
120
+ ]
121
+
122
+ # -------- Gate 1: vector prefilter ------------------------------------
123
+ ve = graph_ops.get_vector_engine()
124
+ try:
125
+ hits = await ve.search(
126
+ _EVIDENCE_COLLECTION,
127
+ query_text=new_fact,
128
+ limit=max_candidates,
129
+ include_payload=True,
130
+ )
131
+ except Exception as exc:
132
+ logger.warning("Gate-1 vector search failed (%s); no contradictions detected", exc)
133
+ return []
134
+
135
+ candidates = [(str(h.id), float(getattr(h, "score", 1.0)), getattr(h, "payload", {}) or {})
136
+ for h in (hits or [])]
137
+ on_topic = [c for c in candidates if c[1] < distance_threshold]
138
+ logger.info(
139
+ "Gate-1: %d hit(s), %d within distance %.2f", len(candidates), len(on_topic), distance_threshold
140
+ )
141
+ if not on_topic:
142
+ return []
143
+
144
+ # -------- Gate 2: LLM adjudication ------------------------------------
145
+ from cognee.infrastructure.llm.LLMGateway import LLMGateway
146
+
147
+ confirmed: List[Contradiction] = []
148
+ for target_id, distance, payload in on_topic:
149
+ existing_claim = _payload_text(payload) or "(existing evidence claim)"
150
+ text_input = (
151
+ f"EXISTING claim:\n{existing_claim}\n\nNEW fact:\n{new_fact}\n\n"
152
+ "Classify the relation of the NEW fact to the EXISTING claim."
153
+ )
154
+ try:
155
+ verdict: ContradictionJudgement = await LLMGateway.acreate_structured_output(
156
+ text_input=text_input,
157
+ system_prompt=_SYSTEM_PROMPT,
158
+ response_model=ContradictionJudgement,
159
+ )
160
+ except Exception as exc:
161
+ logger.warning("Gate-2 LLM judge failed for %s (%s); skipping candidate", target_id, exc)
162
+ continue
163
+
164
+ if verdict.relation in ("contradicts", "supersedes") and verdict.confidence >= confidence_threshold:
165
+ confirmed.append(
166
+ Contradiction(
167
+ target_id=target_id,
168
+ relation=verdict.relation,
169
+ confidence=verdict.confidence,
170
+ rationale=verdict.rationale,
171
+ distance=distance,
172
+ )
173
+ )
174
+ logger.info(
175
+ "Gate-2: CONFIRMED %s on %s (conf=%.2f)", verdict.relation, target_id, verdict.confidence
176
+ )
177
+ else:
178
+ logger.info(
179
+ "Gate-2: rejected %s (relation=%s conf=%.2f)",
180
+ target_id, verdict.relation, verdict.confidence,
181
+ )
182
+
183
+ return confirmed
184
+
185
+
186
+ def _payload_text(payload: dict) -> Optional[str]:
187
+ """Extract the human-readable claim text from a vector-hit payload."""
188
+ if not payload:
189
+ return None
190
+ for key in ("claim", "text", "statement", "content"):
191
+ if payload.get(key):
192
+ return str(payload[key])
193
+ # cognee payloads sometimes nest the original properties
194
+ props = payload.get("properties") or payload.get("metadata")
195
+ if isinstance(props, dict):
196
+ for key in ("claim", "text", "statement"):
197
+ if props.get(key):
198
+ return str(props[key])
199
+ return None
falsify/tasks/propagate_refutation.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Forward refutation propagation — the core of FALSIFY's belief revision.
3
+
4
+ When a piece of Evidence is refuted, the conclusions that *depend on it* can no
5
+ longer stand. This module walks the dependency structure and flips the truth-state
6
+ of every node that transitively rests on the refuted evidence, then re-scores the
7
+ competing hypotheses.
8
+
9
+ Why this can't be done by RAG
10
+ -----------------------------
11
+ Vector similarity has no notion of "this fact supports that conclusion three hops
12
+ away." Refutation propagation is *graph traversal over typed edges* — it is exactly
13
+ the thing a knowledge graph can do and an embedding index cannot. This is FALSIFY's
14
+ differentiator and maps directly to the hackathon's "Best Use of Cognee" criterion.
15
+
16
+ Direction of travel (critical detail)
17
+ -------------------------------------
18
+ The ``depends_on`` edge points **Conclusion -> Evidence** (a conclusion depends on
19
+ the evidence it rests on). So to find what *breaks* when Evidence ``E`` is refuted,
20
+ we look for ``depends_on`` edges whose **target** is ``E``; their **sources** are the
21
+ dependent Conclusions. We then recurse: a newly-invalidated Conclusion may itself be
22
+ the target of further ``depends_on`` edges.
23
+
24
+ Correctness cases handled (REQUIREMENTS §4.3)
25
+ --------------------------------------------
26
+ * **Cycle safety** — a ``visited`` set guarantees termination on cyclic graphs.
27
+ * **Critical vs non-critical** — only a ``critical: true`` dependency can invalidate
28
+ a conclusion. A non-critical dependency being refuted decays confidence but the
29
+ conclusion stays ``alive``.
30
+ * **Diamond / partial refutation** — a conclusion with several critical supporters is
31
+ invalidated only when it loses its **last** alive critical supporter. If an
32
+ alternative critical support is still alive, the conclusion survives (and the
33
+ refuted evidence is *retained*, because it still feeds a live node).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ from dataclasses import dataclass, field
40
+ from typing import Dict, List, Optional, Set
41
+
42
+ from falsify import graph_ops
43
+ from falsify.edges import (
44
+ DEPENDENCY_EDGE_TYPES,
45
+ DEPENDS_ON,
46
+ SUPPORTS,
47
+ is_critical_dependency,
48
+ )
49
+ from falsify.models import TruthState
50
+
51
+ logger = logging.getLogger("falsify.propagate")
52
+
53
+ # Truth states that count as "dead" for the purpose of dependency support.
54
+ _DEAD_STATES = {TruthState.REFUTED.value, TruthState.INVALIDATED.value, TruthState.FORGOTTEN.value}
55
+
56
+
57
+ @dataclass
58
+ class PropagationResult:
59
+ """Outcome of a refutation cascade.
60
+
61
+ Attributes:
62
+ refuted: evidence node ids set to ``refuted`` (the cascade seeds).
63
+ invalidated: conclusion node ids set to ``invalidated`` by the cascade.
64
+ weakened: conclusion ids whose confidence decayed but stayed ``alive``
65
+ (a non-critical dependency was refuted).
66
+ epoch: the revision epoch stamped on every state change in this cascade.
67
+ affected: convenience union of refuted + invalidated ids (the death set
68
+ candidates for :mod:`falsify.tasks.cascade_forget`).
69
+ """
70
+
71
+ refuted: List[str] = field(default_factory=list)
72
+ invalidated: List[str] = field(default_factory=list)
73
+ weakened: List[str] = field(default_factory=list)
74
+ epoch: int = 0
75
+
76
+ @property
77
+ def affected(self) -> List[str]:
78
+ return list(dict.fromkeys(self.refuted + self.invalidated))
79
+
80
+
81
+ async def _next_epoch() -> int:
82
+ """Return a monotonically increasing revision epoch.
83
+
84
+ We derive it from the current maximum ``truth_epoch`` present on any node so the
85
+ counter survives restarts (state is persisted on nodes). Falls back to 1.
86
+ """
87
+ try:
88
+ nodes, _edges = await graph_ops.load_graph()
89
+ max_epoch = 0
90
+ for _nid, props in nodes:
91
+ ep = props.get("truth_epoch")
92
+ if isinstance(ep, int) and ep > max_epoch:
93
+ max_epoch = ep
94
+ return max_epoch + 1
95
+ except Exception as exc: # pragma: no cover - defensive
96
+ logger.debug("epoch derivation failed (%s); defaulting to 1", exc)
97
+ return 1
98
+
99
+
100
+ async def propagate_refutation(
101
+ refuted_evidence_ids: List[str],
102
+ epoch: Optional[int] = None,
103
+ ) -> PropagationResult:
104
+ """Refute the given evidence and cascade the consequence forward.
105
+
106
+ Args:
107
+ refuted_evidence_ids: evidence node ids directly contradicted by a new fact.
108
+ epoch: optional explicit revision epoch; if omitted a fresh one is derived.
109
+
110
+ Returns:
111
+ A :class:`PropagationResult` describing what changed. All state changes are
112
+ persisted on the graph nodes via ``set_node_truth_state`` (so they survive a
113
+ process restart — the basis of cross-session belief revision).
114
+
115
+ Algorithm — grounded least-fixpoint justification
116
+ -------------------------------------------------
117
+ A conclusion is *justified* only if it has a **critical** ``depends_on`` support
118
+ chain that bottoms out in a still-alive node. We therefore:
119
+
120
+ 1. Mark each seed evidence ``refuted``.
121
+ 2. Build the "dead" set = seeds plus anything already refuted / invalidated /
122
+ superseded from prior revisions.
123
+ 3. Compute the GROUNDED set as a least fixpoint: a node is grounded if it is
124
+ not dead and either (a) it has no critical ``depends_on`` edges (a base
125
+ node — evidence, or a conclusion resting only on non-critical support) or
126
+ (b) at least one of its critical dependencies is itself grounded. Iterate
127
+ to convergence.
128
+ 4. Every *conclusion* (a node that is the source of a ``depends_on`` edge)
129
+ that is currently alive but **not** grounded is ``invalidated``.
130
+ 5. A conclusion that survives (stays grounded) yet lost some dependency to the
131
+ dead set is merely ``weakened`` (confidence decayed).
132
+
133
+ This single formulation is correct for chains, diamonds (survives while any
134
+ critical alternative is grounded), partial/non-critical refutation, **and cycles**
135
+ (a mutually-supporting loop with no grounded base is not justified, so it
136
+ collapses) — the fixpoint terminates because ``grounded`` only ever grows.
137
+ """
138
+ result = PropagationResult(epoch=epoch if epoch is not None else await _next_epoch())
139
+ seeds = [str(e) for e in refuted_evidence_ids if e]
140
+ if not seeds:
141
+ logger.info("propagate_refutation called with no seeds; nothing to do")
142
+ return result
143
+
144
+ nodes, edges = await graph_ops.load_graph()
145
+ node_ids = [str(nid) for nid, _p in nodes]
146
+
147
+ # 1) seed refutations (persisted)
148
+ for ev_id in seeds:
149
+ await graph_ops.set_state(ev_id, TruthState.REFUTED, result.epoch)
150
+ await graph_ops.set_weight(ev_id, 0.0)
151
+ result.refuted.append(ev_id)
152
+ logger.info("refuted evidence %s", ev_id)
153
+
154
+ # 2) dead set = seeds + already-dead-from-prior-revisions
155
+ truth = await graph_ops.get_truth(node_ids)
156
+ dead: Set[str] = set(seeds)
157
+ for nid in node_ids:
158
+ alignment = truth.get(nid, [TruthState.ALIVE.value])
159
+ if any(s in _DEAD_STATES or s == TruthState.SUPERSEDED.value for s in alignment):
160
+ dead.add(nid)
161
+
162
+ # Dependency structure: node -> critical / all depends_on targets.
163
+ conclusions: Set[str] = set()
164
+ critical_targets: Dict[str, Set[str]] = {}
165
+ all_targets: Dict[str, Set[str]] = {}
166
+ for src, dst, rel, props in edges:
167
+ if rel not in DEPENDENCY_EDGE_TYPES:
168
+ continue
169
+ s, d = str(src), str(dst)
170
+ conclusions.add(s)
171
+ all_targets.setdefault(s, set()).add(d)
172
+ if is_critical_dependency(rel, props):
173
+ critical_targets.setdefault(s, set()).add(d)
174
+
175
+ def _has_critical(n: str) -> bool:
176
+ return bool(critical_targets.get(n))
177
+
178
+ # 3) grounded least fixpoint
179
+ grounded: Set[str] = {nid for nid in node_ids if nid not in dead and not _has_critical(nid)}
180
+ changed = True
181
+ while changed:
182
+ changed = False
183
+ for c in conclusions:
184
+ if c in grounded or c in dead:
185
+ continue
186
+ if critical_targets.get(c, set()) & grounded:
187
+ grounded.add(c)
188
+ changed = True
189
+
190
+ # 4) invalidate currently-alive conclusions that lost grounding
191
+ for c in conclusions:
192
+ if c in grounded:
193
+ continue
194
+ alignment = truth.get(c, [TruthState.ALIVE.value])
195
+ if TruthState.ALIVE.value not in alignment:
196
+ continue # already dead in a prior revision; don't re-report
197
+ await graph_ops.set_state(c, TruthState.INVALIDATED, result.epoch)
198
+ await graph_ops.set_weight(c, 0.0)
199
+ result.invalidated.append(c)
200
+ logger.info("invalidated conclusion %s (lost grounded critical support)", c)
201
+
202
+ # 5) weaken survivors that lost some dependency to the dead set
203
+ for c in conclusions:
204
+ if c not in grounded:
205
+ continue
206
+ if all_targets.get(c, set()) & dead:
207
+ result.weakened.append(c)
208
+ await graph_ops.set_weight(c, 0.3)
209
+ logger.info("weakened conclusion %s (lost a dependency but stays grounded)", c)
210
+
211
+ logger.info(
212
+ "propagation done: refuted=%d invalidated=%d weakened=%d epoch=%d",
213
+ len(result.refuted),
214
+ len(result.invalidated),
215
+ len(result.weakened),
216
+ result.epoch,
217
+ )
218
+ return result
219
+
220
+
221
+ async def promote_competing_hypothesis(
222
+ refuted_evidence_ids: List[str],
223
+ epoch: int,
224
+ ) -> Dict[str, str]:
225
+ """Demote hypotheses whose support just died; promote the strongest survivor.
226
+
227
+ A hypothesis is ``superseded`` when every ``supports`` Evidence pointing at it is
228
+ now dead. Among the hypotheses still holding at least one alive ``supports`` edge,
229
+ the one with the greatest summed support ``weight`` is promoted (its feedback
230
+ weight is boosted) and becomes the new frontier answer.
231
+
232
+ Returns a dict mapping hypothesis id -> action (``"superseded"`` / ``"promoted"``).
233
+ """
234
+ actions: Dict[str, str] = {}
235
+ _nodes, edges = await graph_ops.load_graph()
236
+
237
+ # Collect hypotheses that are the target of any supports edge.
238
+ supports_edges = [(s, d, p) for (s, d, r, p) in edges if r == SUPPORTS]
239
+ hypothesis_ids = {str(d) for (_s, d, _p) in supports_edges}
240
+ if not hypothesis_ids:
241
+ return actions
242
+
243
+ # Determine current dead evidence set (seeds + anything already refuted/invalidated).
244
+ all_ids = list({str(s) for (s, _d, _p) in supports_edges} | {str(e) for e in refuted_evidence_ids})
245
+ truth = await graph_ops.get_truth(all_ids)
246
+
247
+ def _is_dead(node_id: str) -> bool:
248
+ alignment = truth.get(str(node_id), [TruthState.ALIVE.value])
249
+ return any(state in _DEAD_STATES for state in alignment) or str(node_id) in {
250
+ str(e) for e in refuted_evidence_ids
251
+ }
252
+
253
+ # Score each hypothesis by its surviving support.
254
+ live_support: Dict[str, float] = {}
255
+ for hyp_id in hypothesis_ids:
256
+ total = 0.0
257
+ for (src, dst, props) in supports_edges:
258
+ if str(dst) != hyp_id:
259
+ continue
260
+ if _is_dead(src):
261
+ continue
262
+ total += float(props.get("weight", 0.5))
263
+ live_support[hyp_id] = total
264
+
265
+ # Demote hypotheses with zero surviving support.
266
+ for hyp_id, score in live_support.items():
267
+ if score <= 0.0:
268
+ await graph_ops.set_state(hyp_id, TruthState.SUPERSEDED, epoch)
269
+ await graph_ops.set_weight(hyp_id, 0.0)
270
+ actions[hyp_id] = "superseded"
271
+ logger.info("superseded hypothesis %s (no surviving support)", hyp_id)
272
+
273
+ # Promote the strongest surviving hypothesis, if any.
274
+ survivors = {h: s for h, s in live_support.items() if s > 0.0}
275
+ if survivors:
276
+ winner = max(survivors, key=survivors.get)
277
+ await graph_ops.set_weight(winner, 1.0)
278
+ actions[winner] = "promoted"
279
+ logger.info("promoted hypothesis %s (support=%.2f) as new frontier", winner, survivors[winner])
280
+
281
+ return actions
falsify/utils.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY presentation helpers — console state + interactive graph visualization.
3
+
4
+ These functions turn the belief graph into the two things a judge actually sees:
5
+
6
+ * :func:`print_graph_state` — the BEFORE/AFTER console beat, each node tagged with a
7
+ colored ``ALIVE`` / ``REFUTED`` / ``INVALIDATED`` / ``SUPERSEDED`` marker.
8
+ * :func:`visualize_belief_graph` — a single self-contained HTML file (vis-network via
9
+ CDN, no build step) that colors nodes by truth-state so the cascade is legible at a
10
+ glance: green = alive, red (dashed) = refuted, grey = invalidated, amber = superseded.
11
+ * :func:`get_belief_summary` — counts by type/state, used in output and tests.
12
+ * :func:`verify_propagation` — a small assertion helper for tests.
13
+
14
+ Everything degrades gracefully on an empty graph and never hard-depends on ``rich``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import html
20
+ import json
21
+ import logging
22
+ import os
23
+ from typing import Dict, List, Optional
24
+
25
+ from falsify import graph_ops
26
+ from falsify.models import TruthState
27
+
28
+ logger = logging.getLogger("falsify.utils")
29
+
30
+ # truth-state -> (console tag, hex color for the graph)
31
+ _STATE_STYLE = {
32
+ TruthState.ALIVE.value: ("ALIVE", "#22c55e"),
33
+ TruthState.REFUTED.value: ("REFUTED", "#ef4444"),
34
+ TruthState.INVALIDATED.value: ("INVALIDATED", "#9ca3af"),
35
+ TruthState.SUPERSEDED.value: ("SUPERSEDED", "#f59e0b"),
36
+ TruthState.FORGOTTEN.value: ("FORGOTTEN", "#4b5563"),
37
+ }
38
+
39
+ # ANSI colors for console tags (fall back to plain text if not a TTY).
40
+ _ANSI = {
41
+ "ALIVE": "\033[92m",
42
+ "REFUTED": "\033[91m",
43
+ "INVALIDATED": "\033[90m",
44
+ "SUPERSEDED": "\033[93m",
45
+ "FORGOTTEN": "\033[90m",
46
+ }
47
+ _RESET = "\033[0m"
48
+
49
+
50
+ def _colored(tag: str) -> str:
51
+ """Return an ANSI-colored tag if stdout is a TTY, else the plain tag."""
52
+ if os.environ.get("NO_COLOR") or not _stdout_is_tty():
53
+ return tag
54
+ return f"{_ANSI.get(tag, '')}{tag}{_RESET}"
55
+
56
+
57
+ def _stdout_is_tty() -> bool:
58
+ try:
59
+ import sys
60
+
61
+ return bool(sys.stdout.isatty())
62
+ except Exception:
63
+ return False
64
+
65
+
66
+ async def _state_of(node_ids: List[str]) -> Dict[str, str]:
67
+ """Return ``{id: single-state-string}`` (first alignment entry, default alive)."""
68
+ truth = await graph_ops.get_truth(node_ids)
69
+ return {nid: (align[0] if align else TruthState.ALIVE.value) for nid, align in truth.items()}
70
+
71
+
72
+ async def get_belief_summary(dataset: Optional[str] = None) -> Dict[str, Dict[str, int]]:
73
+ """Count nodes grouped by node type and truth-state.
74
+
75
+ Returns e.g. ``{"Hypothesis": {"alive": 2, "superseded": 1}, "Conclusion": {...}}``.
76
+ Node type is inferred from the node's ``type`` property, falling back to the
77
+ dominant embeddable field present.
78
+ """
79
+ nodes, _edges = await graph_ops.load_graph()
80
+ if not nodes:
81
+ return {}
82
+ states = await _state_of([str(nid) for nid, _p in nodes])
83
+ summary: Dict[str, Dict[str, int]] = {}
84
+ for nid, props in nodes:
85
+ ntype = _infer_type(props)
86
+ state = states.get(str(nid), TruthState.ALIVE.value)
87
+ summary.setdefault(ntype, {})
88
+ summary[ntype][state] = summary[ntype].get(state, 0) + 1
89
+ return summary
90
+
91
+
92
+ def _infer_type(props: dict) -> str:
93
+ """Best-effort node-type label from properties."""
94
+ if props.get("type"):
95
+ return str(props["type"])
96
+ for field, label in (
97
+ ("question", "InvestigationQuestion"),
98
+ ("claim", "Evidence"),
99
+ ("statement", "Hypothesis/Conclusion"),
100
+ ("text", "Assertion"),
101
+ ):
102
+ if props.get(field):
103
+ return label
104
+ return "Node"
105
+
106
+
107
+ async def print_graph_state(title: str, dataset: Optional[str] = None) -> None:
108
+ """Print every node with a colored truth-state tag (the BEFORE/AFTER beat)."""
109
+ nodes, _edges = await graph_ops.load_graph()
110
+ print(f"\n{'=' * 64}\n {title}\n{'=' * 64}")
111
+ if not nodes:
112
+ print(" (empty graph)")
113
+ return
114
+ states = await _state_of([str(nid) for nid, _p in nodes])
115
+ # Stable, readable ordering: questions, hypotheses, evidence, conclusions.
116
+ order = {"InvestigationQuestion": 0, "Hypothesis/Conclusion": 1, "Evidence": 2, "Assertion": 3}
117
+ rows = []
118
+ for nid, props in nodes:
119
+ ntype = _infer_type(props)
120
+ state = states.get(str(nid), TruthState.ALIVE.value)
121
+ tag = _STATE_STYLE.get(state, ("ALIVE", ""))[0]
122
+ label = graph_ops.node_label(props)
123
+ rows.append((order.get(ntype, 9), ntype, tag, label))
124
+ for _o, ntype, tag, label in sorted(rows, key=lambda r: r[0]):
125
+ clipped = label if len(label) <= 66 else label[:63] + "..."
126
+ print(f" [{_colored(tag):<22}] {ntype:<22} {clipped}")
127
+ print()
128
+
129
+
130
+ async def visualize_belief_graph(
131
+ out_path: str = "output/graph.html",
132
+ dataset: Optional[str] = None,
133
+ title: str = "FALSIFY belief graph",
134
+ ) -> Optional[str]:
135
+ """Write a self-contained interactive HTML visualization of the belief graph.
136
+
137
+ Nodes are colored by truth-state (green/red/grey/amber). Refuted nodes are drawn
138
+ with a dashed red border so the cascade result reads at a glance. Returns the
139
+ written path, or ``None`` if the graph is empty.
140
+ """
141
+ nodes, edges = await graph_ops.load_graph()
142
+ if not nodes:
143
+ logger.info("visualize_belief_graph: empty graph, nothing to draw")
144
+ return None
145
+
146
+ states = await _state_of([str(nid) for nid, _p in nodes])
147
+
148
+ vis_nodes = []
149
+ for nid, props in nodes:
150
+ nid = str(nid)
151
+ state = states.get(nid, TruthState.ALIVE.value)
152
+ tag, color = _STATE_STYLE.get(state, ("ALIVE", "#22c55e"))
153
+ label = graph_ops.node_label(props)
154
+ short = label if len(label) <= 40 else label[:37] + "..."
155
+ vis_nodes.append(
156
+ {
157
+ "id": nid,
158
+ "label": short,
159
+ "title": f"{_infer_type(props)} — {tag}\n{html.escape(label)}",
160
+ "color": {"background": color, "border": "#111827"},
161
+ "shapeProperties": {"borderDashes": state == TruthState.REFUTED.value},
162
+ "borderWidth": 3 if state == TruthState.REFUTED.value else 1,
163
+ "font": {"color": "#0b1020"},
164
+ }
165
+ )
166
+
167
+ vis_edges = []
168
+ for src, dst, rel, props in edges:
169
+ vis_edges.append(
170
+ {
171
+ "from": str(src),
172
+ "to": str(dst),
173
+ "label": rel,
174
+ "arrows": "to",
175
+ "font": {"align": "middle", "size": 10},
176
+ "color": {"color": "#94a3b8"},
177
+ }
178
+ )
179
+
180
+ os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
181
+ doc = _HTML_TEMPLATE.replace("__TITLE__", html.escape(title)) \
182
+ .replace("__NODES__", json.dumps(vis_nodes)) \
183
+ .replace("__EDGES__", json.dumps(vis_edges))
184
+ with open(out_path, "w", encoding="utf-8") as fh:
185
+ fh.write(doc)
186
+ logger.info("wrote visualization to %s (%d nodes, %d edges)", out_path, len(vis_nodes), len(vis_edges))
187
+ return out_path
188
+
189
+
190
+ async def verify_propagation(refuted_id: str, expected_affected: List[str]) -> bool:
191
+ """Test helper: assert every ``expected_affected`` id is now non-alive.
192
+
193
+ Returns True iff the refuted node is refuted and each expected dependent is in a
194
+ dead state (refuted/invalidated/forgotten or absent from the graph).
195
+ """
196
+ ids = [str(refuted_id)] + [str(x) for x in expected_affected]
197
+ truth = await graph_ops.get_truth(ids)
198
+ dead = {TruthState.REFUTED.value, TruthState.INVALIDATED.value, TruthState.FORGOTTEN.value}
199
+ nodes, _edges = await graph_ops.load_graph()
200
+ present = {str(nid) for nid, _p in nodes}
201
+
202
+ r_align = truth.get(str(refuted_id), [])
203
+ if TruthState.REFUTED.value not in r_align:
204
+ return False
205
+ for dep in expected_affected:
206
+ dep = str(dep)
207
+ if dep not in present: # forgotten (deleted) counts as affected
208
+ continue
209
+ if not any(s in dead for s in truth.get(dep, [TruthState.ALIVE.value])):
210
+ return False
211
+ return True
212
+
213
+
214
+ _HTML_TEMPLATE = """<!DOCTYPE html>
215
+ <html lang="en">
216
+ <head>
217
+ <meta charset="utf-8" />
218
+ <title>__TITLE__</title>
219
+ <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
220
+ <style>
221
+ body { margin:0; font-family: ui-sans-serif, system-ui, sans-serif; background:#0b1020; color:#e5e7eb; }
222
+ #hdr { padding:14px 18px; font-size:18px; font-weight:600; border-bottom:1px solid #1f2937; }
223
+ #legend { padding:8px 18px; font-size:13px; color:#9ca3af; }
224
+ .chip { display:inline-block; width:11px; height:11px; border-radius:3px; margin:0 5px 0 14px; vertical-align:middle; }
225
+ #net { width:100%; height:calc(100vh - 92px); }
226
+ </style>
227
+ </head>
228
+ <body>
229
+ <div id="hdr">__TITLE__</div>
230
+ <div id="legend">
231
+ <span class="chip" style="background:#22c55e"></span>alive
232
+ <span class="chip" style="background:#ef4444"></span>refuted
233
+ <span class="chip" style="background:#9ca3af"></span>invalidated
234
+ <span class="chip" style="background:#f59e0b"></span>superseded
235
+ </div>
236
+ <div id="net"></div>
237
+ <script>
238
+ const nodes = new vis.DataSet(__NODES__);
239
+ const edges = new vis.DataSet(__EDGES__);
240
+ const container = document.getElementById('net');
241
+ const options = {
242
+ physics: { stabilization: true, barnesHut: { gravitationalConstant: -8000, springLength: 150 } },
243
+ nodes: { shape: 'box', margin: 10, widthConstraint: { maximum: 200 } },
244
+ edges: { smooth: { type: 'cubicBezier' } }
245
+ };
246
+ new vis.Network(container, { nodes, edges }, options);
247
+ </script>
248
+ </body>
249
+ </html>
250
+ """
main.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ FALSIFY — the AI research copilot that *revises*, not forgets.
4
+
5
+ Run this to watch belief revision happen on a real Cognee knowledge graph:
6
+
7
+ python main.py # full run (uses your LLM to judge the contradiction)
8
+ python main.py --demo # deterministic: pins the contradiction so the cascade
9
+ # always runs, even without/with a flaky LLM key
10
+ python main.py --keep # don't prune existing memory first (advanced)
11
+
12
+ The story (Company X recall investigation)
13
+ ------------------------------------------
14
+ Session 1 builds a belief graph with two competing hypotheses:
15
+ A — "X knew via the March 2021 QA report" (supported by evidence E_qa)
16
+ B — "X knew via a January 2021 supplier email" (supported by evidence E_email)
17
+ and a Conclusion K that *depends on* E_qa.
18
+
19
+ Session 2 drops ONE contradicting fact: "the March QA report was back-dated."
20
+ FALSIFY refutes E_qa, cascades the refutation forward (K collapses), promotes B as
21
+ the new frontier, and surgically forgets the orphaned conclusion — writing the
22
+ disbelief onto the graph so it survives a restart. A plain-RAG baseline, which has no
23
+ notion of truth-state, keeps citing the refuted March report. That contrast is the
24
+ whole point: *AI revised, not forgot.*
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import asyncio
31
+ import os
32
+ import sys
33
+
34
+ # Load .env before importing cognee/falsify so provider config is in place.
35
+ try:
36
+ from dotenv import load_dotenv
37
+
38
+ load_dotenv()
39
+ except Exception: # python-dotenv is optional; env may be set another way
40
+ pass
41
+
42
+ # Importing falsify sets single-user Cognee env defaults (access control off, cache on).
43
+ import falsify # noqa: F401 (import-time side effects)
44
+ from falsify.seed import NEW_FACT, QUESTION_TEXT
45
+
46
+
47
+ C = {
48
+ "b": "\033[1m", "dim": "\033[2m", "g": "\033[92m", "r": "\033[91m",
49
+ "y": "\033[93m", "c": "\033[96m", "x": "\033[0m",
50
+ }
51
+ if os.environ.get("NO_COLOR") or not sys.stdout.isatty():
52
+ C = {k: "" for k in C}
53
+
54
+
55
+ def banner(text: str) -> None:
56
+ print(f"\n{C['b']}{C['c']}{'━' * 64}{C['x']}")
57
+ print(f"{C['b']}{C['c']} {text}{C['x']}")
58
+ print(f"{C['b']}{C['c']}{'━' * 64}{C['x']}")
59
+
60
+
61
+ def _has_llm_key() -> bool:
62
+ """True if some LLM API key is configured (OpenAI-compatible or otherwise)."""
63
+ for var in ("LLM_API_KEY", "OPENAI_API_KEY"):
64
+ if os.environ.get(var):
65
+ return True
66
+ return False
67
+
68
+
69
+ def _print_no_key_help() -> None:
70
+ print(
71
+ f"""
72
+ {C['y']}{C['b']}No LLM API key found.{C['x']}
73
+
74
+ FALSIFY needs an OpenAI-compatible API key to (a) embed claims for the vector
75
+ prefilter and (b) judge contradictions. Set it up:
76
+
77
+ 1. cp .env.template .env
78
+ 2. edit .env and set:
79
+ LLM_API_KEY="your_key_here"
80
+ LLM_MODEL="gpt-4o-mini" # or any OpenAI-compatible model
81
+ # For a non-OpenAI endpoint (OpenRouter, vLLM, LM Studio, Groq):
82
+ # LLM_PROVIDER="custom"
83
+ # LLM_ENDPOINT="https://your-endpoint/v1"
84
+ 3. re-run: python main.py
85
+
86
+ {C['dim']}Tip: `python main.py --demo` runs FULLY OFFLINE — fastembed handles
87
+ embeddings locally and the contradiction is pinned, so no API key is needed at all.
88
+ A key is only required for LIVE mode, where the LLM judges the contradiction.{C['x']}
89
+ """
90
+ )
91
+
92
+
93
+ async def run(demo: bool, keep: bool) -> int:
94
+ from falsify.falsify import build_graph, revise, scoreboard
95
+ from falsify.utils import get_belief_summary, print_graph_state, visualize_belief_graph
96
+
97
+ banner("FALSIFY — belief-revision research copilot")
98
+ print(f" Research question: {C['b']}{QUESTION_TEXT}{C['x']}")
99
+ print(f" Mode: {'DEMO (deterministic contradiction pin)' if demo else 'LIVE (LLM judge)'}")
100
+
101
+ # ---- Session 1: build the belief graph -------------------------------
102
+ banner("SESSION 1 — build the investigation")
103
+ if keep:
104
+ from cognee.low_level import setup
105
+
106
+ from falsify.seed import build_investigation
107
+
108
+ await setup()
109
+ seeded = await build_investigation()
110
+ else:
111
+ seeded = await build_graph()
112
+ await print_graph_state("BEFORE — both hypotheses stand, Conclusion K rests on E_qa")
113
+
114
+ # ---- Session 2: drop the contradicting fact --------------------------
115
+ banner("SESSION 2 — a new fact arrives")
116
+ print(f" {C['y']}New fact:{C['x']} {NEW_FACT}\n")
117
+
118
+ pinned = seeded.refuted_target_id if demo else None
119
+ report = await revise(NEW_FACT, pinned_target_id=pinned)
120
+
121
+ if not report.revised:
122
+ print(f" {C['y']}No contradiction was confirmed — graph unchanged.{C['x']}")
123
+ print(f" {C['dim']}(Try `python main.py --demo` to force the cascade deterministically.){C['x']}")
124
+ else:
125
+ print(f" {C['r']}✗ refuted:{C['x']} {len(report.refuted)} evidence node(s)")
126
+ print(f" {C['r']}✗ invalidated:{C['x']} {len(report.invalidated)} conclusion(s)")
127
+ for hid, action in report.hypothesis_actions.items():
128
+ if action == "superseded":
129
+ mark = f"{C['r']}↓ superseded{C['x']}"
130
+ else:
131
+ mark = f"{C['g']}↑ promoted (new frontier){C['x']}"
132
+ print(f" hypothesis {hid[:8]} → {mark}")
133
+ for fid in report.forgotten:
134
+ label = report.forgotten_labels.get(fid, fid)
135
+ print(f" {C['dim']}🗑 forgotten (deleted from graph + vector):{C['x']} {label}")
136
+ if report.retained_provenance:
137
+ print(f" {C['dim']}⚑ kept as red provenance:{C['x']} {len(report.retained_provenance)} node(s)")
138
+
139
+ await print_graph_state("AFTER — refutation cascaded, B ignites, orphan forgotten")
140
+
141
+ # ---- The scoreboard: FALSIFY vs plain RAG ----------------------------
142
+ banner("SCOREBOARD — FALSIFY (revised) vs plain RAG (stale)")
143
+ board = await scoreboard(
144
+ QUESTION_TEXT, seeded,
145
+ rag_snapshot=report.rag_snapshot if report.revised else None,
146
+ )
147
+ print(f" {C['g']}{C['b']}FALSIFY :{C['x']} {board.falsify_answer}")
148
+ if board.falsify_support:
149
+ print(f" {C['dim']}supported by: {', '.join(board.falsify_support)}{C['x']}")
150
+ rag_tag = f"{C['r']}[STALE — still cites a refuted fact]{C['x']}" if board.stale else ""
151
+ print(f" {C['y']}{C['b']}RAG :{C['x']} {board.rag_answer} {rag_tag}")
152
+ print(f"\n {C['b']}→ AI revised, not forgot.{C['x']}")
153
+
154
+ # ---- Cross-session proof: reload belief state fresh ------------------
155
+ banner("CROSS-SESSION PROOF — reopen memory, beliefs stay revised")
156
+ summary = await get_belief_summary()
157
+ print(f" Persisted belief state (re-read from graph): {summary}")
158
+ print(f" {C['dim']}Truth-state lives on the graph nodes, so a brand-new process sees the")
159
+ print(f" revised graph — the refuted branch never comes back.{C['x']}")
160
+
161
+ # ---- Visualization ---------------------------------------------------
162
+ out = await visualize_belief_graph("output/graph.html", title="FALSIFY — Company X investigation")
163
+ if out:
164
+ banner("VISUALIZATION")
165
+ print(f" Interactive belief graph written to: {C['b']}{os.path.abspath(out)}{C['x']}")
166
+ print(f" {C['dim']}Open it in a browser — red = refuted, grey = invalidated, green = alive.{C['x']}")
167
+
168
+ return 0
169
+
170
+
171
+ def main() -> int:
172
+ parser = argparse.ArgumentParser(description="FALSIFY belief-revision demo")
173
+ parser.add_argument("--demo", action="store_true",
174
+ help="pin the contradiction deterministically (LLM-independent cascade)")
175
+ parser.add_argument("--keep", action="store_true",
176
+ help="do not prune existing memory before building")
177
+ args = parser.parse_args()
178
+
179
+ if not _has_llm_key() and not args.demo:
180
+ _print_no_key_help()
181
+ # Live mode needs an LLM key to judge the contradiction. Exit cleanly (0) with
182
+ # setup help rather than a stack trace. (`--demo` runs fully offline below.)
183
+ return 0
184
+
185
+ try:
186
+ return asyncio.run(run(demo=args.demo, keep=args.keep))
187
+ except KeyboardInterrupt:
188
+ print("\ninterrupted.")
189
+ return 130
190
+
191
+
192
+ if __name__ == "__main__":
193
+ raise SystemExit(main())
render.yaml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Render blueprint for the FALSIFY backend (Deployment Plan B).
2
+ # Render builds the repo's Dockerfile and injects $PORT (our CMD honors it).
3
+ #
4
+ # One-click: push this file, then in Render → "New +" → "Blueprint" → pick the repo.
5
+ # Or create a Web Service manually with runtime=docker (see DEPLOYMENT.md §Render).
6
+ services:
7
+ - type: web
8
+ name: falsify-backend
9
+ runtime: docker # uses ./Dockerfile
10
+ plan: free # NOTE: free spins down after 15 min idle (30–60s cold start).
11
+ # Bump to "starter" ($7/mo) to keep it always-on for judging.
12
+ region: oregon
13
+ healthCheckPath: /api/health
14
+ autoDeploy: true
15
+ envVars:
16
+ # The Vercel URL that may call this backend. Set after the frontend deploys.
17
+ - key: FRONTEND_ORIGIN
18
+ sync: false
19
+ # Optional — only needed for Live mode / document upload (cognify) / recall.
20
+ - key: LLM_API_KEY
21
+ sync: false
22
+ - key: LLM_MODEL
23
+ value: gpt-4o-mini
24
+ # Optional — only if you demo the Cognee Cloud toggle from this backend.
25
+ - key: COGNEE_CLOUD_URL
26
+ sync: false
27
+ - key: COGNEE_CLOUD_API_KEY
28
+ sync: false
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FALSIFY — dependencies
2
+ #
3
+ # The only hard requirement is Cognee (the self-hosted AI-memory platform this
4
+ # project is built on). Everything else is small tooling. Cognee itself pulls in
5
+ # LanceDB / Ladybug / SQLite / litellm, so there is no separate DB to install.
6
+
7
+ cognee>=0.1.0 # AI-memory platform: graph + vector + memify/forget APIs
8
+ fastembed>=0.3.0 # local, CPU-only embeddings (no API key needed for the demo)
9
+ python-dotenv>=1.0.0 # load .env configuration
10
+
11
+ # ── web UI (Feature F): FastAPI backend + SSE stream, no WebSocket dep ──
12
+ fastapi>=0.104.0 # async web framework serving the live UI + REST + SSE
13
+ uvicorn[standard]>=0.24.0 # ASGI server
14
+ python-multipart>=0.0.6 # multipart parsing for document upload (/api/upload)
15
+
16
+ # ── dev / test (optional: pip install -r requirements.txt already includes these) ──
17
+ pytest>=7.4.0
18
+ pytest-asyncio>=0.21.0
server.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALSIFY live web server — FastAPI backend for the animated belief-revision UI.
3
+
4
+ This is the additive "web mode" for FALSIFY. The CLI (`python main.py`) is untouched;
5
+ this server exposes the same three core operations — build / revise / scoreboard — over
6
+ HTTP, plus a Server-Sent Events stream so a browser can *watch* belief revision happen:
7
+ the refute flash, the cascade sweep, the forget-dissolve.
8
+
9
+ Why SSE and not WebSockets: our event stream is one-directional (server -> browser), and
10
+ SSE is plain streaming HTTP. That makes it work behind the Hugging Face Spaces single-port
11
+ proxy AND across a Vercel(frontend)+Render(backend) split, neither of which hosts
12
+ long-lived WebSockets on their free tiers. See IMPLEMENTATION_PLAN.md §6.1.
13
+
14
+ Run locally: uvicorn server:app --reload --port 8000 -> http://localhost:8000
15
+ On HF Spaces: uvicorn server:app --host 0.0.0.0 --port 7860
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import os
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ from fastapi import FastAPI, File, UploadFile
27
+ from fastapi.middleware.cors import CORSMiddleware
28
+ from fastapi.responses import StreamingResponse
29
+ from fastapi.staticfiles import StaticFiles
30
+ from pydantic import BaseModel
31
+
32
+ # Importing `falsify` first sets Cognee's env defaults (access-control off, etc.)
33
+ # before any Cognee module is imported — same ordering contract as main.py.
34
+ import falsify # noqa: F401
35
+ from falsify import events, graph_ops
36
+ from falsify.falsify import (
37
+ Scoreboard,
38
+ build_diamond_graph,
39
+ build_graph,
40
+ revise,
41
+ scoreboard,
42
+ use_backend,
43
+ )
44
+ from falsify.models import TruthState
45
+ from falsify.seed import NEW_FACT, NEW_FACT_2, QUESTION_TEXT
46
+ from falsify.utils import _STATE_STYLE, _infer_type, get_belief_summary
47
+
48
+ logger = logging.getLogger("falsify.server")
49
+
50
+ app = FastAPI(title="FALSIFY Live", version="1.0")
51
+
52
+ # Wide-open CORS: the demo may be served same-origin (HF monolith) or cross-origin
53
+ # (Vercel frontend -> Render backend). FRONTEND_ORIGIN narrows it when set.
54
+ app.add_middleware(
55
+ CORSMiddleware,
56
+ allow_origins=[os.environ.get("FRONTEND_ORIGIN", "*")],
57
+ allow_methods=["*"],
58
+ allow_headers=["*"],
59
+ )
60
+
61
+ # ------------------------------------------------------------------ server state
62
+ _seeded = None # the active SeededGraph (set on startup / reset)
63
+ _scenario: str = "simple" # "simple" | "diamond"
64
+ _backend_mode: str = "opensource" # "opensource" | "cloud"
65
+ _revise_lock = asyncio.Lock() # serialize revise() — single-user demo safety
66
+
67
+
68
+ # ------------------------------------------------------------------ request models
69
+ class ChatRequest(BaseModel):
70
+ message: str
71
+ demo: bool = True
72
+
73
+
74
+ class ScenarioRequest(BaseModel):
75
+ kind: str = "simple" # "simple" | "diamond"
76
+
77
+
78
+ class ModeRequest(BaseModel):
79
+ mode: str = "opensource" # "opensource" | "cloud"
80
+ url: Optional[str] = None
81
+ api_key: Optional[str] = None
82
+
83
+
84
+ # ------------------------------------------------------------------ JSON shaping
85
+ async def _graph_json() -> Dict[str, Any]:
86
+ """Current graph as UI-ready JSON: nodes (with truth-state color) + edges.
87
+
88
+ Colors come straight from utils._STATE_STYLE so the web UI, the CLI console,
89
+ and the static HTML export all share one palette.
90
+ """
91
+ nodes, edges = await graph_ops.load_graph()
92
+ ids = [nid for nid, _p in nodes]
93
+ truth = await graph_ops.get_truth(ids)
94
+
95
+ out_nodes: List[Dict[str, Any]] = []
96
+ for nid, props in nodes:
97
+ align = truth.get(str(nid), [TruthState.ALIVE.value])
98
+ state = align[0] if align else TruthState.ALIVE.value
99
+ _tag, color = _STATE_STYLE.get(state, ("ALIVE", "#22c55e"))
100
+ out_nodes.append({
101
+ "id": str(nid),
102
+ "label": graph_ops.node_label(props),
103
+ "type": _infer_type(props),
104
+ "state": state,
105
+ "color": color,
106
+ })
107
+
108
+ out_edges = [
109
+ {"source": str(s), "target": str(d), "relation": r}
110
+ for (s, d, r, _p) in edges
111
+ ]
112
+ return {"nodes": out_nodes, "edges": out_edges,
113
+ "scenario": _scenario, "backend": _backend_mode}
114
+
115
+
116
+ def _scoreboard_json(b: Scoreboard) -> Dict[str, Any]:
117
+ return {
118
+ "question": b.question,
119
+ "falsify_answer": b.falsify_answer,
120
+ "falsify_support": b.falsify_support,
121
+ "rag_answer": b.rag_answer,
122
+ "rag_citations": b.rag_citations,
123
+ "stale": b.stale,
124
+ }
125
+
126
+
127
+ def _report_json(r) -> Dict[str, Any]:
128
+ return {
129
+ "new_fact": r.new_fact,
130
+ "refuted": r.refuted,
131
+ "invalidated": r.invalidated,
132
+ "forgotten": r.forgotten,
133
+ "forgotten_labels": r.forgotten_labels,
134
+ "hypothesis_actions": r.hypothesis_actions,
135
+ "retained_provenance": r.retained_provenance,
136
+ "new_evidence_id": r.new_evidence_id,
137
+ "epoch": r.epoch,
138
+ "revised": r.revised,
139
+ }
140
+
141
+
142
+ # ------------------------------------------------------------------ SSE stream
143
+ @app.get("/api/events")
144
+ async def api_events():
145
+ """Server-Sent Events: one frame per belief mutation, fanned from the event bus."""
146
+ q = events.subscribe()
147
+
148
+ async def gen():
149
+ # Prime the stream so the client's onopen fires immediately behind proxies.
150
+ yield ": connected\n\n"
151
+ try:
152
+ while True:
153
+ try:
154
+ ev = await asyncio.wait_for(q.get(), timeout=15)
155
+ yield f"data: {json.dumps(ev)}\n\n"
156
+ except asyncio.TimeoutError:
157
+ yield ": keepalive\n\n"
158
+ finally:
159
+ events.unsubscribe(q)
160
+
161
+ return StreamingResponse(
162
+ gen(),
163
+ media_type="text/event-stream",
164
+ headers={
165
+ "Cache-Control": "no-cache",
166
+ "Connection": "keep-alive",
167
+ "X-Accel-Buffering": "no", # stop HF/Render proxies buffering the stream
168
+ },
169
+ )
170
+
171
+
172
+ # ------------------------------------------------------------------ REST endpoints
173
+ @app.get("/api/graph")
174
+ async def api_graph():
175
+ return await _graph_json()
176
+
177
+
178
+ @app.get("/api/scoreboard")
179
+ async def api_scoreboard(q: str = QUESTION_TEXT):
180
+ board = await scoreboard(q, _seeded)
181
+ return _scoreboard_json(board)
182
+
183
+
184
+ @app.post("/api/chat")
185
+ async def api_chat(req: ChatRequest):
186
+ """A question (ends with '?') -> scoreboard; anything else -> a new fact to revise.
187
+
188
+ Graph mutations emit SSE events *during* revise(), so the browser animates the
189
+ cascade in real time while this call is still in flight.
190
+ """
191
+ msg = req.message.strip()
192
+ if not msg:
193
+ return {"type": "noop", "data": {}}
194
+
195
+ if msg.endswith("?"):
196
+ board = await scoreboard(msg, _seeded)
197
+ return {"type": "answer", "data": _scoreboard_json(board)}
198
+
199
+ async with _revise_lock:
200
+ pinned = _seeded.refuted_target_id if (req.demo and _seeded) else None
201
+ await events.emit_step("ingest", "New contradicting fact received")
202
+ report = await revise(msg, pinned_target_id=pinned)
203
+ return {"type": "revision", "data": _report_json(report)}
204
+
205
+
206
+ @app.post("/api/scenario")
207
+ async def api_scenario(req: ScenarioRequest):
208
+ """(Re)build the graph as the simple or diamond investigation."""
209
+ global _seeded, _scenario
210
+ if req.kind == "diamond":
211
+ _seeded, _scenario = await build_diamond_graph(), "diamond"
212
+ else:
213
+ _seeded, _scenario = await build_graph(), "simple"
214
+ await events.emit_graph_reset()
215
+ return await _graph_json()
216
+
217
+
218
+ @app.post("/api/reset")
219
+ async def api_reset():
220
+ """Rebuild the current scenario from scratch (fresh belief state)."""
221
+ return await api_scenario(ScenarioRequest(kind=_scenario))
222
+
223
+
224
+ @app.post("/api/demo")
225
+ async def api_demo():
226
+ """One-click headline demo on the simple graph: drop the back-dated-QA fact.
227
+
228
+ Rebuilds the simple investigation, then refutes E_qa (pinned, so it runs
229
+ keyless). Graph mutations stream over SSE while this call is in flight, so the
230
+ browser animates the cascade live; the returned report + scoreboard let the UI
231
+ finish with the promotion 'rise' and the FALSIFY-vs-RAG panel.
232
+ """
233
+ global _seeded, _scenario
234
+ async with _revise_lock:
235
+ _seeded, _scenario = await build_graph(), "simple"
236
+ await events.emit_graph_reset()
237
+ await asyncio.sleep(0.4)
238
+ await events.emit_step("ingest", "New fact: the March 2021 QA report was back-dated")
239
+ report = await revise(NEW_FACT, pinned_target_id=_seeded.refuted_target_id)
240
+ board = await scoreboard(
241
+ QUESTION_TEXT, _seeded,
242
+ rag_snapshot=report.rag_snapshot if report.revised else None,
243
+ )
244
+ return {"report": _report_json(report), "scoreboard": _scoreboard_json(board)}
245
+
246
+
247
+ @app.post("/api/diamond")
248
+ async def api_diamond():
249
+ """Two-phase diamond story: K2 survives phase 1, then collapses in phase 2.
250
+
251
+ Phase 1 refutes E_qa — the single-dependency conclusion K dies but the diamond
252
+ K2 survives on E_email. A pause lets the UI show K2 standing, then phase 2
253
+ refutes E_email and K2 finally collapses. Both targets are pinned so it runs
254
+ keyless and deterministically.
255
+ """
256
+ global _seeded, _scenario
257
+ async with _revise_lock:
258
+ _seeded, _scenario = await build_diamond_graph(), "diamond"
259
+ await events.emit_graph_reset()
260
+ await asyncio.sleep(0.5)
261
+ await events.emit_step("phase1", "Phase 1 — the March 2021 QA report was back-dated")
262
+ r1 = await revise(NEW_FACT, pinned_target_id=_seeded.ids["E_qa"])
263
+ await asyncio.sleep(1.6) # hold on K2 surviving before the second blow
264
+ await events.emit_step("phase2", "Phase 2 — the January 2021 supplier email was fabricated")
265
+ r2 = await revise(NEW_FACT_2, pinned_target_id=_seeded.ids["E_email"])
266
+ board = await scoreboard(
267
+ QUESTION_TEXT, _seeded,
268
+ rag_snapshot=r2.rag_snapshot if r2.revised else None,
269
+ )
270
+ return {"phase1": _report_json(r1), "phase2": _report_json(r2),
271
+ "scoreboard": _scoreboard_json(board)}
272
+
273
+
274
+ @app.post("/api/upload")
275
+ async def api_upload(file: UploadFile = File(...)):
276
+ """Ingest an uploaded document into memory via cognee.add + cognify.
277
+
278
+ Turns the fixed demo into a real product: a judge can drop their own file and
279
+ watch it become graph. Requires an LLM key for cognify; degrades with a clear
280
+ error otherwise (the demo keeps working).
281
+ """
282
+ import cognee
283
+
284
+ raw = await file.read()
285
+ text = raw.decode("utf-8", "ignore").strip()
286
+ if not text:
287
+ return {"ok": False, "error": "empty or unreadable file"}
288
+ try:
289
+ await events.emit_step("upload", f"Ingesting {file.filename}…")
290
+ await cognee.add(text)
291
+ await cognee.cognify()
292
+ await events.emit_graph_reset()
293
+ return {"ok": True, "graph": await _graph_json()}
294
+ except Exception as exc: # most likely: no LLM key for cognify
295
+ logger.warning("upload cognify failed: %s", exc)
296
+ return {"ok": False, "error": str(exc),
297
+ "hint": "Set LLM_API_KEY to enable document ingestion."}
298
+
299
+
300
+ @app.get("/api/verify")
301
+ async def api_verify():
302
+ """Re-read the persisted belief state from the on-disk graph store.
303
+
304
+ Truth-state (refuted / invalidated / superseded) is written ON the graph nodes
305
+ in Cognee's file-backed Kuzu store — not held in process memory — so re-reading
306
+ it here returns the *revised* beliefs, which is exactly what survives a full
307
+ process or container restart.
308
+
309
+ We deliberately do NOT evict the engine mid-request: in Ladybug subprocess mode
310
+ a worker process holds the DB file lock, so an in-process cold-reopen would
311
+ collide with that lock. The durable-proof claim rests on the truth_alignment
312
+ living in storage, and on the graph coming back revised after a real restart.
313
+ """
314
+ try:
315
+ summary = await get_belief_summary()
316
+ return {"persisted": True, "summary": summary}
317
+ except Exception as exc:
318
+ logger.error("verify read failed: %s", exc)
319
+ return {"persisted": False, "error": str(exc)}
320
+
321
+
322
+ @app.post("/api/mode")
323
+ async def api_mode(req: ModeRequest):
324
+ """Toggle the Cognee backend between self-hosted (open source) and Cognee Cloud."""
325
+ global _backend_mode
326
+ # Cloud creds can come from the request or the environment.
327
+ url = req.url or os.environ.get("COGNEE_CLOUD_URL")
328
+ api_key = req.api_key or os.environ.get("COGNEE_CLOUD_API_KEY")
329
+ _backend_mode = await use_backend(req.mode, url=url, api_key=api_key)
330
+ return {"backend": _backend_mode}
331
+
332
+
333
+ @app.get("/api/health")
334
+ async def api_health():
335
+ return {"ok": True, "scenario": _scenario, "backend": _backend_mode,
336
+ "subscribers": events.has_subscribers()}
337
+
338
+
339
+ # ------------------------------------------------------------------ lifecycle
340
+ @app.on_event("startup")
341
+ async def _startup():
342
+ """Seed the simple investigation so the first page load shows a live graph."""
343
+ global _seeded
344
+ try:
345
+ _seeded = await build_graph()
346
+ logger.info("startup: seeded simple investigation")
347
+ except Exception as exc: # don't let a seed hiccup take the server down
348
+ logger.error("startup seed failed: %s", exc)
349
+
350
+
351
+ # Serve the built frontend LAST so /api/* routes take precedence. The directory is
352
+ # created by the Docker build (frontend/dist -> ./static); guard so local dev without
353
+ # a build still boots (API-only).
354
+ if os.path.isdir("static"):
355
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")