Files
willtools-demo/docs/architecture-migration.md
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

642 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Migration — thin in-process gatherer, fat host CPython
Approved by the owner 2026-08-30. This is the order of work, the size of each
step, and what each step has to prove before the next one starts. It is not an
argument for the direction; the direction is settled.
**The rule it implements.** In-process IronPython keeps only *gatherers* —
code that must touch the Revit API: views, schedules, geometry, selection,
dialogs, export. Everything that churns — check judgement, matching,
rendering, config logic — runs on host CPython and is fed by files.
**The contract is the file, not the function.** A versioned JSON document with
a `SCHEMA` int (or string), discarded whole on mismatch, carrying provenance.
The pattern is already proven here three times: `liftexport` → `panellift/1`,
`reinforceindex.extract` → `reinforcecore.SCHEMA = 6`, `designspec` →
`evidence/<DESIGN>.json` + `specformat.SCHEMA_VERSION`. Nothing below invents
a fourth style.
---
## 1. What the restarts are actually for
The direction is right; the stated reason for it is only two thirds right, and
the third that is wrong changes which steps pay off first. Measured
2026-08-30, from the pyRevit 5.0.1 source in `%APPDATA%\pyRevit-Master\` and
from this machine's own configuration:
| what needs a restart | why | can this plan fix it |
| --- | --- | --- |
| adding, renaming or re-laying-out a button | the ribbon and its command assembly are built once, at pyRevit load | **no.** Nothing can. |
| a change to a module the **bridge session** has already imported | `startup.py` registers the routes handler on the engine that first loaded it; that engine's `sys.modules` lives as long as Revit. `docs/revit-bridge-context.md` measured this on 2026-08-30 | **partly** — host CPython is re-read on every spawn, so anything that moves across the file boundary stops being cacheable at all |
| a change to a `lib/vibe` module used by a **button press** | this is the one that is probably already false — see §8 | already free, if §8's reading is right |
So the payoff of this migration is not mainly "presses see lib edits". It is:
- **the bridge stops lying.** A judgement layer on the far side of a file is
re-read every spawn; no session can hold a stale copy of it, and the trap in
`revit-bridge-context.md` ("a module read early and edited later is stuck
for the rest of the session") cannot apply to it.
- **the suite can reach it.** `tests/conftest.py` can only import a module
that does not touch Revit at module scope. Every line moved across the
boundary becomes testable without a licence, a model or Windows — which is
how `panellift` came to have its own suite and `ticketpage` its 250-odd
tests, all of them running on host CPython.
- **the restart-needing surface shrinks.** TicketQC's six modules
(`ticketqc`, `ticketchecks`, `ticketpage`, `ticketvision`, `ticketdelta`,
`ticketextract`) are all in-process today, and §4 ends with roughly 1,700
lines of them in-process and the rest on the host. Their combined total was
measured six times in two days and came out 12,139 → 12,232 → 13,179 →
13,538 → 13,635 → 14,215, the last two both on 2026-08-31 a few hours apart.
The argument does not depend on which of those it is, which is why §3 no
longer prints per-module counts. Re-measure with §3's command if you need
today's.
Write that down honestly in any status note. The plan reduces the surface that
needs a restart. It never reaches zero, because a gatherer is still a module in
Revit's engine.
---
## 2. The contract
One shape, reused. Anything new copies `liftexport.SCHEMA` / `reinforcecore`
rather than inventing:
```
{ "schema": "ticketqc/1", # int or "name/N"; bumped on ANY key change
"written_at": "<iso8601>", # provenance
"written_by": "TicketQC <ver>", # which button, which build
"doc_key": "<stable model key>", # reinforcecore.doc_key idiom
"knowledge_sources": [ ... ], # knowledge.sources(doc), verbatim
... payload ... }
```
Four rules, all of them already load-bearing somewhere in this repo:
1. **Discard whole on mismatch.** `reinforcecore.load()` throws away a file
whose `schema` is not the current int — never partially, never migrated.
A half-read evidence file is a run against a configuration nobody chose.
2. **No live objects.** Element ids as ints, marks as strings, view names and
flattened text as strings. `read_ticket` currently puts the live
`Document` on `t['doc']` and live `View` objects in `t['legends']`.
**Corrected while building step 1: those are not the whole list.**
`t['sheets']` is a list of live `ViewSheet` and `t['sheet']` is one of
them, read in thirteen places in `ticketchecks`; and on the no-panel early
return `t['strand_points']` is still live `XYZ`. Four holdouts, not two —
which is why the serialiser gates on TYPE rather than on a list of known
offenders, and why the test for it walks the whole structure.
3. **Absence is not a value.** A gather that could not read something writes
the key with an explicit `null` *and* a reason beside it. A key that is
simply missing reads as "nothing to check" downstream, which is the exact
silence-looks-like-a-pass failure `ticketchecks._SKIP_PROBES` exists to
prevent. The probe idiom moves across the boundary with the check.
4. **Provenance travels.** Which knowledge layers were in force, which spec
source (`spec` / `regex`), which build. A finding whose provenance was left
behind on the Revit side cannot be re-judged from the file.
---
## 3. Module inventory, with verdicts
Every module under `WillTools.extension/lib/vibe/`. "Revit at module scope"
comes from an AST walk, not from reading; "heat" is commits touching the file
in the seven days to 2026-08-30.
**The line counts are gone from these tables, deliberately.** They were walked
three times in three days and were stale all three times. Two measurements
twenty minutes apart on 2026-08-30 disagreed by 479 lines on `knowledge.py`,
173 on `ticketpage.py` and −71 on `designspec.py`, because two agents were
writing to the tree between them. The 20:52 walk that replaced those was
itself wrong by the next morning: re-walked 2026-08-31, `yamlkit.py` had gone
346 → 791, `configform.py` 612 → 985, `takeoffcount.py` 299 → 470, and ten
more had moved — `knowledge.py` by nearly a thousand lines (2,477 → 3,442),
`ticketpage.py` by three hundred. Both of those moved *again* between two
walks a few hours apart the same day, while agents were writing to the tree.
This section has said "the verdicts do not move, the numbers do" since it was
written, and then printed the numbers anyway. **The format now matches the
note.**
Get them fresh instead — one line, and it is the same walk the table used:
```bash
cd WillTools.extension/lib/vibe && wc -l *.py | sort -rn
```
Rows below are ordered largest-first **as of 2026-08-31**, so the ordering is
a snapshot too. The verdict in each row is not, and that is the column this
table exists for.
**Counts: 4 gather-bound · 28 already-pure · 7 mixed, needs a split line** — 39
modules, re-checked 2026-08-31: none added, none removed since the 20:52 walk.
`plantpath.py` remains the most recent addition and is already in §3.2. The
one growth worth knowing about is not churn: **`ticketqc` grew by roughly 590
lines when step 1 added the evidence writer to it**, which is this plan's own
work.
The `defs` and pure/Revit splits quoted inside §3.3 are **as first measured and
not re-walked**, except where a cell says otherwise. The original counted a def
as Revit-bound by what it reaches for, which an import-based AST walk cannot
reproduce, so re-deriving them means redoing the judgement, not rerunning a
script. They are marked in place rather than deleted; treat them as
unverified-since.
### 3.1 Gather-bound — stays in-process, permanently (4)
| module | heat | note |
| --- | --- | --- |
| `latextract.py` | 1 | `Autodesk.Revit.DB` at module scope; pure model reading. Feeds `latmodel` (already pure). |
| `jobreconscan.py` | 2 | ditto; the scan half of JobRecon, whose judgement half (`jobrecon.py`) is already pure. |
| `helpers.py` | 0 | `q p set_p tx selected ft mm`. This *is* the Revit surface. Never migrates. |
| `__init__.py` | 0 | re-exports `helpers`; the reason `conftest.py` has to stub the package. |
### 3.2 Already pure — no migration, only a runner and a feed (28)
These import no Revit API at module scope and none inside their functions.
Every one of them could run on the host today given its inputs as a file.
| module | heat | what it is |
| --- | --- | --- |
| `ticketpage.py` | 26 | the whole TicketQC page. **Hottest pure module in the repo, and the proof the target works.** |
| `knowledge.py` | 12 | five-layer merge. Reads YAML from paths; already file-fed by construction. |
| `configmodel.py` | 4 | what may be edited, provenance, the splice. |
| `reinforcereport.py` | 2 | |
| `jobrecon.py` | 3 | |
| `reinforcebind.py` | 3 | |
| `configform.py` | new | untracked when this table was first written; tracked since. |
| `ticketdelta.py` | 1 | |
| `yamlkit.py` | 1 | |
| `takeoffreport.py` | 2 | |
| `reinforcematch.py` | 1 | |
| `ticketextract.py` | 1 | |
| `reinforcecore.py` | 2 | owns `SCHEMA = 6` — the reference implementation of rule 1. |
| `configdump.py` | 3 | |
| `takeoffcore.py` | 1 | |
| `configpage.py` | 2 | |
| `snapshotmeta.py` | 3 | |
| `takeoffcount.py` | 1 | |
| `specformat.py` | 2 | owns `SCHEMA_VERSION`. |
| `foldreport.py` | 3 | |
| `latmodel.py` | 1 | |
| `takeoffgather.py` | 1 | pure despite the name — it gathers *counts*, not elements. |
| `scope.py` | 1 | |
| `specsave.py` | 1 | |
| `latrisa.py` | 1 | |
| `plantpath.py` | 0 | **new since the 19:30 walk** — the plant-layer path resolution `designspec` re-exports. Pure, and already file-fed. |
| `compat.py` | 0 | py2/py3 shim; stays in-process as a shim, irrelevant on the host. |
| `batchrun.py` | 3 | **pure of Revit but in-process by nature** — the queue, the lock and the thread factory must be created on the UI thread while the defining scope is alive (`docs/lift-check.md`). Do not "migrate" it; it is the thing that drives a migration. Listed last regardless of size, because that note is the reason it is here. |
### 3.3 Mixed — the split line, named (7)
| module | heat | the split line |
| --- | --- | --- |
| `ticketchecks.py` | **40 — hottest file in the repo** | Top-level defs went 128 (19:30 walk) → 131 (20:52) → 138 (midday 2026-08-31); `grep -c '^def ' ticketchecks.py` for today's, and note that it moved three times in two days. The pure/Revit split is *as first measured, not re-walked*: **97 pure (1967 lines), 31 Revit-touching (~2000)**. **27 registered checks** in `CHECKS` — the 26 this table used to say predates `check_callout_overlap` (checklist row 1.5); re-count by reading the `CHECKS` tuple, which is one name per line. **20 of the 27 carry a skip probe and 7 do not** (see `CLAUDE.md` §5 for why that matters when you add one). About 21 top-level defs reach `t['doc']` or `t['sheets']` — that is the real size of step 4's surface. The older "13 of the checks reach `t['doc']`" figure counted reads through helpers as well as direct ones; only 9 do it in their own body, so the two are not the same measurement and neither is wrong. Split **per check**: each becomes `gather_<check>(doc, t) → dict` (in-process, into the evidence file) plus `check_<name>(t, intent, know, spec)` reading only that dict (host). `_SKIP_PROBES` moves with the judgement half. |
| `ticketqc.py` | 17 | Top-level defs plus classes, not defs alone: the "76" this table used to give was 74 defs + 2 classes, and the def count has since moved again. The half-and-half below is *as first measured, and excludes the evidence writer*: **31 Revit-bound (991 lines)** — `read_ticket`, `member_boxes`, `collect_context`, `_read_bom_schedules`, `parse_design_summary`, `strand_layout_of`, `concrete_extents`, `element_solids`, `run`, `run_for_assemblies`; **31 pure (689 lines)** — `Finding`, `check_ticket`, every `_check_*`, `spec_to_intent`, and all the parsers/formatters (`parse_feet`, `fmt_feet`, `parse_strand_groups`, `strand_signature`, `parse_concrete`, `parse_wwf`, `_compare`). The pure half is what `ticketchecks` imports at module scope, which is the single reason `ticketchecks` cannot be imported on the host without a stub. **Splitting `ticketqc` into `ticketqc` (gather) + `ticketrules` (pure) deletes the stub in `test_ticketchecks_host.py`.** |
| `liftexport.py` | 10 | **already split, correctly.** `export()` and below-`export()` are separated by design; the file boundary is `panellift/1`. Precedent, not work. Only new gathers go in above the line. |
| `ticketvision.py` | 4 | gather = rendering a view to an image and shelling out; pure = the ladder, `report_block`, `qc_json`, `write_run_json`. The pure half is where the NOT RUN / WARN invariant lives and it must cross with the judgement. |
| `designspec.py` | 4 | **half already across.** `gather_evidence` → `evidence/<DESIGN>.json` exists. `build_spec`, `new_spec`, `validate_spec`, `get_path` and the path helpers are pure and can move as soon as a runner exists. Module-scope `Autodesk.Revit.DB` is what pins the rest. |
| `reinforceindex.py` | 2 | `extract` gathers → the index file; everything else already reads the file. Same shape as `liftexport`. Small residue. |
---
## 4. Migration order
Sizes: **S** = one working session · **M** = two or three · **L** = a week of
sessions. Every step ships alone and leaves the tree working; no step depends
on the next one existing.
### Step 1 — TicketQC evidence file, written and ignored — **M** — **DONE 2026-08-30**
`ticketqc` grows `ticket_to_json(t)` and the button writes
`<model>/_ticketqc/evidence/run-<stamp>.json` on every press, schema
`ticketqc/1`. Nothing reads it. `t` itself does not change shape, and no
judgement moves.
The two holdouts named in §2 rule 2 are the work: `t['doc']` becomes
`doc_key` + the model's path stem, `t['legends']` becomes
`[{id, name, text}]` from `_flat_text`. Everything else in `read_ticket` is
already strings, numbers and dicts.
Ships alone because a file nobody reads cannot break a run — wrap the write
the way `write_run_json` is wrapped in the button today, so a failure prints a
line and does not lose the report.
**AS BUILT. There is a THIRD live holdout, and the paragraph above is wrong
about it.** `t['sheets']` is a list of live `ViewSheet` objects and
`t['sheet']` is one of them; `ticketchecks` reads `.Name`, `.Id` and the sheet
number off them in thirteen places, and 20 of its top-level defs reach
`t['doc']` or `t['sheets']`. They serialise the same way the legends do —
`[{id, number, name}]`. A fourth exists only on a failure branch: when
`read_ticket` finds no framing member it returns early, and `t['strand_points']`
is still a list of live `XYZ` at that point rather than the `None` the
success path leaves. Both are handled; neither was in the plan.
Also as built, and deliberately beyond the paragraph above:
- **`load_evidence` ships now, unused.** Rule 1 is a property of the format,
not of the writer, and a discard rule that has never been executed is a
comment. It is copied from `reinforcecore.load()` down to the shape of the
failure, and its test asserts that a schema-mismatched file comes back with
**no** tickets — not merely with `_load_error` set.
- **Rule 3 is enforced by construction, not by discipline.** Every field goes
through one `put()` that will not write a null without writing a sentence
beside it, and the test walks the produced document rather than naming keys.
- **The writer names what it did not serialise.** `unserialised_keys` lists
every ticket key this writer does not know about — today the caches
`ticketchecks` leaves behind (`frame`, `member_boxes`,
`_debond_requirement`, ...). That list is step 4's worklist, and it is there
so a gather added later cannot fall out of the file in silence.
- **`intent` and `spec_source` ride with each ticket**, because §2 rule 4 says
a finding whose provenance stayed on the Revit side cannot be re-judged.
Findings do NOT: `qc-run.json` owns the verdicts, this file owns the inputs,
and step 2 needs them apart to compare one against the other.
Gates: §5.2 / §5.3 / §5.4 / §5.5 met (39 new tests, suite green, scrub clean,
one implementation). **§5.1 is NOT met and could not be**: parity needs a real
press and the bridge was held exclusively by another session. What is unproven
is only what a press can show — that the write costs nothing visible, that the
file is well-formed on real geometry, and its size. The budget in §6 is still
a budget; a synthetic single-piece document is 3.7 KB, which says nothing about
a real one carrying a full BOM and flattened note text.
### Step 2 — the host runner, judging from the file — **M**
`WillTools.extension/runners/ticket_judge.py`, host CPython, no Revit
anywhere: reads the evidence file + `knowledge.yaml` layers + specs, calls
`check_ticket` and `ticketchecks.run_all` for the 13 checks that never touch
`doc`, writes `findings.json`.
The button does not call it yet. This step's whole output is a parity proof
(§5) run offline against the twelve-piece audit set in
`docs/ticket-audit-wp1-12.md`.
### Step 3 — the button calls the runner for the pure checks — **S**
`liftexport.find_python` + `liftexport.run` already know how to find an
interpreter and shell out with a timeout and a tick callback. Reuse them; do
not write a second spawner. The in-process path stays in the file, behind a
knowledge switch, until step 5.
This is the first press where a `ticketchecks` judgement edit is visible
without a restart.
### Step 4 — the 13 doc-touching checks get gather halves — **L, but 13 ships**
One check per commit, in churn order. For each: `gather_<check>` writes its
sub-document into the evidence file under its own key; the judgement half
moves to the host and reads only that key; the skip probe moves with it.
Order by heat and by how much geometry each drags along:
| # | check | why here |
| --- | --- | --- |
| 1 | `check_bom_strand_count` | smallest gather (a schedule read), and 56 lines |
| 2 | `check_jacking_force` | 58 lines, already has a probe |
| 3 | `check_wwf_designation` | 44 lines |
| 4 | `check_ticket_set` | 58 lines |
| 5 | `check_handling_result` | reads a file `liftexport` already writes |
| 6 | `check_bom_members` | 104 lines |
| 7 | `check_picking_match` | 90 lines |
| 8 | `check_note_views` | 80 lines |
| 9 | `check_cast_extras` | 78 lines |
| 10 | `check_strand_debond` | via `_strand_debond_openings` |
| 11 | `check_dunnage_openings` | 227 lines, drags `liftexport` |
| 12 | `check_bed_plan_reverse` | 232 lines |
| 13 | `check_member_interference` | **last.** 300 lines, and the one place where two working-looking versions were both wrong (`docs/interference.md`). Move it only when the other twelve have proved the gather shape. |
### Step 5 — `ticketqc` splits; the stub dies — **M**
`ticketqc` keeps the 31 Revit-bound defs. The 31 pure ones move to
`ticketrules.py`. `ticketchecks` imports `Finding`, `fmt_feet` and `_fmt_num`
from there instead, and `test_ticketchecks_host.py`'s `_stub_revit()` — with
its careful comment about putting `sys.modules` back — is deleted, not
rewritten. Delete the in-process duplicate path from step 3 in the same
commit; double maintenance ends here.
### Step 6 — `ticketpage` renders on the host — **S**
It is already pure and already the hottest pure file. The runner returns
markdown; the button prints it. Small because the work was done in advance.
### Step 7 — ProjectConfig — **S/M**
Five modules, all already pure (`configmodel`, `configpage`, `configform`,
`configdump`, `knowledge`), 4,801 lines, 14 commits in seven days and second
only to TicketQC in churn. The gather is thin: `knowledge.load(doc)` and the
model's paths. Highest ratio of churn relieved to work done in the whole plan
— it may deserve to run before step 4 if config keeps churning.
### Step 8 — ExtractDesign — **S**
`gather_evidence` already writes the file. Move `build_spec` and validation to
a runner.
### Step 9 — ReinforceSuggest — **M**
`reinforceindex.extract` already writes the index; `reinforcematch`,
`reinforcebind` and `reinforcereport` are already pure. Low heat (3 commits in
seven days) — do it when the churn justifies it, not before.
### Never — and say so out loud
| | why |
| --- | --- |
| **SelfCheck** | its entire output is *what this session can see*: engine, knowledge layers, paths, live document. Moving it off-process would make it report a different process's answer. Inherently in-process. |
| **SnapshotClose** | detaches and saves a document. It is a Revit transaction with a progress dialog. Nothing to migrate. |
| **PanelLiftCheck** | already migrated. `panellift` is the model this plan copies. |
| **`batchrun`** | see §3.2 — UI-thread coordination, not logic. |
| **`helpers`, `__init__`** | the Revit surface itself. |
| **LateralExtract, GravityLoadChase** | unfinished, behind the `DesignHub` pulldown on `Design.panel` (the `Lab.panel` slideout until 2026-08-30). Do not migrate a tool that has not settled what it does. |
---
## 5. Acceptance gates
Every step, all five. A step that cannot show all five is not shipped, it is
in progress.
### 5.1 Parity, on real data, with the clock frozen
The standard the `enabled` work set and the `--no-recommend` work met: **the
old path and the new path produce byte-identical output on the reference
data.** `panellift/tests/test_no_recommend.py::test_a_default_run_is_byte_for_byte_what_it_was`
is the shape to copy — assert it in the suite on one case, and run it out of
band across the whole reference set with the clock frozen.
For a check moving in step 4 the unit is the finding, not the byte: the same
`(severity, code, message)` tuples in the same order, for all twelve pieces of
the WP1 audit set, in-process vs host. A message that differs by so much as a
rounded digit is a failed step, not a cosmetic difference.
Freeze the clock. Every document in §2 carries `written_at`, and a parity
diff that is nothing but timestamps teaches nothing and hides everything.
### 5.2 The honesty invariants, re-asserted after the move
These are the reason the tool is trusted and they are the easiest thing to
lose in a refactor. Each needs a test on the **host** side after the move:
- **NOT RUN is never a pass.** A check whose inputs did not arrive reports NOT
RUN with a reason. The `_SKIP_PROBES` disambiguation — probe returns text →
SKIPPED with the why; probe absent → NOT RUN, reason "no probe" — must
survive the split intact. An empty gather key is exactly the case it
catches, and it is the case a file boundary makes more likely, not less.
- **Could-not-judge stays distinct from judged-and-fine.** The
`computed` / `model-read` separation in `ticketvision` never merges into one
list; `qc_json` keeps two keys.
- **Provenance stamps survive.** Knowledge layers, spec source, build. §2
rule 4.
### 5.3 Suite green
`cd WillTools.extension && python -m pytest` — 1440 at time of writing —
and `cd panellift && python -m pytest` — 272. **Mind the root**: run from the
repo root instead and pytest also collects `panellift/` and the gitignored
`_scratch/`, which is a different and larger number (1,847 green at
2026-08-30 21:05, all suites, after step 1's 39). Quote which root a count
came from or two people will compare numbers that were never the same
measurement. Both green, and the count goes
**up** at every step: a step that moves 500 lines to the host and adds no
tests has not banked the reason for moving them.
### 5.4 Scrub clean
`python tools/scrub.py --worktree`. New attention needed: evidence files carry
element ids, marks and sheet names. Ids and marks are naming conventions and
stay; a model filename or an absolute user path in a *committed sample* is a
leak. Evidence files live beside the model and are never committed —
keep it that way, and keep the fixture set synthetic.
### 5.5 The gate that is specific to this migration
**No step may leave two live implementations across a Revit restart.** During
a step the old path may stay behind a switch; by the end of the step it is
deleted. Two paths that both run are two paths that drift, and the drift is
invisible until someone reads a finding that only one of them produces.
---
## 6. Costs, measured
| cost | measured | note |
| --- | --- | --- |
| interpreter spawn, bare | **0.026 s** median of 7, host CPython 3.12 | floor |
| spawn + the stdlib a runner imports (`json os io re argparse datetime html`) | **0.040 s** | |
| spawn + `import panellift.cli` — a real package | **0.066 s** | closest analogue to a judgement runner |
| a Panel Lift press, end to end | **~0.1 s** of overhead | includes .NET `Process.Start`, writing the job and reading the summary back |
| a reference job file | **8.6 KB** median (368 files, 3.0 MB total) | the proven `panellift/1` payload |
| a ticket evidence file | **budget 30–80 KB** per piece, STILL A BUDGET | bigger than a job file: BOM rows, member boxes, flattened note text. Step 1 shipped without the real number — it needs a press, and the bridge was held. A synthetic single-piece document is 3.7 KB, which measures the envelope and nothing else. Measure it on the first real press and replace this row. |
| a 184-piece batch | **6–15 MB** on that budget | one file per run, not per piece |
**The spawn is per press, not per piece.** A batch of 184 pieces pays it once.
It is noise next to a 12.6 s position search and next to the geometry reads
the gather does anyway. It is *not* noise on a button someone presses to see
one number — if such a button appears, it keeps its logic in-process.
**Double maintenance** runs from step 3 to step 5 for TicketQC, and for the
duration of each later step. The gate in §5.5 bounds it: within a step, not
across one.
**The residual truth.** Steps 1, 4 and every future gather still live in
Revit's engine. Editing `read_ticket` or a `gather_*` still needs a restart if
the bridge has imported it. The plan shrinks that surface from 12,139 lines to
roughly 1,700; it does not remove it, and nothing can.
---
## 7. What not to do
- **No big-bang rewrite.** Nine steps, each shippable, each with a parity
proof. A branch that moves TicketQC wholesale cannot be verified against
anything, because the thing it would be verified against no longer runs.
- **Never migrate a module another agent is refactoring this week.**
`ticketchecks` took 38 commits in seven days. A split landing on top of an
in-flight change to the same file loses one of them silently. Check
`git log --since` before starting a step; if the file moved today, pick a
different step.
- **The bridge and fork workflow are untouched.** No `/reload`, no second
session log under one process, and `rvt.py`'s refusal is never worked
around. Prototype read-only before anything that writes.
- **The read-only rule is untouched.** A gatherer reads. A migration is not a
reason to open a transaction on the owner's model.
- **Do not invent a fourth file format.** Three exist and agree. Copy one.
- **Do not move a check and its probe separately.** The probe is the honesty
invariant; a check that arrives on the host without one renders NOT RUN with
reason "no probe" — which is correct behaviour reporting a botched
migration, and will be read as a tool bug.
---
## 8. The quick win, landed: `engine: clean` on the three churn-heaviest buttons
### 8.1 The option is real, and this is exactly what it does
Verified against the installed pyRevit 5.0.1 at `%APPDATA%\pyRevit-Master\`,
not against folklore:
| | |
| --- | --- |
| key | `engine:` → `clean:` — `pyrevit/extensions/__init__.py` lines 101–102, `MDATA_ENGINE = 'engine'`, `MDATA_ENGINE_CLEAN = 'clean'` |
| read by | `GenericUICommand._read_bundle_metadata`, `pyrevit/extensions/genericcomps.py` ~590 |
| siblings | `full_frame`, `persistent`, `mainthread` (+ Dynamo-only keys) |
| value type | **string, always.** `coreutils/yaml.py` wraps YamlDotNet's representation model and returns `YamlScalarNode.Value`, so `clean: true` arrives as `"true"` and the `.lower() == 'true'` test in genericcomps is safe. Unquoted `true` is correct and is what pyRevit's own bundles write (`pyRevitCore/.../Reload.pushbutton/bundle.yaml`). |
| effect | `runtime/pythontypemaker._does_need_clean_engine` → `runtime.create_ipyengine_configs(clean=…)` → a fresh IronPython engine per press instead of the cached one. Fresh engine, fresh `sys.modules`, `lib/vibe` re-imported from disk. |
| cost | engine construction on every press instead of engine reuse |
Our guard test tolerates it already: `test_button_tooltips.py` reads only
`tooltip` / `author` / `authors`, and `vibe.yamlkit.parse` handles the nested
mapping (checked: returns `{'engine': {'clean': True}}` and leaves `tooltip`
`None`). **No test change was needed.**
### 8.2 The caveat that changes what this is
Read `_does_need_clean_engine` in full:
```python
use_clean_engine = True
if user_config.rocket_mode \
and _is_rocketmode_compat(extension.name) \
and not cmd_component.requires_clean_engine:
use_clean_engine = False
```
A clean engine is the **default**. Rocket mode is what takes it away — and
only for an extension that `extpackages.get_ext_package_by_name()` can find.
That lookup drops any package failing `ExtensionPackage.is_valid()`, which is:
```python
return self.name is not None and self.url is not None
```
`WillTools.extension/extension.json` declares `name` and
`rocket_mode_compatible: true` but **no `url`**. So the package is never
registered, `_is_rocketmode_compat('WillTools')` returns `False`, and every
WillTools button already gets a clean engine — even though
`pyrevit configs rocketmode` reports **Rocket Mode is Enabled** on this
machine.
That was checked two ways. `pyrevit.exe configs rocketmode` for the setting;
and the package-discovery and clean-engine decision replayed on host CPython
against the real `pyRevit_config.ini` and the real manifests — WillTools comes
back `is_valid=False`, every shipped pyRevit extension `is_valid=True`.
**So this is a pin, not a lever.** It buys no restart-freedom today, because
the behaviour is already in force. What it buys is that the behaviour stops
depending on a field being absent from a JSON file. Adding a `url` to
`extension.json` — the obvious thing to do the day this ships from its
repository — silently switches every button to the shared engine and brings
back exactly the pain this plan was called for. `engine: clean: true` makes
the three buttons immune to that.
It is landed on that basis and nothing more. It is not the reason the restarts
happened today; §1 is.
### 8.3 Where it was applied
Churn over the seven days to 2026-08-30, by distinct commits touching a
button's modules:
| button | commits, 7d | today | in-process lines (2026-08-30) |
| --- | --- | --- | --- |
| **TicketQC** | 51 | 7 | 13,179 |
| **ProjectConfig** | 14 | 6 | 5,357 |
| **PanelLiftCheck** | 13 | 5 | 2,078 |
| ExtractDesign | 4 | 2 | 1,793 |
| ReinforceSuggest | 3 | 3 | 4,126 |
The top three carry the key. The other seven buttons do not — and that ranking
comes from the commit columns, which are a closed seven-day window and do not
drift.
**The line column is dated and `unverified` as of 2026-08-31.** It has moved
(TicketQC's six modules alone are 13,635 today), but it cannot simply be
re-run: whoever measured it did not record which modules each button's total
covers, and the obvious guesses do not reproduce the 2026-08-30 figures.
Re-deriving it means defining the walk first — per button, its `script.py`
plus the `vibe` modules it imports transitively — and that is a piece of work,
not a `wc -l`. Left in place, stamped, rather than deleted: it is the right
order of magnitude and it is what the decision below was taken on.
### 8.4 What the first press after a restart should show
Nothing measured live: the ribbon is built at load, so this cannot be checked
without a restart, and no bridge call can answer it.
**The cheap check, no code.** pyRevit caches the parsed extension at
`%APPDATA%\pyRevit\2024\pyRevit_2024_cache_WillTools.pickle`, rebuilt on every
load. Before this change, all ten ribbon buttons cached
`requires_clean_engine = False` (verified 2026-08-30 19:17). After a restart it
must read `True` for TicketQC, ProjectConfig and PanelLiftCheck and `False`
for the other seven. It is a Python-2 pickle of `pyrevit.extensions.components`
types; unpickle it with an `Unpickler` whose `find_class` returns a stub, and
read `requires_clean_engine` off each component.
**The behavioural check, one minute.** Press TicketQC. Change a literal string
that renders — a section heading in `ticketpage.py` will do. Press it again
**without restarting**. If the new heading appears, the engine is re-importing
`lib/vibe` on every press and §8.2's reading is confirmed on this machine.
If the old heading persists, §8.2 is wrong, `engine: clean` is doing real work
after all, and §1's table needs its third row rewritten.
Either answer is worth having. Record it here.
### 8.4a Recorded — the offline check, run 2026-08-30 20:45
**The cache still describes the OLD ribbon, so its flags are not a fact about
today.** That is the finding, and it is more useful than the flags would have
been.
| | |
| --- | --- |
| file | `%APPDATA%\pyRevit\2024\pyRevit_2024_cache_WillTools.pickle` |
| mtime | **2026-08-30 19:17:43**, 14,304 bytes — the same moment §8.4 above records its "verified 19:17" reading. It has not been rewritten since. |
| components carrying the flag | 10 |
| `requires_clean_engine = True` | **0 of 10** |
The ten it lists are `ExtractDesign`, `GravityLoadChase`,
`LateralExtract`, `PanelLiftCheck`, `ProjectConfig`, `ReinforceSuggest`,
`SelfCheck`, `SnapshotClose`, `TicketQC` plus one more no longer on the
ribbon. That set is the ribbon as it stood
**before** 2026-08-30's restructure:
`JobRecon` and `Takeoff` have come back onto the ribbon out of the deleted
`parked/`, and the two `.pulldown` containers (`Config`, `DesignHub`) do not
appear at all. Meanwhile `engine: clean: true` is on disk right now in
`TicketQC`, `ProjectConfig` and `PanelLiftCheck`'s `bundle.yaml`.
**So the cache is rebuilt at pyRevit load, not on a bundle edit, and Revit has
not been restarted since either change landed.** Reporting these `False` flags
as current state would be reporting a file that predates the thing it is being
asked about. What the check does establish:
1. the unpickle-with-a-stub technique works, and is the right way to read
this — no `pyrevit` import needed;
2. the flags are a **post-restart** measurement and nothing else. Re-run the
probe after the next Revit restart; §8.2 predicts `True` for exactly
TicketQC, ProjectConfig and PanelLiftCheck, `False` for the rest, and the
component list should show the new ribbon;
3. §8.2's reading is not tested by this either way. The cache said `False` for
all ten before the `engine: clean` keys were written too, which is what the
19:17 reading already said.
**The behavioural check (press, edit a heading, press again) is still not
run** — it needs a person at the keyboard in Revit, and no bridge call can
answer it. It stays open.
### 8.5 When to take it back out
**Per button, as that button stabilises.** The comment sits in each
`bundle.yaml`; the whole of the instruction is "delete these three lines". A
button whose modules have not been touched in a fortnight does not need the
pin — and once §4 and §7 have moved the churn to the host, the pin protects
nothing worth protecting. It is not a permanent setting and it should not
outlive step 6.
---
## 9. Where this starts
Step 1, and read `docs/plan-ticketqc-refactor.md` first — it owns the shape of
the ticket data this plan serialises, and its phases are not finished. Nothing
here reorders that plan; this one starts where a ticket has already been read
correctly.