PR Lens

Diagrams

Why the model should describe the graph and never draw it

An LLM asked for a picture gives you a picture that moves every time you ask. Have it write a typed graph document instead, and let a deterministic renderer draw it.

The smallest document that validates, drawn. One lane, one node, one delta.

Ask a language model for a diagram and you get a picture. Ask it for the same diagram tomorrow and you get a different picture of the same system, with the boxes somewhere else. That is fine for a one-off illustration and useless for a pull request, where the diagram is redrawn on every push and a reader is meant to recognise it from last time. So PR Lens splits the job. The model writes a strongly typed JSON document that says what the system is. A renderer with one dependency turns that document into an SVG. The model never chooses a coordinate.

LinearB's 2026 benchmarks, from 8.1 million pull requests across more than 4,800 organisations, found AI-generated pull requests wait 4.6 times longer for a reviewer to pick them up. The picture is meant to shorten that wait, which only works if a reader can trust it on the second push as much as the first.

What the model actually writes

Here is the smallest document that validates, which is what the render at the top of this post draws:

{
  "schemaVersion": "0.1.1",
  "kind": "graph",
  "title": "Touch the health check",
  "lenses": ["architecture"],
  "provenance": {
    "repo": { "owner": "coldteadotai", "name": "pr-lens" },
    "base": { "sha": "1111111" },
    "head": { "sha": "2222222" }
  },
  "lanes": [{ "id": "api", "label": "API" }],
  "nodes": [
    {
      "id": "health-route",
      "label": "GET /health",
      "kind": "route",
      "delta": "modified",
      "lane": "api",
      "files": [{ "path": "src/routes/health.ts" }]
    }
  ]
}

Read it for what is missing. No width, no height, no x, no y. No colour, no font, no arrow style, no ordering hint that is binding. The document is a claim about a codebase: there is a lane called API, it holds a route called GET /health, the change modified it, and the route is backed by src/routes/health.ts.

A real document adds edges between nodes, ordered flow steps for the data-flow lens, a nested tree of drill-down views, and an optional walkthrough. It still says nothing about where anything goes. There is a layout block, and even that is only a hint: the renderer treats a rank as a floor rather than an answer, so a stale hint can push a node further down and can never lift one above what feeds it.

The model says what the system is, and never says where anything goes.

The schema is the contract, and it is strict

The document is parsed against a zod schema before anything draws it. Parsing runs four checks in one pass and reports every problem it finds rather than stopping at the first:

  1. Structure: types, lengths, enums, no unknown keys, and file paths that can become a diff permalink, so repository-relative, POSIX, no .. segment.
  2. Contract version, so a document written for a version the parser does not implement is refused rather than half understood.
  3. Referential integrity: every node sits in a declared lane, every edge joins declared nodes, every flow step runs between declared participants, every drill-down view names elements that exist.
  4. That a render could describe the document at all.

Failures come back as a typed error with a machine-readable code (INVALID_DOCUMENT, BROKEN_REFERENCE, DUPLICATE_ID, UNSUPPORTED_SCHEMA_VERSION) and an array of issues carrying a path and a message each. That shape is what closes the loop without a human in it: a coding agent that wrote a bad document gets a list of paths into its own output and fixes them.

The promise the package exists to make is one line: if it parses, it renders. Everything strict about the schema is in service of that, including the parts that look fussy. A view tree is capped at 128 views because a render is one asset per view per theme and the manifest budget is 256; a document carrying more views than a manifest could describe is rejected at the contract rather than left for the renderer to discover.

The strictness cuts the other way too. There is no findings field and no security lens in the contract, so a model cannot decide to put a bug report in the diagram. PR Lens is the comprehension layer, and a document that arrives carrying findings is rejected rather than quietly trimmed. Whatever the model was going to editorialise about, the schema has nowhere for it to go.

There is a field-by-field tour of the document in the PR Lens graph document explained.

The renderer has one dependency

@coldtea/pr-lens-renderer depends on @coldtea/pr-lens-schema and nothing else. No layout engine, no headless browser, no runtime of its own. It reads no network, no filesystem and no clock.

Every dependency a renderer takes is a chance for the same document to draw differently on two machines, and the whole design rests on that never happening. A layout engine with its own version drift would undo the rest of this post.

What deterministic means here

The hard requirement is that a diagram does not rearrange itself between two pushes that barely changed anything. Five rules get it there.

Text is measured against an embedded table rather than a font engine, so a CI runner with no fonts installed lays out identically to a laptop with all of them. Every coordinate is rounded before it is written. Every comparison is by code unit rather than localeCompare, because a sort that consults the host's collation data changes the bytes. Depth is a longest path from a source, and a cycle is ranked by dropping the edge that closes it, chosen by a walk in document order rather than by map iteration order.

And every lane is the same fixed width, a constant rather than anything derived from what the lanes hold. A width drawn from content couples every column to the ones before it, so one long node name in the first lane would slide every card in every later lane sideways. The cost is that a lane holding something narrow is wider than it needs to be. That trade is worth making: adding a node to one lane leaves every card in every other lane exactly where it was.

There are tests for that, and golden SVGs for the reference documents. A change to a golden is a change to what a reviewer sees, so a person reads the diff before it is committed.

Why it is worth this much trouble

Two things fall out of determinism that are hard to get any other way.

The first is addressing. A rendered SVG is stored at a key derived from the hash of its own bytes, because GitHub's image proxy caches a URL more or less forever: a changed diagram has to arrive as a new URL rather than as new bytes at the old one. Content addressing only works if the same document reliably produces the same bytes. Animated SVG in GitHub comments goes through the proxy's behaviour in detail.

The second is recognition. A reviewer who looked at the diagram yesterday should find the queue in the same place today. When the layout is a function of the document, the only things that move are the things that changed, which is the entire point of drawing the change.

What the model is genuinely good at

Writing the document is the hard half, and it is judgement work: deciding that the lanes are a runtime boundary rather than the folder tree, that this one edge is what the change is really about, that a helper is worth a card and three others are not, that an untouched store belongs in the picture as context.

Geometry is a different kind of work. It is arithmetic under a pile of constraints, and it wants a function.

The general form of this, for anything you are building: when you want a model to produce a visual, have it produce data with a schema you can check, and write the thing that draws. You get a diagram you can diff, cache, test with goldens and regenerate on every push. How to visualise a pull request as a diagram ranks the routes that skip this step, and what they cost you when the branch moves.

Questions people ask

Should an LLM generate diagrams directly?

Ask it for structured data, not for a picture. A model is good at naming the parts of a system and saying how they connect, and unreliable at coordinates, collision avoidance and consistency between two runs. Have it emit a typed document, validate that document, and let a renderer own the geometry.

Why not just ask the model for Mermaid?

Mermaid is a drawing instruction, so the model is still choosing the picture, and its layout engine is free to rearrange between renders. It is a good fit for documentation someone writes once. For a diagram redrawn on every push, you want a document the model cannot express layout in at all.

What is in a PR Lens graph document?

A kind, a schema version, the lenses to draw, the lanes of the diagram, and the nodes, edges and ordered flow steps that sit in those lanes. Every node, edge and step carries a delta of added, modified, removed or unchanged. Nothing in the document says where anything goes on the page.

How do you stop the model from writing an invalid document?

You validate and hand the failures back. Parsing runs structure, contract version, referential integrity and renderability in one pass and reports every problem it finds with a path into the document, so a producer gets the whole list at once rather than one error per attempt.

Does this make the diagram deterministic?

The drawing is deterministic: the same document renders to the same bytes on any machine, with no network, no filesystem and no clock. The document itself comes from a model, so two runs on the same diff can name things slightly differently. The renderer's job is to make sure that is the only thing that can vary.

Sources

Keep reading

Turn it on

Install the App and every pull request in the repositories you pick gets drawn. Or hand the prompt to the agent you already have open.

Add to GitHub