{
  "markdown": "# knowledge-graph-rdbms\n\n<!-- mcp-name: io.github.cunicopia-dev/knowledge-graph-rdbms -->\n\n![PyPI](https://img.shields.io/pypi/v/knowledge-graph-rdbms?logo=pypi&logoColor=white&color=3775A9)\n[![Downloads](https://img.shields.io/pepy/dt/knowledge-graph-rdbms?logo=pypi&logoColor=white&label=downloads&color=success)](https://pepy.tech/projects/knowledge-graph-rdbms)\n![Python](https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white)\n![License: MIT](https://img.shields.io/badge/license-MIT-green)\n![core dependencies: 0](https://img.shields.io/badge/core_dependencies-0-success)\n![tests: 107 passing](https://img.shields.io/badge/tests-107_passing-brightgreen)\n![MCP](https://img.shields.io/badge/MCP-ready-FF6F00)\n\n**An embedded knowledge graph you own completely — in one SQLite file.**\n\nSQLite-native · zero-dependency core · [MCP](https://modelcontextprotocol.io)-ready · event-sourced · reversible.\n\nModel anything as entities and relationships — **agent memory**, app ontologies,\ndeclarative datasets, \"SQLite but my data is a graph\" — without running Neo4j,\nRDF, Docker, or a separate graph service. No Cypher, no JVM, no server: five\ntables and one file you can copy, inspect, and version.\n\n```bash\npip install \"knowledge-graph-rdbms[mcp]\"\n\n# write a fact — auto-creates the graph in one SQLite file\nkg node add person:ada --kind Person --name \"Ada Lovelace\"\n\n# every write is logged; tail the log and roll any event back by id\nkg events -n 5\nkg revert <event_id>\n\n# rebuild the graph as of any past moment — literal time travel\nkg replay --upto 2026-01-01T00:00:00\n\n# expose the whole graph to an AI agent over MCP\nkg serve\n```\n\n```\n   Agent / CLI / Python\n            │\n     gated + logged writes\n            │\n   SQLite label property graph\n            │\n    replay · revert · time-travel\n```\n\n> Python 3.10+ · MIT · zero-dependency core · library + CLI + MCP\n\nThe world isn't rows in a table — it's *things*, the kinds of things they are,\nand how they relate. A label property graph captures exactly that, and not much\nmore: **nodes** (entities), **typed edges** (relationships), **labels** (sets),\nand **JSON properties** (everything else). There's no schema to design up front;\nmeaning accretes as facts, and the shape stays as flexible as the domain it\ndescribes.\n\nThat flexibility is what makes it a natural substrate for **AI agents**. Hand an\nagent an MCP connection to this graph and it can do what agents are uniquely good\nat: read a domain, model what it learns, connect ideas, and reason over\n*structure* instead of prose. Every write is gated, attributed, and appended to\nan event log — so an agent can reshape the graph freely while you keep the\nreceipts: audit it, replay it to any point in time, or roll a change back with\none command. A memory that records *why*, not just *what* — in one embeddable\nfile that travels wherever the agent runs: a laptop, a CI job, a serverless\nfunction, a Pi.\n\nSmall enough to hold in your head. Flexible enough to model anything.\n\n---\n\n## Contents\n\n- [Where it fits](#where-it-fits)\n- [Why not X?](#why-not-x)\n- [The idea in 30 seconds](#the-idea-in-30-seconds)\n- [Design philosophy](#design-philosophy)\n- [The data model](#the-data-model)\n- [Architecture: three front doors, one engine](#architecture-three-front-doors-one-engine)\n- [Many ontologies: one control plane](#many-ontologies-one-control-plane)\n- [Discovery: read the schema before you query](#discovery-read-the-schema-before-you-query)\n- [Cross-ontology: federation and the backbone](#cross-ontology-federation-and-the-backbone)\n- [Event sourcing: the graph is a projection](#event-sourcing-the-graph-is-a-projection)\n- [The safety gate: invariants vs. policy](#the-safety-gate-invariants-vs-policy)\n- [Install](#install)\n- [Quickstart](#quickstart)\n- [Performance](#performance)\n- [RDF interop: export, SPARQL, RDF-star](#rdf-interop-export-sparql-rdf-star)\n- [Command reference](#command-reference)\n- [Project layout](#project-layout)\n- [Development](#development)\n- [License](#license)\n\n---\n\n## Where it fits\n\nIt's built for graphs you want to **own completely** — small enough to inspect,\nfast enough to embed, transparent enough to trust:\n\n- **Embedded ontologies** — a knowledge graph that ships inside a single app or\n  service, in one file you can copy and version.\n- **Agent & assistant memory** — facts an AI can read and write over MCP, with a\n  full audit trail and one-command rollback.\n- **Declarative datasets** — describe your base graph as YAML/JSON facts and\n  replay them into a queryable graph, deterministically.\n- **\"SQLite, but my data is a graph\"** — the moment relational rows start\n  describing relationships, this is their natural home.\n\nThe design center is **read-heavy, single-writer, up to low millions of\nnodes** — the same sweet spot as SQLite itself: one file, in-process, no server\nto run. That covers a surprising amount of real work. For workloads past that\ncenter, [Performance](#performance) maps out exactly where the curve bends and a\npurpose-built graph engine starts to earn its extra moving parts.\n\n---\n\n## Why not X?\n\nHonest comparisons — reach for the right tool, and know exactly when that tool\nis this one.\n\n### Why not Neo4j?\n\nUse **Neo4j** when you need deep traversal, complex Cypher pattern matching,\nconcurrent writers, or clustered graph infrastructure. Use **kgrdbms** when you\nwant a small embedded graph, agent memory, auditability, local-first ownership,\nand SQLite deployment simplicity.\n\n### Why not NetworkX?\n\n**NetworkX** is great for in-memory algorithms. **kgrdbms** is for persistent\ngraph state with durable storage, CLI/MCP access, event history, and rollback.\n\n### Why not RDFLib?\n\n**RDFLib** is for RDF graphs and linked-data workflows. **kgrdbms** is a label\nproperty graph with RDF as an export/import boundary, not a triplestore.\n\n### Why not just SQLite tables?\n\nBecause some domains are naturally entities and relationships, not rectangular\ntables. **kgrdbms** keeps the storage boring while making the model\ngraph-shaped.\n\n---\n\n## The idea in 30 seconds\n\nA **label property graph** needs only four primitives:\n\n| Primitive    | What it is                                            |\n| ------------ | ----------------------------------------------------- |\n| **Node**     | a stable id, a `kind`, a display `name`               |\n| **Edge**     | a typed, directed relationship between two nodes      |\n| **Label**    | set memberships on a node (many per node)             |\n| **Property** | a JSON-valued key/value bag on a node *or* an edge    |\n\nStore those in SQLite, add a few indexes, and you have a knowledge graph.\nEverything else in this project — traversal, an append-only history, a safety\ngate, the CLI, the MCP server — is built on top of those four facts.\n\n```python\nfrom kgrdbms import Graph\n\nwith Graph(path=\"demo.db\") as g:\n    g.add_node(\"person:ada\", kind=\"Person\", name=\"Ada Lovelace\",\n               labels={\"Person\"}, properties={\"born\": 1815})\n    g.add_node(\"field:cs\", kind=\"Field\", name=\"Computer Science\")\n    g.add_edge(\"person:ada\", \"field:cs\", \"FOUNDED\", properties={\"year\": 1843})\n\n    print(g.shortest_path(\"person:ada\", \"field:cs\"))\n```\n\n```mermaid\nflowchart LR\n    ada([\"person:ada<br/>:Person<br/>born=1815\"])\n    cs([\"field:cs<br/>:Field\"])\n    ada -->|\"FOUNDED (year=1843)\"| cs\n```\n\n---\n\n## Design philosophy\n\n**1. Boring storage is a feature.** SQLite already solved durability,\ntransactions, indexes, and recursive queries. We don't reinvent any of it. One\nfile, copy it to back it up, open it with any SQLite tool to inspect it.\n\n**2. The schema fits on a screen.** Five tables, no surprises. You can read the\nentire storage layer and know exactly where every fact lives. Legibility beats\ncleverness.\n\n**3. The state is a pure function of data.** What you query is a *projection*.\nThe source of truth is an append-only log of facts plus an optional declared\nseed. Your whole graph is reproducible: `state = replay(seed + log)`. That one\nproperty buys audit, undo, and time-travel for free.\n\n**4. Mutation is gated, and the gate has two layers.** When you let an agent\nrewrite your graph over a wire, you need rules. Some rules are *configurable*\n(policy); some must be *un-negotiable* (compiled-in invariants). We separate\nmechanism from policy on purpose.\n\n**5. One engine, many doors.** A library call, a `kg` command, and an MCP tool\nall flow through the same gated, logged write path into the same file. There is\nno \"CLI version\" of the truth and \"MCP version\" of the truth — there's one.\n\n**6. Pay for speed only when you ask.** Every single write commits on its own\n(safe by default). Bulk paths (`batch()`, `add_nodes`, `add_edges`) let you opt\ninto ~10× throughput when you mean to.\n\n---\n\n## The data model\n\nThe graph is five tables. That's the whole storage layer.\n\n```mermaid\nerDiagram\n    nodes ||--o{ node_labels : \"is labeled\"\n    nodes ||--o{ node_properties : \"has\"\n    nodes ||--o{ edges : \"from / to\"\n    edges ||--o{ edge_properties : \"has\"\n\n    nodes {\n        text id PK\n        text kind\n        text name\n        text created_at\n    }\n    node_labels {\n        text node_id FK\n        text label\n    }\n    node_properties {\n        text node_id FK\n        text key\n        text value_json\n    }\n    edges {\n        text id PK\n        text from_node FK\n        text to_node FK\n        text type\n    }\n    edge_properties {\n        text edge_id FK\n        text key\n        text value_json\n    }\n```\n\nDesign notes that matter:\n\n- **Edges are unique on `(from_node, type, to_node)`.** Re-adding the same\n  triple updates its properties instead of duplicating it. Idempotent by\n  construction.\n- **Properties are JSON.** Values round-trip as whatever JSON type you store —\n  ints, bools, lists, nested objects.\n- **Foreign keys cascade.** Delete a node and its labels, properties, and\n  incident edges go with it, enforced by SQLite.\n- **`slug()` deduplicates natural-language ids.** Two strings that slugify the\n  same become the same node — the load-bearing trick for turning prose concepts\n  into stable ids.\n- **Ids are CURIEs.** `person:ada-lovelace`, `company:apple` — a compact URI:\n  `prefix:reference`, where the prefix is a short stable type token and the\n  reference is slugged (`slug(name, prefix=\"person\")` mints them). It stays a\n  plain string today and expands to a full IRI *only* the day you publish to the\n  linked-data world, so interop is a cheap, additive option rather than a tax\n  paid up front. The id is an *address*, not a record: identity goes in the id,\n  changeable facts go in properties.\n\nA sixth table, `graph_events`, holds the append-only history (see below). It\nshares the same file and connection.\n\n---\n\n## Architecture: three front doors, one engine\n\n```mermaid\nflowchart TD\n    subgraph doors[\"front doors\"]\n        CLI[\"kg<br/>(command line)\"]\n        MCP[\"kgrdbms-mcp<br/>(MCP server)\"]\n        LIB[\"import kgrdbms<br/>(library)\"]\n    end\n\n    SVC[\"service.py — gated + logged write path\"]\n    GATE{\"invariants.enforce<br/>then policy.mutation_check\"}\n    GRAPH[(\"SQLite file<br/>nodes · edges · labels · properties\")]\n    LOG[[\"graph_events<br/>(append-only log)\"]]\n\n    CLI -->|writes| SVC\n    MCP -->|writes| SVC\n    LIB -->|writes| SVC\n    SVC --> GATE\n    GATE -->|\"pass\"| GRAPH\n    GATE -->|\"record fact\"| LOG\n\n    CLI -.->|reads| GRAPH\n    MCP -.->|reads| GRAPH\n    LIB -.->|reads / fast bulk writes| GRAPH\n```\n\nThe CLI, the MCP server, and your own Python code all mutate through\n`service.py`, so the safety gate and the event-log bookkeeping exist in exactly\none place and can't drift between front doors.\n\nThe gate resolves `invariants.enforce` and `policy.mutation_check` *through\ntheir modules at call time* — so editing your policy (or monkeypatching it in a\ntest) takes effect across every door at once.\n\n> The library also has a **direct, fast, unlogged** path (`g.add_node(...)`,\n> `g.add_nodes(...)`). It's the right tool for bulk loading, but those writes\n> are not in the event log — see the warning under *Event sourcing*.\n\n---\n\n## Many ontologies: one control plane\n\nOne graph is the *engine*. The **control plane** lets all three front doors\naddress *many named ontologies* through that one engine — each its own SQLite\nfile (and, when a workload earns it, its own engine entirely). You name an\nontology; the resolver routes. Nothing else changes — the gate, the log, and the\n`service.py` write path are exactly the same; only *which* graph + log they act\non is chosen by name.\n\n```mermaid\nflowchart TD\n    subgraph doors[\"front doors, now ontology-aware\"]\n        CLI[\"kg --ontology coffee …\"]\n        MCP[\"kg_node_get(ontology='coffee')\"]\n        LIB[\"resolve('coffee')\"]\n    end\n    RES[\"resolver.py<br/>name → (backend, events, entry)\"]\n    IDX[[\"index.db<br/>the registry — itself a kg\"]]\n    REG{\"backend registry\"}\n    SQ[(\"sqlite · live\")]\n    PG[(\"postgres · live\")]\n    NEO[(\"neo4j · stub\")]\n\n    CLI --> RES\n    MCP --> RES\n    LIB --> RES\n    RES -->|\"look up the name\"| IDX\n    RES -->|\"open the engine\"| REG\n    REG --> SQ\n    REG -.-> PG\n    REG -.-> NEO\n```\n\n- **The registry is itself a kg.** Ontologies are nodes in an index graph\n  (`<root>/index.db`), so *listing* them is a query and *registering* one is an\n  upsert — no new storage machinery. A database **of** databases.\n- **The default ontology is the legacy file.** Omit the name and you hit\n  `<root>/graph.db`, exactly as before. Multi-ontology is purely additive;\n  nothing moves, and every existing command behaves identically.\n- **Isolation is filesystem-shaped.** Each ontology is its own file with its own\n  event log. \"Coffee doesn't know Ada\" because they are different files — no\n  tenant ids, no row filtering, no leak surface.\n- **The engine is pluggable.** A backend is a factory registered under a name.\n  `sqlite` and `postgres` are live; `neo4j` is a stub that routes and fails\n  loudly until built. Most ontologies stay embedded SQLite — the zero-dependency\n  default — while a specific heavy one can be **escalated** to a purpose-built\n  engine when its *workload* (not its row count) turns deep. Philosophy #6, \"pay\n  for speed only when you ask,\" generalized from batching to whole engines.\n- **History is owned by the control plane, not the engine.** A non-sqlite\n  ontology keeps its append-only event log in a control-plane SQLite store\n  (`<root>/ontologies/<slug>/events.db`); the engine is just the *projection*\n  that replay and undo apply to. So a Postgres-backed ontology still gets the\n  full audit / time-travel / one-command-revert story — graph data in Postgres,\n  history in SQLite.\n\n```bash\npip install \"knowledge-graph-rdbms[postgres]\"     # the psycopg-backed engine\nkg ontology create big --backend postgres \\\n   --location \"postgresql://user:pass@host:5432/db\" --stance inferential\nkg --ontology big node add company:acme --kind Company --name Acme   # writes to Postgres\n```\n\n```bash\nkg ontology create coffee --stance inferential       # register (lands in index.db)\nkg ontology list                                     # the database of databases\nkg --ontology coffee node add drink:latte --kind Drink --name Latte\nkg --ontology coffee out drink:latte                 # scoped to that ontology\n```\n\nTwo ways to target a graph, mirroring the MCP `ontology` argument: `--ontology\nNAME` routes through the resolver (named, registered, multi-engine), while\n`--db PATH` stays the raw escape hatch onto one exact file, registry untouched.\n\n---\n\n## Discovery: read the schema before you query\n\nA graph you didn't build is opaque: which `kind`s exist? which edge types? what\nproperty keys live on a `Person`? `schema()` answers all of it in **one read** —\nthe map to read before querying, rather than guessing with trial calls.\n\n```bash\nkg schema             # kinds, edge types, labels, and property keys per kind — with counts\nkg schema --samples   # + a few example ids per kind and the enum-like values a key takes\n```\n\nWhat comes back is the *observed* vocabulary — a profile of what's actually in\nthe graph, not an enforced schema (the graph stays schemaless). The MCP tool\n`kg_schema` carries an instruction to call it **first**, so an agent reads the map\nbefore it moves. It's a plain read — pure `GROUP BY` aggregates, no gate — and\nlike every read it has a federated form (next) that unions the vocabulary across\nmany ontologies at once.\n\n---\n\n## Cross-ontology: federation and the backbone\n\nThe control plane routes to *one* ontology per call. Two layers sit on top to work\nacross *many* at once: **reads federate, writes go through a backbone.**\n\n```mermaid\nflowchart TD\n    Q[\"kg fed node person:ada\"] --> FED[\"federation.py<br/>multithreaded fan-out\"]\n    FED -.->|own thread + connection| O1[(\"people\")]\n    FED -.->|own thread + connection| O2[(\"papers\")]\n    FED -.->|own thread + connection| O3[(\"coffee\")]\n    O1 --> M[\"merge, tagged by source<br/>(identity-aware)\"]\n    O2 --> M\n    O3 --> M\n\n    L[\"kg link same-as …\"] --> BB[\"backbone.py\"]\n    BB --> IDX[[\"index.db — the backbone<br/>Ref + Prefix nodes, SAME_AS edges<br/>gated + logged\"]]\n```\n\n### Federation — cross-ontology reads, multithreaded\n\nA federated read is a *fan-out*: open each member ontology in its own thread (its\nown connection — SQLite releases the GIL during a query, so N ontologies read\n**concurrently**) and merge the results tagged by source. There's no separate\n\"federated\" API: every base read takes an optional `ontologies=[...]` scope.\n\n```python\nfrom kgrdbms import Federation\n\nfed = Federation([\"people\", \"papers\", \"coffee\"])   # or Federation.all()\nfed.schema()                 # unioned vocabulary + a per-ontology breakdown\nfed.nodes_by_kind(\"Person\")  # [Located(ontology, node), ...] — tagged by source\nfed.node(\"person:ada\")       # every occurrence across worlds, identity-merged\n```\n\n```bash\nkg fed schema                # union vocabulary across ALL ontologies\nkg fed node person:ada       # find an id across the federation (identity-aware)\n```\n\nFederation never writes and never silently drops a member (a member that raises\npropagates); `parallel=False` forces sequential for debugging.\n\n### The backbone — cross-ontology links\n\nA leaf edge can't cross ontologies: its foreign key lives in one file. The backbone\nis where cross-ontology structure lives, and it needs **no new storage — it *is* the\nindex graph** growing new kinds. A link becomes a lightweight `Ref` proxy node per\nendpoint (id `<ontology>::<node_id>`, FK-satisfied *inside* the index) joined by an\nedge; the prefix registry becomes `Prefix` nodes. Both go through the **same gated +\nlogged `service` path**, so a cross-domain assertion is audited, reversible, and\nreplayable exactly like leaf data.\n\n```bash\nkg link add coffee drink:latte ENJOYED_BY people person:ada   # a typed cross-ontology edge\nkg link same-as people person:ada wiki person:ada-lovelace    # \"same real-world entity\" (symmetric)\nkg link cluster people person:ada                             # the transitive SAME_AS cluster\nkg prefix add person https://kg.local/person/                 # CURIE prefix -> IRI (identity backbone)\n```\n\n**Identity is opt-in per ontology.** By default identity is *local* — `person:ada`\nin two ontologies are different nodes until you link them via the backbone. An\nontology created with `--shared-identity` opts into *global* identity, and federation\nthen treats same-CURIE nodes across such ontologies as the **same** entity and merges\nthem.\n\n---\n\n## Event sourcing: the graph is a projection\n\nThe graph you query is a cache. The **append-only event log is the source of\ntruth.** Every gated mutation records a reversible fact.\n\n```mermaid\nflowchart LR\n    SEED[\"genesis seed<br/>(optional, declarative)\"] -->|\"seed once\"| PROJ\n    LOG[[\"event log<br/>append-only, never deletes a row\"]] -->|\"replay in order\"| PROJ[(\"graph projection<br/>what you query\")]\n    PROJ -->|\"every gated mutation appends a fact\"| LOG\n```\n\nBecause the log never loses a row, you get three things at once:\n\n- **Audit is archaeology.** Every change is timestamped and attributed to an\n  actor. `kg events` tails the history.\n- **Undo is an event, not a delete.** `compensate()` (CLI: `kg revert <id>`)\n  emits the *inverse* event. The original row stays; you can see that it was\n  reverted and by what.\n- **Time travel.** `replay(upto_ts=...)` rebuilds the projection as of any past\n  instant.\n\n```mermaid\nsequenceDiagram\n    participant U as you\n    participant L as event log\n    participant G as graph projection\n    U->>L: NODE_UPSERT person:ada\n    L->>G: apply → ada exists\n    U->>L: revert(that event)\n    L->>L: append NODE_DELETE (compensates)\n    L->>G: apply → ada gone\n    Note over L: both rows remain — history is intact\n```\n\n### Two write paths, by design\n\nYou choose how each write relates to history:\n\n- **Logged path** — the CLI, the MCP server, and `service.*`. Every mutation is\n  gated and appended to the log, so it's audited, reversible, and reproduced\n  exactly by `replay()`. This is the path you want when the timeline *is* the\n  truth.\n- **Direct path** — `g.add_node`, `g.add_nodes`, `g.batch()`. Writes go straight\n  to the projection: the fastest way to bulk-load or stage data. They aren't in\n  the log, so `replay()` (which rebuilds from the log) won't include them.\n\nPick per workload: direct for raw loading speed, logged when you need the\nhistory. And you can have both — `kg import` runs the logged path *inside* a\nsingle `batch()`, so a bulk load is fast **and** fully recorded.\n\nThe replay seed is just a callable, so you can declare your base graph in\nYAML/JSON and re-seed deterministically before the logged deltas are applied:\n\n```python\nfrom kgrdbms import Graph, EventLog, replay\n\ndef genesis(g):\n    # re-create your declared base facts (e.g. parsed from YAML)\n    g.add_node(\"root\", kind=\"Root\", name=\"root\")\n\nreplay(graph, events, genesis=genesis)              # rebuild from seed + log\nreplay(graph, events, genesis=genesis, upto_ts=ts)  # ...as of an instant\n```\n\n---\n\n## Virtual edges: relationships you don't store\n\nEvent sourcing is the right model for *curated facts* — but the wrong one for a\nhigh-cardinality, machine-generated relationship layer that already lives, fresh\nand authoritative, in some operational store. Mirroring 100k correlation edges\ninto the graph (and the log) every night is wasteful and instantly stale.\n\nA **virtual edge** inverts that. The ontology stores only a *binding* — an edge\nTYPE plus the SQL that resolves its instances against an external source. At\ntraversal time the resolver runs that query, parameterized by the node you're\nstanding on, and synthesizes the edges live. Zero copy, always current, one\nsource of truth — Ontology-Based Data Access in the graph's own terms.\n\n```mermaid\nflowchart LR\n    Q([\"kg_edges('company:NVDA')\"]) --> U{\"union traversal\"}\n    STORE[(\"event-sourced graph<br/>curated, logged edges\")] -->|\"stored edges\"| U\n    U -->|\"for each _VirtualEdge binding\"| R[\"resolver<br/>runs bound SQL, ? = 'NVDA'\"]\n    R -.->|\"parameterized query\"| EXT[(\"operational store<br/>system-of-record\")]\n    EXT -.->|\"rows → synthesized edges<br/>_virtual: true\"| R\n    R --> U\n    U --> OUT[/\"merged edge list<br/>stored + live\"/]\n```\n\n```python\n# Bind CO_HELD_WITH to a query over the operational store (here, any DB-API source)\nkg_virtual_edge_add(\n    edge_type=\"CO_HELD_WITH\",\n    query=\"SELECT b AS to_id, shared FROM co_held WHERE a = ?\",  # '?' sqlite, '%s' postgres\n    dsn_env=\"OUROBOROS_DSN\",          # credentials by reference — never in the graph\n    source=\"id_slug\",                  # company:NVDA -> bind \"NVDA\"\n    target_id_template=\"company:{value}\",\n    prop_cols=[\"shared\"], directions=\"both\", ontology=\"market\",\n)\n\n# now ordinary traversal unions stored + virtual edges; virtual ones carry _virtual:true\nkg_edges(\"company:NVDA\", direction=\"out\", ontology=\"market\")\n# -> [{to: \"company:AMD\", type: \"CO_HELD_WITH\", properties: {shared: 12, _virtual: true}, …}]\n```\n\nTwo properties keep it safe and simple:\n\n- **Read-only.** Virtual edges are never written, so they sidestep the whole\n  gated-write / event-log / compensation machinery. A binding is config; the\n  edges are a view. Nothing to invalidate, replay, or undo.\n- **Parameterized, never interpolated.** The SQL template is operator-authored;\n  the per-node value is always *bound* through the driver, never formatted into\n  the string. Credentials ride `dsn_env` (an env-var name), so secrets stay out\n  of the graph and out of version control.\n\nBindings live as reserved-kind (`_VirtualEdge`) nodes *in the ontology*, so they\ntravel with it and version alongside the schema. This is the seam for a\nschema-graph / data-graph split: keep the curated **schema** (types, contracts,\ndoctrine) in a portable SQLite ontology, and **virtualize the populated extension**\nstraight out of your system-of-record — no ETL, no drift.\n\n```mermaid\nflowchart TB\n    subgraph SG[\"schema graph — portable SQLite ontology\"]\n        T[\"types · contracts · doctrine\"]\n        B[\"_VirtualEdge bindings<br/>(edge TYPE + SQL + dsn_env)\"]\n    end\n    subgraph DG[\"data graph — virtualized extension\"]\n        E[\"live edges, resolved on traversal\"]\n    end\n    SOR[(\"system-of-record<br/>operational store\")]\n    B -.->|\"resolves against\"| SOR\n    SOR -.->|\"synthesizes\"| E\n    T --- E\n```\n\n### Iceberg sources: virtualize the lakehouse\n\nThe system-of-record isn't always an operational database. A lot of\nmachine-generated relationship data lands in a **lakehouse** — Apache Iceberg\ntables in object storage, versioned by snapshot and governed by a catalog. A\nvirtual edge can resolve straight out of Iceberg, with **DuckDB** as the scan\nengine, by setting `source_type=\"iceberg\"`:\n\n```python\nkg_virtual_edge_add(\n    edge_type=\"CO_HELD_WITH\",\n    query=\"SELECT b AS to_id, shared FROM co_held WHERE a = ?\",  # '?' — DuckDB binds it\n    source_type=\"iceberg\",\n    catalog={                          # pyiceberg catalog props; any value as\n        \"name\": \"lake\",                #   \"env:VAR\" is resolved from the env, so\n        \"type\": \"rest\",                #   tokens/keys never sit in the graph\n        \"uri\":  \"env:ICEBERG_REST_URI\",\n        \"warehouse\": \"s3://lake/wh\",\n    },\n    table=\"analytics.co_held\",         # namespace.table — query uses the leaf name\n    # snapshot_id=...,                  # optional: pin a version for time-travel\n    source=\"id_slug\", target_id_template=\"company:{value}\",\n    prop_cols=[\"shared\"], directions=\"both\", ontology=\"market\",\n)\n```\n\nThe split mirrors Iceberg's own architecture: **pyiceberg owns identity and\nversioning** — it loads the catalog, maps `namespace.table` to the current\nmetadata pointer (or a pinned `snapshot_id`), the layer a schema graph actually\ncares about; **DuckDB owns the scan** — it reads the resolved table (format\nversion 2 today; newer versions as the extension gains them) and answers the\nbinding's parameterized query. The table is exposed as a named DuckDB view, so\nthe query is written against an ordinary table name — *identical* to the\nSQLite/Postgres case, and the resolver's bind path is unchanged. Install with the\n`iceberg` extra (`pip install 'knowledge-graph-rdbms[iceberg]'`).\n\nAny catalog pyiceberg speaks works — swap the `catalog` dict:\n\n| Catalog | `catalog` config |\n| --- | --- |\n| **AWS Glue** | `{\"type\": \"glue\"}` (region/creds from the AWS chain) |\n| **S3 Tables** (managed Iceberg) | `{\"type\": \"rest\", \"uri\": \"https://s3tables.<region>.amazonaws.com/iceberg\", \"warehouse\": \"<table-bucket-arn>\", \"rest.sigv4-enabled\": \"true\", \"rest.signing-name\": \"s3tables\", \"rest.signing-region\": \"<region>\"}` |\n| **Local / SQL catalog** | `{\"type\": \"sql\", \"uri\": \"sqlite:///…\", \"warehouse\": \"file://…\"}` |\n\nWhen the resolved metadata lives on `s3://` (S3 Tables, Glue, or a plain S3\nlake), the opener loads DuckDB's `httpfs`/`aws` extensions and a credential-chain\nsecret automatically — DuckDB reads the managed storage with the host's AWS\nidentity, sigv4 included. This is proven live against AWS S3 Tables in\n`tests/test_iceberg.py::test_s3tables_live_resolve` (opt-in via\n`KG_ICEBERG_S3TABLES_ARN`).\n\nTools: `kg_virtual_edge_add`, `kg_virtual_edges_list`, `kg_virtual_edge_remove`.\n\n---\n\n## The safety gate: invariants vs. policy\n\nWhen you expose the graph for live mutation — especially to an AI agent over\nMCP — \"who is allowed to change what\" becomes a real question. The answer here\nis two layers, and the order matters.\n\n```mermaid\nflowchart LR\n    REQ([\"mutation request\"]) --> INV{\"invariants.enforce<br/>compiled-in rule violated?\"}\n    INV -->|yes| E1[/\"InvariantViolation<br/>(cannot be configured away)\"/]\n    INV -->|no| POL{\"policy.mutation_check<br/>configured rule denies?\"}\n    POL -->|yes| E2[/\"PermissionError\"/]\n    POL -->|no| APPLY[\"apply to graph\"]\n    APPLY --> REC[(\"record reversible event\")]\n```\n\n- **`invariants.py` is mechanism.** Rules here are enforced in code, ahead of\n  policy, and cannot be turned off by configuration or talked around over the\n  wire. Changing one is a code change and a redeploy. The default enforces\n  nothing — invariants are inherently domain-specific.\n- **`policy.py` is configuration.** A single `mutation_check(ctx) -> Decision`\n  function. The default is permissive (everything allowed). Edit it to seal the\n  parts that must not change. Five to ten lines is usually enough.\n\nInvariants run **first**, so a permissive (or compromised) policy can never\nre-open something an invariant has sealed. That's the whole reason to separate\nthem.\n\n```python\n# policy.py — append-only example: callers may add, never delete or modify\ndef mutation_check(ctx):\n    if ctx.operation in {\"node_delete\", \"edge_remove\", \"graph_clear\"}:\n        return Decision.deny(\"policy is append-only; no deletions\")\n    return Decision.allow()\n```\n\n---\n\n## Install\n\n```bash\npip install knowledge-graph-rdbms            # core library + the kg CLI\npip install \"knowledge-graph-rdbms[mcp]\"     # + the MCP server\n```\n\nOr, to get the `kg` / `kgrdbms-mcp` commands on your PATH globally\n([uv](https://docs.astral.sh/uv/) or [pipx](https://pipx.pypa.io/)):\n\n```bash\nuv tool install \"knowledge-graph-rdbms[mcp]\"\n# or, from a local checkout, editable:\nuv tool install --editable \"/path/to/knowledge-graph-rdbms[mcp]\"\n```\n\nStorage defaults to `~/.kgrdbms/graph.db`. Override with the `KGRDBMS_HOME`\nenvironment variable, or per-command with `kg --db PATH`, or in code with\n`Graph(path=...)`.\n\n---\n\n## Quickstart\n\n### As a library\n\n```python\nfrom kgrdbms import Graph\n\nwith Graph(path=\"demo.db\") as g:\n    g.add_node(\"person:ada\", kind=\"Person\", name=\"Ada Lovelace\", labels={\"Person\"})\n    g.add_node(\"field:cs\", kind=\"Field\", name=\"Computer Science\")\n    g.add_edge(\"person:ada\", \"field:cs\", \"FOUNDED\", properties={\"year\": 1843})\n\n    for edge, target in g.out(\"person:ada\"):\n        print(edge.type, \"->\", target.name)\n    print(g.shortest_path(\"person:ada\", \"field:cs\"))\n```\n\n**Bulk loading** — every single write commits on its own (one fsync each), so\nfor bulk work opt into one transaction and go ~10× faster:\n\n```python\n# fastest: executemany under a single commit\ng.add_nodes([\n    {\"id\": \"person:ada\", \"kind\": \"Person\", \"name\": \"Ada\", \"labels\": [\"Person\"]},\n    {\"id\": \"field:cs\", \"kind\": \"Field\", \"name\": \"Computer Science\"},\n])\ng.add_edges([(\"person:ada\", \"field:cs\", \"FOUNDED\")])  # dicts / Edge objects work too\n\n# or batch() — defer commits for any mix of writes, atomic rollback on error\nwith g.batch():\n    for spec in many_specs:\n        g.add_node(**spec)\n```\n\n### As a CLI\n\nThe `kg` command ships with the core install (stdlib `argparse`, no extra\ndeps). Reads hit the graph directly; **writes go through the same gate + event\nlog as the MCP server**, so `kg replay` / `kg revert` work and a custom policy\nis honored at the console too.\n\n```bash\nkg node add person:ada --kind Person --name \"Ada Lovelace\" \\\n    --label Person --prop born=1815 --prop fields='[\"math\",\"cs\"]'\nkg node add field:cs --kind Field --name \"Computer Science\"\nkg edge add person:ada field:cs FOUNDED --prop year=1843\n\nkg out person:ada                 # outbound edges\nkg path person:ada field:cs       # shortest path\nkg nodes-by-kind Person\nkg stats\nkg schema                         # observed vocabulary — kinds, edge types, labels, keys-per-kind\nkg schema --samples               # + example ids and enum-like property values per kind\nkg --json node get person:ada     # machine-readable output for piping\n\nkg events -n 10                   # tail the event log\nkg revert <event-id>              # undo a mutation (compensating event)\nkg replay                         # rebuild the projection from the log\n\nkg import graph.json              # bulk {nodes, edges} import (gated + logged)\n\nkg ontology create coffee --stance inferential   # register a named ontology\nkg ontology list                  # the registry (database of databases)\nkg --ontology coffee node add drink:latte --kind Drink   # route to it\nkg serve                          # launch the MCP server (needs [mcp])\n```\n\n`--prop key=value` values are parsed as JSON when possible (`born=1815` → int,\n`ok=true` → bool, `tags='[\"a\"]'` → list) and kept as a plain string otherwise.\nTarget a named ontology with `--ontology NAME` (routed through the resolver, default:\nthe default ontology); `--db PATH` is the raw escape hatch onto one exact file.\nExit codes: `0` ok · `1` not found / bad input · `2` policy denial · `3`\ninvariant violation.\n\n### As an MCP server\n\n```bash\npip install \"knowledge-graph-rdbms[mcp]\"\nclaude mcp add kgrdbms -- kgrdbms-mcp          # register with Claude Code\n```\n\nOr hand-edit a client config (e.g. Claude Desktop):\n\n```json\n{ \"mcpServers\": { \"kgrdbms\": { \"command\": \"kgrdbms-mcp\" } } }\n```\n\nIt exposes `kg_`-prefixed tools over one engine. Reads — `kg_schema` (the\nvocabulary, meant to be called first), `kg_node_get`, `kg_find` (by kind and/or\nlabel), `kg_edges`, `kg_neighborhood`, `kg_shortest_path`, `kg_descendants` — each\ntake an optional `ontologies=[...]` to fan out across many ontologies in one\ncall. Then gated writes (`kg_node_upsert`, `kg_edge_add`, `kg_edge_remove`,\n`kg_node_delete`), bulk composition (`kg_import` — a whole `{nodes, edges}` batch in\none call, so an agent populates an ontology in a single tool call instead of dozens),\nthe cross-ontology backbone (`kg_link`, `kg_links_of`, `kg_identity`, `kg_prefix_add`,\n`kg_prefix_resolve`), RDF interop (`kg_rdf_export`, `kg_rdf_import` — see below), and\nthe event log (`kg_events_tail`, `kg_event_revert`, `kg_replay`). Every write passes\nthe invariants + policy gate and is recorded — same engine, same file as the CLI.\nEvery tool takes an optional `ontology` name, and `kg_ontologies_list` /\n`kg_ontology_create` / `kg_ontology_delete` manage the registry — so an agent can\ndiscover, create, route between, and delete ontologies entirely over MCP.\n\n#### Serving remotely (authenticated)\n\nstdio is a private pipe to one local client. To reach the *same* graph from other\nmachines — a second laptop, a phone, agents on a private mesh like\n[Tailscale](https://tailscale.com) — serve it over HTTP instead. It's the same\nengine and the same file; only the front-door transport changes, and it's purely\nadditive: with no flags `kg serve` is still stdio, byte-for-byte.\n\n```bash\n# bind an HTTP transport; require a bearer token on every request\nexport KGRDBMS_MCP_TOKEN=\"$(python -c 'import secrets; print(secrets.token_urlsafe(32))')\"\nkg serve --transport streamable-http --host 0.0.0.0 --port 8970\n```\n\n- `--host` / `--port` choose the bind address (defaults `127.0.0.1:8000` —\n  localhost-only; widening the bind is always a deliberate opt-in).\n- `--allow-host HOST` declares the `Host` header values clients connect by. The\n  MCP transport has DNS-rebinding protection on by default, so once you bind off\n  localhost you must list the hostname/IP clients use (the bind host and\n  localhost are always allowed). Repeatable; the `host:*` form matches any port;\n  the sentinel `--allow-host '*'` turns Host checking off when a fronting proxy\n  already validates it. Example: `--allow-host graph.example.com:*`.\n- When `KGRDBMS_MCP_TOKEN` is set, the HTTP transports (`streamable-http`, `sse`)\n  reject any request without a matching `Authorization: Bearer <token>` header\n  (constant-time compared). The token is read from the environment — never a flag,\n  so it stays out of your shell history and process list. stdio ignores it.\n- **Zero new dependencies.** The check is a small ASGI wrapper using only the\n  stdlib (`hmac`) over the `uvicorn`/`starlette` that the `[mcp]` extra already\n  installs. Auth is on the connection; the [invariants + policy\n  gate](#the-safety-gate-invariants-vs-policy) still governs *what* a connected\n  client may change.\n\nPoint a client at the URL — Claude Code speaks remote HTTP MCP natively:\n\n```bash\nclaude mcp add --transport http kgrdbms https://your-host:8970/mcp \\\n  --header \"Authorization: Bearer $KGRDBMS_MCP_TOKEN\"\n```\n\n```json\n{ \"mcpServers\": { \"kgrdbms\": {\n    \"type\": \"http\",\n    \"url\": \"https://your-host:8970/mcp\",\n    \"headers\": { \"Authorization\": \"Bearer ${KGRDBMS_MCP_TOKEN}\" }\n} } }\n```\n\nThe token authenticates the connection but doesn't encrypt it. Run it over a\nnetwork that provides transport security (a WireGuard/Tailscale mesh, or a TLS\nreverse proxy) rather than exposing a plain `http://` port to the open internet.\n\n---\n\n## Performance\n\nAll figures come from `bench/benchmark.py`, which reports full distributions\n(p50–p99), not single shots — and the charts below are rendered straight from\nthat data by `bench/charts.py`. Run both on your own machine in one command.\n(Shown: Apple Silicon, CPython 3.14, SQLite 3.50 — illustrative, not a promise.)\n\n| Operation                       | Throughput     |\n| ------------------------------- | -------------- |\n| `node(id)` point lookup         | ~120,000 / s   |\n| `add_node` (per-call, durable)  | ~17,000 / s    |\n| `add_node` inside `batch()`     | ~157,000 / s   |\n| `add_nodes([...])` bulk         | ~189,000 / s   |\n| `replay()` (events/sec)         | ~26,000 / s    |\n\n### Writes — the batching lever\n\n![Write throughput — batch the commit, ~10× faster](https://raw.githubusercontent.com/cunicopia-dev/knowledge-graph-rdbms/main/assets/write_throughput.png)\n\nEach single write commits on its own for durability. Wrapping a bulk load in\n`batch()` / `add_nodes` / `add_edges` collapses those per-call commits into one\ntransaction for an ~10× jump — same engine, you just tell it a batch is coming.\nThe gated + logged path (what the CLI and MCP server use) adds the\ninvariants+policy check and an event record per write, and still clears tens of\nthousands per second.\n\n### Reads — fast, with an honest tail\n\n![Read latency — p50 marker, whisker to p99, log scale](https://raw.githubusercontent.com/cunicopia-dev/knowledge-graph-rdbms/main/assets/read_latency.png)\n\nPoint lookups land in single-digit microseconds, and multi-node reads hydrate\nthe whole result set in a constant number of queries (no N+1 fan-out). The chart\nplots p50 → p99 on purpose: randomized `shortest_path` endpoints make some walks\nshort and some span the whole chain, and an average would bury that tail.\n\n### A note on the runtime\n\nkgrdbms is Python, and for performance that's a deliberate non-issue: the same\nSQLite engine runs under CPython, Node, and Bun, so the gap between them is pure\nbinding overhead — under 2×, and it doesn't even favor one runtime across\noperations.\n\n![Same SQLite across CPython, Node, and Bun](https://raw.githubusercontent.com/cunicopia-dev/knowledge-graph-rdbms/main/assets/runtimes.png)\n\nThe lever that actually moved the needle was transaction batching (~10×, above),\nnot the language. Reproduce it with `python bench/runtimes/compare.py`.\n\n### Where the curve bends\n\nWe measured it against Neo4j — same graph, same queries, identical methodology\n(full harness and reproduction in [`bench/neo4j/`](bench/neo4j/README.md)):\n\n![Where the crossover is — kgrdbms vs Neo4j](https://raw.githubusercontent.com/cunicopia-dev/knowledge-graph-rdbms/main/assets/crossover.png)\n\nQueries compile to SQL over B-tree indexes, so each traversal hop is an index\nlookup — wonderfully cheap for point reads and shallow traversals. An in-process\nlookup here is ~7µs, while the *same* query to Neo4j pays a Bolt round-trip\n(~0.4ms) before it even touches data. So for the small, frequent operations that\nare the bread and butter of agent memory, the embedded graph wins by **30–60×**.\n\nA purpose-built engine pulls ahead exactly where the *workload* — not the row\ncount — turns deep:\n\n- **Deep, high-fan-out traversal.** Index-free adjacency follows direct pointers\n  between nodes. A 1,000-deep walk costs kgrdbms ~52ms (recursive CTE + row\n  hydration) but ~0.7ms for Neo4j, which pointer-chases under its own round-trip\n  budget — a **76× swing the other way.**\n- **Complex pattern matching.** A Cypher planner optimizes multi-pattern queries\n  in ways a fixed traversal API doesn't attempt.\n- **Concurrent writers and scale-out.** Single-file SQLite is one writer at a\n  time; clustered engines aren't.\n\nRule of thumb: read-heavy and shallow up to low millions of nodes is firmly home\nturf; deep-traversal or pattern-heavy work is where a dedicated engine earns its\ncomplexity. The crossover is workload-shaped, not a single magic number — so we\nmeasured ours, and you can [measure yours](bench/neo4j/README.md).\n\n### SQLite vs the live Postgres engine\n\nBecause `postgres` is a live backend, you can run the *same* op suite against\nboth engines and watch the round-trip tax directly\n([`bench/postgres/`](bench/postgres/README.md)). Embedded SQLite wins the small,\nfrequent ops by 30–60× — a point lookup is in-process, the Postgres one pays a\nlocalhost round-trip. The exception is the one deep traversal that runs as a\nsingle server-side query: the recursive-CTE `descendants` is where Postgres pulls\n*ahead* (~0.5×), while the per-hop-BFS `shortest_path` over the same chain is 67×\nslower — identical traversal, opposite verdict, decided entirely by how many\ntimes the work crosses the wire. Postgres earns its place on *concurrency and\nscale*, not single-thread latency; the control plane lets you escalate one\nontology to it while the hot, shallow ones stay embedded.\n\n---\n\n## RDF interop: export, SPARQL, RDF-star\n\nThe store stays a label property graph. RDF is a **boundary** format here, not a\nstorage model — there is no triplestore, no OWL, no embedded SPARQL engine. The\nproject adopts exactly one RDF idea, because it is the only one expensive to\nretrofit: **stable identity** (CURIE node ids). This is where a CURIE finally\nexpands into a real IRI.\n\n```bash\nkg rdf export                         # Turtle, to stdout (RDF-star edges)\nkg rdf export --format ntriples --out graph.nt\nkg rdf import graph.nt                 # back in — gated, logged, replayable\n```\n\nExport is **dependency-free**. Node ids round-trip as CURIEs, because the prefix\nbinding makes the IRI collapse back to exactly what you stored:\n\n```turtle\n@prefix person: <https://kg.local/person/> .\n@prefix kg:     <https://kg.local/vocab#> .\n\nperson:ada a kg:Person ;\n    kg:name \"Ada Lovelace\" ;\n    prop:born \"1815\"^^xsd:integer ;\n    rel:influences person:grace .\n\n<< person:ada rel:influences person:grace >> prop:since \"2020\"^^xsd:integer .\n```\n\nThat last line is the interesting one. A plain triple has nowhere to hang an\n**edge's** properties; `--edge-strategy` decides how they cross:\n\n| strategy           | edge `{since: 2020}` becomes                          | when to use                            |\n| ------------------ | ----------------------------------------------------- | -------------------------------------- |\n| `rdf-star` (default) | `<< :ada :influences :grace >> :since 2020`         | star-aware stores (Stardog, Jena 4.3+, Oxigraph) |\n| `reification`      | an `rdf:Statement` node carrying s/p/o + each property | rdflib and any RDF 1.1 tool            |\n| `lossy`            | the bare triple; properties dropped (count reported)  | you only want topology                 |\n\n**SPARQL?** Yes — against the export, in any store. You don't embed a query\nengine (that would betray the zero-dependency, no-SPARQL design); you emit a\ngraph a real engine can query:\n\n```python\nimport rdflib\nfrom kgrdbms import rdf\n\n# rdflib is RDF 1.1 — no star — so emit reification for it:\nttl = rdf.export(graph, \"turtle\", rdf.IriContext(edge_strategy=\"reification\"))\nstore = rdflib.Graph(); store.parse(data=ttl, format=\"turtle\")\nstore.query(\"SELECT ?s ?o WHERE { ?s <https://kg.local/rel/influences> ?o }\")\n```\n\nFor SPARQL-**star** over the `rdf-star` export, hand the Turtle to a star-native\nstore (Stardog, Jena, Oxigraph, GraphDB). Turtle/foreign-RDF *import* needs the\noptional extra (`pip install \"knowledge-graph-rdbms[rdf]\"`, which pulls\n`rdflib`); N-Triples import and **all** export stay dependency-free. Imported RDF\nrides the same gated, logged path as every other write, so it is audited and\nreplayable.\n\n---\n\n## Command reference\n\n| Command                         | What it does                                  |\n| ------------------------------- | --------------------------------------------- |\n| `kg stats`                      | node/edge counts and db path                  |\n| `kg schema [--samples]`         | observed vocabulary: kinds, edge types, labels, keys-per-kind |\n| `kg node add ID --kind K …`     | create or update a node (gated + logged)      |\n| `kg node get ID`                | fetch a node                                  |\n| `kg node del ID`                | delete a node (cascades edges)                |\n| `kg node set-prop ID KEY VAL`   | set one property                              |\n| `kg node add-label ID LABEL`    | add a label                                   |\n| `kg edge add FROM TO TYPE`      | add an edge                                   |\n| `kg edge rm FROM TO TYPE`       | remove an edge                                |\n| `kg nodes-by-kind KIND`         | list nodes of a kind                          |\n| `kg nodes-by-label LABEL`       | list nodes with a label                       |\n| `kg out ID [--type T]`          | outbound edges                                |\n| `kg in ID [--type T]`           | inbound edges                                 |\n| `kg path FROM TO`               | shortest undirected path                      |\n| `kg neighbors ID [--depth N]`   | nodes within N hops                           |\n| `kg descendants ID TYPE`        | nodes reachable along one edge type           |\n| `kg events [-n N]`              | tail the event log                            |\n| `kg revert EVENT_ID`            | undo an event (compensating event)            |\n| `kg replay [--upto TS]`         | rebuild the projection from the log           |\n| `kg import FILE`                | bulk `{nodes, edges}` import (gated + logged) |\n| `kg rdf export [--format F]`    | serialize to Turtle/N-Triples (RDF-star)      |\n| `kg rdf import FILE`            | load RDF back in (gated + logged)             |\n| `kg ontology list`              | list registered ontologies (the registry)     |\n| `kg ontology create NAME …`     | register an ontology (`--backend`, `--stance`, `--shared-identity`) |\n| `kg ontology delete NAME [--purge]` | deregister an ontology (`--purge` also deletes its data) |\n| `kg fed schema [--samples]`     | union vocabulary across ALL ontologies (multithreaded fan-out) |\n| `kg fed stats`                  | node/edge totals across the federation        |\n| `kg fed nodes-by-kind KIND` / `nodes-by-label LABEL` | nodes across ontologies, tagged by source |\n| `kg fed node ID`                | find an id across the federation (identity-aware) |\n| `kg link add FROM_ONT FROM TYPE TO_ONT TO` | cross-ontology edge (the backbone) |\n| `kg link same-as A FROM B TO`   | assert two nodes are the same entity (symmetric) |\n| `kg link of ONT ID`             | cross-ontology links touching a node          |\n| `kg link cluster ONT ID`        | the transitive SAME_AS identity cluster       |\n| `kg prefix add P IRI_BASE`      | bind a CURIE prefix to an IRI base            |\n| `kg prefix expand CURIE` / `contract IRI` | CURIE ↔ IRI via the registry        |\n| `kg serve [--transport T]`      | run the MCP server                            |\n\nAdd `--json` to any command for machine-readable output. Target a graph with\n`--ontology NAME` (routed through the resolver; default: the default ontology)\nor `--db PATH` (the raw escape hatch onto one exact file, registry bypassed).\n\n---\n\n## Project layout\n\n```\nkgrdbms/\n├── graph.py        # the label property graph over SQLite (no internal deps)\n├── events.py       # append-only event log: record, compensate, replay\n├── policy.py       # configurable mutation policy (permissive by default)\n├── invariants.py   # compiled-in invariants, checked before policy (no-op default)\n├── service.py      # the shared gated + logged write path\n├── resolver.py     # control plane: ontology name → (backend, events, entry) + the index\n├── federation.py   # cross-ontology reads: multithreaded fan-out, identity-aware merge\n├── backbone.py     # cross-ontology links + prefix/IRI registry (lives in the index graph)\n├── backends/       # pluggable engine registry\n│   ├── base.py     #   GraphBackend protocol + raising stub skeleton\n│   ├── sqlite.py   #   live engine (adapter over Graph)\n│   ├── postgres.py #   live engine (psycopg; jsonb + recursive CTEs); [postgres] extra\n│   └── neo4j.py    #   stub (deep-traversal escalation)\n├── rdf.py          # RDF boundary: Turtle/N-Triples export + import (RDF-star); rdflib only for [rdf] import\n├── cli.py          # the `kg` command (stdlib argparse)\n└── mcp_server.py   # the MCP server (optional [mcp] extra)\n```\n\n`graph.py` imports nothing internal — it's a usable, dependency-free LPG on its\nown. Everything else layers on top; `service.py` depends only on the\n`GraphBackend` protocol, never a concrete engine.\n\n---\n\n## Development\n\n```bash\ngit clone <repo> && cd knowledge-graph-rdbms\nuv venv && uv pip install -e \".[dev]\"\npytest                       # 107 tests\npython bench/benchmark.py    # benchmark with p50–p99 (see bench/README.md)\n```\n\n---\n\n## License\n\nMIT.\n",
  "bytes": 50282,
  "sha": "4635e1f97400acc1ee9aa9f853d717795166e472e06e5b37f28c2c78db1bce04",
  "repo_slug": "cunicopia-dev/knowledge-graph-rdbms",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_cunicopia_dev_knowledge_graph__c3790598/readme"
}