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.
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user