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:
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:
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).
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.
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 Lookupsource = "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 Spandimensions.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 Labelparse_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 Gapclassify_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 Segmentmeasure_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 Maskraster-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. |
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.
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.
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:
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 inchesinto36 inches, the token validator flags the mismatch and bounces the transcription.
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:
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:
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.
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:
PlanLint implements a document-level cross-sheet resolution pipeline:
- Callout Grounding: When a callout like
1/A3.2is 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. - Detail Localization: On Sheet A3.2, the detail detector locates the bounding region of Detail 1.
- 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. - Specification Codes: Fixture and finish tags (e.g.
F-60orWD-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.
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:
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).
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: