Skip to content

About

LLM reviews construction drawings by splitting each sheet into 36 parts.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Drawing Analyzer

Extract structured information from a set of construction-drawing PDFs using Claude vision. Each PDF page is treated as one sheet; every sheet is rendered to an overview image plus a grid chosen for that page's physical size — and its vector text layer is extracted and sent verbatim alongside the images — to Claude Opus 5.5 in a single vision request, which returns a structured text digest of the sheet (sheet number, discipline, equipment, tags, notes, schedules, etc.). An optional cross-sheet synthesis pass reconciles tags and conflicts across the set, and an optional per-run focus (anything you particularly want pulled out — see below) adds a dedicated Focus Report on top of the standard output.

The output comes two ways: a self-contained HTML report (report.html) — one portable file with a sidebar table of contents, full-text search, and category filters so you can isolate, say, just the coordination items or the conflicts the model flagged across the whole set — and the underlying plain Markdown, for reading, diffing, or feeding to anything downstream. The HTML is a lossless re-presentation of the same content (it even embeds the verbatim Markdown), so nothing the model returned is lost.

The HTML report also includes an Ask AI assistant (bottom-right button): a chat grounded in the report's own text, so you can ask things like "what are the biggest conflicts?" or "which sheets mention VAV-3?" right inside the file. It streams answers, shows its reasoning, and can search / read the web (codes, standards, product data) — all by calling the Anthropic API directly from your browser; there is no server. The assistant is present by default, whether or not the report was built with a key. By default the report does not contain your API key: the assistant asks for one the first time you use it and keeps it only in that browser tab (sessionStorage), so the file is safe to share and the key never touches disk (a Forget key control clears it). If you'd rather have a zero-friction, double-click-and-ask file, tick Embed API key in HTML report (GUI) or pass embed_api_key=True — the key is then baked into the HTML, the report shows a red "don't share this file" warning, and you should treat the file like a credential (a runtime "forget" cannot remove an embedded key — only regenerating or deleting the file can).

An embedded key is a default, not a lock. The key field is present in both modes, and a key the reader saves takes precedence over the embedded one for their browser tab — so a report that got shared anyway does not bill every question to whoever generated it, and a report whose embedded key has since been rotated stays usable (a 401 on the embedded key points the reader at the field instead of telling them to regenerate a report they cannot regenerate). Use my own key opens the field, Use the report's key hands it back, and Forget key clears the reader's key while saying plainly that the file's own credential is still in the file. Pass include_chat=False to omit the assistant entirely.

Organizations that block browser calls. Some organizations cannot call the Anthropic API directly from a browser — for example those with zero data retention or the Compliance API. Every request from the assistant then returns a 401 whose message begins "CORS requests are not allowed for this Organization". That is not a bad key, and no other key will get past it, so the assistant says so, keeps the key you entered, and does not reopen the key field. The rest of the report works normally; only the chat needs a key from an organization that allows browser calls. Likewise, a 429 caused by the organization's monthly spend cap is reported as the spend cap, not as a rate limit to wait out. The chat model defaults to Sonnet 5.5, which supports the web-search and web-fetch server tools at half Opus 5.5's per-token price (the reader's key pays for it), and can be overridden with DRAWING_ANALYZER_CHAT_MODEL. It runs at a fixed high reasoning effort: there is deliberately no per-question "deep" switch, because asking a reader to predict, before seeing the answer, whether their question deserves more reasoning is a question they cannot answer. The footer meter reports how much context is left, since the only question it exists to answer is whether the next question still fits.

Conversations are kept. The thread auto-saves in your browser for that report, so a refresh, a close, or reopening the file later picks up where you left off (New chat clears it). Save writes the conversation to a chat_history.json you choose — put it next to report.html in the export folder, archive it, or hand it to a colleague — and Load reads one back and lets you carry on asking questions from where it ended. The transcript is data, not a picture: the questions, the answers, the reasoning, the tool steps and the citations all come back. Your API key is never part of it (any sk-ant-… in the conversation is redacted before anything is written), and nothing is uploaded — saving and loading happen entirely on your machine.

Charts, tables and memos, right in the chat. Ask for "a chart of the findings by sheet and severity", "a table of every high-severity finding", or "draft an RFI for the VAV-3 clearance conflict", and the assistant puts it into the conversation as an artifact: a card under its answer, numbered A1, A2, … so later answers can refer to it. Each kind has its own downloads:

Artifact On screen Downloads
Chart (bar, horizontal bar, line, donut) drawn at the panel's width, hover a mark for its values, Data shows the numbers as a table .svg (vector, with title and caption), .png (2×), .csv (the data)
Table sortable columns (numbers sort as numbers) .csv (opens in Excel; formula-looking cells are neutralized like findings.csv), Copy pastes into a spreadsheet as cells
Memo rendered Markdown .md, .html (a standalone page that also opens in Word), Copy

Every card also has Print, which prints that one artifact on its own (pick Save as PDF in the print dialog for a PDF). The assistant is told to take an artifact's numbers from the report, the findings ledger, or its exact calculator — never to estimate them — and an invalid chart or table comes back to it as an error saying what to fix rather than as a broken card.

Artifacts are durable: an artifact is the recorded tool call that made it, so it is saved with the conversation and comes back on reload, on Load, and in a colleague's copy of a saved chat_history.json. New chat clears them with the thread.

The Files panel. The Files button in the chat header (its badge counts the conversation's artifacts) opens one place for everything you can take away:

  • From this conversation — every artifact with its download buttons (click a title to jump to it in the chat), Download all (.zip) for every artifact in every format plus the transcript, and the transcript's own Save and Print rows.
  • Saved with this report — in a folder export (Export All… / write_drawing_export), every file in the export folder as a real link: the marked-up PDFs, findings.csv, run.log, run_manifest.json, diagnostics/, the evidence crops, grouped and filterable. The list is taken from the folder after everything else is written, so it never names a file that is not there; the links are relative, so they work wherever the folder is moved as long as report.html stays in it. A single-file report (Save HTML Report…) has no folder beside it and says so.

The assistant can see the same list (list_files) and drop a clickable file straight into its answer (share_file) — ask "where's the marked-up PDF?" and you get the link, not directions.

See SECURITY.md for the report's trust boundary (all model output is treated as hostile and can never execute), API-key handling, secret redaction in logs, and what project data each artifact and the Ask-AI assistant contains.

Download & install (Windows)

Drawing Analyzer ships as a normal downloadable Windows app — there is no server to host and nothing to run in the cloud. The app runs entirely on your own PC and talks only to the Anthropic API using your own API key.

  1. Go to the latest release and download DrawingAnalyzerSetup.exe.
  2. Run it. Because the app is not code-signed, Windows SmartScreen may show a "Windows protected your PC" notice — click More info → Run anyway. This is expected for independent software; you'll see it on the first install and on each update.
  3. The installer shows the license (AGPL-3.0-or-later, with the copyright notice and a link to the source) — the same page appears on every update. It installs per-user (no admin prompt), adds a Start-menu shortcut plus a Drawing Analyzer License shortcut to the installed LICENSE.txt, and launches. Paste your Anthropic API key into the field at the top and you're ready.

Staying up to date. The app checks for a newer version once a day at launch, and you can check any time with Check for Updates in the bottom-right corner. When an update is available it shows what's new and offers to download and install it — you decide when. Every download is verified against the SHA-256 hash in that release's latest.json before it runs, so a corrupted, truncated or substituted download is rejected automatically. Be clear about what that hash does and does not cover: it is published in the same GitHub release as the installer, by the same workflow, so it protects the transfer — not the publisher. Trust in the contents of a release rests on HTTPS to github.com and on who can push a release to this repository, and the installer is not code-signed. (Building and publishing a release is documented in docs/RELEASE_WINDOWS.md.)

Install from source (developers)

pip install -e ".[gui]"      # GUI + engine
pip install -e ".[dev]"      # engine + test deps

Requires Python 3.11+. Set your API key:

export ANTHROPIC_API_KEY=sk-ant-...

Or skip the env var and paste the key into the Anthropic API Key field at the top of the GUI — it takes effect as soon as you enter it (no extra step), and is saved (OS keyring when available, otherwise a local key file) once you finish editing so it's remembered next launch. The env var still takes precedence when both are set.

Usage

GUI

drawing-analyzer        # or:  python -m drawing_analyzer

Drop in (or browse to) PDFs, confirm the estimated cost, and the analyzer digests every sheet. Save the result as a navigable HTML report (Save HTML Report… — opens in your browser, searchable and filterable).

Closing the window while a run is in flight asks first, and names which job it would discard: the worker threads are daemons and the export happens after the analysis returns, so a stray click on the X used to throw away an hour of work and the API spend behind it with no prompt at all (the help, focus and update windows each already confirmed on close). Quitting mid-analysis stops the run first (the same kill switch as Stop, below) and closes once the stop has settled — at most 30 seconds — because the worker dying with the process used to leave a remote Message Batch running, and billing, after the window was gone. A second click on the X closes at once. An idle window still closes immediately. Two other silent failures are gone with it: pip install drawing-analyzer without the gui extra now reports the missing toolkit in a dialog naming the fix instead of producing no window and no message — the launcher is a console-less Windows executable, so there was nowhere for the traceback to go — and an exception inside a button handler is recorded in the diagnostics log and reported, rather than looking like the click did nothing.

Live view — watch the run

The Activity section near the bottom of the window has two views, switched by the Live view | Log toggle on its first row:

  • Live view (the default) — an animated view of the run as it happens, drawn on a dark blueprint grid. Under its header is a row of five tabs — Board | Story | Thinking | Plan | Findings — described below. Click a tab to show it; the mouse wheel scrolls its lists. A dot beside a tab's name means something new arrived there since you last looked, and the number is how much it holds (reasoning summaries, checklist items, findings). The tab you were on is remembered. The header and the ticker stay up on every tab. The Board tab is the animated board:

    • Header — a pulsing LIVE pill (then COMPLETE / PARTIAL / STOPPED / FAILED), the elapsed clock, the stages running right now with their progress (▸ Read 34/120), and the live figures: tokens per second, calls in flight (or items waiting in the batch queue), total tokens and the estimated spend so far.
    • Pipeline track — one node per stage the run's own configuration plans (Inputs → Read → Identify → Plan → Critique → X-Sheet → Overview → Audit → Harvest → Editions → Anchor → Absence → Verify → Probe → Cite → Markup for a full QC run; just the first few for a standard run). Active nodes pulse and spin; data packets stream along the track into them — fast and blue while real-time reads stream, slow and amber while work waits in the Message Batch queue. A finished node bursts and settles green (complete), amber (partial) or red (failed); a stage the run never reached ends dimmed.
    • Sheet board — one cell per sheet. Cells cascade in when you load files, shimmer amber while queued in a batch, run a scan line while being read, then flash and settle green (read), teal (served from the cache, no charge) or red (failed). A purple corner marks the critique reads. When the run completes a sweep crosses the board.
    • Telemetry — a live token-throughput chart with counters (calls done, live, queued, retries — a retry flashes the counter). At the end of a QC run it becomes the findings breakdown: a bar of verified / uncertain / not checked / rejected findings, the high / medium / low counts, and whether every markup was proven in the saved PDF.
    • Ticker — the latest notable event, typed out ("6 of 48 sheet(s) already in the cache — free", "Findings ledger sealed: 87 finding(s)", "API busy (overloaded) — backing off 2.0s, then retrying").
    • Hover a cell for that sheet's name, state, critique reads and tokens; hover a stage node for its status, time and call counts.

    Before a run the board previews the loaded set and the stages the current QC options would run (tick QC Markups and the track grows the QC stages). After a run the final board stays up until you load a different set.

    The other four tabs say what the run is doing and why:

    • Story — the pipeline as a timeline. Every stage has one plain sentence saying what it does ("Pins each finding to its spot on the PDF by finding its quote in the sheet's text layer. A quote that cannot be found is a warning sign."). The stage running now opens up to show its live progress ("40 critique read(s) back · 4 still out") and its recipe: the model, the effort level, the thinking mode, whether it went through the half-price batch queue, and its calls, tokens and spend so far. Finished stages show their outcome and time; a stage's warning replaces its description. Beside the timeline, Where the spend goes is a bar per stage, and Insights are a few computed observations: how many sheets the cache served free, what one sheet read costs in tokens, which stage dominates the spend, how many duplicate reports the ledger merged, how often the API pushed back. Before a run it is a preview of the stages your options will run.
    • Thinking — the model's own reasoning, call by call. Each entry names the stage, the sheet (or finding, or code reference) and the model, then shows the reasoning summary as it streams in; the right-hand column lists the calls in flight and whether each is still thinking or already writing its answer. The feed follows the newest entry until you scroll up to read something, and resumes when you scroll back to the end. See What the Thinking tab is below.
    • Plan — what the set was identified as (project and set type, where it sits, language and units, its disciplines, and the code editions it adopts, with an edition you set marked set by you and one that is only mentioned marked as such), and the review checklist: your own checklists and the plans the model wrote, item by item with their ids (U1.3, M2.7). As each sheet's critique comes back, every item's meter fills in: flagged, unclear, reported met, not applicable or unanswered, counted in sheets. Hover an item for its full text and counts. A standard run writes no checklist, and the tab says so.
    • Findings — the path from report to proof, as four steps that light up as the run reaches them: reported by the reads (first read, critique reads, cross-sheet), in the ledger (after duplicate reports are merged), placed on the sheet (exact quote / close match / tile only / not placed) and re-checked (verified / uncertain / not checked / rejected). Under it, every finding in the ledger: severity, QC-###, sheet, text and its current status, updating as verdicts land. Hover a row for the whole finding, who reported it and how it was placed.
  • Log — the timestamped activity log, as before. While the board is showing, warnings and errors logged behind it are counted beside the toggle (● 2 warnings in the log); click it to jump to the log.

Turning the visuals off. Pick Log and the board stops drawing entirely; collapse the Activity header and nothing is drawn either (during a run the collapsed header still shows a one-line summary, e.g. Activity — Critique · 48/48 sheets · ≈$12.40, refreshed once a second). The choice is remembered across launches (gui_preferences.json in the app's config folder, the same file as the full-QC confirmation choice).

What the Thinking tab is. The API can return a readable summary of a model's reasoning with each reply. It is a summary written by the API, not the raw chain of thought, which no setting returns. A run started from the GUI asks for these summaries (thinking: {"type": "adaptive", "display": "summarized"}); they change nothing about what the model thinks, what it answers or what the run costs, and no cache key depends on them, so a cached sheet is still a cache hit. Real-time calls stream their summary as it is written. Batch reads report theirs when the batch's results come back, and a sheet served from the cache makes no call and has none. Some calls return no summary at all; the tab counts those. The summaries are shown on this screen only. They are held in memory for the run and are never written to the export folder, run.log, the diagnostics folder or the cache. Set DRAWING_ANALYZER_SHOW_THINKING=0 to stop asking for them. The setting can never cost a sheet read: a request refused for it is sent again without it, a batch item refused for it is resubmitted without it, and a batch that has no resubmission (the critique's) asks only a model that has already accepted the setting in this session. A refusal turns it off for the rest of the session.

What it shows, and what it cannot change. The board is a read-only window onto the run's own records: the pipeline hands a live_view.RunMonitor (extract_drawing_context(monitor=…)) its sanitized run journal (the same events run.log is rendered from — no key, no path) and its per-call telemetry. Nothing the board does can alter, slow or fail a run, and a failure inside it is logged once and otherwise ignored. The spend shown during a run is an estimate (each finished call priced at the model that answered it; marked ≈); when the run returns it is replaced by the run's own usage-ledger figure — the same number run.log and the report show. Drawing paces itself — up to 25 frames a second, backing off so it never takes more than about a third of the UI thread (in development testing, a 600-sheet board with every cell animating drew in ~11 ms a frame), and it stops completely when nothing is moving.

Loaded files — what a run will send

Everything a run uses is listed in the Loaded files section, by category, with each file's token count and its own Remove button (Remove takes out that one file; Clear still empties everything). The section expands by itself whenever you add something and collapses when a run starts, so the Activity section (the Live view board, or the log) gets the room. Its buttons add inputs; every pick adds to what is already loaded, and picking a file again replaces its earlier copy:

Button Category What it does
Add drawings… Drawings The same as the drop zone / Browse…. Each PDF shows its sheet count and its image + text-layer tokens for one digest read — measured from the real page shapes once the background preflight has scanned the files, a conservative per-sheet allowance until then.
Add specs… Specifications The real project specifications (.pdf / .docx / .txt / .md), after the same quality warning as before. Sent with every sheet's digest; conflicts become ordinary findings.
Add context… Additional context Background that is not the specifications — a basis of design, owner or client standards, a code analysis, prior review comments. Same formats as specs. Sent with every sheet beside the specs, under its own <project_context> block framed as background; requirements it states are checked like the specs. Budgeted at 200,000 characters (~50k tokens).
Add checklists… Checklists Your own QC checklists: .xlsx/.xlsm/.csv (one item per row; a header row and item-number cells are dropped), .docx, .pdf, .md or .txt (list bullets, or one item per line), or a .md in the review-profile format. Each becomes a review profile the QC Markups critique applies item by item and answers item by item. Scope… on a loaded checklist limits it to sheets whose number starts with the prefixes you type (F, FP, SP); blank applies it to every sheet.
Load AHJ Atlas Input… AHJ Atlas Input (optional) An Excel workbook of the code editions the AHJ adopted. Its editions become the run's governing code editions. If it lists several jurisdictions you pick one. Without it, the editions on the drawings are used.

Token counts are per digest read of each sheet at ≈4 characters per text token; specs and context are added to every sheet's request (prompt-cached after the first on the real-time path), and the cost line below the list prices it all.

AHJ Atlas Input layouts. There is no fixed template; the loader (drawing_analyzer.ahj_atlas.load_ahj_atlas) recognizes, per worksheet:

  • one code per row — a header row with an edition column (Edition, Year, Version, Adopted) and a column of code names, with optional Jurisdiction/AHJ/City/County and Amendments/Notes columns (a blank jurisdiction cell continues the one above, as merged cells read);
  • one column per code — one row per jurisdiction, a column per code (IBC, IFC, NFPA 13, …) with the edition years in the cells;
  • one tab per jurisdiction — either layout above on several tabs, each tab named for its AHJ;
  • free text — any cell naming a known code with a year (NFPA 13 2025, 2024 IBC).

Spelled-out names are normalized to their designations (International Building Code → IBC, Life Safety Code → NFPA 101) so they line up with what the identity stage detects; an amendments note rides along as the edition's note. .xlsx, .xlsm and .csv are read; an old .xls must be saved as .xlsx first. A row naming a code with no year is skipped with a warning in the activity log.

Diagnostics: Save tile images

The Save tile images checkbox under Open Diagnostics Log keeps every tile image the run renders; Export All… then writes them to a tiles/ folder with per-tile notes, so you can see exactly what the model read. It places no call and changes no result. It starts ticked when DRAWING_ANALYZER_SAVE_TILES is set.

GUI export options. The GUI keeps the buttons a reviewer actually reaches for: the HTML report (Save HTML Report…), the marked-up PDFs (Save Reviewed PDF(s)…, after a QC run), and the one-click full folder export (Export All…, which also writes the run.log / run_manifest.json run record even for a run that analyzed nothing — every input rejected, or a preflight block — because that record is the diagnostic for exactly those failures. A run that raised is the one case it cannot cover: there is no context to export, so the button stays disabled and the failure is reported in the activity log and the diagnostics file instead). The per-artifact Save Markdown… and Save Findings CSV… buttons were removed from the GUI to keep it fast and uncluttered — both artifacts are still written inside the Export All… folder. This is a GUI-only change: none of that functionality was deleted. The two removed buttons' handlers remain in the codebase as dead code (see the btn_row note in gui.py), the folder export still emits findings.json / findings.csv / sheet_text/ / the raw Markdown / run_manifest.json, and the library API (write_drawing_export, write_findings_csv, build_html_report) produces all of it. Either button can be brought back later just by re-adding it in gui.py.

Stopping a run (the kill switch)

Stop (the red button beside Analyze Drawings) is live for as long as an analysis runs, in every processing mode — Economy, Hybrid, Fast and XFast. After a one-click confirmation it:

  • sends nothing new — every real-time request, Message Batch submission and Files-API upload is refused from that moment (core.api_config._dispatch_messages is the single gate every real-time stage goes through);
  • abandons streamed requests in flight (digest, critique, synthesis, focus, review plan, investigation) at their next streamed event, which closes the connection so the model stops generating;
  • cancels every Message Batch the run has open, at once — from a background thread, wherever the run happens to be — and says so in the activity log (Canceled remote batch msgbatch_…). A cancel the API refuses on a batch that may still be running is reported in red with the batch id, so you can cancel it from the Batches page of the Anthropic Console. Each batch id is also logged when it is submitted;
  • wakes every poll and backoff wait immediately, so a run queued for hours stops in seconds, not at its next 2-minute poll.

What it cannot do: a short, non-streamed request already in flight (verification, citation check, cross-sheet QC, set identity, prose harvest) runs to completion, and requests already made are billed. Nothing paid is thrown away: every sheet that finished is in the cache, so re-running the same files reuses it at no cost, and a canceled batch keeps its receipt, so the next run collects whatever the batch finished before the cancel landed (Anthropic finishes, and bills, items that were already generating when a batch is canceled). Uploads a stopped batch submission never sent are deleted immediately. A stop that arrives after the run's last paid request — once markup writing has begun — lets the run finish, and the log says so.

Live spend and the budget cap

While a run works, the line under the Analyze Drawings row shows its spend, refreshed every second:

Spent $12.40  ·  in flight ~$3.10–$5.80 (24 batch request(s))  ·  projected $41.20–$58.00  ·  quoted $30.00–$190.00  ·  cap $60.00
  • Spent is every response received so far, priced the moment it arrives with the same rule the usage ledger uses (batch results at the batch rate, each attempt of a refusal fallback at the model that ran it, web searches per use). "at least" appears if a response ran on a model the price table does not know.
  • In flight is Message Batch requests that have been submitted and not yet read. A batch reports no usage until its results exist, so this is a band rather than a figure; it moves to Spent as results are read.
  • Projected is where the total is heading. It starts at the quoted estimate and narrows as the run learns: each stage's remaining items are priced at a rate that moves from the estimate's per-item figure to the stage's own observed average as its items complete, a stage that finishes counts exactly what it billed, and a stage that turns out to be cached or unnecessary drops out. When the run ends the line reads Final cost, taken from the usage ledger.
  • Quoted is the estimate shown before the run, for comparison.

Every figure is this app's price table applied to the tokens the API reports — an estimate, not your invoice. The Live view's header shows its own running spend, priced from the same responses; the spend line adds what is in flight, the projection and the cap, and stays visible when the Activity section shows the log or is collapsed.

Stop the run at $ (beside the spend line) is a budget cap. Tick the box, type an amount, and the run is stopped through the kill switch above when its spend reaches it. The box and the amount stay live during a run: change the amount or untick the box and the change applies to the run in progress. Setting a cap at or below what the run has already spent asks for confirmation first, because it stops the run at once. An amount that cannot be read leaves the current cap in force, and Analyze will not start with the box ticked and no usable amount. The setting is remembered between sessions.

What the cap compares against is Spent plus a floor on In flight: the low per-item rate, counted only when it can be trusted (the estimate priced the pages from their measured sizes, or the stage has already shown what its items cost). An over-wide estimate of queued work therefore never stops a run that is within its cap. The other side of that choice: work already sent is billed for whatever it completes, so a batch run can finish above the cap by up to the batch that was in flight when it tripped. A run stopped by the cap behaves like any stopped run — nothing is exported, finished sheets are in the cache, and the next run picks up from there once the cap is raised or turned off.

Optionally, type a per-run focus before pressing Analyze — e.g. "the rooms, and what types of plumbing fixtures each has". You always get the standard digest; a focus adds the Focus Report on top (see Per-run focus).

Even with neither box checked, a run is not throwaway: it retains each sheet's extracted text and the digest's parsed findings, anchors them offline for free, shows them in the report, and exports findings.json / findings.csv / sheet_text/ (DA-012). No paid QC stage runs.

Two QC review checkboxes sit beside the focus:

  • QC Markups — the full QC stack (DA-010). Checking it runs the entire stack: synthesis, two critique reads per sheet, cross-sheet QC, the deterministic auditors, prose harvest, anchoring, the absence cross-check, verification, citation checks, markup, and coverage reconciliation — then produces a marked-up PDF + findings CSV (see Reviewed PDFs & findings CSV). By default every ledger entry gets ink except the ones the verifier proved wrong (§18 gating): unverified findings draw dashed with an [UNVERIFIED] prefix, rect-less ones become margin callouts. Two sub-toggles adjust that — Verified & deterministic only (off by default; the conservative opt-in that suppresses unverified ink) and Include rejected (grey) (also inks verifier-rejected findings, struck grey). Because it runs the whole stack, it costs substantially more than a plain digest; the cost line says so. Analyze shows the full per-stage estimate in a Confirm full QC run dialog before anything is sent. Ticking Don't show this again there (and clicking Yes) skips that dialog on later full QC runs — the choice is remembered in gui_preferences.json in the app's config folder (on Windows, %LOCALAPPDATA%\DrawingAnalyzer\), and the estimate is written to the activity log instead. The Confirm estimated cost before each full QC run box under QC Markups brings the dialog back. Ticking the box and clicking No remembers nothing. The standard (non-QC) cost confirmation is unaffected.
  • Deterministic audit only — a free, zero-API pass that adds the whole deterministic auditor battery (references, arithmetic, naming, title-block, and sheet-index — see Deterministic auditors) on top of the standard analysis, with no additional API calls. It is already included in QC Markups' exhaustive stack, so the checkbox is disabled/redundant while QC Markups is checked.

After a QC run, the completion summary reports the finding count, how many were clouded, and the receipt-derived coverage tally (Ledger 47: 39 clouded, 5 margin, 2 rejected (indexed), 1 gated (verified-only mode), 0 failed; coverage COMPLETE), and the Save Reviewed PDF(s)… button lights up (it copies every *_reviewed.pdf into a folder you pick). The standalone findings-CSV button is no longer in the GUI (see GUI export options above), but the CSV is still written by Export All… and the library write_findings_csv.

That tally is artifact-backed, not aspirational (Phase 21): after each reviewed PDF is saved it is reopened and reconciled against the plan — every cloud, callout, and index row is stamped with a private placement id, and a placement counts only when its stamp is found again in the saved file. If a planned markup is missing, failed, or a source changed mid-run, the run's coverage is INCOMPLETE: the completion line reads QC incomplete (distinct from Completed and Completed with QC warnings), the affected reviewed PDF is named …_reviewed_INCOMPLETE.pdf, the HTML report shows a red Markup coverage: INCOMPLETE banner, and the exported markup_manifest.json records every planned placement and its terminal receipt. Pre-existing annotations on the source (or markups from an earlier review run) never distort this accounting — they carry no stamp from this run and are ignored.

An exhaustive run also carries one normalized QC status — NOT_REQUESTED, COMPLETE, PARTIAL, or FAILED (ctx.qc_status) — rolled up deterministically from a typed StageResult per stage (ctx.stage_results). The GUI completion dialog, the export index, and an HTML report banner all lead with it. A stage that failed or a coverage that came back INCOMPLETE makes the run PARTIAL/FAILED; disabling a normally-required stage from the advanced API (e.g. qc_markups=True, critique=False) marks the configuration DEBUG_OVERRIDE and likewise caps it at PARTIAL. A clean NORMAL exhaustive run earns COMPLETE — "All QC stages complete". That means every stage ran to completion, not that the set has been fully reviewed: the tool still misses things a good reviewer catches. (The label used to read "Exhaustive QC complete", which overstated it.) The Phase 23 completeness gate opened in Phase 26B once cross-set reconciliation, claim-complete citations, complete evidence, and callout-overflow handling landed. That claim is guarded, not assumed: a failed reconciliation, an unchecked cited claim, a missing verification crop, or a source that changed mid-run each hold a required stage at PARTIAL, and the roll-up can never call such a run COMPLETE.

Browsing the result

The HTML report (the folder export's report.html, or Save HTML Report… in the GUI) makes a large set easy to navigate:

  • Sidebar table of contents — jump to the QC findings, the cross-sheet overview, or any sheet; each sheet shows an OK / cached / failed dot.

  • QC Findings card (when a QC run produced findings) — a pinned, sortable table (ID, sheet, category, severity, status, finding, quote). Each row carries a color-coded status chip — Verified (green), Deterministic (blue), Uncertain (amber), Not checked (grey), Unanchored (red outline), Rejected (struck grey) — and links to the sheet it sits on. The chips honour the filter chips and ⚠ Issues only; folder exports also thumbnail the verifier's evidence crop on each row.

    Uncertain and Not checked are different claims. Uncertain means a verifier examined the finding and could not settle it; Not checked means no verification stage ran for it — the state of every finding on a standard run, where verification is NOT_REQUESTED. Collapsing the two would report a clean standard run as several hundred inconclusive verdicts.

  • Overview and focus report are pre-QC narrative — both are written from the sheet digests before the QC stages run, and they ship as written. A conflict the overview alleges can therefore outlive a finding that verification later Rejected. On a QC run with findings, each card opens with a one-line note pointing to the QC Findings table, and the Ask AI assistant is told the same thing: where the narrative and a finding's status disagree, the status is the later judgment.

  • Grouped repeats — a general note printed on every plan sheet is one real ledger finding per sheet, and correctly so: fifteen sheets carrying the same note are fifteen places to check. But fifteen identical table rows are noise — in one 39-sheet fire-protection set, 190 of 287 findings shared a quote with another, and PRELIMINARY alone was raised eighteen times. Rows quoting the same verbatim text therefore collapse behind the first one, which grows a +N more sheets control that expands the group in place. This is display only: the ledger, the exports, the markups and the badge total keep every finding (§18.6), the grouping recomputes after every sort and filter (so a collapsed row is never stranded without a lead), search still reaches a collapsed row, and Group repeated quotes turns the whole thing off.

  • Search — live full-text filter across every sheet. When a QC run captured the sheets' raw text layers, search runs over what each sheet actually says (a collapsed Sheet text layer block per sheet), not only what the digest said about it. Scanned / pasted-raster sheets are badged Raster.

  • Category filters — one-click chips, including ⚠ Issues only, which isolates the Coordination and Conflict sections (and every finding) the model flagged across the entire set. Those sections are also highlighted inline.

  • Lossless — a collapsed Complete raw Markdown block carries the exact model output, so the original text is always one copy away.

Library

from pathlib import Path
from drawing_analyzer import extract_drawing_context
from drawing_analyzer.cancellation import CancelToken

token = CancelToken()   # token.cancel() from any thread stops the run (optional)
ctx = extract_drawing_context(
    [Path("M-101.pdf"), Path("P-201.pdf")],
    use_batch=True,     # Message Batches API (≈50% cheaper)
    use_cache=True,     # skip re-paying for unchanged sheets
    focus="the rooms, and what types of plumbing fixtures each has",  # optional
    # One product switch turns on the exhaustive stack (DA-010): synthesis, two
    # critique reads/sheet, cross-sheet QC, the deterministic auditors, prose
    # harvest, anchoring, the absence cross-check, verification, citation checks,
    # markup, and coverage.
    qc_markups=True,
    profiles=["my-office-checklist"], # optional user checklists (added to the model plan)
    markup_verified_only=False,       # opt-in conservative ink gate (§18 default: ink all but REJECTED)
    ink_rejected=False,               # also draw verifier-rejected findings (grey/struck)
    focus_findings_to_markups=False,  # harvest Focus sections into markups too
    qc_work_dir=Path("run-out"),      # where evidence crops + reviewed PDFs land
    # Expert overrides (bool | None): leave a stage None to take the product default;
    # an explicit True/False overrides it. Disabling a required exhaustive stage
    # marks the run DEBUG_OVERRIDE and caps qc_status at PARTIAL:
    #   critique=False, cross_qc=False, citation_check=False, verify_findings=False,
    #   synthesize=False, identity=False, review_plan=False, absence_check=False,
    # governing_editions="NFPA 13 2025; IBC 2024" (or a list of entries) names
    # the code editions that govern when the drawings state the wrong one (see
    # Set identity below); load_ahj_atlas(path) reads them from an AHJ Atlas
    # workbook: governing_editions=entries_for([atlas.jurisdictions[0]]).
    # project_context="..." adds background documents beside the specs.
    # fast_mode=True (all real-time) sends Opus requests in API fast mode (XFast).
    # save_tile_artifacts=True keeps every rendered tile for the export's tiles/.
    # reference_audit=True alone (without qc_markups) is the free, zero-API battery.
    cancel=token,                     # optional kill switch (see below)
    spend=spend,                      # optional live spend + budget cap (see below)
    # monitor=RunMonitor() (drawing_analyzer.live_view) is a read-only window a UI
    # can poll while the run works — what the GUI's Live view draws from.
)
print(ctx.combined_text)
print(ctx.qc_status)            # NOT_REQUESTED | COMPLETE | PARTIAL | FAILED (§3.3)
for s in ctx.stage_results:     # one typed outcome per QC stage
    print(s.stage, s.status)
print(ctx.focus_report_text)    # the set-level answer to the focus ("" if none)
for sheet in ctx.sheets:
    print(sheet.ref.display_label, "->", "ok" if sheet.ok else sheet.error)

# QC results — findings/text are retained even in a standard run (DA-012):
for f in ctx.all_findings:      # model findings + deterministic reference findings
    print(f.sheet_id, f.category, f.anchor.status, f.verification.status, "-", f.text)
print(ctx.finding_count, "findings,", ctx.clouded_finding_count, "clouded")
for pdf in ctx.reviewed_pdf_paths:   # the *_reviewed.pdf files (qc_markups only)
    print("marked-up:", pdf)

The kill switch is a drawing_analyzer.cancellation.CancelToken. Call token.cancel() from any thread and the run stops exactly as the GUI's Stop does, on every transport; extract_drawing_context then raises RunCancelled instead of returning. RunCancelled derives from BaseException (like asyncio.CancelledError) so the stages' non-fatal except Exception guards cannot swallow it; catch it by name. token.wait_for_remote_cancels(timeout) waits for the stop's remote batch cancels to be sent, which matters if the process is about to exit.

To follow a run from your own UI, pass a drawing_analyzer.live_view.RunMonitor as monitor= and poll a LiveRunModel from your UI thread (model.begin(), then model.poll(monitor) on a timer, then model.finish(ctx)). The run attaches its own journal, per-call telemetry, resolved configuration and sheet list to the monitor; the model folds them into per-stage and per-sheet state, call/token counters, a live spend estimate and a ticker of notable events. live_scene.LiveScene + CanvasPainter render that model onto any Tk-like canvas, and live_panel.LivePanel is the GUI's self-pacing driver. All three are toolkit-free and never raise into the run.

The same model feeds the insight tabs. As the run goes it publishes its own objects to the monitor by name (monitor.facts()): identity (the SetIdentity), checklist (the ChecklistPlan) and checklist_answers (the dict the critique fills per sheet), digests, critique_findings, cross_findings and ledger (the findings ledger). They are references to the run's live objects: read them, never write them. The model re-reads them twice a second into model.identity, model.checklist, model.ledger_findings and the model.ledger_by counts. Passing a monitor also makes the run collect the models' reasoning summaries (ctx.resource_pressure.thoughts, a thought_stream.ThoughtStream; model.thoughts holds copies) unless DRAWING_ANALYZER_SHOW_THINKING=0. A run with no monitor sends exactly the requests it always sent. live_tabs holds the four insight views, and LiveScene.set_tab / click / wheel are the host's inputs.

Live spend is a drawing_analyzer.live_spend.SpendMonitor:

from drawing_analyzer import CancelToken, SpendMonitor
from drawing_analyzer.cost import estimate_exhaustive_run_cost, spend_forecast

token = CancelToken()
spend = SpendMonitor(
    budget=60.0,                                   # optional cap, in dollars
    forecast=spend_forecast(                       # optional: enables the projection
        estimate_exhaustive_run_cost(sheet_count, batch=True)
    ),
)
# ...run extract_drawing_context(..., cancel=token, spend=spend) on a worker thread...
snap = spend.snapshot()         # any thread, any time
print(snap.billed, snap.in_flight_items, snap.projected_low, snap.projected_high)
spend.set_budget(80.0)          # raise, lower or clear (None) while the run works

snapshot() returns a SpendSnapshot: billed, in_flight_items with its in_flight_low / in_flight_high band, projected_low / projected_high (None without a forecast until the run ends), the quoted_* estimate, a per-family stages breakdown, unpriced_calls, and budget_hit once the cap has stopped the run. The cap acts through cancel, so a monitor with a budget needs cancel= (ValueError otherwise), and a run it stops raises RunCancelled with token.reason set. After a normal return the snapshot is final and equals ctx.run_usage.

extract_drawing_context returns a DrawingContext (combined text, per-sheet SheetDigests, token totals, errors, optional synthesis_text, and — when a focus was given — focus / focus_report_text). It always carries the normalized run status: qc_status (NOT_REQUESTED / COMPLETE / PARTIAL / FAILED), the typed stage_results, and the resolved run_configuration (§3.3 / §15.1); the append-only usage ledger run_usage backs the derived total_input_tokens / total_output_tokens / total_estimated_cost and the usage_by_family breakdown (§15.6). Even a standard run carries the QC record — findings are retained and offline-anchored (DA-012). When exhaustive QC or the free audit ran it fills the rest: findings (the model's, anchored + verified), reference_findings (the deterministic auditors', anchored + DETERMINISTIC), the all_findings / finding_count / clouded_finding_count conveniences, reviewed_pdf_paths, the lightweight sheet_geometries, audit_stats (the auditors' checks-passed tally), ledger_tally / ledger_tally_line (the receipt-derived coverage tally), coverage_status + markup_run (the Phase 21 placement receipts, COMPLETE / INCOMPLETE / NOT_REQUESTED), and qc_work_dir (holding evidence/ crops and the reviewed PDFs). Every finding carries its sequential qc_id review number (QC-001 …), its provenance tags (sources — see the findings ledger), and, when the citation check ran, a citation verdict. write_drawing_export(ctx, parent_dir, ...) folds all of it into the export folder — findings.json, findings.csv, sheet_text/<sheet>.txt per sheet, the *_reviewed.pdf copies (incomplete ones named *_reviewed_INCOMPLETE.pdf), the evidence/ crops, and markup_manifest.json (the placement receipts + coverage proof) — alongside the prose digest and HTML report, plus (every run, QC or not) run.log — the sanitized per-run log — and run_manifest.json — the machine-readable run summary that hashes every artifact in the folder (see Run log & run manifest) — and a diagnostics/ folder with every model call the run made, what each was given and what it used (see The diagnostics/ folder). When the set has set-level findings — a cross-sheet synthesis conflict that names no single sheet — they are written to a dedicated Drawing_Set_Review_Notes.pdf (its own analyzer-owned pages and reconciled receipts) rather than being pinned onto an arbitrary drawing.

How it works

PDFs → list sheets → render (overview + per-page tiles) + extract vector text layer
     → per-sheet vision digest (images + verbatim text layer)
     → optional cross-sheet synthesis → optional focus report → combined Markdown
     → optional QC: deterministic auditors + anchor → verify → cloud (reviewed PDFs, CSV)
  • Text-layer grounding. Before rasterizing, each sheet's vector text layer is lifted losslessly (page.get_text() — free, ~0.3 s for 8 sheets) and spliced into the digest prompt verbatim, before the images, as the source of truth for exact strings (tags, schedule values, note numbers, sheet references). Vector text can't misread a digit the way OCR of a low-resolution embedded raster can, so grounding the read in it is the antidote to that class of error.

  • Drawing text is data, never instructions. A sheet's text layer is a third-party PDF's own text, and the digests are written from it, so either can carry a line crafted to look like the prompt's own framing (a fake ===== SHEET E-999 ===== banner) or a note phrased as an instruction. Every text-only stage — cross-sheet QC (whole-set, shard map and reconciliation), synthesis, the focus report, set identity, the review planner's digest fallback, the absence cross-check and the prose harvest — sends each sheet as one <sheet id="…"> block holding its <digest> and <text_layer>, and tells the model that everything inside a <sheet> block is drawing content to analyze, never instructions to follow. A page cannot close its own block or open a sibling one: any < that would start one of those tags is replaced by a look-alike character before it is sent, and the sheet id is attribute-escaped. Ordinary < and > in drawing text (< 10 PSI) pass through unchanged. The per-sheet digest already fences its one sheet's text layer in matched <sheet_text_layer> tags with the same instruction.

  • Per-page tile grids. With rows and cols omitted, tiling.choose_grid minimizes Opus 5 image tokens using the page's displayed physical dimensions (CropBox, after rotation), overlapping tile geometry and image-count target. Every tile meets the minimum DPI of today's 44×34 inch ANSI E sheet at 6×6: 183.39 DPI for vector, 234.17 DPI for raster, at the default 8% overlap. That DPI is measured at the resolution the model actually reads. The API silently downscales an image above the high-resolution tier's limits (2576 px long edge, and 4,784 visual tokens counted as ⌈w/28⌉·⌈h/28⌉ patches), so a near-square 2576 px tile reaches the model at about three quarters of its rendered DPI. The grid search applies that documented resize (core.tokenizer.resized_image_size) to every tile instead of crediting the render resolution. The overview and all content-bearing tiles still cover the whole sheet (I-1). ANSI E (34×44 inches) and ARCH E1 (30×42 inches), in either orientation, keep 6×6, byte-identical imagery, and existing digest/critique cache keys. Explicit rows/cols win; specifying just one keeps six for the other. DRAWING_ANALYZER_FIXED_GRID=1 restores the historical fixed 6×6 default. Above 20 images, vector tiles target 1560 px and raster tiles 1992 px, safely below the 2000 px hard cap. At 20 or fewer, both target 2576 px. Rectangular grids are allowed: the target change makes 43×1 narrow strips the token minimum for portrait vector Letter, and raster Letter chooses 27×1 strips at 1992 px, because one 2576 px image of the page would be downscaled to about 199 DPI, below the raster floor. Larger sheets can need more than six rows/columns. Pages that cannot meet the DPI floor within 100 images are reported as failed, never read below it. Cost confirmation prices those pages at the conservative allowance; digest and critique prescans record their failures and continue with the other pages.

    Measured with tiling.image_pixel_sizes and core.tokenizer on Opus 5, counting every tile before blank suppression, per vision read. Tokens are priced as the API bills them: ⌈w/28⌉·⌈h/28⌉ patches of each image after the API's resize. The DPI column is the lowest DPI the model reads, after that resize:

    Page (portrait) Grid Fixed 6×6 tokens Chosen tokens Savings Minimum tile DPI read
    Vector Letter, 8.5×11 in 43×1 91,168 7,280 92.0% 183.53
    Vector Tabloid, 11×17 in 2×1 76,216 14,125 81.5% 190.91
    Raster Letter, 8.5×11 in 27×1 148,248 11,736 92.1% 234.35
    Vector ANSI D, 22×34 in 7×3 76,216 47,040 38.3% 183.39
    Vector ARCH D, 24×36 in 5×2 78,288 51,952 33.6% 184.29
    Vector ANSI E, 34×44 in 6×6 91,168 91,168 0% 183.39
    Vector ARCH E1, 30×42 in 6×6 83,328 83,328 0% 192.12

    A sheet larger than ANSI E can cost more than the fixed 6×6, because 6×6 reads it below the floor: vector ARCH E (36×48 in) chooses 7×5 at 107,408 tokens, where 6×6 would cost 87,024 but read at 168.10 DPI.

    The same savings apply to the digest and both critique reads. Tile labels, findings bounds, anchors, crops, cross-QC legs, upload/spool reuse and tile artifacts all use each sheet's own grid.

  • Cost preview from each page's real shape. The old image figure assumed every image was a square at the raster target hitting the model's per-image token cap. cost.estimate_image_tokens_for_bases now prices the chosen grid, including every tile, from the page's size and whether it has selectable words. Without measurements, pipeline.estimate_image_tokens_for_set bounds the full adaptive image budget: 478,400 tokens/page on Opus 5, including oversized pages requiring more than 36 tiles. Explicit 6×6 or the fixed-grid switch retains the historical 177,008 tokens/page allowance. The dialog says which basis produced its number — "Based on each page's measured size and text layer" or "Conservative planning estimate" — so the figure is never ambiguous about what it rests on. Nothing extra is opened to get this. The profile preflight already walks every page to read its title-block sheet id; it now keeps the page shape too, which costs 0.4 ms for 120 sheets against 6,896 ms for a separate scan of the same set. Click Analyze before that finishes and you get the conservative figure, labelled as such — never a wait, never a spinner. The conservative allowance also stays the answer for a page that cannot be classified or measured: an unclassifiable page is never assumed to be vector, since vector is the cheaper target and guessing it would quote low on exactly the pages the tool understands least. A page that cannot be measured at all is still quoted, never silently dropped from the total.

  • Every stage is priced with the model it will actually run on. Synthesis and the focus report have their own overrides and their own default (Sonnet 5.5) rather than the digest's model, so they are priced at Sonnet's rate even under an Opus digest. The critique line follows DRAWING_ANALYZER_CRITIQUE_RUNS instead of assuming two reads, and its images are counted with the critique model's own token cap.

  • Economy mode (use_batch=True, the GUI default) digests every uncached sheet through the Message Batches API, uploading images via the Files API so no request body approaches the 32 MB limit. It uses the same models, prompts, inputs, and review contract at roughly 50% of the real-time token rate. In the GUI, Processing offers Economy, Hybrid, Fast, and XFast; the cost preview and confirmation dialog reprice every paid stage to match. A library caller can opt every run into Batch globally with DRAWING_ANALYZER_USE_BATCH=1 — no call-site edits — while an explicit use_batch= argument always wins. If the Files API is unavailable for your key/workspace (uploads return 404), each affected sheet falls back to an inline real-time digest so the run still completes instead of producing nothing. In exhaustive QC the critique also rides the Batches path: each uncached sheet's images upload once and both self-consistency reads reference that one shared upload (distinct custom_ids), so the reviewer earns the same batch rate and the sheet is neither re-rendered nor re-uploaded per read. The uploaded files are released on every exit — a fully-collected batch, a confirmed cancel, or an unexpected collection error (best-effort cancel, then release); a batch this run can't cancel keeps its files to expire server-side. A user Stop cancels the open batch at once and keeps both its files and its receipt: the canceled batch can still finish (and bill) items that were already generating, and the next run collects them, then releases the files (see Stopping a run).

  • Hybrid mode uses real-time digests (use_batch=False) and Batch critique (critique_use_batch=True). Standard analysis therefore starts immediately; exhaustive QC can still wait for its two critique reads in the shared queue.

  • Fast mode (use_batch=False, critique_use_batch=False) digests sheets concurrently on a bounded thread pool while rendering stays sequential (PyMuPDF is not thread-safe). Running the exhaustive stack real-time is the most expensive configuration (three vision reads per sheet), so the run logs a one-time nudge toward batch. Three vision reads is not three full-price image reads: the two self-consistency critique reads are byte-identical in their image prefix, so the second bills those ~90k image tokens at the cache-read multiplier (~0.1×) — identical tokens in, so findings are unchanged (see the cost note below). What a run cost is what its usage records say it cost; multiplying one read's price by three quotes a configuration nobody ran.

  • XFast mode (fast_mode=True, with both transports real-time) is Fast plus Anthropic's API fast mode: every real-time request to a model that supports it carries speed: "fast" — the same model and output, up to 2.5x faster output tokens per second, at a premium per-token price. On Opus 5.5 that is $8 / $40 per million input / output tokens, 2x the standard $4 / $20 (prompt-caching multipliers apply on top). Only Opus 5.5 requests (digest, critique, cross-sheet QC, review plan, investigation) go fast; stages on Sonnet or Haiku run at their normal speed and price, so a full QC run costs less than double. The Batch API has no fast mode, so fast_mode resolves off whenever either transport batches. The cost preview, the confirmation dialog and the run's usage ledger all price fast responses at the fast rate (a response reports usage.speed; each usage record carries speed: "fast"). If the account or platform rejects speed (a 400), fast mode turns off for the rest of the process and the request is re-sent at standard speed — the run never fails over a speed choice. Fast mode has its own rate limit; its 429s go through the ordinary retry loop.

  • Caching is content-keyed per sheet, so re-running a set after editing one sheet only re-pays vision for the changed sheet. It is now two-level — a cheap pre-render key recognizes an unchanged sheet before rasterizing, so a cached re-run skips rendering entirely (see Performance).

Performance

Two headline savings, both measured on real dense sets, plus a deliberate non-goal:

  • Skip-render on a cache hit (two-level key). Rasterizing a sheet's overview
    • 36 tiles is the dominant re-run cost (~4.5 s/sheet; ~2.5 min for a 33-sheet set). A digest is deterministic given the rendered images, so before rendering the pipeline computes a level-1 key from cheap page access alone and, on a hit, serves the cached digest without rendering. The level-1 identity uses a conservative page dependency hash rooted in the source content_sha256: content streams, forms, images, fonts, inherited page attributes, rendered annotations/appearance streams, and relevant document rendering globals. An edit to one page therefore rekeys that page while unchanged siblings keep their hits. If dependency isolation is ever ambiguous, it safely falls back to the whole-source hash (a possible false miss, never a stale hit). It also includes the render config (grid / overlap / target / blank-suppression), the canonical coordinate-space version, and the renderer environment (OS + PyMuPDF/MuPDF build, so a cache copied between installations misses rather than serving pixels this one wouldn't reproduce). A fully-cached 33-sheet re-run drops ~2.5 min of render to ~0. On a miss the sheet renders, the digest is computed, and it is stored under both the level-1 key and the existing rendered-bytes (level-2) key, so the next run skips it. The critique has its own level-1 key too, so an unchanged exhaustive re-run skips both its API calls and rasterization — previously the critique had to re-render every sheet just to discover its result was already cached.
  • Parallel Files-API uploads. An E-size sheet's 37 images upload on a small pool (default 6, DRAWING_ANALYZER_UPLOAD_WORKERS) instead of one at a time — the dominant batch-path latency after rendering. Parallelism changes only scheduling: each image keeps the same retry taxonomy (transient 503s re-issued with backoff; ambiguous connection/timeout errors left to the SDK's idempotent retries), so a lost response still can't orphan a stored file, and the first hard failure stops the sheet's remaining uploads.
  • Exact paid-stage reuse. Successful synthesis, focus, cross-sheet QC, prose-structuring, verification, and investigation results are content-addressed over their complete model-visible inputs, prompts, models, and output-shaping parameters. A warm identical run makes zero calls for those stages; partial, malformed, degraded, or unsaved-evidence results are never admitted.
  • Render/upload reuse within a run. Real-time digest renders are spooled and consumed byte-for-byte by critique instead of rasterizing the same page again. Economy-mode digest uploads transfer ownership to critique, which changes only the final task instruction and reuses the same remote file IDs; every ID still has exactly one cleanup owner. Batch rendering also looks ahead by one sheet so the next page can render while the current page uploads, with a hard two-sheet memory bound and original-order assembly.
  • Scalable persistent cache. The legacy whole-file JSON store migrates in place to transactional SQLite/WAL storage (the configured filename remains compatible). Reads and upserts touch one row instead of rewriting every cached drawing, concurrent app instances coordinate through SQLite, and a corrupt row degrades to one cache miss instead of losing the store.
  • Bounded deterministic fan-out. Independent cross-QC shards, prose stragglers, and verification calls run on small configurable pools. PDF crop rendering/evidence persistence stays serialized; independent source-PDF markup writers use isolated spawned processes. Independent set-level synthesis, focus, and cross-QC calls also overlap the identity/planning/critique path when the real SDK client is in use. Results still fold back in input order, so completion timing cannot reorder the exported review.
  • Blank-tile suppression. A tile whose pixmap is pixel-uniform (a truly empty crop of a sparse sheet) carries no information, so it is dropped before upload and disclosed to the model ("Tiles omitted as completely blank: …"). The strict, uniform-only check is always on and can never drop a tile with any mark on it. An opt-in near-blank heuristic (DRAWING_ANALYZER_SUPPRESS_NEAR_BLANK=1, a PNG-byte threshold) is far more aggressive — it dropped ~9 % of tiles on a dense fire-protection set and up to a third on schedule sheets — but can drop a tile bearing a few faint marks, so it is off by default: data over savings.
  • Non-goal — render-once-then-crop. Benchmarked against the current per-tile clip rendering on a real dense set (34.4 s vs 35.7 s — a wash: the ~75-megapixel full-page raster on sparse sheets eats the display-list savings on dense ones), so the current render path is left as-is. Process-parallel rendering across sheets remains a legitimate future option for 100+-sheet sets.

Note: the digest cache's schema version folds into every key, so a change to what is stored or how a sheet is identified invalidates every pre-existing entry once; the first run after upgrading re-digests each sheet, then caches as before. It was bumped for the two-level key and blank-tile suppression, for the safe whole-source identity, and again for conservative page-local dependency hashing (so sibling pages survive unrelated edits without reintroducing stale hits).

Cost of the exhaustive critique. A plain digest is roughly $0.4–0.6/sheet real-time (~half that via Batches). Turning on the critique pass adds a second full-coverage read run twice — but in a use_batch run those two reads ride the Message Batches path at the ~50% batch rate (Phase 23C), so the reviewer is no longer double-priced at real time. On the real-time path the two self-consistency reads instead prompt-cache their shared image prefix: they are byte-identical requests issued back-to-back, so the first writes the cache and the second serves the ~90k image tokens at ~0.1× — the cache write/read tokens are priced by their own rate class in the usage ledger, so total_estimated_cost stays honest. An exhaustive critique=True run lands around $2–3.5/sheet once verification (real-time) is included. The offline stages (reference audit, anchoring, markup, text extraction) stay $0. Every stage is individually cached, so a re-run of an unchanged set skips the model calls.

Usage & cost accounting. Every API call/attempt appends a priced UsageRecord to an append-only ledger on DrawingContext.run_usage (stage_family, transport real-time / batch / cache, model, input/output/cache tokens, tool uses, cache-hit, parse-success, and estimated_cost); the run's total_input_tokens / total_output_tokens / total_estimated_cost are derived sums, so no stage can overwrite another's counters and the totals always equal the exact sum of the records. A cache hit records zero billed tokens; a batch call is priced at the batch rate, a real-time call at the standard rate — per record. Before an exhaustive run the GUI shows a per-stage estimate with a low–high range (verification and citation scale with how many findings/citations turn up); while it runs the spend line shows what is billed, in flight and projected (see Live spend and the budget cap); after the run the completion summary and a collapsible Token usage & estimated cost by stage table in the report show the actuals. Pricing constants carry a verified effective date (core.pricing.PRICING_EFFECTIVE_DATE) — re-verify against the published rates before relying on a dollar figure.

Haiku 5.5 is the default for set identity and prose-to-finding structuring, both at medium effort. The visual review, cross-sheet QC and verification stages retain their existing models. Synthesis and the focus report run on Sonnet 5.5 at high effort: both are text-only calls over the digests the Opus vision read already wrote, so Sonnet does the same reconciliation at half Opus 5.5's per-token price. Use DRAWING_ANALYZER_IDENTITY_MODEL and DRAWING_ANALYZER_HARVEST_MODEL to select another model for either extraction stage. Haiku 5.5 is also registered for the other model overrides, including the report assistant.

Haiku's input/output rates are $0.10/$0.50 per million tokens for prompts up to 100,000 tokens, and $0.50/$2.50 above that threshold. The threshold counts ordinary input plus cached reads and writes in each API request; output tokens and the run's cumulative token total do not select the tier. The preview, usage ledger and report chat price requests separately, with cache multipliers and the Batch discount applied afterwards. Refused or truncated extraction replies are not cached as valid results; prose harvest retains the original item through its existing degraded finding path. See the Haiku 5.5 overview and pricing.

Structured findings

Alongside the prose digest, the vision model emits a machine-readable findings block — a fenced json block appended after all prose:

{"findings": [
  {"sheet_id": "M-101", "category": "code", "severity": "high",
   "text": "VAV-3 has no shown clearance to the wall.",
   "source_quote": "VAV-3", "tile": [2, 3], "refs": ["CMC 310"]}
]}

Each finding carries a category (code / conflict / coordination / question), a severity (high / medium / low), a one-to-two-sentence text, a source_quote copied verbatim from the sheet's text layer (the hook the anchor resolver uses to place it, and the hallucination alarm when a quote can't be found), the tile where the model saw it, and any refs it believes apply. The model emits at most 40 per sheet.

The parser reads that block line-anchored: a fence must start its own line, may be three or more backticks or tildes, and a closing fence has to match its opener's character and be at least as long. That is stricter than it looks and deliberately so. Scanning for a bare ``` at any offset meant an inline triple-backtick span inside the prose opened a phantom block, which the real json opener then closed — the findings came back empty while the whole JSON body leaked into the prose digest, and the prose after the inline span was silently dropped. A truncated block (the model cut off mid-JSON) is still recognised and stripped, so a partial machine block never reaches the prose.

A reply the model did not finish is never treated as one it did. When a digest stops at max_tokens the request is retried once at a raised output cap; if it is still cut off, the sheet is reported as failed and the result is not cached — a stored truncation would otherwise be served on every later run, indistinguishable from a complete read. The partial prose still ships, including in the combined digest, where a note under it says the sheet's digest is partial. If the raised-cap retry fails, or comes back with no text at all, the first read's partial prose is what ships, in every processing mode.

A reply the model declined (stop_reason="refusal", part-way through or before any output) is handled the same way, except that it is not re-sent. A real-time digest (Hybrid and Fast modes) has already been through the server-side refusal fallback; a Message Batches item (Economy mode) cannot carry that fallback, so the same request would only be declined again — a sheet declined in Economy mode may still succeed in Hybrid or Fast. A digest that an earlier version cached under either stop reason is no longer served: the next run reads that sheet again. The cross-sheet synthesis and the focus report follow the same rule. A reply cut off at its 32k cap is retried once at a raised cap; one that is still cut off, or declined, ships its partial text with a note in the combined digest saying it is partial, holds that stage at partial, appears in the run's errors, and is never cached. A partial overview is not mined for findings.

A tolerant parser splits this block off and strips it from the prose, so the digest text — and the combined_text a downstream spec reviewer consumes — is byte-for-byte what it was before the block existed (the prose digest is sacred). The parser absorbs the small ways models drift: it takes the last fenced block, trims to the outermost {…}, tolerates a trailing comma, and drops any item that fails validation (logging the count). It is line-aware, so even an unclosed block — output cut off mid-JSON by max_tokens, with no closing fence — is recognised and stripped from the prose (and, when the JSON object it holds is nonetheless complete, still parsed); a truncated machine block can never contaminate combined_text (DA-009). A malformed or missing block is never fatal — the prose digest still ships; the findings simply come back empty. Parsed findings are cached with the digest, so a cached re-run restores them for free.

Critique pass (the reviewer)

The digest describes a sheet; the critique attacks it. Turned on with critique=True (the exhaustive QC mode), the analyzer makes a second full-coverage vision read of each sheet — the same overview + tile grid + verbatim text layer the digest saw — but under a different persona: a senior engineer performing a rigorous back-check / QA markup of a check print before it is issued, whose only job is to find problems. It is instructed to report, with severity: outright errors; likely code concerns (cited conservatively); RFI-worthy ambiguities; internal inconsistencies; stale / copy-paste text; and — crucially — absences: content a complete sheet of that discipline should show but this one doesn't (a required test, drain, sign, clearance, note, or detail), each phrased "expected X; not found on this sheet." The critique sees one sheet, and much of that content properly lives elsewhere: a fire protection plan sheet's test, signage and bracing requirements usually sit on the general-notes sheet, its details on the 500-series sheets. So an absence is reported at low severity unless the missing item belongs on that sheet in particular (part of what the sheet itself draws, schedules or details), and then the finding says why. Absence and sheet-level findings carry an anchor_hint of SHEET instead of a quote, so the anchor resolver places them against the whole sheet. After the critique, the absence cross-check searches the rest of the set's text for each one.

The critique emits only the machine-readable findings block — no prose — so the prose digest (and combined_text) is untouched (I-2). The shared block parser is line-aware: even a response cut off mid-JSON (a max_tokens truncation with no closing fence) is recognised and stripped, so a partial machine block can never leak into the sacred prose (DA-009).

Self-consistency. The critique runs twice. A read counts as a real read only if it returned a valid findings schema — an explicit {"findings": []} included; a prose-only, truncated, or malformed body is a failed read, never an empty success, so it is never merged as a clean sheet or cached as corroborated (DA-008). Each read stamps its own provenance (critique_1 / critique_2) at production, and the merge follows the honest verdict: a finding both valid reads surface is REPRODUCED (reproduced = true); one only a single read raised is a SINGLETON (reproduced = false) but kept (more markups is better); and when a requested read failed, a surviving finding is NOT_ASSESSED_PARTIAL — never silently marked reproduced because a broken read "agreed". Finding.confidence carries this verdict; reproduced is derived from it and, like before, is a soft signal that never suppresses a finding. The merge deduplicates on semantic sameness with compatible critical signatures (a shared tile or overlapping rectangle alone never merges — see the ledger). The digest's findings and the merged critique's then pool into one per-sheet set before anchoring — an issue the digest and the critique independently raised is also marked reproduced. The ledger applies the same rule when it merges across channels, and raises confidence with it: an entry can never read "corroborated by two channels" and "only one of the two reads saw it" at the same time. The checklist a sheet receives is sent identically to both of its reads (never split across them), so the two stay directly comparable.

The merged critique is cached under its own key, so a re-run skips the extra calls. The digest's images are gone by the time the critique runs (the batch path streams and discards them), so the critique renders each uncached sheet once more — but in a use_batch run it uploads that render once and runs both reads off the shared upload through the Message Batches API (the ~50% batch rate), rather than inlining the images twice at real-time price. (Sharing a single upload across the digest and the critique — so the sheet is neither rendered nor uploaded a second time — is a deferred follow-up; today the reuse is within the critique's two reads.) critique=True is still more expensive than a plain digest (see Performance), just no longer double-priced. It is additive and non-fatal: a failure is recorded and the standard deliverable ships — a critique batch that can't be collected degrades those sheets' critique, never the digest. The model defaults to Opus 5.5 (DRAWING_ANALYZER_CRITIQUE_MODEL); the run count is DRAWING_ANALYZER_CRITIQUE_RUNS (default 2; set 1 to disable self-consistency).

Structured outputs (opt-in)

The critique is the one high-volume call in the pipeline whose reply is JSON and only JSON — "no prose before or after it." That makes it the single stage a whole-response schema actually fits. The digest cannot use one at all: it writes a full Markdown digest and then a findings block, and output_config.format constrains the entire response, so there is no schema that describes "prose, then JSON."

Set DRAWING_ANALYZER_CRITIQUE_STRUCTURED_OUTPUTS=1 and each critique read carries a JSON schema the decoder itself enforces, with the "output a fenced json block" sentence swapped out of the prompt. Everything else in that prompt is unchanged — it is derived from the normal instruction by substitution rather than maintained as a second copy, so the category enum, the verbatim-quote rule, the 40-finding cap and the whole claims contract keep exactly one author.

It is off by default, and that is a statement about evidence rather than about confidence. Anthropic documents citations and message prefill as the only incompatibilities of structured outputs, and says nothing either way about image inputs — while every critique request carries an overview plus a full grid of tiles. No hermetic test can settle that; one live call can:

ANTHROPIC_API_KEY=sk-ant-... python -m pytest -m network \
  tests/test_live_api_canary.py -k output_config_format

That canary asserts the request is accepted, that the reply comes back with no fence, and that the self-consistency merge still completes both reads. If it passes on your account, turn the flag on.

Three things keep it safe to try. The run self-heals: the first 400 naming the contract turns the feature off for the rest of the process and re-sends that same read under the fenced contract, so a sheet is never lost learning that the platform won't take it — and that retry does not consume the sheet's transient retry budget, because a capability answer is permanent rather than a blip. The tolerant parser stays, and a schema-constrained request whose reply comes back fenced anyway parses on the ordinary path without the latch ever firing. And structured and fenced reads cache under separate keys, so nothing you have already paid for is discarded and flipping the flag back and forth costs one re-read per sheet each way rather than invalidating both sides. The Message-Batches transport ignores the flag entirely: a batch item's shape is fixed at submit and a rejection surfaces per item after the whole batch is built and billed, so the transport that cannot degrade does not opt in.

The same contract is available, behind its own flag, on the two other stages whose reply is JSON and nothing else. The prose harvest's per-straggler structuring call (DRAWING_ANALYZER_HARVEST_STRUCTURED_OUTPUTS=1) is text-only, Haiku 5.5, one flat finding object — the lowest-risk place to try the feature — and its schema mirrors the prompt's field list one-for-one, with the same category and severity enums the host validator checks. The verifier's verdict (DRAWING_ANALYZER_VERIFY_STRUCTURED_OUTPUTS=1) becomes verdict as a three-way enum plus note, on the single-crop and cross-sheet calls alike; it is vision-involved, so it carries the critique's caveat and its own canary. All three stages share one gate (core/structured_outputs.py: env opt-in → model capability → self-healing latch), but each owns its own latch — a rejection on the critique's multi-image request says nothing about a text-only call and must not switch it off — and each keys structured and plain results apart, so nothing already paid for is discarded. Each has a live canary (-k harvest_under_output_config_format, -k verify_under_output_config_format); flip a default only after its canary passes on your account.

Set identity & the model-authored review plan

The universal-reviewer stages (Phase A): before the critique runs, the pipeline first works out what the set IS, then what a specialist would check.

Set identity. One text-only call reads a budgeted corpus — every sheet's digest head, the earliest sheets' verbatim text layers (cover sheet / index / general notes), and verbatim windows around every code-edition mention anywhere in the set — and returns a structured SetIdentity: the disciplines present, a sheet → discipline map, the project's jurisdiction / country / region, its language and units, and the adopted codes (each with a verbatim evidence quote and source sheet). Codes may come from any country's system — NFPA, IBC, Eurocode/EN, BS, DIN, AS/NZS, GB, … — and the deterministic regex edition harvest is kept as a backstop, so a stated edition can never be argued away. The regex finds a code next to a year and cannot tell an adoption from a mention: NFPA 13 2016 is not the governing edition matches as readily as all work per NFPA 13 2016. So an edition only the regex found is recorded as a mentioned code (mentioned_codes, origin="regex"), never an adopted one. Mentions stay visible, labelled not stated to govern the work, in set_identity.json, the Set Identity section, the review plan's facts and the citation check's request. The identity is advisory: it steers the stages below but never gates or suppresses a finding; a wrong detection is visible (not laundered) in set_identity.json, the run manifest, and the combined text's Set Identity section. The identity call runs on Haiku 5.5 at medium effort by default (DRAWING_ANALYZER_IDENTITY_MODEL): it is structured extraction, it is advisory only — nothing gates a finding on it — and the deterministic regex edition harvest is the backstop the model cannot argue away.

Governing code editions (your override). Detection reads what the drawings say, and the drawings can be wrong or silent: a general note copied from an older job, or an AHJ that adopted NFPA 13 a cycle or two behind the current edition. In the GUI, load your AHJ Atlas Input (Loaded files → Load AHJ Atlas Input…, an Excel workbook — see Loaded files); from the library pass governing_editions="NFPA 13 2025; IBC 2024" or a list of entries. Separate entries with semicolons or new lines; each needs a code and a 4-digit year, with an optional note in parentheses (NFPA 13 2022 (state amendments)). The year is read from the end of the entry, so NFPA 2001 2022 is NFPA 2001, 2022 edition, and one code may be listed at two editions (NFPA 13 2016 (existing system)). An entry without a year stops the run before anything is spent. For each code you name, your edition replaces the detected one as the adopted edition seen by the citation check, the review plan, cross-sheet QC and the edition audit; the edition the drawings stated is kept as a mention, and codes you don't name keep the detected record. The audit treats your edition as text-grounded, so a citation of any other edition of that code is flagged at medium severity when the cited edition is in the sheet text. Your entries are marked set by the reviewer in set_identity.json and the Set Identity section, and recorded in the run manifest's configuration. They are applied after the identity cache, so the identity call itself is not repeated. On a QC Markups run, though, setting or changing them rewrites the review plan, and the plan is part of the critique prompt, so every sheet's critique re-runs once (later runs with the same entries read from cache). A run with no QC ignores them.

Model-authored review plan. From the identity's canonical review facts, a second text-only call authors the per-discipline checklist a specialist for this set would apply — one-line "flag X when Y" items in the exact review-profile style, bounded host-side (≤60 items by default, DRAWING_ANALYZER_MAX_PLAN_ITEMS, with the overage shared across disciplines rather than spent on the alphabetically-last one). Every code-based item must name its code + section + edition inline and never invent a section number: the critique echoes those refs into the findings, and the citation check verifies them against the published section text — the plan's code claims are never trusted, they are checked. The plan is injected through the profile machinery below (after any user checklists), snapshotted with source="model", exported as review_plan.md, and cached content-addressed so warm re-runs rebuild it byte-identically (keeping the critique cache hot).

The planner consumes and caches only canonical review facts: disciplines, project and set type, jurisdiction, country/region, language, units, adopted code editions/amendments, and editions the drawings only mention (under a separate mentioned_not_adopted_codes key, present only when there are some). Routine revisions retain the checklist whenever those facts match, even if no sheets remain unchanged. Reuse requires detected disciplines plus a project type, jurisdiction, or a named adopted or mentioned code. With an insufficient identity, the planner falls back to digest-based authoring without caching that plan.

Renaming a PDF, adding/removing a page, revising a sheet, or recovering a failed digest therefore preserves paid critique results for unchanged sheets. Evidence, confidence, sheet classifications, and code source labels do not reset the plan. The scope cache persists across restarts; its key never includes filenames, page counts, or digest prose. Changed review requirements or planner settings require a new plan. Older corpus-based plan entries miss once under the new planner prompt; a newly authored checklist can also miss existing critique entries once. Per-sheet caches keep their existing schema and keys. Retained plans are identified in review_plan.md and the run journal. The critique cache continues to hash the exact injected checklist, including any edited user profiles.

Both stages ride the critique stack automatically (qc_markups=True, or an expert critique=True / citation_check=True) and add two real-time text calls on a cold run, disclosed in the pre-run cost estimate; standard digest-only runs make neither call. Expert overrides: identity=False / review_plan=False (recorded debug overrides inside exhaustive QC). The identity also feeds the citation check (an adopted-codes line, a separate line for editions only mentioned, and a jurisdiction/locale line) and cross-sheet QC (a set-context preamble that kills metric-vs-imperial false conflicts). Models are overridable via DRAWING_ANALYZER_IDENTITY_MODEL / DRAWING_ANALYZER_REVIEW_PLAN_MODEL.

Review profiles

The critique reads with a senior engineer's general judgment, plus the model-authored plan above. A review profile adds your own judgment: the owner's QC knowledge written down as a versioned checklist, injected into the critique prompt so the model applies each item deliberately, not incidentally. Pass the profile names to a critique run:

ctx = extract_drawing_context(
    pdfs, critique=True, qc_markups=True,
    profiles=["my-office-checklist"],   # names of user profiles to apply
)

Uploaded checklists. The GUI's Add checklists… turns a checklist file — Excel (one item per row), Word, PDF, Markdown or text — into a profile for this run without installing it (profiles.load_checklist_file(path); pass the returned Profile in profiles= alongside names). Its name is upload:<file name>, so it never shadows an installed profile, and the run manifest records its source as uploaded.

Selected profiles' checklists are appended to the critique prompt under "APPLY THIS REVIEW CHECKLIST EXPLICITLY, ITEM BY ITEM" (user profiles first, then the model-authored plan). Every item is printed with an id — U1.3 is the third item of your first checklist, M2.7 the seventh of the second model plan — and the ids are assigned over the whole selection, so an item keeps one id on every sheet. The exact text injected into a sheet's request folds into that sheet's critique cache key — so editing a checklist re-critiques the sheets that received it, while every other sheet is served from cache. Both of a sheet's self-consistency reads receive the identical checklist (the merge's reproduced flag depends on it). Profiles only take effect with critique=True; unknown names are skipped without failing the run.

Which sheets get which items

A checklist is no longer sent to every sheet regardless of discipline:

Checklist Scoped by Example
Yours (installed profile or uploaded file) with disciplines the sheet's own number prefix, read from its title block disciplines: F, FP → FP-101, F-D-01-1; not E-201
Yours with no disciplines nothing every sheet
A model-authored plan the discipline the set-identity stage classified the sheet under the mechanical plan → sheets classified mechanical

Scoping narrows only on evidence and fails open everywhere else. A sheet whose number cannot be read, a sheet the identity stage did not classify, and a sheet classified under a discipline no plan was written for all receive the checklist. A wrong guess costs attention, never a skipped check. An uploaded spreadsheet has no header to carry disciplines, so the GUI's Scope… button sets it (profiles.scoped_to(profile, "F, FP") from code).

Checklist results

The reviewer must account for every item on every sheet it was given: violated, met, not applicable, or undetermined. A finding names the item(s) it reports against (checklist_items), and that link survives every merge. After verification the run rolls this up (ctx.checklist_report) into three places:

  • report.html — a Checklist Results card: each item, its result, how many sheets ended at each result, and the QC-### findings against it. Each finding row shows [checklist U1.3].
  • checklist_matrix.csv — the full grid, one row per item and one column per sheet (UTF-8 BOM, CRLF, opens in Excel).
  • run_manifest.json — a checklist block with each item's result, per-result sheet counts and finding numbers. findings.csv gains a checklist_items column.
Result What it means
Violated A finding stands against the item. It went through anchoring and verification like every other finding.
Flagged, no finding The reviewer called it violated but no finding reached the ledger. Nothing backs it.
Flagged, rejected on verification Every finding against it was rejected by the crop re-check.
Undetermined The sheet shows the subject but cannot settle it alone.
Reported met The reviewer says the sheet complies. Nothing verified this. It is never worded "pass".
Not applicable The sheet does not show what the item concerns.
Not answered The reviewer did not account for the item. Never assumed met.
Not reviewed The sheet had no valid critique read.
Out of scope The sheet was never given the item.

The host decides, the model only reports: an id the sheet was never given is dropped, a finding that cites an item makes it violated whatever list the reply put it in, and when a sheet's two reads disagree the answer that hides least wins (violated, then undetermined, then met). An item's set-level result is its most attention-worthy sheet, so one unanswered sheet outranks ten that reported it met. Unanswered checks are counted in a stage warning and in the GUI's completion line; they do not lower the run's QC status.

No built-in checklists ship — the model authors the review plan per set, so the tool carries no discipline bias. A worked NFPA 13 sprinkler example (the former starter profile) lives at docs/examples/fire_protection.md; copy it into your profiles directory to use or adapt it. drawing_analyzer.profiles.suggest_profiles(sheet_ids) proposes profiles whose disciplines match a set's sheet numbering — via a shared, project-prefix-aware discipline detector (auditors/sheet_ids.py), so a project-coded id like AVC10-F-D-01-1 is matched on its fire-protection segment (F), not the project code, and compact forms like FP101 work too. suggest_profiles_for_paths(paths) runs a cheap text-only preflight (no rasterization) to read each sheet id and auto-suggest for a whole set; the GUI uses it to pre-check applicable profiles once files are loaded (manual choice always wins). The profiles actually applied are snapshotted (name + version + content hash + source) at Analyze time onto ctx.profile_snapshots.

Writing a profile

A profile is a Markdown file with a small frontmatter header and a flat list of one-line checklist items:

---
name: fire-protection
title: Fire Protection — NFPA 13 sprinkler QC
disciplines: F, FP, SP
version: 1
author: Your Name
date: 2026-07-08
---

- Dry/DIPA design areas carry the +30% increase over the wet base; flag a dry
  row whose remote area equals the wet base. [high] (NFPA 13 §19.2.3.2.5)
- EH standard-spray coverage ≤ 100 ft²; flag a larger max-area value. [medium]
- Every dry/preaction system must show its ITV, air supply, low-point drains,
  and gauges; flag any missing (an absence). [medium]
  • Frontmatter (between the --- lines): name (stable id — a user file reusing a packaged profile's name shadows it), title, disciplines (comma-separated tags matched against sheet-id prefixes for auto-suggest), version, author, date.
  • Items: each Markdown bullet is one check, injected verbatim — say what to check, what "wrong" looks like, a severity hint in [brackets], and a code reference where one applies. Absences read best as "expected X; not found on this sheet."

Profiles are discovered from the packaged profiles directory (empty by default) and from your own — ~/.drawing_analyzer/profiles/ (override with DRAWING_ANALYZER_PROFILES_DIR), where your file wins on a name collision. Bump version (or just edit — the content hash changes either way) to re-run the critique against the new checklist.

Cross-sheet QC

The digest and critique read each sheet on its own. A whole class of real errors is only visible between sheets: the same tag valued two ways, a standard note that diverged on a sibling sheet, a note on one sheet contradicted by another, a cross-reference sending you to a sheet that disclaims what the pointer promised. Turned on with cross_qc=True, the cross-sheet QC pass hunts exactly those.

It is a text-reasoning task — for a set up to 40 sheets, one Opus call over all the per-sheet digests + verbatim text layers, with no images. It is distinct from the prose synthesis, which stays as-is, and like the critique it never touches combined_text (the conflicts live only in the findings artifacts).

Whole-set above 40 sheets. A larger set shards by discipline and uses a map → reconcile pass: each shard call returns its local conflicts and a set of compact, grounded facts (the comparable values another shard might contradict), then a final reconciliation call compares those facts across all shards — so a conflict whose two sheets fell in different shards is still found (a plain shard-and-union would miss it). The model sees only request-local opaque handles in the sharded path — never source identity — and every fact/leg quote is validated against the retained source text before it is trusted, so an ungrounded quote never becomes a dual-anchor finding. Each sheet's text layer is budgeted with the omission counted and surfaced (never a silent truncation) — and so is the per-response findings cap: a response carrying more conflicts than the cap used to be truncated with no counter and still report itself complete, which on a large set (exactly where coordination conflicts matter most) is indistinguishable from a set that simply had fewer. A failed shard, a failed reconciliation, or a degraded budget holds the stage at PARTIAL while its findings stay usable.

Its findings carry dual anchors: a primary anchor on one sheet plus one or more also_on legs on the other sheets in the conflict, each resolved to its own sheet (via the set's title-block sheet-ids). The markup writer then clouds both sheets — each cloud's popup cross-references the other ("Conflicts with F-A-01-1: 'COLO 1'") — so a reviewer opening either drawing sees the conflict and where its counterpart lives. The model defaults to Opus 5.5 (DRAWING_ANALYZER_CROSS_QC_MODEL).

A cross-sheet conflict can't be judged from one sheet's crop, so these findings get a dual-crop verification: the verifier is sent one crop per sheet in a single call and asked whether the sheets actually conflict — so a cross-sheet finding can reach VERIFIED / REJECTED on the merits like any other finding (and a rejected one loses its ink, §18) instead of idling at UNCERTAIN.

Anchoring findings

A finding is only useful on a marked-up drawing if it sits on the thing it's about. The anchor resolver maps each finding's verbatim source_quote back to a rectangle on its page — in the canonical PAGE_VIEW_V2 coordinate space (see Rotation & page boxes), offline and with no model call, using a tiered strategy that records which tier fired:

  • EXACT — the normalized quote matches a run of words verbatim. When the quote appears more than once on the sheet (the "BATTERY ROOM in two schedule rows" trap), the occurrence inside the tile the model reported is preferred; if that still doesn't settle it, the finding is flagged exact_ambiguous.

  • FUZZY — no exact run, but a sliding window of words overlaps the quote's tokens ≥ 85%, or the longest distinctive sub-phrase (≥ 3 tokens) of the quote appears verbatim. Whitespace/linebreak artifacts and Unicode punctuation (dashes, curly quotes, the 2 1/2" vs 2-1/2" and ″/" inch marks, the Ø diameter symbol) are the usual reason exact fails; normalization folds most of them — including vulgar fractions, which need care: Unicode normalization expands ½ to 1⁄2 with no separating space, so 2½" used to become 21⁄2" — twenty-one halves — and could never match a sheet reading 2-1/2". On a pipe size that is the difference between a 2.5 inch drain and a 21 inch one.

    A fuzzy match must additionally clear the numeric veto. Token overlap is blind to the substitution that matters most on a drawing: swap one digit and the score barely moves. On a sheet reading PROVIDE 6 INCH DRAIN AT COLUMN LINE 4, every one-number substitution tried — 4, 12, 2-1/2, a changed column line — scored 6/7 = 0.857 and cleared the 85% floor, so a finding claiming a 4-inch drain was clouded onto text specifying 6. On a fire-sprinkler drain that is not cosmetic, and it defeated the hallucination signal precisely where it matters. So every measurement the quote carries must appear at its own position in the matched span, within a drift budget derived from the overlap floor itself, and each position in the span counts once — a note reading 4 4-INCH DRAINS cannot satisfy both mentions from a sheet's single 4. A sub-phrase match may not drop a measurement either. A quote carrying no digits is unaffected, and ordinary transcription variance (an extra word on the sheet, an abbreviation, a paraphrase) still matches. The 85% threshold itself is unchanged: this is a veto, not a stricter score.

  • TILE — a graphics-only finding (empty quote) is anchored to its reported tile's rectangle: coarse, but honest.

  • UNANCHORED — a non-empty quote that matches nothing anywhere. This is the hallucination signal: with no rectangle to cloud, the finding lands as a margin callout with an [UNANCHORED] prefix (§18) — flagged loudly, never dropped, and never drawn as if it had a location.

Findings the deterministic auditors already placed arrive pre-anchored and are left untouched. Like the tile geometry, the resolver imports no PDF engine — it works on the extracted word rectangles alone.

Rotation & page boxes

A drawing sheet can be rotated (a portrait page displayed landscape via /Rotate 90) or cropped (a CropBox smaller than, or offset from, the MediaBox). PyMuPDF reports and accepts coordinates in two different spaces that diverge on such a page — text extraction and annotation writing use an un-rotated, CropBox-relative space, while the rasterizer clips in the rotated page-view space the model actually sees. Using one where the other is meant mis-places findings on rotated pages (the class of bug this release closes).

The pipeline settles on one canonical space, PAGE_VIEW_V2: top-left origin, post-CropBox, post-rotation — exactly the frame of the overview + tile images the model reads. Every word rectangle, anchor, verification crop, and persisted finding rectangle lives in it. The two PyMuPDF-touching modules convert only at their own boundaries: render.py transforms extracted words into view space once (via the page's rotation matrix), and annotate.py transforms each rectangle back to page space (via the derotation matrix) just before it draws — with callout text rotated so it reads upright on a rotated sheet. Everything in between works on plain view-space numbers with no PDF engine. On an ordinary un-rotated page with a default CropBox both transforms are the identity, so nothing changes. Each page's frame and the two transforms are captured in a PageGeometry record (matrices as plain floats, so no PyMuPDF type leaks past render.py).

A GOTO destination is a third space again, and the reason a QC bookmark could land nowhere near its mark. A /XYZ destination is expressed in default user space — the same space an annotation's /Rect uses, which is what lets the two be compared and is how this was pinned by measurement — while annotation placement goes through the un-rotated CropBox-relative space above. Handing a destination the annotation-space point drops both the CropBox origin and the MediaBox origin. Measured across rotation × CropBox × MediaBox-origin, 24 cases: the index and overflow links were wrong in 12 of them and the bookmark outline in 22, with errors up to ~550 pt. The two PyMuPDF entry points that write destinations apply different internal transforms, so each is inverted separately, composed from PyMuPDF's own published matrices rather than a hard-coded offset. Generated index and notes pages also pin an explicit CropBox equal to their own MediaBox, because /CropBox is an inheritable page-tree attribute and a set whose /Pages node carries one made a new 612×792 page render as 512×712.

Absence cross-check

An absence is the one kind of finding the crop verifier never sees: it has no quote and no rectangle, only a sheet. And the critique reads one sheet at a time, so "expected an inspector's test connection; not found on this sheet" is often answered by a note on the general-notes sheet two pages away. The absence cross-check closes that gap before markup. It runs with the critique (so with QC Markups), and absence_check=False turns it off as a debug override.

A keyword search cannot do this job. Drawings abbreviate (ITC, INSPECTOR'S TEST CONN.), and a false match would tell the reviewer an item is covered when it is not. So a model does the search and the host checks its answer:

  1. Every sheet-level absence in the ledger (anchor_hint="SHEET", one sheet, absence wording) is sent, up to 25 per request, with the verbatim text layers of every sheet in the set, each under an opaque handle (S001) and its sheet id. Each request is one text-only call on Haiku 5.5 at medium effort (DRAWING_ANALYZER_ABSENCE_CHECK_MODEL) and re-reads the whole set's text, under a cent per request at 40 sheets. The pre-run estimate quotes 2–5 requests for a 40-sheet set (about 2–4 cents), assuming 15% of the findings band are sheet-level absences.
  2. For each absence the model answers FOUND (a sheet handle and a verbatim quote) or NOT_FOUND. It is told that a note requiring a component does not show the component; only a missing note or requirement is answered by a note.
  3. The host accepts a FOUND only when the quote (at least two words and six characters, compared case- and whitespace-insensitively) is in the named sheet's own source text. A quote that is on a different sheet, a handle that does not exist, a single generic word (SPRINKLER), or an invented string is discarded and counted in a stage warning.
  4. An accepted hit is recorded on the finding (located_elsewhere: the sheet id, the quote, and whether it is the same sheet) and lowers a high or medium finding to low. Nothing is deleted: the reviewer still sees the finding, now saying where the content is.

NOT_FOUND changes nothing. A text layer cannot show content drawn only as graphics, so its silence proves no absence. Specifications are not searched: a spec that requires an item is not evidence that a drawing shows it.

The located sheet and quote appear in the popup on the drawing (Shown on FP-001: "PROVIDE ITC AT REMOTE END" (lowered from medium)), in the HTML report's findings table, and in three findings.csv columns (located_sheet, located_quote, located_lowered_from). Each request's corpus is held to 280,000 characters, under Haiku 5.5's long-context price tier; a larger set keeps short sheets whole and trims the longest ones equally from the end, and the trimmed character count is a stage warning. A reply that does not parse, is cut off, or skips an absence leaves that absence unchecked and holds the stage at PARTIAL. Complete replies are cached on the set's text and the absences sent, and a cached hit is re-checked against the sheet text on every read.

Verification pass

Coverage proposes; precision disposes. Before any finding is clouded onto an issued drawing, the verification pass takes a surgical second look: for each anchored finding the model itself produced, it renders a high-DPI crop around the anchor and asks one small model call whether the finding actually holds in that crop — instructed to judge only what's visible, not re-argue the whole issue.

Every crop the verifier saw is preserved byte-for-byte: it is saved and hashed before it is sent, and only saved crops are sent — a verdict may never rest on an image absent from the trail. Each finding gets an evidence/<QC-ID>/ directory with one leg-NN__<sheet>_pN.png per leg (a cross-sheet conflict saves every sheet's crop, in request order) plus a request.json recording the ordered artifact metadata and the verdict. A cross-sheet conflict is never decided from a single crop — fewer than two saved legs degrades it to SKIPPED with a precise missing-leg reason. (The report/popup still surface verification.evidence_png, now an alias to the first saved crop.)

The model answers CONFIRMED / CONTRADICTED / NOT_VISIBLE, mapped to a finding status:

Status Source Meaning
VERIFIED verifier CONFIRMED the crop shows the finding is correct. For a code finding that means the drawing fact it rests on, not the violation: the popup says so, the index reads Fact confirmed, and the report chip reads Drawing fact confirmed (the citation check is what tests the code reference)
REJECTED verifier CONTRADICTED the crop shows the finding is wrong — pulled from the ink and listed in the index's Rejected by verification section (grey struck ink only with ink_rejected=True)
UNCERTAIN verifier NOT_VISIBLE (or a garbled reply) can't be decided from this crop (e.g. it depends on another sheet) — a perfectly fine outcome
DETERMINISTIC the offline auditors trusted without a model re-check (references, naming, title-block, sheet-index; arithmetic only when its operands are text-extracted from the quote — a model-transcribed mismatch is UNCERTAIN and crop-verified)
SKIPPED — nothing to look at (unanchored), no crop, or the pass was unavailable (no key)

The pass is additive and non-fatal: crops render sequentially (PyMuPDF is not thread-safe) while the small verify calls run on a bounded pool; a per-finding failure degrades that finding to UNCERTAIN, and a fatal auth failure marks the rest SKIPPED — the run always completes. Each call sends one ~2–5k-token crop image and a short prompt. The model defaults to Sonnet 5.5 at medium effort, overridable with DRAWING_ANALYZER_VERIFICATION_MODEL (or the older DRAWING_ANALYZER_VERIFY_MODEL, which wins when set).

Not every UNCERTAIN is a judgment. A garbled reply, a reply cut off by the output envelope or declined, and a call that failed outright all land on UNCERTAIN too — the safe status, never REJECTED — and until now nothing kept them apart from a real NOT_VISIBLE. The pass counts the live calls that returned no verdict (malformed, truncated, failed, a breakdown of the UNCERTAIN tally, never additional to it), and when any did, the verification stage carries one warning line in run.log and run_manifest.json:

verification: 3 of 120 live verdict calls returned no judgment (malformed=2, truncated=1, failed=0); each was left UNCERTAIN

Observational only: no status moves and the stage's completeness is unchanged. It answers the question the UNCERTAIN share alone cannot — how much of it the parser or the envelope produced before you blame the drawings — and it is the number that says whether DRAWING_ANALYZER_VERIFY_STRUCTURED_OUTPUTS is worth turning on for your sets: a near-zero malformed count means the schema buys nothing, a persistent one is a measured quality win.

The verdict itself is one or two sentences, but the call runs adaptive thinking at medium effort — judging whether a crop supports a finding is a reasoning task, and thinking draws from the same output envelope as the answer. (It always did: omitting the thinking parameter does not disable thinking on the current models. The pass previously ran with a 1,000-token cap that had to fit both, so verdicts came back empty and degraded to UNCERTAIN, pushing findings into the far more expensive investigation loop for no reason.)

The investigation loop (Phase C)

One crop is sometimes not enough: the answer lives in a schedule elsewhere on the sheet, or on another sheet entirely, and the verifier honestly answers NOT_VISIBLE. On markup runs those UNCERTAIN findings get a second, agentic look: the model can request more evidence through three host-executed tools — crop_region (another region of a sheet, up to 300 DPI), find_text (a free, deterministic search of a sheet's vector text layer that returns match rectangles to crop next), and view_sheet (an overview of another sheet in the set) — iterating until it can conclude CONFIRMED/CONTRADICTED/NOT_VISIBLE. Every requested image is saved to the finding's evidence directory before it is sent (the trail also gets an investigation.json with the ordered tool trace), and the verdict updates the finding in place: a confirmation turns the dashed "double-check" callout into a solid verified cloud; a contradiction removes the ink.

The loop is hard-bounded: DRAWING_ANALYZER_INVESTIGATION_MAX_ROUNDS evidence requests per finding (default 6) — counted per request, not per turn, since the model may ask for several crops in one turn and each is a real image rendered, saved and sent — and DRAWING_ANALYZER_INVESTIGATION_MAX_FINDINGS investigations per run, severity-first. That per-run budget scales with the set (10 plus one per 4 sheets, capped at 40): a flat number is generous on a 20-sheet permit set and severe on a 200-sheet one, which simply has more UNCERTAIN findings worth resolving.

Each investigation also carries an advisory task budget — a token countdown the model can see while it works, so it converges on an answer rather than being cut off mid-thought when the host withdraws its tools. At the budget the host forces a final text-only answer, and a finding that still can't be decided stays UNCERTAIN — a budget cap or a garbled reply can never mark a finding wrong. Runs on the escalation model (Opus 5.5, high effort) by default, overridable with DRAWING_ANALYZER_INVESTIGATION_MODEL. Each turn re-reads the previous turn's cached prefix within seconds, so the breakpoints use the 5-minute cache entry (a 1.25x write, not the 1-hour entry's 2x); the forced text-only close changes tool_choice, which voids the message cache, so it writes none. Concluded verdicts cache content-addressed against a whole-set content fingerprint; a warm re-run replays the tool trace (re-render + hash-compare, zero API calls) so the evidence bytes are recreated exactly and any source edit falls back to a live investigation.

The three evidence tools are declared with Anthropic strict tool use, so the API guarantees each request validates against its schema before the host sees it. That matters here for one specific reason: the evidence budget is charged per granted request, and a malformed request still spends its slot — costing the model one of six scarce looks and buying nothing.

Strict mode is not a substitute for the host's own checks, and the schemas say so. It constrains the schema language as well as the model: numeric bounds, string bounds and array length are all rejected by the compiler, so those rules moved into each parameter's description — where they still steer the model — and stay enforced where they always were. The sharpest case is the crop rectangle. Strict can promise it is present and is an array of numbers; it cannot promise it holds exactly four, which is precisely what the old minItems/maxItems pair expressed. So a three-number rect is schema-valid, still reaches the host, and is still rejected there. As with the critique flag above, a rejection of the strict schemas latches the feature off for the process and re-sends the same turn unconstrained rather than failing the stage.

Reviewed PDFs & findings CSV

Turned on with the GUI's QC Markups checkbox or qc_markups=True in the library. The payoff is a marked-up copy of the original drawing — <stem>_reviewed.pdf — that reads like a numbered, navigable, senior plan-review set. Every annotation is a real PDF object authored "Drawing Analyzer (AI review)" so provenance is unmistakable: opened in Bluebeam Revu they populate the Markups List (filter, sort, reply, export all work), and Acrobat and Chromium render them too (the appearance stream is built explicitly, so nothing shows up blank).

Each input is tracked by a host-owned source id (SRC-0001 …), so two PDFs that share a filename (M-101.pdf from two folders) are never confused: a finding is anchored, verified, and clouded only onto the drawing it actually came from, and the reviewed copies of duplicate-stem inputs are named <stem>__SRC-0002_reviewed.pdf (source-identifying) rather than a bare _2. The source_id is also a column in findings.csv / findings.json; absolute paths never appear in any exported artifact.

Markup anatomy (the legend)

  • QC numbers. Every finding in the run gets a sequential review number (QC-001 …, ordered sheet → position). Each inked finding carries the number as a small FreeText tag beside its markup, in the severity color; the same number appears in findings.csv, findings.json, the HTML report, and the index page — one namespace everywhere.
  • Severity colors. high = red, medium = orange, low and any question-category finding = blue.
  • Border styles say who found it. A model finding that verified draws a revision cloud; a DETERMINISTIC (auditor) finding draws a solid border — the host computed it, no cloud theatrics; an opted-in unverified finding draws dashed with an [UNVERIFIED] popup prefix.
  • Sheet-level / absence findings (anchor_hint="SHEET" — "expected X; not found on this sheet") have no rectangle to cloud. They become callout boxes packed into visually-clear bands — each box validated against the sheet's word rectangles, a rendered occupancy mask (so a text-free band that actually sits on piping, symbols, or a raster block is not used), and its neighbours, so a callout never obscures drawing content. The mask samples at 1:1 and asks whether any ink falls in each small cell, rather than what fraction of the box's pixels are dark: at a coarse scale a 0.5 pt pipe line antialiases lighter than the ink threshold, so a single sprinkler main across a band used to read as clear. Bands are found per vertical column as well as full-width, because a right-hand title block spans nearly the full sheet height and used to leave the whole clear drawing area beside it unusable. A leader-line arrow points to the reported tile when one is known and it would not cross another callout. A callout that will not fit any clear band overflows to an appended "AI Review Notes" page (with a GOTO link back to its source sheet) rather than being stacked over the drawing.
  • Index page(s). Each reviewed PDF opens with "AI DRAFT REVIEW - FINDINGS INDEX" — a table (ID, sheet, severity, status, one-line finding) where every row is a GOTO link that jumps straight to the page its mark actually landed on and the rectangle (works in Revu, Acrobat, and Chromium): for a callout that overflowed, that is the AI Review Notes page, not its source sheet, so a row never sends you to a page with nothing on it. Multi-page as needed. Every cell is fitted to its column by measured width and folded to characters the page fonts can actually draw, so an uppercase drawing note does not run into the next column and an inch mark does not render as a stray dot.
  • Popups are exhaustive: the finding text, the verbatim quote, the cross-sheet pointer (dual-anchored conflicts cite each other by QC number), verification status + verifier note, code refs plus the citation-check verdict, the reproduced flag (when a self-consistency read didn't corroborate it), the evidence-crop filename, and both ids.
  • Optional appendix (DRAWING_ANALYZER_MARKUP_APPENDIX=1, off by default): a final "checked and consistent" page listing what the deterministic auditors verified clean — numeric relationships that checked out, references that resolved — the balance column of a real review.

Gating — all findings get ink (Part III, §18)

The exhaustive default puts every ledger entry on the paper except the ones the verifier proved wrong:

Entry Default ink
Anchored, VERIFIED / DETERMINISTIC cloud (or solid border for deterministic)
Anchored, UNCERTAIN / SKIPPED dashed cloud, [UNVERIFIED] prefix
Rect-less: sheet-level / absence margin callout, [SHEET] prefix
Rect-less: quote matched nothing margin callout, [UNANCHORED] prefix — the hallucination signal, flagged loudly, never dropped
REJECTED (verifier contradicted it) no ink — but a reconciled index row under "Rejected by verification (n)" with page links, so nothing is invisible. ink_rejected=True (GUI: Include rejected (grey)) additionally draws them grey and dashed with a [REJECTED] prefix.
Gated (verified-only mode suppressed the ink) no ink — but a reconciled index row under "Not inked by operator gate (n)", so a deliberately-gated finding is accounted, never silently dropped.

The conservative mode is the opt-in: markup_verified_only=True (GUI: Verified & deterministic only, default off) suppresses everything but VERIFIED + DETERMINISTIC; suppressed entries are tallied as gated and get a real "Not inked by operator gate" index row (a proven placement, not a bare status), so nothing is invisible.

The index pages read as a punch list (Phase 26B §18.7): rows sort high → medium → low severity, then source input order, page, and position — the stable QC-### ids never change, only their display order. The rejected and operator-gated sections follow the main table as before.

Coverage is artifact-backed (Phase 21, DA-007)

At the end of a markup run the coverage tally is derived from receipts, not intention. The writer follows a plan → draw → stamp → save → reopen → reconcile protocol: it plans one placement per finding (and one per cross-sheet leg), draws each mark, stamps every annotation and index row with a private PDF object key carrying its placement id, saves a new file (the source is never touched), then reopens the saved file and reconciles — a placement counts only when its stamped, mandatory component is found again in the artifact. The result is a MarkupRunResult: per-placement MarkupReceipts (WRITTEN / INDEXED / FAILED), the receipt-derived tally, and a coverage_status.

  • The tally (Ledger 47: 39 clouded, 5 margin, 2 rejected (indexed), 1 gated (verified-only mode), 0 failed; coverage COMPLETE) appears in the GUI completion summary, the report's findings card + a coverage banner, and ctx.ledger_tally / ctx.ledger_tally_line. It describes PDF ink, so runs without markups (reference audit only) leave it empty.
  • If any planned markup is missing, failed, duplicated, or a source changed mid-run, coverage is INCOMPLETE: the completion line reads QC incomplete, the affected reviewed PDF is renamed …_reviewed_INCOMPLETE.pdf, the report shows a red banner, and the run is never presented as a clean success.
  • Because each stamp embeds a per-run id, pre-existing annotations on the source and markups from an earlier review run carry no stamp from this run and are ignored — they can neither satisfy nor break a placement.
  • Every placement + receipt + the coverage status + the sha256 of each reviewed PDF are written to markup_manifest.json (portable — no key, no absolute path).

Every finding — inked or not — is also written to findings.csv, one row per finding with every field flattened (qc_id first; provenance in the sources column; citation columns, then the absence cross-check's three located_* columns, at the end), UTF-8 with a BOM and CRLF line endings so Excel on Windows opens it cleanly. Model/drawing-controlled text cells are formula-injection safe (Phase 26B, DA-031): a cell starting with =, +, -, @, a tab, or a carriage return — even behind leading whitespace — is apostrophe-prefixed so Excel shows it as text instead of executing it, while host-owned numeric columns (pages, rectangles) keep their exact values and findings.json keeps the canonical, un-prefixed strings.

The folder export itself is hardened (Phase 26B, DA-033): every artifact name passes one containment allocator (traversal and drive prefixes dropped, Windows reserved names and alternate-data-stream colons neutralized, collisions deduped), every write target is proven to stay inside the export folder, evidence copies never follow symlinks, and the whole folder is staged and atomically published — an interrupted export leaves an explicitly *_INCOMPLETE-labeled directory, never a final-looking one with silently missing files. In the GUI, Export All… produces this complete folder in one action (see GUI export options).

Two Windows realities are handled explicitly. Name collisions are deduped case-insensitively, because M-101 and m-101 are one file on Windows and macOS and the second sheet's text (or evidence directory) would otherwise overwrite the first silently; the original case is still what gets written. And every write goes through the \\?\ long-path form: an artifact name may be 120 characters, the operator picks the parent directory, and a real one (…\OneDrive - <Company>\Projects\2026\<job>\Fire Protection\QC Reviews\ plus the export folder, sheet_text\ and the file name) passes the 260-character limit that applies unless the machine has LongPathsEnabled — off by default on many images. The prefixed form stays internal: the path returned to the caller and shown in the GUI is the plain one, and on POSIX the transform is an identity function. The final publish rename also waits between retries now, since an antivirus or search-indexer handle on the just-written folder does not clear within the same microsecond three attempts used to span.

Run log & run manifest

Every analysis run — standard, audit-only, exhaustive, even one where every input was rejected — owns a run journal (ctx.run_journal): an append-only, thread-safe event trace with a fresh opaque RUN-… id. Every export folder then carries two artifacts built from it (Phase 26A, DA-024):

  • run.log — the human-readable per-run record: run id, start/end, environment identity (OS / Python / PyMuPDF / SDK versions, model, prompt + cache-schema + coordinate-space versions), the classified input inventory (SRC-#### per accepted file, an explicit line per rejected one), the normalized configuration + profile snapshots, a per-sheet table (ok/failed, cache hit, raster/vector, text-layer length, omitted blank tiles, parser drift), a stage table (status, calls, items, duration) that includes the digest, per-family token/cost usage with derived totals — a stage that placed no call contributes no usage row at all — the ledger/receipt/coverage accounting, the resource-starvation check (a Starvation: header verdict plus a Resource pressure section — see below), the agent headroom check (a Headroom: header verdict plus the Agent provisioning & headroom and Model calls by stage sections — see below), prose carry-through counts, the artifacts written, every run error, and the full event trace. UTF-8 + CRLF so Notepad reads it cleanly. This is per-run and ships in the export — distinct from the rotating drawing_analyzer.log diagnostics file, which stays on the machine.
  • run_manifest.json — the machine-readable counterpart (schema_version 1): final status, configuration, source inventory, typed stage results, the usage ledger with derived totals, prose/evidence/markup-coverage summaries, the resource_pressure starvation record, the agent_headroom block (per-stage provisioning, headroom verdicts and the model map), sanitized errors, and the sha256 + size of every artifact in the export (run.log and markup_manifest.json included). Finalization is non-circular (§18.4): ordinary artifacts are written first, then run.log, then the manifest last — it hashes everything and excludes only itself.

Both are sanitized at the source: every journal field passes the shared Phase 17 secret-redaction filter — which matches a secret's field name through any underscore-joined prefix, so ANTHROPIC_API_KEY= and DRAWING_ANALYZER_ANTHROPIC_API_KEY= are redacted and not only the values that happen to look like Anthropic keys — plus an absolute-path scrubber at emit time (/home/user/…/M-101.pdf → .../M-101.pdf), and the log/manifest carry counts and identifiers — never prompts, image bytes, drawing text, long quotes, API keys, or your directory layout. Set DRAWING_ANALYZER_BUILD to stamp a build/commit identifier into the header.

The run's known private directories (input parents, work dir, home) are additionally replaced with ... by literal match, which is what makes a spacey Windows path scrub reliably — the bare regexes cannot tell where C:\Users\Jane Smith\Job 4471\SET 01.pdf ends. That match is case-insensitive and accepts either separator: Windows paths are case-insensitive, and one lowercase drive letter in an exception string used to be enough to leave the directory names in the file. Every renderer of both artifacts receives that list, not just the errors section.

The same boundary applies to the exported Markdown and the HTML report. 00_index.md, each per-sheet file and report.html render host-generated status and error strings — a rejected input's PermissionError, a failed API call's exception repr, a stage note — and those now pass through the same secret/path redaction. Digest prose deliberately does not: a sheet note reading TOKEN: 12 at grid C:4 is drawing content, and rewriting it to protect a credential that is not there would corrupt the review (I-2). Model findings are left alone for the same reason.

Was the run starved of resources?

A slow or partial run raises one question before any other: were the agents starved while they worked? run.log and run_manifest.json answer it directly. Every run carries a resource-pressure record (ctx.resource_pressure, module resource_pressure.py) built from four legs:

  • API capacity. Every transient retry in every stage — digest, critique, verification, investigation, identity, review plan, focus, synthesis, cross-QC, prose harvest, citation, plus the Files-API upload and the batch transport's results read, status polling, harvest polling and resubmission — is recorded: stage, kind (rate_limited 429 / overloaded 529 / unavailable 503 / server_error / timeout / connection), HTTP status, attempt, the backoff slept, the server's retry-after, the anthropic-ratelimit-*-remaining headers when the error carried them, the request id, and whether the loop finally gave up. Before this, every one of those loops slept and continued silently, so a run that spent forty minutes in rate-limit backoff looked like one that spent them reading.
  • Host resources. A daemon sampler ticks every 5 s (DRAWING_ANALYZER_RESOURCE_SAMPLE_SECONDS; 0 disables it) and records its own scheduling lag — how late it woke, the direct sign that this process could not get CPU, whatever the cause — plus process CPU time, resident and peak memory, system memory load, system CPU busy, free disk at the run's working locations (temp, work_dir, cache — by label, never by path) and the thread count. Windows reads come from psapi/kernel32 through ctypes, Linux from /proc; a metric a platform cannot read is listed as unavailable, never guessed. No new dependency.
  • Agent budgets. Allotments an agent exhausted: digests still cut off at max_tokens after the raised-cap retry, verifier replies truncated at the cap, investigations that hit the evidence-round cap or the per-run finding cap, citation references beyond the per-run cap, and batch attempts the stall watch or the time bound abandoned.
  • API spend limits. Two answers cannot be waited out inside a run: the organization's monthly spend cap for its usage tier (HTTP 429 with error_code: enforced_spend_limit_reached and no retry-after; access resumes at the start of the next month or when the tier is raised) and a spend limit you set in the Claude Console, for the organization or a workspace (HTTP 400, "You have reached your specified … API usage limits"). Neither is retried. The first one is recorded — stage, HTTP status, request id, and the API's own message, which says when access resumes — and from then on the run sends no further paid requests: every later model call, batch submission and image upload is refused before it leaves the machine and fails with a short "not sent: the … spend limit was reached earlier in this run" error. Each stage handles that like any other failure, so the zero-API stages still run and everything finished before the limit still exports. A batch the API had already accepted is still collected. The record counts how many requests the limit answered and how many paid requests were refused. Setting a monthly spend limit on the workspace whose key drawing-analyzer uses (especially a key embedded in shared reports) is a cheap guard; with this leg, reaching it ends a run cleanly instead of failing every remaining sheet one by one.

The record derives starvation_status — DETECTED or NOT_DETECTED — from named signals, each carrying its evidence: SPEND_LIMIT (an API spend limit was reached; listed first, since it explains every failure after it), API_THROTTLED (a give-up, three capacity answers, or 30 s+ spent in backoff), CPU_STARVED (two late ticks, or one lag of 10 s+), MEMORY_PRESSURE (system memory at 90 %+ load or under 512 MB available), LOW_DISK (under 1 GB free at a watched location) and BUDGET_EXHAUSTED. A couple of recovered retries over a long run are reported as counts but are not a verdict.

Where it shows: run.log gets a Starvation: header line and a Resource pressure (starvation check) section; run_manifest.json a resource_pressure block (verdict, signals, API counts with the bounded event list, host aggregates, the budget list, the spend_limit block — the first hit, how many answers said so, how many paid requests were refused — and the thresholds used); the HTML report's Run record block and the GUI completion summary each carry the one-line verdict, and the GUI adds its own red line naming a reached spend limit with the API's message; and the journal trace shows the events as they happened — API_RETRY, API_GIVE_UP, SPEND_LIMIT, RESOURCE_PRESSURE (one per threshold breach, capped at 50), BUDGET_EXHAUSTED and the closing RESOURCE_SUMMARY. Aggregates count everything; only the listed events are bounded. The SDK's own in-client retries (two per request by default) happen beneath these loops and are visible only in the wire capture (DRAWING_ANALYZER_DEBUG).

Did the agents have enough room? (agent headroom)

The starvation check above asks whether the API or the computer held the run back. The other half of "were the agents starved" is what each agent was given: its output envelope (max_tokens, which adaptive thinking shares with the reply), its effort level and thinking mode, the tools it could call and how many uses each allowed, and the allotments the pipeline sets per work item (the investigation's evidence rounds, the citation check's pause_turn resumes). A thin result from an agent that ran out of room looks the same in the findings as one from an agent that had nothing more to say. The per-call record tells them apart.

Every model request is recorded where it is sent (module call_telemetry.py): every real-time call at the one dispatch point all stages share, and every Message Batch item at submit, completed when its results are read. Each record holds what the call was given (model, effort and the highest effort that model accepts, thinking mode, max_tokens, tools offered with their per-call max_uses, tool_choice, task budget, structured outputs, strict tool schemas, refusal fallback, image count) and what it used (stop reason, input/output/cache tokens, share of max_tokens used, thinking blocks, tool calls by name, the server's own web-search/web-fetch counts, tool errors such as max_uses_exceeded, duration, message id). Each call also names its stage, using the same family names as the usage table (digest, critique, verify, investigate, citation, …), and its work item: SRC-0001:p0 for a sheet, a finding (joined to its QC-### in the export), a code reference for the citation check, or set for one-per-run stages. A call made outside a labeled stage is still recorded, as unattributed (<module>).

From those records each stage gets a headroom verdict:

Verdict Meaning
STARVED The stage ran out of something it needed and its result shows it: a work item still cut off at max_tokens on its last call, a server tool that refused a use because max_uses was spent, a pause_turn still unresolved when resumes ran out, or an allotment the pipeline saw it exhaust (evidence rounds, the per-run investigation or citation cap, the digest/verifier output cap, a batch abandoned by the time bound).
TIGHT It finished, but with no margin or with less than it was built for: a call at 90 %+ of max_tokens, a cap hit that a raised-cap retry recovered (a retry that fails, is stopped or is cut off again does not count as recovering it), a tool used up to its per-call limit, an item that asked for its whole allotment, a forced no-tools close, a recovered pause_turn, or an ability turned off mid-run (structured outputs, strict tool schemas, the refusal fallback or the task budget, latched off after an API rejection).
ADEQUATE Every call finished inside its limits with room to spare.
NOT_ASSESSED No call came back with a response this run can judge: everything was served from cache, failed or was stopped, or the only results are from a batch an earlier run submitted (its request, and so its limits, were never seen by this run).

The run's verdict is its worst stage. Effort and thinking are reported as sent, next to the highest effort the model accepts; they are per-stage design choices, not inputs to the verdict. Output tokens include thinking, because the API reports them together.

Where it shows: a Headroom: header line in run.log plus an Agent provisioning & headroom section (one block per stage: models, effort, thinking, cap and peak use, tools and their limits, item allotments, abilities sent, and the reasons behind a TIGHT or STARVED verdict) and a Model calls by stage table (where each model ran: calls, real-time vs batch, work items, tokens); run_manifest.json's agent_headroom block; one line in the GUI completion summary and the report's Run record; and the diagnostics/ folder below.

The diagnostics/ folder

Every export carries a diagnostics/ folder with the detail behind those summaries, in forms Excel or a script can open. Start with 00_summary.md.

File What it holds
00_summary.md The answers first (did the agents have room, were they starved by the API or the computer, did another model answer, which models ran), then the per-stage headroom table, the model map, the resource-pressure record and a guide to every file.
api_calls.csv One row per model call: stage, work item and qc_id, transport, model asked for and model that answered, effort, thinking, max_tokens, output tokens and output_fraction, stop reason/outcome, tools offered, their limits, calls made and tools_at_limit, forced_close, task budget, item allotments, duration, batch/custom/message ids, error.
api_calls.json The same calls with every field, for scripts.
agent_headroom.json The per-stage provisioning and headroom counters and the model map (also in run_manifest.json).
api_retries.csv Every transient API failure (429/529/503/5xx/timeout/connection) the run waited out or gave up on: stage, HTTP status, attempt, backoff, retry-after, rate-limit headers, request id.
host_samples.csv The CPU / memory / disk / thread timeline the host sampler recorded during the run. lag_seconds is how late the sampler woke, the direct sign of CPU starvation. Bounded at 2,000 rows; a longer run keeps an evenly spaced series over its whole length.
events.csv The run journal's event trace (the same events as run.log's Event trace), one per row.

Same privacy boundary as run.log: counts, labels and identifiers only, never a prompt, drawing text, a reply, an image or a key. Error strings and item labels pass the same secret-redaction and path-scrubbing filter. The CSVs are UTF-8 with a BOM and CRLF line endings, like findings.csv. run_manifest.json hashes every file in the folder.

Where every diagnostic lives

Output Where Scope
run.log export folder One run, human-readable: inputs, configuration, stages, usage, starvation, headroom, model map, errors, full event trace.
run_manifest.json export folder The same run, machine-readable, plus the sha256 of every exported file.
diagnostics/ export folder Every model call, API retry, host sample and journal event (above).
markup_manifest.json export folder (QC runs) Every planned markup placement and its reconciled receipt.
checklist_matrix.csv export folder (QC runs with a checklist) Every checklist item against every sheet: its result and the findings against it.
evidence/<QC-###>/investigation.json export folder (when investigation ran) The full tool trace of each investigated finding: every crop/search requested and the verdict.
drawing_analyzer.log %LOCALAPPDATA%\DrawingAnalyzer\logs\ on Windows (the app's settings folder elsewhere) The app session's rotating diagnostic log: request ids, batch ids, custom_id-to-sheet maps, retry detail. Stays on the computer; reset at each app launch. Set DRAWING_ANALYZER_DEBUG=1 to add the SDK's wire-level request/response lines.

Did another model answer? (refusal fallback)

Opus 5.5 and Sonnet 5.5 run safety classifiers that can decline a request. Every real-time call to them opts into Anthropic's server-side refusal fallback (fallbacks: "default", beta server-side-fallback-2026-07-01), so a false-positive refusal does not cost that sheet or finding its coverage: the API re-runs the same request on a substitute it picks by refusal category inside the same call. For Opus 5.5 that substitute is Opus 5 or Opus 4.8 (cyber-category refusals go to Opus 4.8); for Sonnet 5.5 it is Sonnet 5. Message Batches calls cannot carry the parameter. It is unlikely to fire on construction drawings, but when it does the run says so:

  • Pricing. The response's usage.iterations is read, and each billed attempt becomes its own usage record priced at the model that ran it: the declined attempt at the requested model (terminal_status REFUSED), the rescue at the fallback's rate (model is the model that ran it, requested_model the one the stage asked for). by_model shows the fallback model's spend.
  • Not cached. A fallback answer came from a different model than the cache key names, so no stage stores it; the next run asks the requested model again.
  • Visible. run.log gets a Fallback: header line and one Refusal fallback line per stage under Usage & estimated cost; run_manifest.json gets usage.model_fallbacks (stage family, requested model, serving model, responses served, attempts declined). The review plan's review_plan.md names the model that actually wrote it.
  • History stays valid. In the multi-turn investigation loop and the citation check's pause resumes, the declined model's partial (its thinking and tool requests before the fallback marker) is neither executed nor sent back, as the API requires.

DRAWING_ANALYZER_REFUSAL_FALLBACK=0 turns the opt-in off, so a refusal returns as one (that sheet or stage then reports the failure).

Citation check

Findings often cite code sections (refs), and citations have a failure mode of their own: a drawing citing 2016-era numbering under a 2019 basis (the prototype found exactly that — NFPA 13's Table 13.2.1 became §4.3.1.7 in the 2019 renumbering). The drawing set can't validate its own citations, so citation_check=True adds web-backed model calls (the API's server-side web_search and web_fetch tools): does this section — in the edition the set adopts and in the current edition — actually support the finding citing it?

Web fetch matters here more than the search does. With search alone the model answers from result snippets; with fetch it can open the publisher's page and read the section text before ruling. This stage runs on Sonnet 5.5 at medium effort. Opus 5 had no web fetch, so it could only read snippets; Opus 5.5 has it, so Sonnet 5.5 is now a cost choice, at half the per-token price. Both tools carry a shared source-quality blocklist, so a code verdict is never grounded on a forum post or another assistant's output. Fetch can only retrieve URLs a prior search surfaced in the same request, so the model cannot reach a page it invented.

References are normalized for case, whitespace, cosmetic punctuation and §/Section/Sec. before grouping; each finding keeps its original spelling for display. The pass admits at most 50 normalized refs per run, ordered by the highest severity of their eligible citing findings (then QC id/finding id). DRAWING_ANALYZER_CITATION_MAX_REFS overrides the cap; 0 checks none. Claims whose citing findings are all REJECTED and refs beyond the cap receive UNCHECKED assessments with explicit reasons, included in exports and stage warnings. Skips happen before cache lookup or client creation and keep the stage PARTIAL, consistent with the unchecked-claim gate.

The check is claim-complete: a verdict attaches to a finding only if that finding's claim was in the request that produced it. Every eligible distinct claim for an admitted reference is checked (chunked into claim-complete requests when there are many), the model returns a per-claim verdict, and each CitationAssessment is bound to exactly the findings whose claim it covered. A finding that cites several references keeps one assessment per reference (finding.citations), and finding.citation is a derived summary (CHECKED_SUPPORTS / CHECKED_MISMATCH / UNRESOLVABLE / UNCHECKED) shown in the markup popup, the CSV, and the report.

Each claim gets one of three verdicts. CHECKED_SUPPORTS means the model found the section's text (or an authoritative excerpt) and it supports the claim; it must name that page as its evidence link. CHECKED_MISMATCH means it found concrete evidence the section does not. UNRESOLVABLE means it could not find authoritative text to decide either way. That is common for codes that are not freely published, such as the NFPA standards. Before this third answer existed, the model had to pick a side, and the instruction to report a mismatch only on concrete evidence pushed every gap toward "supports". A support with no https evidence link is recorded as UNRESOLVABLE, with the reason in its note. UNRESOLVABLE is a concluded check, not a failure: it is cached like any other verdict, does not hold the stage at PARTIAL, and is counted in a stage warning. On the drawing it reads Code ref not confirmed. In the derived summary a mismatch outranks an unconfirmed reference, which outranks a support, so a finding citing two references never reads "supports" while one of them went unconfirmed.

The adopted editions are harvested offline from the sheet text layers (the general-notes "NFPA 13, 2016 EDITION" claims), merged with the identity-detected adopted codes (Phase A), and included in the prompt. A MISMATCH downgrades nothing automatically — it is surfaced for the engineer, because sometimes the stale citation is the finding; in the HTML report a mismatch renders with a distinct warning style plus the structured provenance (which edition the verdict was checked against, the current published edition, and the model-selected https evidence link), and findings.csv carries four provenance columns. Each fetch is capped at 4,000 tokens: enough for a clause and nearby definitions/exceptions, without admitting 40,000 tokens of a whole code book. The prompt asks for clause-level pages. Pause resumes cache the conversation through the newest assistant turn, retaining the preceding breakpoint to reuse long prefixes beyond the API's 20-block lookup window. The sliding pair stays within the four-breakpoint limit. Resumes are sent at once, so every breakpoint uses the 5-minute cache entry and usage prices writes at that rate. Real-time only, billed by reported tokens and server search counts, additive, and non-fatal: any failure leaves the affected claim UNCHECKED and holds the stage at PARTIAL.

Warm runs are cheap (Phase B): complete verdicts cache content-addressed on the normalized ref + claims + editions/jurisdiction context, expiring after DRAWING_ANALYZER_CITATION_TTL_DAYS (default 30; 0 disables the cache). A partial or failed check is never cached, so a cache hit can never hide an unchecked claim. The pre-run cost estimate quotes the cold-run figure — warm runs bill only cache-missed chunks.

The edition audit (Phase B) runs before any of this, zero-API and pre-seal: when a ref cites an edition of a code the set adopts at a different edition (a note citing NFPA 13 2013 on a set that adopts 2016 — the classic worldwide-set failure), it becomes a real ledger finding that numbers, anchors to the stale-edition text itself, and gets ink. When both the adopted and the cited edition are re-found verbatim in the sheet text, the finding earns the deterministic tier (auto-ink, like the offline auditors); a basis only the identity detected ships as a low-severity, explicitly-labeled advisory that is crop-verified and web-checked downstream. An edition the drawings only mention is not an adoption: when the identity adopts NFPA 13 2025, a note reading "NFPA 13 2016 is not the governing edition" does not make 2016 adopted, so a finding citing NFPA 13 2016 is still flagged. (With no identity, as in the free battery, the text scan alone is the basis.) Runs with the citation check or the free deterministic battery (reference_audit=True gains it at zero cost, regex basis only).

The findings ledger (Part III)

Every QC item from every channel becomes an entry in one append-only per-run findings ledger — and everything downstream (anchoring, verification, the citation check, the markup writer, the CSV/JSON exports, the report's findings table, the index page) consumes the ledger and nothing else. If an item is not in the ledger it does not exist; if it is, the coverage assertion guarantees it is accounted for on the PDF.

Duplicates merge conservatively and losslessly. Two findings merge only when they are semantically the same and their critical signatures agree: a shared tile is a search hint, never identity, and geometric rectangle overlap alone is never enough — two unrelated issues can share a table cell. A conflicting signature blocks the merge even when the prose is similar (500 gpm vs 550 gpm, M-101 vs M-102, shown vs not shown, or a different cross-sheet leg). A measurement is compared by its value, not by how it was typed, so 1/2" is half an inch and never the denominator — Provide 1/2" drain and Provide 2" drain are two pipe sizes, not one finding twice — while 2 1/2", 2-1/2" and 2.5" are one quantity. Feet-inches keeps both halves (12'-6" signs as 12 ft and 6 in, neither negative), and a plural unit folds to its singular so 6 amps and 6 amp are not read as a conflict. psig is deliberately not folded into psi: gauge and absolute are different measurements, and collapsing them would hide a real conflict rather than a formatting one. A measurement is compared by its value, not by how it was typed, so 1/2" is half an inch and never the denominator — Provide 1/2" drain and Provide 2" drain are two pipe sizes, not one finding twice — while 2 1/2", 2-1/2" and 2.5" are one quantity. Feet-inches keeps both halves (12'-6" signs as 12 ft and 6 in, neither negative), and a plural unit folds to its singular so 6 amps and 6 amp are not read as a conflict. psig is deliberately not folded into psi: gauge and absolute are different measurements, and collapsing them would hide a real conflict rather than a formatting one. A merge keeps coherent grounding: the survivor's text and quote come from one member as an atomic bundle — never one finding's text paired with another's quote — while the loser's quote is kept as a supporting quote. The verdict rides that bundle, for the same reason the rectangle does: DETERMINISTIC means "a host computed this result from this quote", so it is only ever true of the text the host computed. A member the auditors produced therefore wins the representative outright — provenance outranks quote length, and the auditor normally holds the shorter quote, because it quotes only the term it computed over while the model quotes a whole schedule line. An auditor's pre-anchored DETERMINISTIC duplicate still upgrades a model entry; what no longer happens is the reverse, where the model's phrasing of the same issue inherited the host's label. A survivor's exact rectangle is likewise never erased by an unanchored member. The merge unions the provenance, keeps the most severe severity, and — when the merged provenance spans two source families — marks the entry corroborated, raising both reproduced and confidence so the two can never disagree. Multi-source provenance doubles as a confidence signal, shown as chips in the report rows and the markup popups (prose+json+critique×2).

The ledger runs an explicit lifecycle: it ingests while open, seals, then anchors every finding, folds any duplicate the ingest pass couldn't see without geometry, and only then assigns the sequential QC-### numbers — so they follow visual order (source input order → page → top-to-bottom). Numbering strictly after anchoring is what makes the numbers positional.

A finding that arrives after the seal is an orchestration failure, and the ledger treats it as one whether or not it duplicates an existing entry. It is counted, logged, and the run is marked incomplete — never numbered as ordinary output. A post-seal duplicate is additionally dropped rather than merged: a QC-### is a promise about specific content, and by then that number may already be exported, inked on a reviewed PDF, and the name of an evidence directory. The run still ships (I-3); it just says so.

Source-tag glossary (Finding.sources):

Tag Channel
digest_json the digest's machine-read findings block
digest_prose_coordination / digest_prose_conflict harvested digest prose sections
critique_1 / critique_2 the critique's self-consistency reads (both = corroborated)
cross_qc the cross-sheet conflict hunt
synthesis_prose harvested synthesis conflict statements
auditor_reference / auditor_arithmetic / auditor_naming / auditor_titleblock / auditor_sheet_index the deterministic auditors
focus_prose per-sheet Focus sections (only with focus_findings_to_markups=True)

Prose harvest — the legacy channel's guarantee

The prose digest predates the structured findings and feeds a downstream consumer, so it is never modified — it is mirrored. Three layered mechanisms make the mirror a guarantee: (1) the digest prompt requires every prose Coordination/Conflict item to also appear in the JSON block; (2) the harvester splits those prose sections into items (using the same section grammar as the report's "⚠ Issues only" filter) and fuzzy-matches each against the same-sheet ledger entries — a match just tags provenance, free; (3) each unmatched straggler gets one small structuring call (item + the sheet's text layer → one finding with a verbatim quote), and if even that fails a degraded entry is ingested — the prose item verbatim, sheet-level — which still reaches the PDF as a margin callout. No prose QC item can fail to produce a ledger entry. Synthesis prose contributes its conflict statements the same way, anchored on the first sheet each names and dual-anchored when a second sheet is named.

Deterministic auditors

Alongside the model reads, the analyzer runs a battery of deterministic, zero-API auditors over the extracted vector text layers — no model call, milliseconds of CPU. They catch the class of defect a vision model is unreliable at but code is exact at: a stale cross-reference, a column that doesn't add up, a tag spelled two ways, a title-block field that drifted, an index that disagrees with the set. Every finding they emit is anchored to its own word rectangle and marked DETERMINISTIC — trusted without a model re-check — so it is clouded onto the reviewed PDFs by default when QC Markups is on.

They live in the drawing_analyzer.auditors package; run_auditors(rendered_sheets, claims=…) runs the whole battery and returns the combined findings plus a small stats tally. Each auditor is isolated, so one failing never loses the others (I-3). The battery wires into a run through reference_audit=True (or the GUI's Reference audit checkbox); its findings arrive on ctx.reference_findings, join ctx.all_findings, and the checks-passed tally lands on ctx.audit_stats.

Auditor Module What it catches Anchoring Severity
References auditors.references Stale / missing cross-references (SEE DRAWING X, detail bubbles NN/X, CSI spec sections) resolved against the set inventory the reference's own words medium (miss), low (spec/malformed)
Arithmetic auditors.arithmetic Numbers that don't add up — column totals, density × area = demand, base area × 1.3 = design area — computed by the host, never the model the claim's verbatim quote graded by magnitude
Naming auditors.naming The same thing tagged two ways across the set (C1R vs C1-R — same letters and digits, only formatting differs). A changed number (A1-2 vs A2) is meaning-bearing and is not flagged, and neither is a regrouped one (VAV-2-1 vs VAV-21) unless the set's own usage establishes which spelling is the convention each drifting occurrence low (question)
Title-block auditors.titleblock A project-number / package/project name (incl. multiword) / date that differs on one sheet from the set consensus — found by its field label, so a substantially different value is caught, not just a one-character typo the drifting value low (coordination)
Sheet-index auditors.sheet_index A drawing index that lists a sheet not in the set (or a malformed / out-of-convention entry), or omits one that is the index entry / header medium / low

All the auditors share one host-owned sheet-ID grammar (auditors.sheet_ids): it learns the set's numbering convention from each sheet's title-block ID — hyphenated (M-101), compact (FP101), or dotted (M1.01) — so it works across offices without a hardcoded scheme, and it resolves a reference as present, well-formed but not present in the provided set (with the closest in-set ID suggested), or malformed. A negative corpus keeps code/standard citations (NFPA 13), transmittal numbers (RFI-123), drawing annotations (REV-2, DET-3, DWG-4, TYP-2, SIM-1, NTS-1), voltages (480V), room numbers, and dimensions from ever becoming a sheet finding. The annotation prefixes matter more than they look — they are among the most common words on a sheet, so INSTALL PER REV-2 was being reported as a stale reference ("did you mean E-2?") with deterministic ink behind it. Paper sizes are deliberately not in the corpus: A1–A4 are ISO sizes and perfectly good architectural sheet ids, and the corpus never consults the set, so a blanket veto would drop real references. A strict subset of the corpus also vetoes a candidate while the sheet's own id is being detected, so a code citation or a drawing annotation sitting further into the bottom-right corner than the title block can no longer become the sheet's id — which matters because the set's grammar is learned from exactly those ids. Only a strict subset, because the corpus answers "does this point at a sheet in the set?", which is safely no for things a sheet can still be named: SK-1 is a sketch issued as a sheet, and so are PR-04 and ADD-2. It never claims a sheet doesn't exist — only that it isn't in the set you provided — because a partial set legitimately omits sheets. On a real 8-sheet fire-protection set this alone caught three genuine coordination errors. A one-sheet set is treated as low-confidence: only strong trigger phrases run, and the finding reports the limitation.

The numeric-claims contract (arithmetic auditor)

The arithmetic auditor embodies the tool's core principle — coverage proposes, precision disposes — for the one thing a vision model is worst at: mental arithmetic on a table it just transcribed (the prototype watched one misread a flow-test total, 540 → 660). So the model never calculates. The critique and cross-sheet QC passes emit, alongside their findings, a claims array — the numbers they read and how those should relate:

{
  "sheet_id": "F-D-01-1",
  "quote": "20  20  20   TOTAL  540",
  "kind": "sum",                  // "sum" | "product" | "factor"
  "terms": [20, 20, 20],
  "expected": 540,
  "note": "flow-test column total"
}

The host then does the arithmetic itself — parsing every term to an exact decimal (tolerant of commas, units, and fractions like 2 1/2), adding or multiplying with the standard library, never eval, never the model's answer — and raises a finding only when the numbers genuinely don't add up (base area × 1.3 = 1950, but the DIPA row still states 1500). Relationships that check out are counted, not flagged, and surfaced in the report as "N numeric relationships checked ✓" — the balance column of a real review.

The operation is always host-deterministic, but the numbers it operated on may have been misread. So a mismatch is trusted DETERMINISTIC (and clouded automatically, even in verified-only mode) only when the claim's own quote independently carries every operand; when the operands were merely transcribed by the model — the common case for a column sum whose addends live in a table — the mismatch is left UNCERTAIN and sent to the crop verifier before it inks as ground truth. The popup states which ("host-computed from model-transcribed terms"), and it keeps saying so after the crop check: verification and the investigation loop replace the verdict, never the provenance behind it. A VERIFIED crop means a verifier looked at the drawing — it does not turn numbers the model transcribed into numbers the sheet printed, and the caveat a reviewer reads must not quietly become "AI-verified against the drawing". The match tolerance is relative (magnitude-aware), so a small-value error like 0.2 + 0.2 printed as 0.5 is caught rather than swallowed by a fixed slack.

A term the host cannot read as one value is refused outright rather than truncated to its leading run, and its claim is then reported as unchecked instead of as a mismatch. 12,5 is not 12, 12'-6" is twelve feet six, and a percent is a ratio, not the quantity — so 1500 SF + 30% = 1950 SF is not "the sum of 1500 and 30". That last one mattered most: because the bare 30 appeared literally in the quote, the claim cleared the independent-validation gate and inked a HIGH-severity "the product of 1500, 30 is 45000" wearing the host's label. A separator only disqualifies when it is tight, so an ordinary operand list (0.5, 1.5, TOTAL 2.0) keeps every term while the compact malformed forms (12,5, 10,20) stay rejected. The quote scanner applies the identical rule, so the two can never disagree about what a number is — and one claim the host cannot compute or print no longer costs the run every other claim's arithmetic.

Per-run focus

The analyzer's built-in goals are fixed (a spec-reviewer-oriented digest). A per-run focus lets you add your own, for one run, at your discretion — it is never required, and the standard output is always produced unchanged. With a focus set:

  • Each sheet is read with your question in mind — the vision prompt asks for one extra, final Focus findings section per sheet (the standard digest sections are unaffected).
  • A set-level Focus Report is assembled in one extra text-only pass (Sonnet 5.5 at high effort by default): a direct, cross-sheet answer to your focus, citing the sheets that carry each fact. It leads the combined Markdown, is written as 00_focus.md in folder exports, and gets a pinned card (plus a Focus filter chip) in the HTML report.

Cache interplay: the focus is folded into the per-sheet cache key. Re-running with the same focus is served from cache; changing or adding a focus re-analyzes the sheets (the model must re-read the drawings with the new question), while your existing no-focus cache entries remain valid for ordinary runs.

Configuration

claude-opus-5-5 and claude-sonnet-5-5 are registered model overrides. Switching the review model invalidates the digest and critique caches once: their keys include the resolved model, so the first run with the new model rebuilds them and later runs reuse its entries. Model selection remains a quality decision.

Variable Default Effect
ANTHROPIC_API_KEY — Required (or paste the key into the GUI).
DRAWING_ANALYZER_MODEL Opus 5.5 Vision model for per-sheet digests (high effort). Also the default for critique, cross-sheet QC and the review plan, which each have their own override below.
DRAWING_ANALYZER_SYNTHESIS_MODEL Sonnet 5.5 Cross-sheet synthesis model (text-only, high effort). It reconciles digests the vision passes already wrote, so Sonnet is a cost choice at half Opus 5.5's per-token price. Does not follow DRAWING_ANALYZER_MODEL.
DRAWING_ANALYZER_FOCUS_MODEL Sonnet 5.5 Focus-report model (text-only, high effort). Same reasoning as synthesis. Does not follow DRAWING_ANALYZER_MODEL.
DRAWING_ANALYZER_VERIFICATION_MODEL Sonnet 5.5 Per-finding verification model (crop + short prompt, medium effort). The older DRAWING_ANALYZER_VERIFY_MODEL wins when set.
DRAWING_ANALYZER_INVESTIGATION_MODEL escalation model (Opus 5.5) The Phase C investigation loop's model (multi-turn, vision + tools, high effort).
DRAWING_ANALYZER_INVESTIGATION_MAX_ROUNDS 6 Evidence requests per investigation before the forced text-only close (rides the verdict-cache key).
DRAWING_ANALYZER_INVESTIGATION_TASK_BUDGET 40000 Advisory token budget handed to the model per investigation, so it paces itself instead of being cut off at the round cap. Anthropic's 20,000 floor is enforced; rides the verdict-cache key.
DRAWING_ANALYZER_INVESTIGATION_MAX_FINDINGS scales with the set UNCERTAIN findings investigated per run (severity-first; the excess is disclosed as a stage warning). Defaults to 10 + one per 4 sheets, capped at 40; setting this pins an absolute number at any set size.
DRAWING_ANALYZER_CRITIQUE_MODEL Opus 5.5 Critique-pass vision model (critique=True, high effort).
DRAWING_ANALYZER_CROSS_QC_MODEL Opus 5.5 Cross-sheet QC model, text-only (cross_qc=True, high effort).
DRAWING_ANALYZER_CITATION_MODEL Sonnet 5.5 Citation-check model, with web search and web fetch (citation_check=True, medium effort). Sonnet rather than the review flagship is a cost choice: Opus 5.5 also has web fetch (Opus 5 did not). A model without web fetch degrades to search-only.
DRAWING_ANALYZER_CITATION_MAX_REFS 50 Per-run normalized reference cap, severity-first after skipping rejected-only claims. Cache hits share the cap. 0 checks none; invalid/negative values use the default. Every skipped claim is explicitly unchecked.
DRAWING_ANALYZER_IDENTITY_MODEL Haiku 5.5 Set-identity model, text-only (Phase A), at medium effort. Advisory-only, with the regex edition harvest as a backstop.
DRAWING_ANALYZER_REVIEW_PLAN_MODEL Opus 5.5 Review-plan authoring model, text-only (Phase A, high effort).
DRAWING_ANALYZER_MAX_PLAN_ITEMS 60 Total item cap on the model-authored review plan. When the model writes more than this, the overage is taken from the longest discipline each round, so the loss is shared: five disciplines of 20 items leave 12 each. (It used to trim the last plan's tail until that plan was gone, and plans sort alphabetically — so mechanical and plumbing were deleted outright while architectural kept all 20.)
DRAWING_ANALYZER_HARVEST_MODEL Haiku 5.5 Prose-harvest structuring model (one small call per straggler, at medium effort on Haiku 5.5; existing model overrides retain low effort).
DRAWING_ANALYZER_ABSENCE_CHECK_MODEL Haiku 5.5 Absence cross-check model, text-only, at medium effort: one call per 25 sheet-level absences. Every hit it reports is re-checked by the host before it changes anything.
DRAWING_ANALYZER_CHAT_MODEL Sonnet 5.5 The HTML report's in-browser Ask AI assistant (billed to the reader's key). Needs adaptive thinking plus the web-search and web-fetch server tools; a model without web fetch (such as Opus 5) degrades the widget to search-only.
DRAWING_ANALYZER_WEB_SEARCH_TOOL_TYPE web_search_20260209 Server-side web-search tool type string (survives an API rename).
DRAWING_ANALYZER_WEB_SEARCH_MAX_USES 10 Per-request web-search budget for citation checks (rides the verdict-cache key).
DRAWING_ANALYZER_WEB_FETCH_MAX_USES 4 Per-request web-fetch budget for citation checks. Each fetch pulls at most 4k tokens of clause text and nearby context into the request. Rides the verdict-cache key.
DRAWING_ANALYZER_CITATION_TTL_DAYS 30 Citation verdict-cache TTL; 0 disables the cache (no read, no write).
DRAWING_ANALYZER_MARKUP_APPENDIX off Append the "checked and consistent" page to reviewed PDFs.
DRAWING_ANALYZER_CRITIQUE_RUNS 2 Critique self-consistency reads to merge (1 disables it).
DRAWING_ANALYZER_CRITIQUE_STRUCTURED_OUTPUTS off Constrain the critique's reply with Anthropic structured outputs (output_config.format + a JSON schema) instead of asking for a fenced ```json block in prose. The critique is the only high-volume call whose reply is JSON and nothing else, which is what makes it the one stage a whole-response schema actually fits (the digest writes a prose digest then a findings block, so a schema cannot describe it). Opt-in on purpose: Anthropic documents citations and prefill as the only incompatibilities and says nothing either way about image inputs — and every critique request carries an overview plus a full tile grid. Prove it on your own account with pytest -m network tests/test_live_api_canary.py -k output_config_format before turning it on. Ignored on the Message-Batches transport (a batch item's shape is fixed at submit, so it cannot degrade). Structured and fenced reads cache under separate keys, so flipping this costs one re-read per sheet each way and discards nothing.
DRAWING_ANALYZER_HARVEST_STRUCTURED_OUTPUTS off The same structured-outputs contract on the prose harvest's per-straggler structuring call: text-only, Haiku 5.5, one flat finding object whose schema mirrors the prompt's field list. Its own self-healing latch and its own cache-key fold-in (structured and fenced items never collide). Prove it with pytest -m network tests/test_live_api_canary.py -k harvest_under_output_config_format.
DRAWING_ANALYZER_VERIFY_STRUCTURED_OUTPUTS off The same contract on the verifier's two-field verdict (verdict as a three-way enum + note), on the single-crop and cross-sheet calls alike. Vision-involved, so it carries the critique's caveat; the prompt is unchanged (it already asks for a bare object), so the requests differ only by the schema. Structured and plain verdicts cache under separate keys. Prove it with -k verify_under_output_config_format. Whether it is worth turning on is answered by the parse-loss line the verification stage now writes (see Verification pass).
DRAWING_ANALYZER_ARITHMETIC_REL_TOL 0.01 Arithmetic auditor's relative match tolerance (drawings round).
DRAWING_ANALYZER_NAMING_DOMINANT_MIN_FREQ 2 Naming auditor: occurrences that make a tag "established" vocabulary.
DRAWING_ANALYZER_NAMING_DRIFT_MAX_FREQ 2 Naming auditor: a tag is only flagged as drift when this rare.
DRAWING_ANALYZER_PROFILES_DIR ~/.drawing_analyzer/profiles User review-profile directory (wins over packaged profiles on name).
DRAWING_ANALYZER_SAVE_TILES off Keep every rendered tile image and export a tiles/ folder with per-tile notes when save_tile_artifacts is not passed. In the GUI it only sets the starting state of the Save tile images checkbox.
DRAWING_ANALYZER_USE_BATCH off Opt every run into the Message Batches transport (~50% token-rate discount with the same model/prompt/review contract) without editing call sites. An explicit use_batch= argument still wins.
DRAWING_ANALYZER_BATCH_STALL_TIMEOUT_MIN 25 first watch, 60 after Minutes of completely frozen batch request counts before the batch is abandoned and its sheets resubmitted. Setting this applies one value to every watch (see Stuck batches).
DRAWING_ANALYZER_BATCH_HARVEST_TIMEOUT_MIN request-derived (~112 min at 64k) Maximum minutes to wait for an abandoned batch to settle before harvesting its completed items. Default: largest submitted max_tokens / 10 tokens per second + 5 minutes of cancellation grace, including raised retry caps. A positive finite override is floored at one minute; invalid values use the default. Harvest time is additional to the collection budget. Shorter overrides can cause duplicate billing if in-flight generation outlasts the wait.
DRAWING_ANALYZER_MAX_BATCH_RESUBMIT_ROUNDS 4 Fresh batches recovery will submit for the sheets a stuck batch left unresolved, before the run keeps a clean retriable batch error.
DRAWING_ANALYZER_BATCH_MAX_ELAPSED_HOURS 24 Hours a batch run will wait before detaching from the remote batch. The default is the Batches API's own SLA. Lower it to cap wall clock; a malformed or non-positive value falls back to the default, and any override is floored at one minute. The cost dialog quotes whatever this resolves to.
DRAWING_ANALYZER_MAX_WORKERS 6 for digest/verification; 4 for critique Real-time stage concurrency (1 = sequential). When set, overrides all three stage defaults.
DRAWING_ANALYZER_STAGE_OVERLAP auto Overlap independent set-level calls for the real SDK client; 0 disables it and 1 explicitly opts a thread-safe custom client in. DRAWING_ANALYZER_MAX_WORKERS=1 remains fully sequential.
DRAWING_ANALYZER_UPLOAD_WORKERS 6 Files-API image-upload concurrency per sheet (1 = sequential).
DRAWING_ANALYZER_CROSS_QC_WORKERS 3 Independent cross-QC map/reconcile calls; results still fold deterministically.
DRAWING_ANALYZER_HARVEST_WORKERS 4 Independent prose-straggler structuring calls.
DRAWING_ANALYZER_ANNOTATE_WORKERS 2 Independent source-PDF markup processes (1 disables process fan-out; hard cap 4).
DRAWING_ANALYZER_SUPPRESS_NEAR_BLANK off Also drop near-blank tiles (PNG-byte threshold), not just pixel-uniform ones.
DRAWING_ANALYZER_NEAR_BLANK_MAX_BYTES 3072 Near-blank PNG-byte threshold (only when the above is on).
DRAWING_ANALYZER_FIXED_GRID off Set 1 to restore fixed 6×6 tiling. Otherwise choose each page's grid from displayed size, minimizing image tokens while preserving the matching vector/raster ANSI E DPI floor. Explicit rows/cols still win.
DRAWING_ANALYZER_TILE_TARGET_PX 1560 Measurement knob. Vector-sheet tile long edge, in pixels. Image tokens scale with the square of this, and the tiles ride the digest plus both critique reads, so it is the single highest-leverage number in the bill: 1400 ≈ −20% image tokens per sheet, 1240 ≈ −36%, 1100 ≈ −50%. Whether a lower value still reads the drawing is a quality question — sweep it against a real set before changing anything, and note that a changed target re-renders (it invalidates the digest/critique caches by design). Raster sheets are unaffected: with no text layer the pixels are the only channel. Clamped to [400, 1992] so no value can breach the API's hard 2000 px many-image cap.
DRAWING_ANALYZER_CACHE_PATH ~/.drawing_analyzer/drawing_digest_cache.json On-disk SQLite/WAL cache (legacy filename retained; old JSON migrates automatically).
DRAWING_ANALYZER_CACHE_PERSIST on Disable to keep the cache in-memory only.
DRAWING_ANALYZER_REFUSAL_FALLBACK on Real-time Opus 5.5 / Sonnet 5.5 calls opt into the server-side refusal fallback. Set 0/false to let a refusal return as one (see Did another model answer?).
DRAWING_ANALYZER_DIAGNOSTICS on Set 0/false to disable the rotating drawing_analyzer.log diagnostics file the GUI writes.
DRAWING_ANALYZER_SHOW_THINKING on Whether a run with a live view (monitor=; every GUI run) asks the models for reasoning summaries for the Live view's Thinking tab. 0/off sends the requests without thinking.display. Display only: the summaries are never exported, logged or cached, and no cache key depends on the setting.
DRAWING_ANALYZER_RESOURCE_SAMPLE_SECONDS 5 Cadence of the host sampler behind the run's resource-starvation record (scheduling lag, CPU, memory, disk; see Was the run starved of resources?). Clamped to 1–300; 0/off disables the sampler, while API retries and exhausted agent budgets are still recorded.
DRAWING_ANALYZER_DISABLE_UPDATE_CHECK off Set truthy to turn off the desktop app's daily update check and "Check for Updates" button (locked-down deployments).
DRAWING_ANALYZER_UPDATE_URL GitHub releases latest.json Override the update-manifest URL (testing, or self-hosting a fork's releases).
DRAWING_ANALYZER_DEBUG off Also route the Anthropic SDK / httpx wire-level logs (status codes, request-ids, retries) into the diagnostics file. Long base64 runs are elided with their size and each record is capped, so a sheet's ~3 MB of image data cannot rotate the log ring away — what stays is the request shape, which is what wire capture is for.
DRAWING_ANALYZER_CACHE_DIAGNOSTICS off Request the prompt-cache diagnostics beta on API calls (operator debugging only).
DRAWING_ANALYZER_MAX_SHEETS 2000 Sheet count above which a run needs explicit confirmation (confirm_large_set=True) rather than silent truncation.
DRAWING_ANALYZER_MAX_FILES 500 Input-file count above which a run needs explicit confirmation.
DRAWING_ANALYZER_MAX_PAGE_PT 20000 Largest sane page dimension (points); a larger/NaN/infinite box is treated as pathological and its page is skipped before rendering.
DRAWING_ANALYZER_EST_BYTES_PER_SHEET 3145728 Per-sheet temp/output estimate for the QC-run disk preflight (evidence crops, reviewed PDFs).
DRAWING_ANALYZER_WORKDIR_MAX_AGE_HOURS 24 How long a leftover drawing_qc_* work directory may sit in the system temp dir before a later run reaps it. These hold the high-DPI evidence crops and are created lazily by the verify / investigate / markup stages, so on a repeatedly-reviewed set they are the largest thing the tool leaves behind. Pruned on the way in, never at run end: extract_drawing_context returns before the caller exports, and the export copies the evidence out — deleting at run end would destroy the crops before anything saved them. The current run's own directory is never touched, and neither is one with anything recent anywhere inside it (a concurrent run writes crops two levels down, where the top-level mtime cannot see them). 0 disables pruning; a malformed value falls back to the default.

Resilient inputs

Every selected PDF is classified up front (Phase 18B): a corrupt, missing, password-protected, zero-page, or not-actually-a-PDF file is recorded and skipped — it never aborts the rest of the set — and the same file selected twice is processed once. The not-a-PDF case is worth naming: the PDF engine also opens images, XPS, EPUB and CBZ, so a genuine PNG or EPUB renamed .pdf used to be accepted and pushed through a pipeline that assumes a drawing set throughout. (A text file renamed .pdf was always rejected, which is why the gap was easy to miss.) The reason names the format that was detected — "not a PDF (opened as Image)" — because that is what tells you which file to look at. The run's report and error list name exactly what was dropped and why (without leaking absolute paths), so a mixed good/bad drop still produces a partial deliverable for the good files. A single unreadable page inside an otherwise good PDF is likewise skipped rather than failing the whole file, and a pathological (oversized/NaN) page box is caught before it can exhaust memory.

Each source is also fingerprinted at the start of the run, and re-checked just before its reviewed PDF is written. If a source file changes on disk mid-run (e.g. it's re-exported while a long QC pass is running), it is left un-marked — its anchors were computed from the earlier revision and would land on the wrong content — and the run tells you which file changed and that a re-run is needed. The other files are marked up normally.

Stuck batches and the stall watch

Accepted digest and critique batches, including follow-up and recovery rounds, are recorded in the digest cache before polling. On the next run, finished batches are collected into the cache before new work is submitted. Sheets owned by a batch that is still processing are left with a retriable status instead of being submitted again. This works after an application exit or crash when cache persistence is enabled.

Recovery also runs when ordinary result caching is disabled, including after switching from Economy to real-time analysis. If the submission receipt cannot be saved, the accepted batch is canceled. Its uploads are released only after the API confirms the batch has ended; otherwise they remain retained.

Interrupted results downloads retry from the beginning using the normal transient-error backoff. If collection remains unavailable, the run returns retriable errors for the affected sheets and keeps the batch record for the next run. A failed download never triggers another paid batch round. Records are removed after results reach the durable cache, or after the API's results retention window expires (29 days from batch creation).

The Message Batches API is asynchronous and occasionally sticks: a submitted batch sits at processing=N with zero completions, sometimes for hours, while every upload and request in hand is perfectly valid. A healthy 39-sheet drawing batch lands in ~10 minutes, so a batch that has not completed a single item is the signal.

The poll therefore watches for completely frozen request counts — any completion resets the timer — and, when the caller opted into recovery, gives up on a frozen batch, best-effort cancels it (an abandoned batch left running only burns quota and pins the uploaded files), and resubmits its sheets as a fresh batch on the same still-uploaded file_ids. Recovery never silently drops to full-rate real-time calls, so a stuck run keeps the ~50% batch discount.

A sheet the batch already finished is never resubmitted. Before anything is sent again, the run reads the abandoned batch's completed items back and fills those sheets from them — cancellation on the Batches API is asynchronous, so a canceled batch reaches ended only after its in-flight requests finish, and those requests are still billed. Their finished items stay readable. Without that step a stalled batch was paid for twice: results are filled only from a terminal read, so on a batch that never terminated every sheet looked unresolved, including the ones already produced and billed. A real 40-sheet run detached with 11 sheets in hand, resubmitted all 40, and returned nothing after paying for 12 digests. The harvest allows one full generation at a conservative 10 tokens per second, using the largest actual submitted max_tokens (including raised retry caps), plus five minutes for cancellation to settle: about 112 minutes at 64k, or 218 minutes at 128k. It is not shortened by a small collection budget. Set DRAWING_ANALYZER_BATCH_HARVEST_TIMEOUT_MIN to override this finite ceiling. Harvest time is added back to the caller's start mark, so it never spends the recovery's budget. If the batch will not settle within that ceiling, the run resubmits unresolved sheets as before; an in-flight generation that outlasts the ceiling can still be billed twice.

The critique batch shares the main poller, but has no stall watch, harvest or automatic resubmission. It waits for the full collection bound and reports uncollected critique reads if it must detach; it does not enter this recovery loop.

The run waits up to 24 hours for a batch — the Batches API's own SLA, and what makes the app's "can run overnight" wording true rather than aspirational. Set DRAWING_ANALYZER_BATCH_MAX_ELAPSED_HOURS to cap the wall clock instead. Raising the bound is only safe because of the harvest above: a longer wait would otherwise just be a longer window in which completed sheets get re-billed. A batch that is still completing items when the bound arrives is recorded distinctly from one that froze — it is handled the same way, since the cancel is what makes its finished sheets readable at all, but the log, the per-sheet error and the usage ledger say which happened, so "stuck backend" and "slower than my bound" are not the same line to read.

The window is tiered, because the first watch and the later ones ask different questions:

Watch Window The question
The primary batch 25 min Is this batch moving at all?
Every resubmission after it 60 min Is the queue deep, or is the backend sick?

A flat hour on the first watch is expensive: one real 39-sheet run burned two consecutive frozen hours before its third batch completed in 589 s — 2 h 16 m of wall clock for ~25 min of work. Set DRAWING_ANALYZER_BATCH_STALL_TIMEOUT_MIN to apply one window to every watch (an operator who names a threshold means it for the whole run); DRAWING_ANALYZER_MAX_BATCH_RESUBMIT_ROUNDS bounds how many fresh batches recovery will try before the sheets keep a clean retriable error.

While a batch is queued the run is not silent: a heartbeat every five minutes reports elapsed time, items done, and how long the watch will keep waiting, in both the diagnostics log and the GUI activity list, and the progress line carries the elapsed clock — so a deep queue reads as a queue rather than a hung app. Every abandoned batch is recorded in the usage ledger as a non-billable attempt (ABANDONED_*, zero tokens), so run_manifest.json accounts for the attempts a run made and threw away, and the collect log names both the batch that was submitted and the batch that actually served the digests.

Testing

python -m pytest

The suite is hermetic — no API key, no network. Tests that render real PDFs are skipped when PyMuPDF is unavailable. tests/test_drawing_acceptance.py encodes the QC acceptance script end to end (a fresh both-checkbox run, the reviewed-PDF appearance streams, the stale-reference suggestion, a zero-digest-API cached re-run, and the raster fallback) against fake clients, so the whole findings → verify → cloud chain is exercised without a key.

The same file also carries the Phase 27 trust gauntlet: one deterministic synthetic oracle set (tests/fixtures/gauntlet.py) containing duplicate basenames, every page rotation plus a reduced CropBox, vector/raster/hybrid pages, a pre-existing reviewer annotation, unrelated same-tile findings, repeated text, prose stragglers with a forced structuring failure, a reproduced-vs-singleton critique pair, a deterministic arithmetic mismatch, a dual-leg cross-sheet conflict, a rejected finding, an unanchored finding, a set-level synthesis conflict, two claims sharing one code reference, and a corrupt input — driven cold (fifteen first-run guarantees, from wrong-source isolation to byte-exact verifier evidence), warm (zero digest/critique calls, zero rasterization, stable QC numbering), and mutated (only the changed source re-analyzed), plus per-stage failure injection proving the run degrades to PARTIAL/FAILED honestly while the standard digest survives. The §19.2 large-set tests prove the >40-sheet map/reconcile topology (44 sheets, two shards) and the 84-sheet three-shard reduction find a conflict no single shard can see.

Browser security tests

The report's security boundary (see SECURITY.md) is proven end to end in real headless Chromium — a DOM emulator can't verify CSP enforcement, file:// behavior, browser URL normalization, or event execution. These tests carry the browser marker and skip automatically when Playwright or its browser binary is absent, so the default python -m pytest is unaffected. To run them locally:

pip install -e ".[dev,browsertest]"
python -m playwright install chromium   # one-time browser download
python -m pytest -m browser

The tests are still hermetic: the report is loaded over file:// and the Anthropic fetch is stubbed with a canned (malicious) stream, so there is no network and no key. A global execution sentinel must stay unset while a hostile payload streams through the assistant and while hostile filenames / findings / errors sit in the report body.

Continuous integration

.github/workflows/ci.yml runs the hermetic suite on Windows + Ubuntu across Python 3.11 and 3.12 (byte-compile, the PyMuPDF import-isolation check, then the full suite), plus the headless-Chromium report-security suite on Linux, plus two release-gate jobs (Phase 27 §19.8): security-gates (repo secret scan, correctness-class static analysis, the dependency license/AGPL audit, and a pip-audit vulnerability scan with a documented dated-exception policy) and build (wheel/sdist build under the committed requirements-release.lock constraints, twine check, and a clean-venv install smoke test proving the profile mechanism works in the installed wheel — the loader tolerates the absent packaged directory and the parser round-trips a checklist item — and that the installed version matches drawing_analyzer.__version__). No built-in profiles ship: the packaged profiles/ directory is empty by design and review plans are model-authored per set (Phase A), so there is nothing in the wheel for that smoke to find. Actions are pinned to immutable commit SHAs, permissions are read-only, and the workflow never uses pull_request_target. Making these checks required is a one-time branch-protection setting an owner/admin configures in repository settings.

Three additions worth knowing about:

  • The workflow also runs on a weekly schedule (Monday 06:00 UTC) and on workflow_dispatch, so the pip-audit gate catches a CVE disclosed against an already-pinned dependency instead of waiting for someone to commit. .github/dependabot.yml opens grouped weekly updates for both the SHA-pinned actions and requirements-release.lock. The Windows installer build is constrained by that lock, and the Inno Setup compiler — the one tool whose output is the shipped artifact — has its exact version recorded in the build summary and in the acceptance record. It is deliberately not fetched: the runner image already ships it, and a build that downloads no compiler cannot be served a bad package. The compatibility contract is the Inno Setup 6 directory installer.iss targets, not a version comparison — ISCC.exe reports ProductVersion 0.0.0.0, so a floor check on it fails good compilers.
  • The browser job now writes a JUnit report and runs scripts/check_browser_suite.py against it. Every test in that suite skips itself when Chromium will not launch and pytest exits 0 on an all-skipped run, so the required check was green over a suite that executed nothing — measured on one commit, one environment variable apart: 98 passed and 98 skipped, 2180 deselected, both exit 0. The guard fails the job below a floor of genuinely executed tests. A skip is not a pass.
  • release.yml's publish job now needs two tag-gated jobs in its own needs chain: gates (the full scripts/run_acceptance.py with Chromium installed, plus static analysis and the license and CVE audits) and gates-windows (the hermetic suite on Windows). Branch protection does not apply to a tag push and a tag can name any commit, so a release used to be gated only on the installer compiling. CI also triggers on v* tags, but that is visibility only — a separate workflow run started by the same tag is not a dependency of the release workflow, so it cannot gate the publish.

Acceptance & release gate

A release candidate is not done when the test suite is green — it is done when the recorded acceptance evidence says so (Phase 27, §19.9). The pieces:

  • Automated gates, one command: python scripts/run_acceptance.py runs byte-compile, import isolation, the full hermetic suite (including the trust gauntlet), the secret scan, the browser exploit suite (when Playwright is installed), and a wheel/sdist build + clean-venv install smoke — and prints a PASS/FAIL table. CI runs the same gates per push. Every pytest gate deselects the network marker in the child it spawns, so the run stays hermetic even on a machine with a real ANTHROPIC_API_KEY exported — the billable canary below only ever runs when you ask for it by name.

  • Live API canary (opt-in, billable): ANTHROPIC_API_KEY=... python -m pytest -m network -rs -s tests/test_live_api_canary.py verifies the live request schemas, critique structured-output compliance, the pinned web-search tool type, the Files API upload→delete lifecycle, and that exported logs/manifests carry no key. Never runs in CI.

  • Host evidence vs. prompt text: a sheet's text layer exists in three forms and they are not interchangeable. sheet_text is capped at 15,000 characters and is what the model is shown; full_sheet_text is the uncapped text kept for host-side checks and never sent anywhere; words is the coordinate stream the anchor resolver uses. All three are extracted from the page's own content, with annotations excluded. That matters most on a re-review: the PDF engine folds annotation text into ordinary text extraction, so a set marked up by an earlier run used to feed its own prior QC callouts back to the model as sheet text — the analyzer reading its previous output as if the engineer had drawn it — and the inflated word count also decided whether the sheet was treated as raster, so a scanned sheet carrying nothing but a prior callout was rendered at the cheaper vector target. Cross-sheet grounding asks models.sheet_evidence_text(), so a quote the model read off the drawing past the cap is still recognised as real source text instead of being discarded as a hallucination.

  • Evidence the host cannot check: a scanned sheet has no text layer, and a hybrid sheet's pasted detail is invisible to a text search even though the sheet has words. A cross-sheet quote the host cannot check is admitted at reduced trust rather than discarded — labelled on the drawing with a plain-words reason and flagged beside the quote in the report. The label says which case it is: [NO TEXT TO CHECK] where the region carried no searchable text, [NO QUOTE TO CHECK] where the AI named no quote to look for. A quote that fails to match text the sheet's own region does have is unchanged: that stays the hallucination signal.

    Grounding therefore has three outcomes, not two, and the third is the one a boolean used to swallow:

    outcome meaning what happens
    TEXT_GROUNDED re-found in the sheet's source text full trust
    NOT_MATCHED_IN_TEXT should have been findable, was not dropped — the hallucination signal
    TEXT_EVIDENCE_UNAVAILABLE no text existed to check, or no quote was given admitted at reduced trust, labelled, still verified

    Collapsing the last two into "not grounded" is what made a scanned sheet — and the pasted raster region of a hybrid sheet, which has words and so cannot be identified by is_raster — look like a sheet full of invented quotes. The question is asked of the reported tile, not the whole sheet: one selectable title block would otherwise answer "yes, there was text" for a detail pasted in as pixels, and the recovery would never fire on a real page.

  • The cross-sheet pass reports no tile. It reads digests and text layers only, never a tile image or the sheet's grid, so a tile it named was a guess. A guess could rescue a quote the text did not support (a wordless tile answered "no text here") and put the cloud on the wrong part of the sheet, so its prompts no longer ask for one. With no tile, a scanned sheet's quote is still admitted at reduced trust; it is placed as a margin callout instead of on a tile. A quote from a hybrid sheet's pasted raster detail is now dropped as unmatched, because without a location nothing shows that the region had no text. The host still bounds-checks a tile if a model volunteers one.

  • Recovered evidence costs more downstream, by design. Findings that used to be dropped now survive into the later stages, and those stages bill. A reduced-trust finding with a reported tile is anchored to it so the crop re-check and the investigation loop can still look at it — so a set with scanned or hybrid sheets can show more verification calls, more investigation rounds, and a higher total than the same set produced before, with nothing having gone wrong. That is the trade: the alternative was dropping real findings silently.

  • What each evidence change did to the cross-QC cache, and why they differ. Recovering quotes past the prompt cap added the uncapped text's digest to the cache key only for a sheet that is actually truncated — for every other sheet the evidence equals the text already in the key, so those keys stayed byte-identical and no stored result was thrown away. Admitting visual evidence changed what a cached entry means, so every entry had to go; that invalidation comes from the edited map prompt, which rides every cross-QC key, rather than from a second mechanism bolted on beside it. Note that _CROSS_QC_CACHE_CONTRACT sits at 2 for an unrelated reason — entries written before the findings cap became loss-aware were stored as complete after a silent truncation — so that number is not the record of either evidence change.

  • Evidence coverage (zero API calls): python scripts/measure_evidence_coverage.py --pdf SET.pdf [--export-dir DIR] scans a set without rendering or calling the API and reports how much evidence the host can actually check: sheets whose text layer runs past the 15,000-character cap (and by how much), the textless/hybrid population, the cross-QC 4,000-character budget spend, and — with --export-dir — the anchor tiers of cross-sheet findings a previous run kept. It states plainly what it cannot see: the grounding discard rate is a property of what a model returned, and the survivors in an export are not a substitute for it.

  • Benchmarks: python scripts/benchmark_drawing_analyzer.py --check measures the §19.7 scenarios (cold/warm/mutated/exhaustive/corrupt-partial) on medians and enforces the mechanical gates (a warm run makes zero digest/critique calls and rasterizes nothing; sources hash once per run; usage totals reconcile). See docs/PERFORMANCE_AND_COST_VALIDATION.md.

  • Manual records: docs/WINDOWS_ACCEPTANCE.md (real-Windows path/input matrix) and docs/RELEASE_ACCEPTANCE_TEMPLATE.md (the master §19.9 checklist, including the Bluebeam Revu / Acrobat / Chromium viewer script and the Excel/Notepad output script) are completed per release candidate and kept with the release.

  • Reproducible builds: requirements-release.lock pins the dependency set release artifacts are built and smoke-tested against (pip install -c requirements-release.lock ".[dev]"); regenerate it deliberately and note it in the changelog when bumping dependencies.

Changelog

See CHANGELOG.md for a consolidated record of changes, including the QC findings / verification / markup integration and the periodic re-verification of the Anthropic vision / Files / Batches API facts these constants depend on.

Licensing

Drawing Analyzer is free software. Copyright © 2026 Abraham Borg; distributed under the GNU Affero General Public License, version 3 or later (AGPL-3.0-or-later — see LICENSE). Anyone may use, study, modify, and redistribute it, commercially or otherwise, under that license's terms; there are no restrictions on distribution beyond the AGPL's own conditions (share the source of modified versions, including when they are offered over a network).

The copyleft license is not optional here: the project depends on PyMuPDF, which is dual-licensed AGPL-3.0 / Artifex commercial, so a combined work distributed with PyMuPDF must itself be AGPL (unless you obtain Artifex's commercial license). Commercial use and AGPL-compliant redistribution are both allowed as is; a proprietary (closed-source) distribution is not, unless PyMuPDF's licensing is resolved with Artifex or PyMuPDF is replaced. All PyMuPDF usage is isolated to exactly two modules — src/drawing_analyzer/render.py (rasterizing sheets and crops) and src/drawing_analyzer/annotate.py (writing cloud annotations onto reviewed PDFs) — so the PDF backend can be swapped for a permissively-licensed one by rewriting just those two files, should you want to relicense.

For the rasterization side, pypdfium2 + Pillow is a drop-in permissive replacement. The markup writer is the harder swap: pypdf can build /Square annotations with a cloud border-effect dict (/BE {/S /C /I 2}), but it does not generate the appearance stream (/AP) that PyMuPDF's annot.update() produces, so some viewers render those annotations blank — which is why PyMuPDF is used here. That trade-off is documented in annotate.py's module docstring.

The Windows installer carries the same notices as the source: its license page shows the AGPL text under the copyright line (on a fresh install and on every in-app update, which runs the full wizard). The page is read-only — there is no "I accept" step, since the AGPL does not require accepting it to run the program. The license is installed next to the program as LICENSE.txt with a Start-menu shortcut, and the Setup exe's version resource and the Add/Remove Programs entry carry the copyright and license.

About

LLM reviews construction drawings by splitting each sheet into 36 parts.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages