Lossless Changelog
product

The Graphiti Graph Gets a Face — and a Way to Build It for Free

We built a viz for the knowledge graph, lost it to a cleaned temp folder, and rebuilt it properly. Plus: local extraction, debugged.

Michael StatonMusic Editoraugmented with Claude Code on Claude Opus 5 (1M context)

The Graphiti Graph Gets a Face — and a Way to Build It for Free

Why Care?

We've been building a temporal knowledge graph over every changelog entry in the Lossless tree — 525 dated ship notes, extracted into entities and relationships, with the bi-temporal wrinkle that a fact can be recorded as true and later learned to have stopped being true. That's the thing a vector index can't do.

But a graph you can't look at is a graph you can't judge. And ours had no face: Graphiti is a Python library, not an application. It ships no UI at all.

We built one. Then we lost it. Then we built it again, properly — and the story of how we lost it is the more useful half.

What's New?

  • scripts/graph-viz.py — one command turns the graph into a single self-contained HTML file. No login, no Cypher, no CDN, no server.

  • scripts/run-ingest-local-safe.py — makes extraction work against a local model, fixing a write-time crash that killed every episode.

  • GRAPHITI_LLM_PROVIDER in scripts/graphiti_clients.py — flip extraction between hosted Claude and a local LM Studio / Ollama endpoint.

  • Measured economics for both paths, so the next person doesn't extrapolate.

  • The exploration doc now carries a source map — every file, what it does.

The part where we lost it

On 2026-08-15 we built a graph visualization, opened it, looked at it together, and moved on. It was assembled as two throwaway heredocs writing into a session scratchpad — a JSON dump and a mkhtml.py.

Five days later: "Can you help me open the Graph for Graphiti?"

The honest answer at the time was that no such thing existed. graphiti-core ships no UI, the compose file defines one service, and nothing in the repo looked like a viz. All true — and all beside the point, because we'd made one and shown it. The scratchpad had been cleaned. The artifact was gone and so was the generator, so there was nothing to re-run.

The root CLAUDE.md already says this about browser drives:

a drive that lives only in a session transcript is not codified

It applies to anything an agent builds. The rebuild is a tracked script for exactly that reason, and the recovery path was to read back the old session transcript to find out what had been built in the first place.

How the viz works

Two Cypher reads, then one string of HTML.

BASH
.venv/bin/python scripts/graph-viz.py                      # top 300 by degree
.venv/bin/python scripts/graph-viz.py --limit 400 --open
scripts/graph-viz.py          ← generator (tracked)
.graphiti-state/graph.html    ← output (gitignored, regenerate freely)

The node query ranks every entity by degree and keeps the top --limit. That ranking is what makes a truncated view honest — it keeps the hubs and drops one-off leaves instead of taking an arbitrary 300. The edge query then pulls only edges among survivors, so nothing dangles.

EncodingMeaning
Node colorRepo / Capability / Convention / Tool / Person
Node radiussqrt(degree) — hubs are visibly bigger
Red dashed edgeinvalid_at IS NOT NULL — a fact that stopped being true
HoverEntity summary, or an edge's fact + validity window
Legend clickFilter a type in or out

The red dashed edges are the whole argument. When the graph held 10 episodes there were 17 of them. It now holds 285 and there are 941. Whether all 941 are correct is still unmeasured — which is precisely why being able to hover one and read its validity window matters.

Two choices worth not re-litigating: the force simulation is ~60 lines of hand-rolled vanilla JS rather than a d3 import, because "self-contained" means the file still works emailed or offline; and --limit defaults to 300 because past ~600 nodes a force layout stops being readable.

Under the Hood: getting a local model to do the extraction

Hosted extraction works and costs $0.759/episode — measured properly, by noting a console balance of $117, running exactly 50 episodes, and reading $79.04 afterward. For the 240 still to go, that's ~$183.

So we tried local. Three things bit, in order:

1. Concurrency overflows the KV cache. SEMAPHORE_LIMIT defaults to 8. Two concurrent 30k-token prompts against a 65536-token window collide and throw exceed_context_size_error. Set it to 1. No real throughput loss — a single-GPU engine serialises anyway.

2. LM Studio rejects response_format: {"type":"json_object"}, demanding json_schema or text. Graphiti sends json_object only when no Pydantic response_model is passed; the normal extraction paths do pass one and take the json_schema branch. A latent failure, not a blanket blocker.

3. Non-primitive attributes fail the write. The real one:

CypherTypeError: Property values can only be of primitive types or arrays
thereof. Encountered: Map{tree_path -> ..., supports -> List{...}, role -> ...}

Our entity types declare a docstring and no fields, so Graphiti's attribute-extraction step is unconstrained — the model returns whatever shape it likes. Neo4j properties take primitives or arrays of primitives, nothing else. Hosted Haiku happened to return flat values across 150 episodes with zero failures. gemma-3-12b-it-qat returns nested objects and died on episode 1.

run-ingest-local-safe.py wraps the ingester and coerces any non-primitive attribute to a JSON string before the write. Flat values pass through untouched, so running Haiku through the wrapper changes nothing.

One detail that is easy to get wrong and silent when you do: it patches the name bound inside graphiti_core.graphiti, not the defining module. graphiti.py:102 does from ...node_operations import extract_attributes_from_nodes at import time, so patching the source module leaves the already-bound reference untouched.

[local-safe] coerced 10 non-primitive attribute(s) to JSON strings
[1/1] 2026-05-19 perplexed: Gemini joins the provider lineup  (335s elapsed)
done — 1 ingested, 0 failed, 335s total

4. Then the fix let it run far enough to hit the next bug. Empty keys and labels:

Neo.ClientError.Schema.TokenNameError: '' is not a valid token name.

Neo4j calls labels, relationship types and property keys tokens, and none may be empty. gemma emits {"": "..."} attributes and blank entity types often enough to cost 2 failures in 12 episodes — 17% attrition. The wrapper now also drops unusable attribute keys (an empty key can't be repaired; the name was the meaning) and strips blank labels, falling back to ["Entity"] so a node is never left label-less and unfindable. 0 failures in the 30 episodes since.

The lesson we keep relearning: a fix that lets the run go further mostly reveals the next bug. Neither failure was visible until the one before it was solved.

And one that had nothing to do with code

The first local run failed every episode on a 600-second engine timeout, which looked like a model problem. It wasn't. The laptop was at 25% battery and falling, with macOS Low Power Mode on for AC as well as battery (pmset -g custom → powermode 1). llama.cpp sat at 13.9% CPU. A prompt that answered in 3 seconds earlier didn't return within 120.

Also worth knowing: this machine sleeps after 1 minute idle even on AC, so caffeinate is mandatory for any unattended run — and caffeinate on a dying battery just means the hard power-off lands mid-write to Neo4j.

The outage that taught us the most

With extraction finally working, we set it running unattended. Hours later it had ingested nothing. Every episode was dying on the same opaque line:

APIConnectionError: Connection error.

We tested LM Studio. It was fine — a 35,206-token prompt came back in 202 seconds while the ingest was failing on that exact workload. So we theorised a poisoned HTTP connection pool in the long-running process, and built a supervisor to recycle it. The fresh process failed nine-for-nine in five minutes.

Ollama was dead. Port closed, no process.

This stack needs two local services — LM Studio for extraction and Ollama for the 384-dim embeddings — and we had spent the entire investigation testing only one of them. Both failures surface as the identical APIConnectionError, because the OpenAI client wraps them the same way.

The tell we misread: failures arrived every ~33 seconds. A slow model or a bad connection pool doesn't fail that fast. Immediate refusal means something isn't listening. We read a 1-second reply from a tiny test prompt as proof the path was healthy, when it only ever proved the server was up — which was never the thing in doubt.

Two guards came out of it, and both survive the incident:

A preflight check (run-ingest-local-safe.py) that pings both endpoints and round-trips a real embedding before the run starts — because a reachable Ollama that has forgotten all-minilm is just as fatal as a dead one. A multi-hour misdiagnosis becomes a one-line startup failure that names the culprit:

preflight FAILED — not starting:
  embedder: unreachable at http://localhost:11434/v1/models (URLError)

A supervisor loop that runs the ingest in 25-episode slices, each a fresh process, detecting a slice that makes no progress and backing off rather than grinding forever. Built for the wrong reason, useful anyway — it now covers this failure mode too, and it recycles cleanly:

supervisor: slice ended 329 -> 354
supervisor: slice starting at 354/525 00:48:29
preflight: embedder OK (all-minilm, 384-dim)

Writing the preflight also caught its own bug before it shipped: the first version referenced os without importing it, so every call raised NameError. Testing it against a live stack, a dead embedder, and a dead LLM took two minutes and prevented exactly the kind of silent stall it exists to prevent.

The economics, honestly

PathRateRemaining ~200
claude-haiku-4-5~46s/episode~$150
gemma-3-12b-it-qat~1,050s/episode$0, ~60 hours

Local gets slower as the graph fills. 831s/episode at 5 done, 1,061s at 30 — dedup prompts carry more of the existing graph as context each time. Haiku shows the same curve, but each extra token costs ~7× less there, so it flattens instead of compounding. Our first smoke test read 335s/episode; the settled rate is triple that. One measurement is not a rate.

The other unpriced cost is consistency. Episodes extracted by different models name and type entities differently, so a half-Haiku, half-gemma graph has a seam down the middle. We can see it in the counts already — Repo entities nearly doubled overnight, to 112, which is more repos than the tree actually has. gemma is likely minting Repo nodes for directories and plugin modules. Whether that seam matters is open, not settled.

The seam is measurable now

We flagged extraction consistency as a theoretical cost of going local. It isn't theoretical any more. Between 285 and 361 episodes — the stretch gemma extracted — the entity counts moved like this:

at 285 episodesat 361 episodes
entities2,4613,273 (+33%)
fact edges4,3904,565 (+4%)
Repo entities112186

The tree has roughly 40 repos. Haiku had already been generous at 64; gemma has tripled it, minting Repo nodes for directories, plugin modules and probably bare file paths. And entities growing 33% while fact edges grow 4% is the signature of a model producing weakly-connected junk rather than real structure.

Cheaper extraction is not free extraction. It bills you in graph quality instead of dollars, and that bill is harder to read.

What's Next

  • Reconcile 9 orphan episodes. The state file tracked 284 while the graph held 293 — leftovers from early local attempts that wrote episodes without recording their hashes. They'll re-ingest as duplicates.

  • communities is 0, and always has been. Community detection is a separate build_communities() pass nothing has ever called. The "which projects cluster together" view doesn't exist yet.

  • Judge the superseded edges. 941 of them, now inspectable by eye. This is open question 2 from the exploration, finally testable.

  • Prune the junk Repo entities, or re-extract that range on Haiku.

  • Move the supervisor into scripts/. It currently lives under the gitignored .graphiti-state/, which is the same "it only exists in a scratchpad" mistake this entry opens with, one directory over.

References

  • [[Graphiti-Over-The-Lossless-Corpus]] — the exploration, now at v0.0.2.0 with a full source map

  • [[Chroma-and-Graphiti-Gotchas]] — the reminders counterpart