{"id":"20687264-5e3f-4ea3-b6a0-a714c073d34a","slug":"reasongraph","kind":"mcp","name":"reasongraph","summary":"Graph memory for AI agents: facts in, entities and cause->effect relations extracted with small open models, multi-hop recall with a cited chain. Library, HTTP/MCP service, LangChain, hosted at memory","owner":"bgokden","repoUrl":"https://github.com/bgokden/reasongraph","license":"MIT","licenseTier":"permissive","trust":"community","stars":2,"logoUrl":"https://avatars.githubusercontent.com/u/974543?u=2cc4b5f30580de1d18d18fb0b442600c486435a8&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["memory-knowledge","search","developer-tools"],"tags":["causal-reasoning","embeddings","graph-traversal","knowledge-graph","ner","nlp","python","rag"],"runtime":"remote","local":false,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-14T04:37:08.199Z","sample":false,"url":"https://conduits.dev/tools/reasongraph","language":"Python","transports":["http"],"install":{"remoteUrl":"https://memory.primaxiom.ai/mcp"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# ReasonGraph\n\n<!-- mcp-name: ai.primaxiom.memory/reasongraph -->\n\nA graph-based **memory for AI agents**: it ingests facts, auto-extracts entities and cause->effect relations, and discovers connections across independent documents *and* across agent sessions -- with conflict resolution, time-travel, causal tracing, and counterfactuals.\n\n[![PyPI version](https://img.shields.io/pypi/v/reasongraph?color=blue)](https://pypi.org/project/reasongraph/)\n[![Python 3.11+](https://img.shields.io/pypi/pyversions/reasongraph?color=blue)](https://pypi.org/project/reasongraph/)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)\n\n## Why ReasonGraph?\n\nStandard RAG retrieves documents similar to your query. ReasonGraph is a persistent, updatable memory that discovers connections *between* facts that were written independently.\n\nWhen you feed text into `add_texts()`, ReasonGraph automatically extracts **entities** (via GLiNER) and **cause-effect relations** (via a dedicated causal model) that become nodes and typed edges in a graph. Facts that share entities or causal chains get connected -- even if they never reference each other. Multi-hop traversal then walks these connections to build reasoning chains that span multiple sources.\n\nOn top of retrieval it works as agent memory: **scopes/sessions** (agents discover into each other's memory through shared entities), **contradiction resolution** (a new fact soft-supersedes what it contradicts), **time-travel** (`query(as_of=...)`), **causal tracing** (`trace_effects` / `root_causes` / `causal_chain`), **counterfactuals** (`what_if`), and a shippable **MemoryService** over HTTP and MCP.\n\n**Zero config, strong defaults.** `ReasonGraph()` picks the best available entity extractor, causal model, embedder, and reranker automatically -- the eval numbers below come from these defaults. For the SOTA causal model (~0.70 F1) use `pip install reasongraph[causal]` and the graph uses it automatically. The configuration sections are optional depth, not required reading.\n\n## Use it in 60 seconds\n\n**Claude Code / Cursor / any MCP client, hosted (EU, no LLM in the loop):**\n\n```bash\nclaude mcp add --transport http memory https://memory.primaxiom.ai/mcp \\\n  --header \"Authorization: Bearer rgm_YOUR_KEY\"\n```\n\n**Local MCP server (stdio, everything on your machine, SQLite):**\n\n```bash\npip install \"reasongraph[service,gliner,fastembed,sqlite]\"\nclaude mcp add memory -- reasongraph-mcp\n```\n\n**Python, in-process:**\n\n```bash\npip install \"reasongraph[all]\"\n```\n\n```python\nfrom reasongraph import ReasonGraph\n\ngraph = ReasonGraph()\ngraph.initialize_sync()\ngraph.add_texts_sync([\"TSMC is building a chip fab in Phoenix, Arizona.\",\n                      \"Arizona ordered water cuts for industrial users in Maricopa County.\"])\nprint(graph.discover_sync(\"water and chips\"))   # a path: water cuts -> Arizona -> TSMC fab\n```\n\n**Any language, over HTTP (self-hosted or hosted):**\n\n```bash\ncurl -X POST https://memory.primaxiom.ai/sessions/notes/memory \\\n  -H \"Authorization: Bearer rgm_YOUR_KEY\" -H \"Content-Type: application/json\" \\\n  -d '{\"text\": \"Apple sources M-series chips from TSMC in Arizona.\"}'\n```\n\nReady-to-copy agents (Groq/OpenAI-compatible research agent, two agents sharing one\nmemory, Claude Code with persistent memory, LangGraph) live in\n[`examples/agents/`](examples/agents/).\n\n### Hosted: ReasonGraph Cloud\n\n[memory.primaxiom.ai](https://memory.primaxiom.ai) runs this library as a service: sign in,\nget a free key (10k requests a month), remote MCP endpoint, browser console and playground.\nExtraction runs with small models on servers PrimAxiom operates (currently in the EU); facts are\nonly sent to an LLM provider if you ask for a synthesized answer. Early access.\n\n### JavaScript and TypeScript\n\nAgents that are not written in Python talk to the hosted service through a dependency-free client\n([`clients/typescript`](clients/typescript)). It uses the platform `fetch`, so it runs on Node 18+,\nBun, Deno, Cloudflare Workers and the browser. `npm install reasongraph`.\n\n```ts\nimport { Memory, memoryTools } from \"reasongraph\";\n\nconst mem = new Memory({ apiKey: process.env.REASONGRAPH_API_KEY });\nawait mem.remember(\"scout\", \"TSMC is building a chip fab in Phoenix, Arizona.\");\nconst facts = await mem.discover(\"What could disrupt the Phoenix fab?\");\n```\n\n`memoryTools(mem)` returns OpenAI-style tool definitions with their executors attached, for agents\nthat call functions directly without a framework: `remember`, `recall` and `why`.\n\n\n## Installation\n\n```bash\npip install reasongraph[all]        # everything included\n```\n\nOr install only what you need:\n\n```bash\npip install reasongraph             # core: in-memory backend, NER extraction, embeddings\npip install reasongraph[gliner]     # + GLiNER entity extraction + hybrid causal (default, recommended)\npip install reasongraph[causal]     # + SOTA span-pointer causal model (~0.70 F1) + hybrid fallback\npip install reasongraph[gliner2]    # + GLiNER2 alternative (single model does entities + causal)\npip install reasongraph[sqlite]     # + SQLite backend with sqlite-vec\npip install reasongraph[postgres]   # + PostgreSQL + pgvector backend\npip install reasongraph[service]    # + HTTP + MCP memory service\npip install reasongraph[fastembed]  # + pure-ONNX embedder / reranker (faster cold start)\n```\n\n## Cross-Source Discovery\n\nTwo reports about different topics. Source A covers TSMC's semiconductor plant. Source B covers Arizona's water crisis. Neither mentions the other's subject.\n\n```python\nimport asyncio\nfrom reasongraph import ReasonGraph\n\nsource_a = [  # Tech industry report\n    \"TSMC announced plans to build a $40 billion semiconductor fabrication plant in Phoenix, Arizona.\",\n    \"The Phoenix fab requires 10 million gallons of purified water daily to cool wafers during the chip etching process.\",\n    \"TSMC signed a long-term supply agreement with Apple to manufacture next-generation M-series processors at the Arizona facility.\",\n    \"Construction delays at the Phoenix site pushed first production to late 2025, raising concerns among TSMC's major customers.\",\n]\n\nsource_b = [  # Environmental report -- never mentions TSMC, semiconductors, or chips\n    \"Arizona declared a water emergency after Lake Mead dropped to its lowest level since the 1930s, threatening water supply for millions.\",\n    \"The Arizona Department of Water Resources ordered mandatory water cuts for all industrial users in Maricopa County, where Phoenix is located.\",\n    \"Intel paused expansion of its Chandler, Arizona chip plant citing water availability concerns and rising operational costs.\",\n    \"Apple warned investors that component shortages from its Asian and North American suppliers could impact iPhone production timelines through 2026.\",\n]\n\nasync def main():\n    async with ReasonGraph() as graph:\n        await graph.add_texts(source_a)\n        await graph.add_texts(source_b)\n        results = await graph.query(\"How does the Arizona water crisis affect semiconductor manufacturing?\")\n        for i, text in enumerate(results, 1):\n            source = \"A\" if text in source_a else \"B\"\n            print(f\"{i}. [Source {source}] {text}\")\n\nasyncio.run(main())\n```\n\n```\n1. [Source B] Intel paused expansion of its Chandler, Arizona chip plant citing water availability concerns and rising operational costs.\n2. [Source B] The Arizona Department of Water Resources ordered mandatory water cuts for all industrial users in Maricopa County, where Phoenix is located.\n3. [Source A] The Phoenix fab requires 10 million gallons of purified water daily to cool wafers during the chip etching process.\n4. [Source B] Arizona declared a water emergency after Lake Mead dropped to its lowest level since the 1930s.\n5. [Source A] TSMC announced plans to build a $40 billion semiconductor fabrication plant in Phoenix, Arizona.\n6. [Source A] TSMC signed a long-term supply agreement with Apple to manufacture M-series processors at the Arizona facility.\n```\n\nResults come from both sources. No single document contains this chain. Here is what happens under the hood:\n\n**ReasonGraph extracts entities and causal relations from each text** (requires an entity+causal extractor, e.g. `pip install reasongraph[gliner]` or `[all]`)**:**\n\n| Text (abbreviated) | Entities | Causal relations |\n|---------------------|----------|------------------|\n| TSMC to build fab in Phoenix, Arizona... | TSMC, Phoenix, Arizona | -- |\n| Phoenix fab requires 10M gallons water... | Phoenix | -- |\n| TSMC supply agreement with Apple... | TSMC, Apple, Arizona | -- |\n| Construction delays at Phoenix site... | TSMC, Phoenix | Construction delays -> first production |\n| Arizona water emergency, Lake Mead... | Arizona, Lake Mead | Lake Mead dropped -> water emergency |\n| Mandatory water cuts in Maricopa County... | Arizona Dept. of Water Resources, Phoenix, Maricopa County | -- |\n| Intel paused Arizona chip plant... | Intel, Chandler, Arizona | -- |\n| Apple warned of component shortages... | Apple | component shortages -> iPhone production timelines |\n\n**Three entities appear in both sources, creating bridge nodes:**\n\n| Bridge entity | Source A connections | Source B connections |\n|---------------|---------------------|---------------------|\n| Arizona | TSMC fab, TSMC-Apple deal | water emergency, Intel pause, water cuts |\n| Phoenix | TSMC fab, water usage, delays | water cuts for industrial users |\n| Apple | TSMC supply agreement | component shortage warning |\n\n**The query traversal path:**\n\nWater crisis query -> finds water-related texts from both sources via embeddings -> follows `Arizona` and `Phoenix` entity edges to discover TSMC's water-intensive fab -> follows `Apple` entity edge from TSMC supply agreement to Apple's component shortage warning. The causal relation `Lake Mead dropped -> water emergency` connects the environmental trigger to the industrial impact.\n\nFull demo: `uv run python examples/cross_source_discovery.py`\n\n## Quick Start\n\n### Using a built-in dataset\n\n```python\nfrom reasongraph import ReasonGraph\n\ngraph = ReasonGraph()\ngraph.initialize_sync()\ngraph.load_dataset_sync(\"financial\")\n\nresults = graph.query_sync(\"What caused the 2008 financial crisis?\")\nfor i, text in enumerate(results, 1):\n    print(f\"{i}. {text}\")\n\ngraph.close_sync()\n```\n\nOutput -- a connected reasoning chain, not just keyword matches:\n\n```\n1. Lehman Brothers filed for bankruptcy in September 2008 after massive MBS losses.\n2. Loose lending standards fueled a housing price bubble across the United States.\n3. Lehman's collapse triggered a global credit freeze as interbank lending stopped.\n4. Mortgage-backed securities built on subprime loans collapsed when defaults surged.\n5. The U.S. government enacted TARP, a $700 billion bailout to stabilize the financial system.\n6. Banks issued subprime mortgages to borrowers with poor credit histories.\n```\n\n### Async API\n\n```python\nimport asyncio\nfrom reasongraph import ReasonGraph\n\nasync def main():\n    async with ReasonGraph() as graph:\n        await graph.load_dataset(\"financial\")\n        results = await graph.query(\"What caused the 2008 crisis?\")\n        for text in results:\n            print(text)\n\nasyncio.run(main())\n```\n\n## Features\n\n- **Cross-source discovery** -- connect facts across independent documents through shared entities and causal relations\n- **Automatic extraction** -- entities (GLiNER `gliner_small-v2.5` by default) and cause->effect relations (a dedicated span-pointer / hybrid causal model) are extracted on add, both on by default; falls back to GLiNER2 then BERT NER when `gliner` is not installed\n- **Agent memory** -- scopes/sessions with cross-session discovery, contradiction resolution (soft-supersede), time-travel (`as_of`), semantic dedup, and auto-forget\n- **Causal reasoning** -- trace downstream effects, root causes, and directed causal paths; ask counterfactual `what_if`\n- **Hybrid search** -- combine embedding similarity, keyword (trigram) matching, or both\n- **Multi-hop traversal** -- follow graph edges to discover connected reasoning chains\n- **Cross-encoder reranking** -- rerank results at each hop with a cross-encoder (`ms-marco-MiniLM-L-6-v2` by default, multilingual mMARCO in the hosted service)\n- **Memory service** -- ready HTTP + MCP server so agents share and query memory\n- **Built-in datasets** -- load curated reasoning graphs for immediate use\n- **Async-first** -- native async API with sync convenience wrappers\n- **Pluggable backends** -- in-memory (zero-config default), SQLite, or PostgreSQL with pgvector\n\n### Causal eval cases\n\n`tests/data/causal_cases.jsonl` (40 reviewed cases), `tests/data/causal_cases_batch2.jsonl`\n(40 more, 8 domains x 5, 12 non-English) `tests/data/causal_cases_batch3.jsonl` (60 more:\n20 each German, Spanish, French) and `tests/data/causal_cases_batch4_trnl.jsonl` (20 Turkish,\n20 Dutch) each hold multi-source why-questions with a gold\nchain. Run `python tests/eval_causal_cases.py --cases tests/data/causal_cases_batch2.jsonl`.\nBaseline on batch2 with the default models: chain recovered 100%, ordered 47%, answer 100%,\ncausal chain 65%.\n\n## Models\n\nEvery model slot is pluggable; these are the defaults and what ReasonGraph Cloud runs.\nAll of them are small and run on CPU.\n\n| Step | Library default | ReasonGraph Cloud | Notes |\n|---|---|---|---|\n| Sentence splitting | off (`split=\"sat\"` or `\"regex\"` to enable) | SaT `sat-3l-sm` (wtpsplit) | 84% boundary recovery on messy text vs 40% for the regex splitter |\n| Entities | GLiNER `gliner-community/gliner_small-v2.5` | same | zero-shot, multilingual; 97% recall on a 6-language check, ~19 ms/call. `REASONGRAPH_ENTITY_NORMALIZE=1` (or `canonicalizer=EntityNormalizer()`) merges surface forms (\"Sabah\"/\"sabah\", \"Bulk Export's\"/\"Bulk Export\") into one node, and `REASONGRAPH_ENTITY_CONTAINMENT=1` (or `link_contained_entities=True`) also links a fact to an existing entity that its entity whole-word-prefixes or extends (\"malzeme\" / \"malzeme eksikliği\"), through an indexed first-word lookup. Both are off by default: measured on the 80-case busy-tenant eval they gave no root-recall gain and containment linked across unrelated cases 79% of the time; they remain for experiments |\n| Cause → effect | `Berk/causal-span-pointer-v2` (fine-tuned mDeBERTa-v3, open weights) | v3 (private for now; purpose-clause direction 97% vs 20%, CNC 0.705), plus its token gate at threshold 0.1 | 0.70 F1 on CausalNewsCorpus dev; the gate keeps plain statements out of the causal graph. `REASONGRAPH_CAUSAL_ONNX=hf://owner/repo/file.onnx` runs the same model through onnxruntime, 2x faster on CPU with identical spans |\n| Embeddings | `all-MiniLM-L12-v2` | `paraphrase-multilingual-MiniLM-L12-v2` (fastembed) | switch when your facts are not only English |\n| Span linking | embedding cosine ≥ `span_link_threshold` (0.85) | a span-link cross-encoder (`REASONGRAPH_SPAN_LINKER`, logit ≥ `REASONGRAPH_SPAN_LINK_LOGIT`) once measured; ties paraphrased hops cosine misses (17/20 vs 7/20 recovered at zero false links on held-out cases) |\n| Reranker | `cross-encoder/ms-marco-MiniLM-L-6-v2` | `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | the multilingual reranker lifted German discovery from 62% to 88% in our eval |\n| Contradiction check | off (`resolve_conflicts=True` needs a resolver) | `Berk/reasongraph-extractor-1.7b` (fine-tuned Qwen3 1.7B, open weights) on llama.cpp, with an embedding pre-filter | 0.95 F1 on the hand-checked pairs; ~0.4 s per pair on two CPU threads |\n| Chat / written answers | none (bring your own `call_model`) | an outside provider, currently `gpt-oss-120b` on Groq | the only step that uses a large model, and only when you use chat or ask for an answer |\n\nEvaluation scripts for each slot are in `tests/` (`eval_causal_extraction.py`,\n`eval_causal_cases.py`) and results are quoted next to the options below.\n\n## Built-in Datasets\n\n| Dataset | Description |\n|---------|-------------|\n| `syllogisms` | Classical syllogistic reasoning chains |\n| `causal` | Cause-effect reasoning with entity annotations |\n| `taxonomy` | Hierarchical concept taxonomy |\n| `financial` | Financial crisis causal chains (2008 crisis, dot-com, inflation, eurozone) |\n| `medical` | Medical causal chains (heart disease, diabetes, infectious disease, cancer) |\n| `analysis_patterns` | Data analysis reasoning: scenario detection, technique selection, implementation patterns |\n\n```python\ngraph.load_dataset_sync(\"financial\")\n```\n\n## Search Modes\n\n`embedding` (the default) finds seeds by meaning. `hybrid` fuses that with a word-level\ntrigram channel (`pg_trgm` strict word similarity, index-assisted on Postgres): a name, a code,\na number or a compound in the question matches the fact that contains it even when the\nembedder never saw the word. `keyword` is the trigram channel alone, for known-term lookups.\nThe memory loop takes the same option (`MemoryLoop(search_mode=\"hybrid\")`, or\n`REASONGRAPH_LOOP_SEARCH=hybrid`), and stays `embedding` by default.\n\n**Measured, so you do not have to guess:** on the causal-root eval (180 cases, six languages,\none busy tenant) hybrid does *not* help and slightly hurts: root recall 9% -> 7%, English 15% -> 8%,\np95 latency +22%. The reason is structural, not a tuning failure: a root cause is phrased nothing\nlike the question, so matching the question's words surfaces mid-chain filler that then competes for\nthe context budget. Hybrid's own case (exact names, codes, part numbers, identifiers) is real but is\na *lookup* task, which that eval does not measure. Turn it on for lookup-shaped workloads; leave it\noff for \"why\" questions.\n\n### How far the walk goes, and in which direction\n\n`hops` bounds the walk's depth. `causal_hops=(forward, backward)` bounds it *per direction*:\nforward follows cause -> effect (consequences), backward follows effect -> cause (what led here).\nEntity bridges are unaffected. \"Why\" is a backward question, so a walk that spends its depth\nasymmetrically can reach a root cause a symmetric one misses:\n\n```python\nloop = MemoryLoop(graph, session=\"chat\", causal_hops=(3, 2))   # or REASONGRAPH_CAUSAL_HOPS=3,2\n```\n\nThe default is symmetric (`None`), and on our own causal eval the split makes **no difference at all**:\nno path there runs more than two causal edges in one direction before the overall `hops` limit binds, so a\nper-direction cap never fires. Reach for this only when your graph has genuinely long one-directional\nchains, four or more hops of pure cause-to-cause. Raising `hops` itself was also measured: depth 5 buys\ntwo more correct answers in 180 for roughly twice the database work, which is why the default stays 3.\n\n### Linking two wordings of one event\n\nThe same event turns up written two ways: \"costs were reduced\" in one sentence, \"the cost reduction\" in\nthe next. Cosine over a general retrieval embedder does not put those together, and when it fails the\ncausal chain breaks, which is measurably where root causes get lost.\n\nThat decision is its own job, so it can use its own model. `span_linker` accepts either shape:\n\n```python\nReasonGraph(span_linker=\"bi:my-org/same-event-multilingual\")   # a similarity model, scored by cosine\nReasonGraph(span_linker=\"my-org/same-event-cross-encoder\")     # a cross-encoder, scored pairwise\n```\n\nor `REASONGRAPH_SPAN_LINKER` with the same values. Both see only direction-aware candidates: an effect\nspan is only ever compared with a cause span. `span_link_top_k` sets how many near neighbours a span is compared against before anything judges them.\nIt defaults to 6. It used to widen to 10 whenever a linker was configured, and that was measurably wrong:\non 341 cases the wider shortlist floods the walk with look-alikes, halving the gain on rephrased chains and\nturning a small gain on ordinary chains into a small loss. Narrowing it back was the single largest\nimprovement in this whole line of work, and it costs nothing.\n\nBy default a linker **replaces** the embedder's own cosine, which means it can also lose links cosine was\nright about. `span_link_floor` makes it **add** instead: cosine keeps every link it would have made, and the\nlinker only speaks for pairs whose cosine falls in the band between the floor and the threshold.\n\n```python\nReasonGraph(span_linker=\"bi:my-org/same-event\", span_link_floor=0.6)   # or REASONGRAPH_SPAN_LINK_FLOOR\n```\n\nOn our own corpus the floor turned out to be a **no-op at every setting**: within the linker's candidate\nset, cosine and the trained model never disagree in the band the floor arbitrates, so there is nothing for\nit to keep or cut. It stays available because another corpus may well contain that disagreement, but do not\nexpect it to help without measuring.\n\n### Keeping a long conversation inside its context window\n\nThree things hold a long chat together: the recent messages verbatim, a summary of what came before,\nand recall of anything older that turns out to be relevant. The loop does the third by default, and\nthe first two are configured on the session.\n\n```python\nloop = MemoryLoop(\n    graph, session=\"support-chat\",\n    max_history_tokens=6000,        # fold once the transcript passes this\n    keep_tail_tokens=2000,          # this much of the newest talk stays word for word\n    summarizer=my_model,            # fn(messages, max_tokens=None) -> str; omit and old turns are dropped\n    summary_length_target=False,    # cap the summary at its share of the budget; costs detail\n    summarize_in_background=True,   # summarise after the reply, never before it\n)\n```\n\n**Why a token budget rather than the last N messages.** A fixed count is wrong in both directions: ten\none-line exchanges are nothing, and ten pasted stack traces overflow the window. The budget counts\nwhat actually costs you.\n\n**Why fold a block at a time.** When the transcript outgrows the budget, everything older than the\ntail becomes one summary and the tail stays verbatim. Summarising a block rather than a message means\nthe summarizer runs rarely, and a conversation that never reaches the budget never summarises at all.\nThe next fold takes the previous summary in with the newly-aged messages, so summaries merge instead\nof stacking.\n\n**What the summary costs, and why it is not capped by default.** The summary and the verbatim tail\nshare `max_history_tokens`, so what is left once the tail is counted is the room the summary has:\n`loop.summary_budget_tokens`. Nothing makes the model respect it. Measured on qwen2.5 7B over eight\nfolds with a 110-token share, keeping a list of 17 details that a later reply could need:\n\n| | summary after 8 folds | details kept |\n| --- | --- | --- |\n| no length asked for (default) | 374 tokens | 17 of 17 |\n| `summary_length_target=True` | 228 tokens | 11 of 17 |\n\nTold a length, the model holds near it and drops detail to get there; told nothing, it keeps\neverything and overruns its share instead. Neither is free, so the choice is yours: the default\nkeeps the detail, and `summary_length_target=True` passes `summary_budget_tokens` to the summarizer\nas `max_tokens` when the prompt has to fit. A summarizer that takes only `messages` still works\neither way. One model, one transcript, one seed — worth re-measuring on yours.\n\n**The summarizer is never on the hot path.** Folding uses the summary the session already has and\nrecords what still needs summarising. With `summarize_in_background` the work happens after the reply\nis sent, so the summary lands one turn later; without it the fold waits. Either way a summarizer that\nis slow, down, or returns nothing leaves the conversation working, because losing the wording of old\nturns is survivable and stalling the answer is not.\n\n**What a summary is not.** It is a model's paraphrase with no source behind it, so it arrives as its\nown system message and never mixes with the recalled facts, which carry theirs. And it matters less\nhere than elsewhere: messages that fall out of the window were stored as facts by `observe`, so they\ncome back by meaning when they are relevant. The summary is for continuity, not for remembering.\n\n### Notes that point backwards\n\nPeople chain causes by pointing rather than repeating: \"this broke checkout\", \"because of that we rolled\nback\". Extraction reads one sentence at a time, so \"this\" refers to nothing it can see and the link is lost.\nOn real incident write-ups about a fifth of the causal links between sentences are of this kind.\n\n```python\nReasonGraph(resolve_back_references=True)   # or REASONGRAPH_RESOLVE_BACK_REFERENCES=1\n```\n\nA sentence that opens with a back-reference and asserts a cause is linked to the note before it. The device\ndiffers by language and is often not a pronoun at all: German and Dutch carry the reference inside an adverb\n(\"dadurch\", \"daardoor\"), Turkish marks it with case endings (\"bu nedenle\", \"bundan dolayı\"), and a connective\nlike \"as a result\" or \"por ello\" asserts the link on its own.\n\n**Off by default, and here is the honest state.** On generated cases it recovers a third of these otherwise\nlost links with no wrong links at all, and it changes nothing on the standing evaluation. What is not\nestablished is its precision on ordinary traffic, so turn it on deliberately and check. Two limits no rule\ncan fix: Spanish and Turkish often drop the subject entirely, leaving no marker to find, and Turkish tends to\nexpress cause inside a single sentence, so it has less of this to recover in the first place.\n\n**Measured, so you can skip what we tried.** A general cross-encoder scored *below* plain cosine. A\npurpose-trained same-event model, on the other hand, triples root-cause recall on exactly the cases where\nthe two sides are phrased differently (1.6% to 4.9%), while slightly hurting the cases that never needed it\n(6.0% to 5.0%). Whether that trade is worth it depends on your text: it breaks even when about a quarter of\nyour causal links are phrased differently on each side, and in ordinary domain notes roughly two-thirds are,\nso it usually pays about three times over. Measure your own mix before assuming it.\n\n### Repeatable answers at scale\n\nMeasured on a 100k-fact tenant, and the answer is reassuring: **a running service is already\nrepeatable.** Ask the same question twice against the same index and you get the same facts, every\ntime, on every version. The order within a result is fixed too, at no cost.\n\nWhat drifts is a **rebuild**. Two indexes built independently over the same data return slightly\ndifferent neighbours, because the search is approximate. On that tenant, 30 of 80 questions differed\nbetween two fresh builds, and 4 of those changed the root cause itself, so it is not merely cosmetic.\n\nMaking two rebuilds agree requires exact search, which costs roughly 3.6x on recall latency. That is a\nmigration setting, not a serving one:\n\n```\nREASONGRAPH_PG_DETERMINISTIC=1     # exact search: rebuild-stable, much slower\nREASONGRAPH_PG_EF_SEARCH=<n>       # a wider candidate window, if you want it too\n```\n\nLeave both off for serving. Turn the first on when you rebuild a store and need the answers to match\nthe one it replaces.\n\n### Walking in levels\n\nA recall walks the graph outward from its seeds. Each level is fetched in **one backend call**\n(`nearest_neighbors_many`), not one call per node, because over a network a recall's cost is its\nround trips: measured at 110 round trips per recall, a 5 ms hop to the database nearly quadrupled\nrecall latency. Backends that cannot batch inherit a default that loops, so this is transparent.\n\nWithin one recall, a fact's scopes, causal relations and timestamps are fetched **once**\n(`ReasonGraph.request_cache()`, opened by `MemoryLoop.recall`): a recall makes several passes over\noverlapping facts, and over a network every repeat is a round trip. The cache is dropped when the\nrequest ends, so nothing is stale across requests, and only read-only metadata is memoised.\n\nConcurrency, for deployments that run several writers (an API plus extraction workers):\nnode and edge upserts are ordered by key so writers cannot deadlock on the same rows, schema\ncreation is serialised with an advisory lock, and transient write failures (deadlock, serialization\nfailure, an aborted pipeline) are retried with backoff.\n\n```python\n# Pure embedding similarity (default)\nresults = graph.query_sync(\"credit freeze\", search_mode=\"embedding\")\n\n# Pure keyword/trigram matching\nresults = graph.query_sync(\"credit freeze\", search_mode=\"keyword\")\n\n# Hybrid: Reciprocal Rank Fusion of embedding + trigram rankings\nresults = graph.query_sync(\"credit freeze\", search_mode=\"hybrid\")\n\n# Tune the RRF smoothing constant (default 60, lower = more weight to top ranks)\nresults = graph.query_sync(\"credit freeze\", search_mode=\"hybrid\", rrf_k=30)\n```\n\n## Entity and Causal Extraction\n\nEntity extraction and causal extraction are **two independent, both-on-by-default** capabilities. `add_text()` / `add_texts()` use **`gliner_small-v2.5`** for entities (fast, multilingual, highest entity recall) when `gliner` is installed, falling back to GLiNER2 then BERT NER. Override per call with the `extractor` argument -- e.g. `gliner_large-v2.5` for higher precision.\n\n```python\nfrom reasongraph import ReasonGraph, NERExtractor, GLiNER2Extractor\n\ngraph = ReasonGraph()\ngraph.initialize_sync()\n\n# Default: GLiNER gliner_small-v2.5 for entities (+ the default causal model),\n# falling back to GLiNER2 then BERT NER\nentities = graph.add_text_sync(\"Apple released the iPhone in 2007.\")\nprint(entities)  # ['Apple', 'iPhone']\n\n# Explicit: force BERT NER even if a GLiNER model is installed\nentities = graph.add_text_sync(\"Apple released the iPhone in 2007.\", extractor=NERExtractor())\n\n# Explicit: GLiNER2 with custom entity types\ngliner = GLiNER2Extractor(entity_types=[\"company\", \"product\", \"date\"])\nentities = graph.add_text_sync(\"Apple released the iPhone in 2007.\", extractor=gliner)\n\n# Conversational memory: ChatExtractor also captures preference/plan/topic,\n# so \"hard techno\" or \"visit\" become bridgeable nodes -- not just people/places\nfrom reasongraph import ChatExtractor\nentities = graph.add_text_sync(\n    \"I love hard techno and plan to visit Berlin.\", extractor=ChatExtractor()\n)  # ['Berlin', 'hard techno', 'visit']\n\n# Any callable works\nentities = graph.add_text_sync(\"some text\", extractor=lambda t: [\"custom\"])\n```\n\n### Causal reasoning (default on)\n\nCausality is the headline feature, so causal extraction runs **by default**\n(opt out per call with `causal=False`). Directed cause->effect relations become\nfirst-class **typed edges** (`label=\"causes\"`) in the graph, distinct from\nanonymous entity bridges, and `discover()` returns them per fact:\n\n```python\ngraph.add_text_sync(\"Heavy rainfall caused severe flooding.\")\n# -> typed edge  heavy rainfall --causes--> severe flooding\n\nfor fact in graph.discover_sync(\"flooding\"):\n    print(fact[\"content\"], fact[\"causes\"])  # [{'cause': 'Heavy rainfall', 'effect': 'severe flooding'}]\n```\n\n### Causal chain tracing\n\nBecause cause->effect edges are directed and first-class, you can **walk the causal\ngraph** -- something a flat vector store cannot do. Trace downstream impact, trace\nback to root causes, or find a directed causal path between two facts:\n\n```python\ngraph.add_texts_sync([\n    \"Heavy rainfall caused flooding.\",\n    \"Flooding caused power outages.\",\n    \"Power outages caused hospital disruptions.\",\n])\n\ngraph.trace_effects_sync(\"Heavy rainfall caused flooding.\")[\"terminals\"]\n# e.g. -> ['hospital disruptions']       # downstream impact\n\ngraph.trace_causes_sync(\"Power outages caused hospital disruptions.\")[\"terminals\"]\n# e.g. -> ['rainfall']                   # upstream causes (same as root_causes_sync)\n\ngraph.causal_chain_sync(\"Heavy rainfall caused flooding.\",\n                        \"Power outages caused hospital disruptions.\")\n# -> ordered causal hops, each cited to the fact that asserted it\n```\n\nThe exact spans depend on the causal extractor; a hop chains when one fact's effect\nspan matches the next fact's cause span. Each hop is tagged with the fact that\nasserts it, its scopes, and a `cross_session` flag; with a `conflict_resolver`\nconfigured, retired (superseded) facts are skipped by default (`include_superseded=True`\nkeeps them). Tunable with `max_depth` (default 6) and `max_visited` (default 1000).\nExposed to agents as the `trace_memory` MCP tool and the `/trace` HTTP endpoint.\n\n### Counterfactual: what breaks if a fact were false\n\nBecause the causal edges are first-class, you can ask the inverse of a trace:\n**if one fact were false, which downstream effects collapse?** `what_if` prunes a\nfact hypothetically (no graph mutation), re-walks reachability, and reports which\neffect spans lost **all** causal support versus which **survived** via an alternate\npath. Only edges the pruned fact *solely* supports are removed -- an effect another\nfact also explains still stands.\n\n```python\ngraph.what_if_sync(\"Flooding caused power outages.\")\n# {\n#   'pruned': 'Flooding caused power outages.',\n#   'origin': 'Flooding caused power outages.',     # walk start (== pruned unless origin= given)\n#   'pruned_edges': [{'cause': 'flooding', 'effect': 'power outages'}],\n#   'collapsed': [                                 # lost their only causal path\n#       {'span': 'power outages', 'fact': 'Flooding caused power outages.', 'depth': 0, ...},\n#       {'span': 'hospital disruptions', 'fact': 'Power outages caused hospital disruptions.', 'depth': 1, ...},\n#   ],\n#   'survived': [],                                # spans an alternate path rescued\n# }\n```\n\nPass `origin=` to measure collapse relative to an upstream fact, or\n`direction='causes'` to see which upstream causes become orphaned. Exposed as the\n`what_if_memory` MCP tool and the `/what_if` HTTP endpoint.\n\nThe default causal extractor picks the **best available** backend. When the\n`causal-span-model` package is installed it uses the **span-pointer model**\n(`CausalPointerExtractor`): a fine-tuned mDeBERTa-v3 that scores **~0.70 F1** on the\nCausal News Corpus Subtask-2 official scorer -- beating the 0.627 organizer baseline,\nthe hybrid, and a few-shot LLM baseline (~0.24-0.41). It is trained on English but\nmultilingual at inference (script-aware segmentation, verified on es/fr/de/pt/tr/ru/ar\nand zh/ja) and has a built-in causal gate, so it returns nothing on non-causal text.\n\nThe built-in gate can be replaced by a **decoupled embedding gate**: a small\nclassifier on sentence embeddings (train one with `scripts/train_embed_gate.py` in\ncausal-span-model; it saves a `.joblib`). It costs a millisecond per sentence, is\nretrained on any negative mix without touching the span heads, and on our causal\neval it gave fewer, more precise edges than the built-in gate. Pass a local path or\nan `hf://owner/repo/file.joblib` reference:\n\n```python\nReasonGraph(causal_extractor=CausalPointerExtractor(\n    model=\"Berk/causal-span-pointer-v2\", gate_threshold=1.0,          # built-in gate off\n    embed_gate=\"hf://Berk/causal-span-pointer-v2/embed_gate_mlp.joblib\",\n    embed_gate_threshold=0.9))                                        # keep P(causal) >= 0.9\n```\n\n`causal_chain` also bridges facts that phrase one event differently (\"the system\nthrottles performance\" -> \"Throttling performance\", or a plain root fact whose\nwords reappear in the next cause span), so directed chains survive wording changes\neven without `span_link_threshold`. `trace_causes(..., bridge=True)` walks back the same\nway: from a cause span to the effect spans of other facts that name its event (\"the billing\njob ran twice whenever a payment retried\" -> \"The billing job ran twice\"), and from a plain\nfact with no relation of its own to the effect spans that describe it, so a symptom someone\nnoted (\"the billing job started double-charging\") leads to the explanation recorded later.\n\nOtherwise it falls back to the **hybrid** (`HybridCausalExtractor`): a fast,\nmodel-free multilingual **cue pass** handles explicit and reversed phrasing with\ncorrect direction, and sentences with no causal connective (implicit causality)\nfall through to **`gliner-relex-multi`** (Apache-2.0, mDeBERTa, ~100 languages).\nOn a four-regime probe set (explicit / multilingual / implicit / reversed) the\nhybrid reached **100% directed-pair recall vs 61-79%** for either part alone --\neach covers the other's blind spot -- and most sentences never touch the model,\nso the average cost is low. Reproduce with `tests/bench_causal_extractors.py`.\n\n```python\nfrom reasongraph import CausalPointerExtractor, HybridCausalExtractor, GlinerRelexExtractor\n\nReasonGraph()                                          # best available (pointer if installed, else hybrid)\nReasonGraph(causal_extractor=CausalPointerExtractor())  # force the span-pointer model\nReasonGraph(causal_extractor=HybridCausalExtractor())  # force the hybrid\nReasonGraph(causal_extractor=GlinerRelexExtractor())   # relex model only\nReasonGraph(causal_extractor=False)                    # disable causal extraction\n```\n\n`pip install reasongraph[causal]` installs both the pointer model\n(`causal-span-model`) and the hybrid (`gliner`), so the graph uses the SOTA pointer\nby default and falls back to the hybrid automatically. If neither is available the\ndefault warns once rather than silently dropping causality; `add_text(..., causal=True)`\nraises when no causal extractor can be resolved.\n\n### Sentence splitting at ingest\n\nEvery model in the pipeline is trained on single sentences, so a paragraph pushed as one\nfact hurts entities, causal spans and retrieval alike (on our 39-case causal eval, chain\nrecall drops from 79% to 10%). Pass a splitter and each text becomes one fact per sentence:\n\n```python\nReasonGraph(sentence_splitter=\"sat\")          # Segment-any-Text, 85 languages: pip install reasongraph[split]\nReasonGraph(sentence_splitter=\"regex\")        # dependency-free fallback (punctuation + newlines)\ngraph.add_texts([paragraph], split=True)      # or per call; split=False keeps a text whole\n```\n\nThe service reads `REASONGRAPH_SPLIT_SENTENCES=sat|regex`; pushes accept `split: true/false`.\n\n## Deep memory integration: the memory loop\n\nNo tools, no prompts to write: wrap any chat model and every exchange becomes memory, and\nwhatever is relevant comes back by itself before the next call.\n\n```python\nfrom reasongraph import ReasonGraph, MemoryLoop\n\ngraph = ReasonGraph()                                  # or your Postgres-backed graph\nloop = MemoryLoop(graph, session=\"support-chat\", max_facts=8)\n\nhistory = [{\"role\": \"user\", \"content\": \"Why did the Rotterdam warehouse lose power?\"}]\nreply, context = loop.chat_sync(call_model, history, system=\"You are a careful assistant.\")\n# call_model is any fn(messages) -> str: OpenAI-compatible, Claude, Ollama, llama.cpp\n# context.facts  -> what was recalled (with sources and cause->effect links)\n# context.roots  -> for why-questions, the root cause(s) the chain walks back to;\n#                   they are spelled out in the injected block so a small model\n#                   answers with the root, not only the nearest cause\n# the question and the reply are now remembered in \"support-chat\"\n```\n\nThe loop also remembers the conversation itself. Those stored turns are recalled like any\nother fact, but judged after everything else: the question you just asked and an earlier\n\"I don't know\" would otherwise score highest and take every slot.\n`graph.forget(scopes)` erases a session (or a tenant, or a test run): facts and the entities\nonly they linked are deleted; a sentence also held elsewhere is detached, not deleted.\n\n`loop.messages(history)` returns the message list with the recalled facts injected as a\nsystem message, if you want to call the model yourself; `loop.observe(user, assistant)`\nstores an exchange. Options: `max_facts` / `max_chars` (context budget), `min_score`\n(no unrelated filler), `rerank_min` (an optional cross-encoder cutoff on top of it:\ncosine cannot tell \"same topic\" from \"answers this\", the reranker can; -4 with the\ndefault reranker), `extend_query` (when a why-question's chain ends in a root cause\nno recalled fact states, one more targeted query fetches the plain fact behind it),\n`bridge_causes` (on by default: the causal trace crosses facts that word one event\ndifferently and starts from a plain symptom, which is what a vague question such as\n\"anything to watch before the next billing run?\" seeds; on a busy 15-incident memory that\ntook the root cause from 33% to 73% of such questions with the small production embedder),\n`observe_user` / `observe_assistant`, `redact` (a function that\ndrops or rewrites text before it is stored), `resolve_conflicts`. The hosted service\nexposes the same loop as `POST /chat`. Example agent: `examples/agents/memory_loop_agent.py`.\n\n## LangChain and LangGraph\n\n`pip install langchain-reasongraph` (the LangChain partner-style package, with LangChain's\nstandard retriever tests; source in `packages/langchain-reasongraph`) or\n`pip install \"reasongraph[langchain]\"` adds three adapters that work with a local\n`ReasonGraph` or the hosted service through `reasongraph.client.MemoryClient`:\n\n```python\nfrom reasongraph.integrations.langchain import ReasonGraphRetriever, with_memory, memory_tools\n\nretriever = ReasonGraphRetriever(target=graph)          # documents = the facts the graph connects\nmodel = with_memory(ChatOpenAI(...), graph, session=\"support-chat\")   # recall in, exchange remembered\ntools = memory_tools(graph, session=\"agent\")            # remember / recall / discover for agents\n```\n\nDocuments carry the sources, the names the fact was reached through and its cause->effect\nlinks in `metadata`. Full example: `examples/agents/langchain_memory.py`; a LangGraph agent\nthat uses the tools: `examples/agents/langgraph_agent.py`.\n\n### A memory class for chains and agents\n\n`ReasonGraphMemory` is the classic `BaseMemory` shape (`load_memory_variables` /\n`save_context`), so it drops in wherever a LangChain memory goes. Each turn the agent sees\ntwo things: `memory`, the facts the graph connects to the input with their sources and\ncause->effect links, and `history`, the transcript folded to a token budget with the oldest\nturns as one rolling summary and the newest verbatim.\n\n```python\nfrom reasongraph.integrations.langchain import ReasonGraphMemory\nfrom reasongraph.loop import make_summarizer\n\nmemory = ReasonGraphMemory(target=graph, session=\"ops-chat\",\n                           max_history_tokens=2000, keep_tail_tokens=800,\n                           summarizer=make_summarizer(lambda msgs: llm.invoke(msgs).content))\n\nseen = memory.load_memory_variables({\"input\": \"Which version is staging on, and why?\"})\nseen[\"memory\"]    # \"- We rolled staging back to 2.3.1 because the certificate expired. [ops-chat]\\n  because: ...\"\nseen[\"history\"]   # \"Summary: ...\\nHuman: How do I speed up rollbacks?\\nAI: Keep the previous image warm...\"\nmemory.save_context({\"input\": \"...\"}, {\"output\": \"...\"})\n```\n\nFolding decides what the model sees and deletes nothing: a turn that left the window is\nstill a fact and comes back through `memory` when it is relevant again. The summary is\nwritten after `save_context`, never in the middle of a turn, and without a summarizer the\nbudget still holds. `ReasonGraphChatMessageHistory` offers the same transcript as a\n`BaseChatMessageHistory` for `RunnableWithMessageHistory`. Walkthrough with what the agent\nsees at every turn: `examples/agents/langchain_memory_class.py`.\n\n## Fast inference (optional, pure ONNX)\n\nThe defaults already deliver the eval quality below; this is purely a\nspeed/memory optimization. Every model slot is pluggable, so you can trade the\nPyTorch defaults for CPU-optimized ONNX models at equal-or-better quality.\nMeasured on the 32-case mixed-domain eval:\n\n```python\nfrom reasongraph import ReasonGraph, FastEmbedEmbedder, FastEmbedReranker\n\ngraph = ReasonGraph(\n    embed_model=FastEmbedEmbedder(\"sentence-transformers/all-MiniLM-L6-v2\"),\n    rerank_model=FastEmbedReranker(\"Xenova/ms-marco-MiniLM-L-6-v2\"),\n)\n```\n\n- **Reranker → `Xenova/ms-marco-MiniLM-L-6-v2`**: the ONNX build of the default\n  reranker, so scores (and eval quality) are identical, but cold start drops\n  from ~2.4s to ~0.03s.\n- **Embedder → `all-MiniLM-L6-v2` (ONNX)**: ~2.3x faster load, equal-or-better\n  eval quality.\n- **Full ONNX pipeline**: ~3x faster cold start and ~23% less RAM at\n  equal-or-better quality; per-query latency rises (~12ms to ~100ms), a good\n  trade when cold start and memory matter more than warm latency.\n- Multilingual embedder (`paraphrase-multilingual-MiniLM-L12-v2`) is available\n  as an option; it costs a few points of English quality.\n\nRequires `pip install reasongraph[fastembed]`. Benchmark any configuration with\n`tests/bench_pipeline.py`.\n\n### Choosing an extractor (optional)\n\nYou don't need to choose -- the default (`gliner_small-v2.5` for entities plus the\ndefault causal model) is the recommended, benchmarked setup. This section is the\nevidence behind that default and the alternatives for special cases; swap the entity\nmodel with the `extractor` argument if you have a specific need (reproduce the numbers\nwith `tests/bench_extractors.py`):\n\n- **`GlinerExtractor`** (default) -- GLiNER v1 zero-shot with convert-and-cache ONNX\n  inference (fast, flexible entity types; entities only -- causal relations come from\n  the separate default causal model). Defaults to `gliner-community/gliner_small-v2.5`,\n  which on a 10-language WikiANN benchmark led on entity recall (**86%**, vs GLiNER2's\n  74%) at **~67 ms/call and ~2.2 GB** -- and unlike GLiNER2 it holds up on\n  Korean/Arabic/Turkish/Russian. The checkpoint matters a lot: the older\n  `urchade/gliner_multi-v2.1` scores ~12%, so pin the model and benchmark with\n  `tests/bench_ner_multilingual.py`.\n- **`GLiNER2Extractor`** -- a single model that does entity types **and** causal\n  relations in one pass. Reach for it when you want one model for both, but it is the\n  heaviest (loads slowly, ~4.6 GB) and lower on multilingual entity recall.\n- **`OnnxTokenClassifierExtractor`** -- runs any BIO token-classification model\n  exported to ONNX, decoding entities from the model's own `id2label`. Fast\n  (~30 ms/call) and multilingual with a suitable model; the label scheme is the\n  model's, so a specialized place model or a custom general NER both drop in\n  with no code change.\n\nSize sweep (same WikiANN benchmark) -- bigger is not uniformly better:\n\n| model | infer | RAM | recall | prec | F1 |\n|---|---|---|---|---|---|\n| `gliner_small-v2.5` | 67 ms | 2.2 GB | 86% | 73% | 79% |\n| `gliner_medium-v2.5` | 73 ms | 2.7 GB | 84% | 75% | 79% |\n| `gliner_large-v2.5` | 142 ms | 4.8 GB | 86% | 84% | 85% |\n| `knowledgator/gliner-x-base` | 151 ms | 4.2 GB | 87% | 79% | 83% |\n| GLiNER2 | 250 ms | 4.8 GB | 74% | 84% | 79% |\n\nSmall ties large on recall; large's extra size buys precision (best F1). Medium is\ndominated -- skip it. `large-v2.5` beats GLiNER2 outright (same precision, higher\nrecall, faster, far stronger on Arabic/Korean). `knowledgator/gliner-x-base`\n(20+ languages) edges recall/precision above `small-v2.5` but needs `stanza` +\n`langdetect` (with per-language models fetched at runtime), runs ~7x slower, and\nis no better on the WikiANN Chinese reconstruction -- so `small-v2.5` stays the\ndefault; reach for `x-base` only when precision matters more than latency.\n\nRunning `gliner_small-v2.5` through ONNX (`GlinerExtractor(onnx=True)`) cuts\ninference from ~67 ms to **~12 ms/call** with recall preserved -- the fastest\nhigh-recall multilingual option (the conversion is cached on first use).\n\nRough guide: **`gliner_small-v2.5`** for the best speed/RAM at high recall (add\n`onnx=True` for ~12 ms/call); **`gliner_large-v2.5`** for the best overall quality\nand a strict upgrade over GLiNER2 on multilingual; **GLiNER2** only when you need\nits causal-relation extraction; **place ONNX** for the fastest location-heavy path.\n\n## Scopes\n\nScopes are free-text tags on facts (`\"user-alice\"`, `\"topic-economy\"`, `\"session-42\"`)\n-- **not partitions**. The graph stays shared: a fact can carry several scopes at\nonce, and multi-hop reasoning follows shared entities across every scope. A scope\non `query()` only narrows where the search *seeds*; traversal still reaches\nconnected facts in other scopes.\n\n```python\nawait graph.add_texts(alice_facts, scopes=[\"user-alice\"])\nawait graph.add_texts(economy_facts, scopes=[\"topic-economy\"])\n# One fact can belong to several scopes at once\nawait graph.add_texts(shared, scopes=[\"user-alice\", \"topic-economy\"])\n\n# Seeds come from user-alice; reasoning still bridges into topic-economy facts\nresults = await graph.query(\"Will it get harder to afford a home?\", scopes=[\"user-alice\"])\n```\n\nAdding the same content under a new scope unions the tags (never drops the old\nones). For a hard boundary where a query can only reach its own scope's facts, pass\n`isolate=True` (see [Multi-tenant and production](#multi-tenant-and-production)).\nFull demo: `uv run python examples/scoped_reasoning.py`\n\n## Backends\n\nBy default, `ReasonGraph()` uses a pure Python in-memory backend (`MemoryBackend`). This works everywhere with zero dependencies beyond numpy. For persistence, pass a file path to save/load as JSON:\n\n```python\nfrom reasongraph import ReasonGraph, MemoryBackend\n\n# In-memory only (default)\ngraph = ReasonGraph()\n\n# In-memory with JSON file persistence (loads on init, saves on close)\ngraph = ReasonGraph(backend=MemoryBackend(file_path=\"graph.json\"))\n```\n\n### SQLite Backend\n\nFor larger graphs or concurrent access, use the SQLite backend with `sqlite-vec` for vector search. Requires `pip install reasongraph[sqlite]`.\n\n```python\nfrom reasongraph import ReasonGraph\nfrom reasongraph.backends import SqliteBackend\n\ngraph = ReasonGraph(backend=SqliteBackend(db_path=\"graph.db\"))\n```\n\n### PostgreSQL Backend\n\n```python\nfrom reasongraph import ReasonGraph\nfrom reasongraph.backends import PostgresBackend\n\ngraph = ReasonGraph(backend=PostgresBackend(database_url=\"postgresql://user:pass@localhost/db\"))\n```\n\nRequires `pip install reasongraph[postgres]` and the `pgvector` + `pg_trgm` extensions enabled on your database.\n\n## Evaluation: Mixed-Domain Reasoning\n\nWe evaluate reasoning quality by loading all 6 built-in datasets into a single graph (~130 text nodes, ~104 entity nodes, ~280 edges) and testing whether the library can trace the correct causal chains, syllogistic proofs, taxonomic hierarchies, and data analysis patterns -- without being distracted by unrelated facts from other domains.\n\n32 test cases simulate agent-style queries like *\"I need to understand what caused the 2008 financial crisis\"*, *\"How does insulin resistance lead to kidney failure?\"*, or *\"I have two numeric columns, check if related\"* and check whether the returned reasoning chain matches the expected ground truth.\n\n**Per-domain results (hybrid search, `top_k=5`, `hops=4`, `rerank_top_k=4`):**\n\n| Domain | Cases | Chain Completeness | Recall@5 | Precision@5 | Domain Accuracy |\n|--------|------:|--------------------|----------|-------------|-----------------|\n| Causal | 5 | 100% | 100% | 92% | 100% |\n| Financial | 6 | 100% | 82% | 60% | 100% |\n| Medical | 5 | 100% | 92% | 76% | 92% |\n| Syllogisms | 5 | 100% | 100% | 92% | 85% |\n| Taxonomy | 3 | 100% | 83% | 53% | 92% |\n| Analysis Patterns | 8 | 96% | 75% | 45% | 96% |\n| **Overall** | **32** | **99%** | **88%** | **68%** | **95%** |\n\n32/32 cases pass (>= 50% chain completeness). Split reranking gives chain continuations (text-to-text edges) priority over bridge discoveries (entity-to-text edges), keeping traversal focused.\n\n**Search mode comparison:**\n\n| Mode | Chain Completeness | Recall@5 | Precision@5 | Domain Accuracy |\n|------|-------------------|----------|-------------|-----------------|\n| Embedding | 99% | 88% | 68% | 95% |\n| Keyword | 0% | 0% | 0% | 0% |\n| Hybrid | 99% | 88% | 68% | 95% |\n\nKeyword-only mode scores 0% because the eval queries are natural language questions that don't substring-match the dataset's declarative statements. This is expected -- keyword search is designed for known-term lookups, not question answering.\n\nReproduce: `uv run python tests/eval_financial_reasoning.py`\n\n## API Reference\n\n### `ReasonGraph(backend=None, embed_model=None, rerank_model=None, forget_after=30, forget_every=None, synthesizer=None, causal_extractor=None, isolate_traversal=False, conflict_resolver=None)`\n\n- `causal_extractor`: `None` builds the best available causal extractor lazily (the span-pointer model when `causal-span-model` is installed, else the hybrid); `False` disables causal extraction; a callable/object with `extract_causal` uses it.\n- `isolate_traversal`: graph-wide default for whether a scoped query confines traversal to its scopes (multi-tenant). Off keeps cross-scope discovery; override per call with `query(..., isolate=...)`.\n- `conflict_resolver`: enables contradiction resolution (soft-supersede) on write and retired-fact filtering on read (see [Multi-tenant and production](#multi-tenant-and-production)).\n\n**Ingest**\n\n| Method | Description |\n|--------|-------------|\n| `add_nodes(nodes, scopes=None)` | Add `(content, type)` tuples to the graph |\n| `add_edges(edges)` | Add `(from, to)` or `(from, to, label)` content edges (label e.g. `\"causes\"`) |\n| `add_text(text, extractor=None, scopes=None, causal_extractor=None, causal=None, dedup_threshold=None, resolve_conflicts=None)` | Add text with entity + causal extraction; `causal=False` disables, `True` forces (raises if unavailable); `dedup_threshold` drops near-duplicates (unioning scopes); `resolve_conflicts` soft-supersedes contradicted facts when a `conflict_resolver` is set |\n| `add_texts(texts, extractor=None, causal_extractor=None, scopes=None, causal=None, dedup_threshold=None, resolve_conflicts=None)` | Batch form of `add_text` (causal on by default) |\n\n**Retrieve**\n\n| Method | Description |\n|--------|-------------|\n| `query(query, top_k=5, hops=4, rerank_top_k=4, search_mode=\"embedding\", rrf_k=60, recency_weight=0.0, scopes=None, isolate=None, include_superseded=False, as_of=None)` | Search and traverse the graph. `recency_weight` in [0,1] blends recency into ranking; `scopes` narrows the seeds (traversal still crosses scopes unless `isolate=True`); `as_of=<datetime>` time-travels to what was current then; `include_superseded=True` keeps retired facts |\n| `query_detailed(...)` | Same signature as `query`, but returns `{content, score, created_at, scopes}` per hit for thresholding/dedup |\n| `discover(query, top_k=5, hops=4, search_mode=\"embedding\", rrf_k=60, scopes=None, max_results=10, max_visited=1000, isolate=None, include_superseded=False)` | Like `query`, but returns *connection paths* -- how each fact links back to a seed via bridging entities, tagged with scopes, flagging cross-session links, and listing each fact's directed `causes` relations. The walk stops after `max_visited` nodes |\n| `answer(query, use_discover=True, top_k=5, hops=4, search_mode=\"embedding\", scopes=None, max_results=10)` | Rephrase the retrieved facts/paths into logical free text via the pluggable `synthesizer` |\n\n**Causal reasoning**\n\n| Method | Description |\n|--------|-------------|\n| `trace_effects(content, max_depth=6, scopes=None, isolate=None, include_superseded=False, max_visited=1000)` | Forward causal walk: `{origin, chain, terminals}` for downstream impact |\n| `trace_causes(content, ...)` | Backward causal walk: what led to `content` |\n| `root_causes(content, ...)` | The root cause spans behind `content` (backward-walk terminals) |\n| `causal_chain(from_content, to_content, max_depth=6, scopes=None, isolate=None, include_superseded=False)` | Ordered causal hops linking two facts, or `None` |\n| `what_if(content, origin=None, direction=\"effects\", max_depth=6, scopes=None, isolate=None, include_superseded=False, max_visited=1000)` | Counterfactual: prune a fact and report `collapsed` vs `survived` downstream spans |\n\n**Update, forget, temporal**\n\n| Method | Description |\n|--------|-------------|\n| `delete(content, purge_orphans=False)` | Remove a node and its incident edges by exact content; `purge_orphans=True` also removes entities left dangling |\n| `supersede(old_content, new_text, extractor=None, purge_orphans=False)` | Replace a stale fact: add `new_text`, then delete `old_content` |\n| `supersession_history(content)` | Audit `{supersedes, superseded_by}` for a fact |\n| `delete_stale()` | Remove nodes not accessed within `forget_after` days |\n| `maybe_forget()` | Throttled `delete_stale()`: sweeps at most once per `forget_every` seconds (no-op when `forget_every` is `None`) |\n\n**Datasets and inspection**\n\n| Method | Description |\n|--------|-------------|\n| `load_dataset(name)` | Load a built-in dataset |\n| `get_all_nodes(scopes=None)` / `get_all_edges()` | Inspect graph contents (nodes optionally filtered by scope) |\n\nLifecycle is `initialize()` / `close()`, or use `async with ReasonGraph() as graph:`. All methods are async; every one has a `_sync` twin with the same parameters (e.g. `query_sync`, `what_if_sync`, `add_text_sync`).\n\n`embed_model` accepts a model name (`str`), a `SentenceTransformer`, or any\nobject/callable that encodes text. The encoder must take a `str` (returning one\nvector) or a `list[str]` (returning one vector per text); numpy, torch, or list\noutputs are all accepted. This lets a host reuse an embedder it already runs\ninstead of loading a second stack:\n\n```python\ndef encode(text_or_texts):\n    # reuse your own embedding library; return list[float] or list[list[float]]\n    ...\n\ngraph = ReasonGraph(embed_model=encode)\n```\n\n## Agent memory service\n\nA ready service turns reasongraph into shared, discoverable memory for many\nagents. **Knowledge sessions are scopes**: an agent pushes memory into its\nsession, and a query seeds from that session but traversal crosses all sessions\n-- so agents **discover connections into each other's memory** through shared\nentities. `pip install reasongraph[service]`.\n\n```python\nfrom reasongraph.service import MemoryService\nfrom reasongraph.backends import PostgresBackend\n\nservice = MemoryService(backend=PostgresBackend(\"postgresql:///memory\"),\n                        synthesizer=my_small_llm)   # synthesizer is optional\n\nawait service.push(\"research-bot\", \"TSMC is building a chip fab in Arizona.\")\nawait service.push(\"news-bot\", \"Arizona declared a water emergency.\")\n\n# research-bot discovers news-bot's fact via the shared 'Arizona' entity\npaths = await service.discover(\"Arizona\", session=\"research-bot\")\nanswer = await service.answer(\"Arizona\", session=\"research-bot\")   # logical free text\n```\n\nExpose it over **HTTP** (`reasongraph.service.http.create_app`) or **MCP**\n(`reasongraph.service.mcp_server.create_mcp`) -- the HTTP `query`/`discover`\nendpoints take a `synthesize` flag that adds the free-text `answer`. The demo\n`uv run python examples/agent_memory_service.py` pushes an economy / supply-chain\n/ energy / health / policy world across five agent sessions and shows a markets\nquery reaching a Taiwan drought and a chip fab recorded by other agents.\n\n**HTTP endpoints**: `POST /sessions/{session}/memory` (and `/batch`; pass\n`resolve_conflicts: true` to check nearby facts for contradictions), `/query` (supports\n`as_of` and `include_superseded` for time-travel), `/discover`, `/causal_chain`,\n`/supersede`, `/delete`, `/history`, `/trace`, `/what_if`, `/forget`, `GET /sessions`,\n`/stats`, plus `/health` and `/ready` probes.\n\n**MCP tools**: `push_memory`, `query_memory`, `query_memory_detailed`,\n`discover_connections`, `causal_chain_memory`, `trace_memory`, `what_if_memory`, `answer`,\n`update_memory`, `delete_memory`, `memory_history`, `forget_stale`, `list_sessions`.\n\n### Synthesizers\n\n`answer()` and the `synthesize` flag rephrase retrieved facts and their\nconnection paths into logical free text. The core stays model-free -- pass any\n`callable(query, context) -> str`, or one of the shipped adapters:\n\n```python\nfrom reasongraph import TemplateSynthesizer, PromptSynthesizer, TransformersSynthesizer\n\nReasonGraph(synthesizer=TemplateSynthesizer())            # deterministic, no model\nReasonGraph(synthesizer=PromptSynthesizer(my_generate))   # bring any LLM: generate(prompt) -> str\nReasonGraph(synthesizer=TransformersSynthesizer())        # local small LLM (Qwen2.5-0.5B-Instruct)\n```\n\n`PromptSynthesizer` builds the prompt (question + facts + cross-session bridges)\nand calls your `generate` (sync or async); `TransformersSynthesizer` runs a small\ninstruct model locally on the torch/transformers stack already pulled in by\n`sentence-transformers`.\n\n### Deploy\n\nAn env-driven entrypoint wires the backend, embedder, and synthesizer from\nenvironment variables. Run the Postgres-backed stack with Docker:\n\n```bash\ndocker compose up --build          # Postgres (pgvector) + the service on :8000\ncurl localhost:8000/stats\n```\n\nOr serve directly (`pip install reasongraph[service]` adds the `reasongraph-serve`\nconsole script and the ASGI factory):\n\n```bash\nREASONGRAPH_BACKEND=postgres \\\nREASONGRAPH_DATABASE_URL=postgresql:///memory \\\nREASONGRAPH_SYNTHESIZER=template \\\nreasongraph-serve            # or: uvicorn reasongraph.service.app:create_app_from_env --factory\n```\n\n| Variable | Default | Purpose |\n|----------|---------|---------|\n| `REASONGRAPH_BACKEND` | `memory` | `memory` \\| `sqlite` \\| `postgres` |\n| `REASONGRAPH_DATABASE_URL` | -- | Postgres URL, sqlite path, or memory JSON path |\n| `REASONGRAPH_EMBED_MODEL` | built-in | Model name; prefix `fastembed:` for pure-ONNX |\n| `REASONGRAPH_SYNTHESIZER` | `template` | `none` \\| `template` \\| `transformers` |\n| `REASONGRAPH_SYNTH_MODEL` | built-in | Instruct model for the `transformers` synthesizer |\n| `REASONGRAPH_FORGET_AFTER` / `REASONGRAPH_FORGET_EVERY` | `30` / off | Auto-forget window (days) and sweep interval (seconds). When the interval is set the service runs the sweep on a background task. |\n| `REASONGRAPH_ISOLATE` | off | Confine traversal to the query session (multi-tenant). Off keeps cross-session discovery. |\n| `REASONGRAPH_RESOLVE_CONFLICTS` | off | Enable contradiction resolution (soft-supersede) with the default NLI resolver. |\n| `REASONGRAPH_API_KEY` | -- | When set, data endpoints require it (`Authorization: Bearer` or `X-API-Key`); `/health` and `/ready` stay open. |\n| `REASONGRAPH_DEFER_EXTRACT` | off | Run entity/causal extraction in a background worker (off the event loop) so pushes return immediately. |\n| `REASONGRAPH_SPAN_LINK_THRESHOLD` | off | Cosine threshold (e.g. `0.85`) above which a new cause/effect span is tied (`same_as`) to an existing causal span, so `trace_*` / `causal_chain` cross facts that phrase the same event differently. |\n| `REASONGRAPH_CAUSAL_MODEL` | `berk/causal-span-pointer-mdeberta` | HF repo id or local dir of the span-pointer model. |\n| `REASONGRAPH_CAUSAL_GATE_THRESHOLD` | `0.5` | Built-in gate: P(non-causal) above which the pointer abstains; `1.0` turns it off. |\n| `REASONGRAPH_CAUSAL_EMBED_GATE` | off | Path or `hf://owner/repo/file.joblib` of an embedding-gate classifier; texts under `REASONGRAPH_CAUSAL_EMBED_GATE_THRESHOLD` (default `0.9`) get no relations. |\n| `REASONGRAPH_DEDUP_THRESHOLD` | off | Cosine threshold (e.g. `0.95`) above which a pushed fact is treated as a paraphrase of an existing one: scopes are unioned, nothing new is stored. |\n| `REASONGRAPH_HOST` / `REASONGRAPH_PORT` | `0.0.0.0` / `8000` | Bind address and port for `reasongraph-serve`. |\n\nUse a persistent backend (PostgresBackend) for real multi-agent concurrency.\n\n### Multi-tenant and production\n\nBy default the graph is shared: a scoped query seeds from its session but the walk\ncrosses sessions, which is the cross-session discovery feature. For a\nconfidentiality boundary between tenants, turn on **traversal isolation** so a query\ncan only reach its own session's facts:\n\n```python\nReasonGraph(isolate_traversal=True)               # graph-wide default\ngraph.query(\"...\", scopes={\"tenant-a\"}, isolate=True)   # or per query\n```\n\nOther production controls:\n\n- **Auth**: `create_app(service, api_key=\"...\")` (or `REASONGRAPH_API_KEY`) gates every\n  data endpoint; the MCP server exposes the same tools.\n- **Structured results**: `query_detailed(...)` (and the HTTP `detailed` flag /\n  `query_memory_detailed` MCP tool) return `{content, score, created_at, scopes}` for\n  thresholding, dedup, and \"remembered on <date>\".\n- **Erasure**: `delete(content, purge_orphans=True)` / `supersede(..., purge_orphans=True)`\n  also remove entities left dangling by the deletion (right-to-be-forgotten); shared\n  entities survive. Exposed as `delete_memory` / `update_memory` MCP tools.\n- **Semantic dedup**: `add_text(..., dedup_threshold=0.95)` drops near-duplicate\n  restatements instead of accumulating them, unioning scopes onto the kept fact.\n- **Contradiction resolution**: `ReasonGraph(conflict_resolver=NLIConflictResolver())`\n  (or `REASONGRAPH_RESOLVE_CONFLICTS=1`) soft-supersedes facts a new one contradicts\n  -- a `supersedes` edge drops the old fact from default `query`/`discover` recall\n  while keeping it auditable and retrievable via `include_superseded=True`;\n  `supersession_history(fact)` shows what replaced what. The resolver is pluggable\n  (any object with `contradictions(new, candidates)`): the `NLIConflictResolver`\n  cross-encoder needs no LLM but can over-flag complements (it treats \"works in\n  Munich\" vs \"lives in Berlin\" as a conflict), while `LLMConflictResolver(generate)`\n  brings any LLM and is more precise. Soft-supersede is deliberately reversible:\n  re-asserting a fact revives it, and `query(..., as_of=<datetime>)` **time-travels**\n  to what was current at that moment (each fact carries a `created_at`/`invalid_at`\n  validity interval). Exposed to agents as the `memory_history` MCP tool and\n  `/history` endpoint.\n- **Health**: `/health` (liveness) and `/ready` (readiness) for orchestration probes.\n- **Postgres** creates an HNSW cosine index, so vector search is index-accelerated\n  rather than a sequential scan.\n- **Speed**: `tests/bench_speed.py` measures write throughput and read latency\n  (query / discover / trace, p50/p95) at a configurable graph size and backend\n  (`--fake` for model-free timing). Indicative real-model, in-memory numbers:\n  query ~14ms, discover ~7ms, causal trace ~3ms p50.\n\n## Work with us\n\nWant ReasonGraph on your own data, as a hosted service ([memory.primaxiom.ai](https://memory.primaxiom.ai)), or inside your agents? **[PrimAxiom Labs](https://primaxiom.ai)** builds and runs AI systems for companies: a short paid scoping step, then a four-week pilot.\nEmail **berk@primaxiom.ai** · [primaxiom.ai](https://primaxiom.ai)\n\n## License\n\nMIT\n"}