Spaces:
Sleeping
Sleeping
Aaryan Kumar commited on
Commit ·
1605cbb
0
Parent(s):
deploy to hugging face
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +29 -0
- .env.template +84 -0
- .gitignore +8 -0
- DEPLOYMENT.md +133 -0
- Dockerfile +46 -0
- IMPLEMENTATION_PLAN.md +764 -0
- LICENSE +21 -0
- README.md +301 -0
- REQUIREMENTS.md +393 -0
- cognee-hackathon-project-main/.env.template +84 -0
- cognee-hackathon-project-main/.gitignore +9 -0
- cognee-hackathon-project-main/LICENSE +21 -0
- cognee-hackathon-project-main/README.md +289 -0
- cognee-hackathon-project-main/REQUIREMENTS.md +393 -0
- cognee-hackathon-project-main/demo_video_script.md +62 -0
- cognee-hackathon-project-main/falsify/__init__.py +57 -0
- cognee-hackathon-project-main/falsify/edges.py +70 -0
- cognee-hackathon-project-main/falsify/falsify.py +240 -0
- cognee-hackathon-project-main/falsify/graph_ops.py +185 -0
- cognee-hackathon-project-main/falsify/models.py +202 -0
- cognee-hackathon-project-main/falsify/seed.py +197 -0
- cognee-hackathon-project-main/falsify/tasks/__init__.py +36 -0
- cognee-hackathon-project-main/falsify/tasks/cascade_forget.py +159 -0
- cognee-hackathon-project-main/falsify/tasks/detect_contradictions.py +199 -0
- cognee-hackathon-project-main/falsify/tasks/propagate_refutation.py +281 -0
- cognee-hackathon-project-main/falsify/utils.py +250 -0
- cognee-hackathon-project-main/main.py +190 -0
- cognee-hackathon-project-main/requirements.txt +13 -0
- cognee-hackathon-project-main/tests/__init__.py +1 -0
- cognee-hackathon-project-main/tests/conftest.py +97 -0
- cognee-hackathon-project-main/tests/test_detect.py +93 -0
- cognee-hackathon-project-main/tests/test_falsify.py +163 -0
- cognee-hackathon-project-main/tools/make_demo_gif.py +285 -0
- demo_video_script.md +62 -0
- falsify/__init__.py +57 -0
- falsify/edges.py +70 -0
- falsify/events.py +86 -0
- falsify/falsify.py +463 -0
- falsify/graph_ops.py +219 -0
- falsify/models.py +202 -0
- falsify/seed.py +317 -0
- falsify/tasks/__init__.py +36 -0
- falsify/tasks/cascade_forget.py +159 -0
- falsify/tasks/detect_contradictions.py +199 -0
- falsify/tasks/propagate_refutation.py +281 -0
- falsify/utils.py +250 -0
- main.py +193 -0
- render.yaml +28 -0
- requirements.txt +18 -0
- 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 |
+
[](#-hackathon-track--theme)
|
| 22 |
+
[](#-hackathon-track--theme)
|
| 23 |
+
[](https://github.com/topoteretes/cognee)
|
| 24 |
+
[](https://www.python.org/)
|
| 25 |
+
[](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 |
+

|
| 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 • 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 |
+
[](#-hackathon-track--theme)
|
| 10 |
+
[](#-hackathon-track--theme)
|
| 11 |
+
[](https://github.com/topoteretes/cognee)
|
| 12 |
+
[](https://www.python.org/)
|
| 13 |
+
[](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 |
+

|
| 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 • 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")
|