PlanLint · Part 1

Building a compliance linter where no LLM decides compliance

PlanLint is an open-source compliance linter that checks architectural drawings against building codes, and refuses to answer what the drawing cannot prove.

When you provide an architectural drawing and a codebook to an AI, and ask questions about compliance, the AI will provide confident answers with plausible reasoning. However, there is a very high probability that the model did not measure CAD geometry or check the schedule, and instead hallucinated the verdict based on its statistical likelihood in architectural training sets.

In architecture, engineering, and construction (AEC), a hallucinated pass is catastrophic. If an automated tool misses a check, a human reviewer can easily catch it during a review. But if the tool falsely marks an asset as COMPLIES on a drawing, human reviewers tend to trust it. The drawing with the hallucinated compliance gets signed, only to find out later that a specific door that the AI marked as compliant cannot pass an ADA inspection.

When I started designing PlanLint, that reality dictated the core invariant of the entire architecture:

LLMs extract structure and labels only. A pure-Python checker is the sole verdict authority. Every single magnitude must be grounded in snapped geometry or printed text before it is permitted to enter a verdict.

In PlanLint no LLM is ever allowed to measure or declare whether something complies. They are only kept for that they are already exceptional at: recognizing entities on a drawing, parsing messy multi-column text, and transforming legal prose into machine-readable logic rules. Every verdict is rendered by deterministic Python arithmetic and committed as an immutable, auditable edge in a Neo4j graph:

(:PhysicalAsset {id: "D2", type: "door"}) -[:VIOLATES { measured: 30.0, required: ">= 32 in", reason: "clear_width is 30.0 inches, code requires 32 inches minimum" }]-> (:Regulation {clause: "ADA 404.2.3"})
Live Capture: PlanLint running over the American Farmhouse set. The vision model locates the doors, their widths are resolved from the door schedule on another sheet, and the pure-Python checker flags a 24-inch opening against ADA 404.2.3 in real time.

The Architecture: Two Inputs, One Graph

Real architectural review bridges two fundamentally disparate worlds. On one side are spatial drawing sets: multi-page PDF sets containing vector primitives, hatch patterns, raster scans, schedules, dimension strings, and cross-sheet callouts. On the other side are semantic codebooks: hierarchical regulations like the International Building Code (IBC) or the ADA Standards for Accessible Design, written in heavily nested legal prose.

PlanLint models this problem as a bipartite graph in Neo4j, orchestrated through a staged pipeline:

Floor Plan PDF ──► Spatial Ingestion ──► (:PhysicalAsset) ─┐ PyMuPDF vector geometry + OpenCV raster masks, │ 4-Stage Verification VLM classifies, schedules & dims measure ├─► 1. Code Hunter (fastembed ONNX + clause ancestry) │ 2. Rule Extractor (Pydantic AI → typed Constraint) Codebook PDF ──► Semantic Ingestion ─► (:Regulation) ───┘ 3. Checker (pure Python, sole verdict authority) Multi-column parser + layout verification, 4. Compiler (idempotent Neo4j MERGE) clause hierarchy, embeddings in Neo4j

The backend is built in FastAPI with Pydantic AI for structured extraction, PyMuPDF for CAD vector extraction, OpenCV for raster analysis, fastembed for local ONNX embeddings, and Neo4j 5 with native vector search. The frontend is a Next.js reviewer streaming progress in real time via Server-Sent Events (SSE).

PlanLint dual-pane reviewer interface showing a door violating ADA 404.2.3 with failing measurement and clause details
Figure 1: The dual-pane review workspace. Selecting a highlighted asset on the plan spotlights its bounding geometry, dims unrelated elements, and scrolls the governing regulation clause into view in the companion pane.

Spatial Ingestion: How Plans Become Grounded Assets

To an image model, a floor plan just looks like a picture. To an architect, it is a complex coordinate system with precise measurements and relationships. Bridging that gap between what a drawing looks like and what the numbers actually measure is the hardest part of the pipeline. To keep those approximations from polluting our data, spatial ingestion runs in a few strictly isolated stages:

1. Sheet-Type Classification

A full architectural set might have 40 sheets: foundation plans, floor plans, reflected ceiling plans (RCP), electrical schematics, exterior elevations, building sections, and door/window schedules. Running an opening detector on an electrical layout or a foundation trench produces nonsensical noise.

On native vector PDFs exported from CAD or Revit, we don't need an LLM to guess what sheet we're looking at, we just look at the typography. Drawing titles are almost always typeset significantly larger than the surrounding room tags and dimension strings. By inspecting the largest text blocks on the sheet, PlanLint identifies the sheet type immediately and routes it down one of four specialized paths:

  • Floor plans (and untitled sheets, which fall through rather than get dropped) go to the plan-view detector that finds openings and room boxes.
  • Elevations and sections go to a separate vertical-dimension detector. A section is read, but for stair riser and tread heights, which only exist in vertical views. Running the plan-view detector on one produces garbage boxes.
  • Schedules define specs, not locations. A schedule row has no location on a plan, so schedule sheets simply build a lookup table mapping each mark to its dimensions (like D101 → 36" × 84") which we then join to physical openings on the plan by their callout tags.
  • Foundation, roof, site, electrical, detail and cover sheets are recorded but not measured in the current pipeline.

For flattened raster plans where no text layer exists, classification falls back to a visual layout classifier or RapidOCR.

Eight sheets from the American Farmhouse set, each tagged with its detected sheet type and which of four detector routes it takes
Figure 2: Eight of the twenty-five sheets in the American Farmhouse set (CC-BY-SA, freefarmhouse.com), with the route each one takes.

2. Grounding the Bounding Box: Boxes to Walls

When a vision model detects a door or room, it draws a loose rectangle around the visual neighborhood: the swing arc, the frame, and whatever text tag sits nearby. That box is an approximation, often drifting by dozens of points. Taking that raw rectangle, scaling it, and evaluating it against a building code would guarantee hallucinated results.

Before any measurement can happen, PlanLint forces the model's box onto real physical geometry:

  • On vector CAD drawings: PyMuPDF extracts raw line segments. Door boxes snap into the flanked gaps between continuous wall runs, while room proposals expand and snap against their bounding inner wall faces.
  • On scanned blueprints: OpenCV morphological masks identify structural wall pixels, reconciling flood-fill leakage through doorways against verified wall backing.

Once the box is grounded, the asset has a verified physical location. But location is only half the battle. The next challenge is finding its actual dimensions.

3. How an Opening Gets its Width

When an opening is detected, PlanLint separates where the object lives from how big it is. First, the bounding box snaps to CAD linework (setting the baseline source to vector-snapped at 0.95 or vlm-only at 0.60). Then, a strict priority waterfall determines which clear-width measurement wins:

Priority Measurement Source Path Confidence Impact Why It Wins
1 (Top) Schedule Lookup
source = "schedule"
Both Forces ≥ 0.95 Plan callout (e.g. or 101) matches a parsed door/window schedule row (e.g. 36" × 84").
2 Dimension Grid Span
dimensions.py
Vector Forces ≥ 0.95 A printed dimension string bound to an axis-aligned dimension line where printed value ≈ line length × scale brackets the opening.
3 Nearby Dimension Label
parse_dimension_label
Both Inherits box confidence
(0.95 if the box snapped, else 0.60)
A clean dimension string (e.g. 3'-0") within search radius: CAD text on vector, OCR text on scans. Proximity only, so it can't raise confidence on its own; framing notes (2X12 @ 16" OC) are rejected.
4 Flanked Wall Gap
classify_opening_vector
Vector Often drops to 0.60 The clear span between flanking CAD wall runs. Only evaluated when no schedule size, grid dimension, or nearby label resolved. (Interior linework triggers the longest-segment heuristic first, so confidence typically caps at 0.60.)
5 Longest Interior Segment
measure_asset
Vector Forces ≤ 0.60 Last-resort vector heuristic measuring the longest non-curved segment × scale. Exclusively permitted for openings; a space never takes a clear width this way.
6 Pixel Wall Mask
raster-snapped
Raster 0.80 On flattened scans with no vector linework, OpenCV morphological masks measure the gap between wall pixels. Real evidence, but weaker than vector CAD lines.
7 (Bottom) Uncorroborated Both No measurement No schedule, dimension line, or geometry could measure the opening. Rather than guessing, the asset carries no measurement, and the checker emits NEEDS_REVIEW.
Two doors on sheet A1.2 with PlanLint's wall runs drawn in blue and the snapped opening in green: door C measures 24 inches matching its schedule row, door B measures 37.5 inches against a 36 inch schedule row
Figure 3: The hierarchy resolving two real doors, with the wall runs and gaps drawn by PlanLint's own geometry pass. On door C the measured gap and the schedule agree exactly. On door B they do not, and the schedule wins, because the gap between the wall runs is the rough opening while the schedule gives the leaf that actually swings in it.

The schedule is the highest-precedence source here. Technically, schedules list nominal leaf sizes rather than true clear opening sizes, but it's the closest thing to ground truth on the sheet.

4. Room Area Grounding

Rooms present their own trap: vision models love to guess floor areas. A prompt asking for room boundaries will frequently report floor_area_m2: 18.5 based on nothing more than visual plausibility. On residential plans, room areas are almost never printed.

The living room on sheet A1.2 with the stored box dashed in amber and the snapped box in green, and the two dimension span lookups both returning None
Figure 4: The box PlanLint stored for this room, put back through snap_room_box. The boundary comes out clean, with the top edge alone moving 7.3 inches onto the real wall face, and the area still does not follow. No dimension line brackets this room on either axis, so the two span lookups come back empty and nothing is stored. A good outline is not an area.

PlanLint strictly enforces that a VLM's bare estimated area is dropped unless corroborated by either:

  • The dimension grid (multiplying verified width × depth bounding spans).
  • A printed area label (e.g. 142 SQ FT) verified against the PDF text layer via _printed_area_present.
  • A closed morphological flood fill on the raster wall mask.
If no printed or geometric area exists, the room is kept without an area measurement. When a minimum-area egress rule is checked against it, the system declares NEEDS_REVIEW rather than passing or failing on a fabricated square footage.

Semantic Ingestion: Compiling the Codebook

Building codes are legal documents structured into chapters, sections, sub-clauses, and exceptions. A clause like "404.2.3 Clear Width" cannot be evaluated in isolation. Its scope is defined by its parent sections: "Chapter 4: Accessible Routes" and "404 Doors, Doorways, and Gates".

Semantic ingestion parses the codebook into an explicit graph hierarchy, so a clause is always stored alongside the chain of sections it sits under:

ADA clause 404.2.3 highlighted inside its parent clauses 404 and 404.2, with the 32-inch minimum extracted into a typed Constraint
Figure 5: A clause and its ancestry, rendered from the source ADA PDF. Only 404.2.3 carries a number. The two clauses above it carry the scope that makes the number mean anything, which is why the parent chain is stored rather than the clause alone.

Because codebooks are multi-column, layout-sensitive PDFs, PlanLint uses an intelligent per-page router:

  • Clean single-column pages parse straight through PyMuPDF text extraction.
  • Complex multi-column or table-dense pages route through either the Docling layout engine or a VLM transcriber.
  • When the VLM transcribes a page, it is guarded by an exact dimension cross-check against the PDF's raw text layer. If the model accidentally flips 32 inches into 36 inches, the token validator flags the mismatch and bounces the transcription.
Every clause is embedded locally using fastembed ONNX (bge-small-en-v1.5, 384 dimensions) and stored in Neo4j's native vector index. No external API calls are made for embedding.

The 4-Stage Verification Engine

Once spatial ingestion has produced grounded PhysicalAsset nodes and semantic ingestion has populated the Regulation tree, verification runs as a strictly sequenced four-stage pipeline:

Stage 1: Code Hunter (Deterministic Retrieval)

For each asset on a sheet (e.g. door D2), the Code Hunter queries Neo4j's native vector index using the asset type and its available parameter keys as query text. When candidate clauses are retrieved, the engine walks up to six levels of PARENT_OF ancestry, outermost section first, and reads the clause together with everything that governs it.

This ensures that when a rule is evaluated, the full context (such as "Exceptions: In residential dwelling units..." or "Applies only to egress doors...") is recovered without hallucination.

Stage 2: Rule Extractor (Validated Pydantic AI)

This is the one place where an LLM interprets text. The model is asked to turn the legal prose of the matched clause into a typed, validated Constraint:

class Constraint(BaseModel): applies_to: AssetType # door, fire_exit, stair, room, corridor... parameter: Parameter # clear_width, area_m2, slope, riser_height... operator: Operator # min, max, range, boolean, qualitative value: float # e.g., 32.0 value_high: float | None # for range constraints unit: str # "in", "ft", "m", "mm", "%" summary: str

Crucially, this extraction is protected by Pydantic AI output validators. If the model emits a negative door width, an implausible stair riser height (e.g. 48 inches), or an unknown unit, the validator triggers a ModelRetry, sending the error back to the model to correct itself before anything can enter the graph.

The Graph is the Cache

Once a Constraint is extracted from a clause, it is committed to Neo4j as a (:Regulation)-[:DEFINES]->(:Constraint) node. If you update the architectural drawing set and re-verify, PlanLint runs zero LLM extractions against previously analyzed clauses. The entire rule extraction cost is paid exactly once per codebook.

Stage 3: The Pure-Python Checker

The checker contains zero machine learning. It is a deterministic Python module that takes a PhysicalAsset and a Constraint and performs rigorous unit normalization and comparison:

def check(asset: PhysicalAsset, constraint: Constraint) -> CheckResult | None: # This constraint does not govern this kind of asset if constraint.applies_to != asset.type: return None # Qualitative rules require human sign-off if constraint.operator == Operator.QUALITATIVE: return CheckResult(NEEDS_REVIEW, "Qualitative clause requires reviewer judgment") # A missing measurement can never pass measured = asset.measurements.get(constraint.parameter) if measured is None: return CheckResult(NEEDS_REVIEW, f"Asset lacks measured {constraint.parameter}") # Canonical units, then plain arithmetic req = to_inches(constraint.value, constraint.unit) if constraint.operator == Operator.MIN: return CheckResult(COMPLIES_WITH if measured >= req else VIOLATES, measured=measured, required=f">= {req} in")

There are no fuzzy thresholds, no probabilistic confidence weights, and no chances of a model "feeling generous" about a 31.8-inch opening on a 32.0-inch threshold. Math is math.

Stage 4: Cypher Compiler

The result is written into Neo4j using an idempotent MERGE on the composite key (asset_id, regulation_id, run_id), so re-running a check overwrites its own verdict and nothing else.

Every prior run remains intact in the graph. You can diff run v1 against v2 following an architectural revision to see precisely which doors moved from VIOLATES to COMPLIES_WITH.

Cross-Sheet Linking: Beyond the Single Page

A major shortcoming of simplistic document AI is treating every page as an isolated island. Real architectural drawing sets are heavily interconnected. A door on a floor plan is rarely dimensioned there at all. It carries a mark, and the size for that mark lives in a schedule on a different sheet entirely. An exit stair might show only riser counts on the floor plan, while the actual tread depth and riser height are drawn on an elevation or section sheet.

Door mark C on floor plan A1.2, the whole of sheet A4.3 with the door schedule block ringed, and the schedule row listing mark C as 24 by 80 inches
Figure 6: The same door as above, resolved the other way. The plan gives a letter; the size for that letter lives in a table on a different sheet, and the middle panel shows exactly where on that sheet it sits. Worth reading the caveat underneath: what the schedule prints is the nominal leaf size, and the code stores it as clear width. It is the right call, and far truer than a measured wall gap, but it is not the quantity ADA defines, and a door listed near the limit is the one that will slip through.

A schedule lookup is only one kind of link. A marker drawn on the plan is another. It does not name a value at all, it names a sheet, and the measurement only exists once you follow it:

A section marker on sheet A1.2 outlined beside the stair asset it binds to, with the 18.6 point separation marked against the 40 point radius, the sheet corroboration checks, and the stored REFERENCES record
Figure 7: A marker has to answer two questions before it becomes an edge. Which sheet? The answer is only accepted if the sheet number is corroborated against the page's own text and the project sheet index, so a misread tag is dropped rather than linked. Whose marker is it? That is settled by proximity, here 18.6 points from the stair box against a 40-point radius. A cut through open plan lands near nothing and binds to no asset.

PlanLint implements a document-level cross-sheet resolution pipeline:

  1. Callout Grounding: When a callout like 1/A3.2 is detected, it is validated against the project-wide sheet index. If Sheet A3.2 exists in the set, a (:PhysicalAsset)-[:REFERENCES]->(:Sheet) edge is created.
  2. Detail Localization: On Sheet A3.2, the detail detector locates the bounding region of Detail 1.
  3. Measurement Harvesting: Dimensions appearing inside Detail 1's boundary are parsed from the dimension grid and harvested back onto the referring plan asset with source detail-referenced.
  4. Specification Codes: Fixture and finish tags (e.g. F-60 or WD-1) on the plan are joined to material spec tables, attaching flame-spread or slip-resistance parameters directly to the asset.

Why NEEDS_REVIEW is a Feature, Not a Gap

In software engineering, linters don't guess. If a syntax tree cannot be verified by ESLint or Clippy, the linter emits a warning or error; it doesn't invent code that might compile. In AEC compliance, that principle is ten times more critical.

PlanLint is intentionally built to say "I don't know" whenever any link in the evidentiary chain is broken:

  • Missing Drawing Scale: If a floor plan lacks a parsed graphic or text scale (e.g. 1/4" = 1'-0") and no manual override is provided, all geometric dimension checks immediately return NEEDS_REVIEW.
  • Qualitative Clauses: When a building code specifies that corridors must have "adequate natural illumination" or doors must have "hardware operable with minimal effort as approved by the AHJ", the Rule Extractor classifies the operator as qualitative. The checker flags it for human review rather than pretending a machine can measure "adequate".
  • Uncorroborated Pixels: On scanned raster plans, an ambiguous wall opening that might be a window, a louver, or a hatched wall pier is recorded as an informational review item. It is never allowed to fail an egress check on a pixel guess.
ADA clause 404.2.9 with one phrase highlighted amber as needing review and the five-pound limit highlighted green as machine-checkable
Figure 8: ADA 404.2.9 splits down the middle. The five-pound limit is arithmetic. “The appropriate administrative authority” is a person, and no amount of model confidence turns one into the other.

The Trade: Honesty Over Inflated Metrics

It is easy to achieve a 98% "accuracy" claim on a benchmark by forcing an LLM to pick binary Pass/Fail on every entity. In the real world, that model will quietly approve non-compliant doors. PlanLint trades away cheap automated certainty in exchange for structural honesty.

Zero-API Offline Mode for Deterministic Review

To verify the entire architecture without network latency, API costs, or LLM non-determinism, PlanLint includes a complete offline execution mode:

# In your .env file PLANLINT_OFFLINE_SAMPLE=1 docker compose up --build

When running with offline mode enabled, the bundled sample (a vector floor plan with three interior doors and a fire exit checked against public-domain 2010 ADA clauses) replaces LLM calls with deterministic stand-ins:

  • Door D1 (36 inches) → COMPLIES_WITH ADA 404.2.3 (32" minimum).
  • Door D2 (30 inches) → VIOLATES ADA 404.2.3 (30 < 32 in).
  • Door D3 (32 inches) → COMPLIES_WITH (exact boundary condition).
The entire flow runs locally, with zero API keys, inside Docker in under five seconds.

What Comes Next

The core discipline is settled: extraction and verdict stay apart. The roadmap focuses on expanding what geometry can ground:

  • Chained Dimension Strings: Adding an algebraic solver for chained architectural dimension lines (e.g. 6'-6" + 5'-6" = 12'-0") so multi-span room envelopes calculate automatically.
  • Nominal vs. Clear Width Tables: Expanding schedule joins to account for frame jamb deductions (a nominal 36-inch leaf typically yields ~34.25 inches of clear passage).
  • Egress Path Walking: Using the snapped wall graph to calculate actual travel distances to exit stairs, checking travel limits under NFPA 101.

You can explore the full codebase, run the Docker stack, or inspect the graph schema on GitHub:

View PlanLint on GitHub