# What to check on a shop ticket Developed 2026-08-27 from a real audit of twelve pieces (WP194, 195, 196, 199, 200, 210, 213, 214, 215, 219, 220, 412 — all design WP1) against the model they were drawn from. Every item below is either something the existing checks already do, something the audit found they get wrong, or something nobody was checking. The organising question for each item is not "is this checkable" but **what does it take to check it**, because that decides whether it can be code: | grade | meaning | | --- | --- | | **A** | pure code. Two values exist in the model; compare them. | | **B** | code, once a plant fact is written down. The rule is mechanical, the *constant* is local knowledge. | | **C** | code can narrow it to a shortlist; a person decides. | | **D** | a person reads the drawing. Code can only make sure they were asked. | --- ## 0. Before any of it: find the whole ticket **A ticket is not one sheet.** In this model 97 tickets are `1 OF 2` + `2 OF 2` under the same sheet name, and 9 are `1 OF 1`. The two sheets carry different things: | sheet | carries | | --- | --- | | `1 OF 2` | `Elevation Top` (the BED PLAN, with the embed callouts), the TICKET NOTE views, the strand jacking force | | `2 OF 2` | `Elevation Front`, `Elevation Right` — and the strand count lives in Right | Reading one sheet and calling it the ticket is how the audit produced 60 findings that were not real. **Grade A** — collect every sheet whose name equals the mark, and treat their union as the ticket. --- ## 1. Identity — is this ticket about this piece | # | check | grade | | --- | --- | --- | | 1.1 | Sheet name equals the piece mark | A | | 1.2 | Sheet's design-number parameter reduces to the piece's design | A | | 1.3 | Exactly one ticket set per piece — no orphan sheet, no duplicate | A | | 1.4 | **Every placed view's name refers to this design.** | ~~A~~ **B** (re-graded 2026-08-30) | | 1.5 | **Printed call-outs do not overlap each other.** | **C** (new 2026-08-30) | ### 1.5 — two call-outs printed on top of each other Text-bearing annotations — text notes, tags, keynotes — on the **same placed view**, whose view-space bounding boxes intersect with **positive area**. WARN, naming both texts and the size of the overlap (`CALLOUT-OVERLAP`, `ticketpage.callout_overlap_status`). **Text against linework is deliberately out of scope**, and that is a ruling, not an omission. A call-out printed over a drawn edge is the normal condition on every elevation on every ticket; flagging it would report the whole sheet and teach the reader to skip the row. Only text against text is asked. **Two honesty limits, and the check states both in its own reporting.** 1. **A bounding box approximates the printed extent.** It carries the text's leading and side bearings, so it is a little larger than the ink, and a small intersection can be two notes that print clear of each other. That is why the message states the overlap **size** in printed inches, and why this is **grade C**: the check narrows the sheet to the pairs worth looking at, and a person decides. It is never a defect claim. 2. **Rotated text reports an axis-aligned box**, which circumscribes the rotated glyphs and overstates the ink by up to the length of the string. **Those pairs are set aside, not flagged** — reported under `CALLOUT-OVERLAP-UNREAD` at OK severity, named, so they can neither masquerade as a finding nor silently disappear. An annotation whose rotation cannot be read at all is set aside the same way: unknown is not the same as zero. Boxes are converted to **printed inches** through the view's scale, because inches on paper is the only unit a reader can act on. Verified against the live model: a `DUNNAGE` note on a 1:96 view boxes 5.198 x 2.156 ft, which prints 0.65" x 0.27" — the size of the word. **Measured before it was built** (bridge read, 2026-08-30, one WP ticket): `get_BoundingBox(view)` returned non-null for **22 of 22** text notes, and **zero pairs overlapped** across all seven placed views. So on a good sheet this row is quiet, and a finding is signal rather than noise — which is the condition a WARN-only check has to meet before it is worth adding. 1.4 is new and came out of the audit. A view named `TICKET NOTE - WP2- STRAND JACKING FORCE` is placed on **100 sheets**, all of them WP1 pieces. Reading the content settled it — `0.70 fpu = 28.9 kips` is WP1's own number, so the note is right and the *name* is a leftover. That is the point: a stale name on a correct note is a trap for the next person, and the check is one string comparison. **Report the name mismatch; never infer the content is wrong from it.** **Re-graded A -> B, 2026-08-30, built the same day** (`VIEW-DSN`, `ticketpage.view_name_design_status`). Two reasons, and the first is the weaker one: 1. It is not one string comparison. Recognising that `WP2` inside `TICKET NOTE - WP2- STRAND JACKING FORCE` is a design label at all needs the plant's design-token pattern, and comparing it needs the same suffix reduction 1.2 uses (`PNL1.0` and `PNL1` are one design). That is "mechanical rule, local constant" — the definition of B. The pattern is derived from `nomenclature.design_number_re` rather than added as a second plant fact, so nothing new has to be written down. 2. **It is never worth failing a ticket over, and that is what settles it.** This check sits in tension with a rule this project already holds: a view name does not identify a design. The check does not break that rule, it depends on it — the design comes from the sheet's design-number parameter, and 1.4 only reports a view that *labels itself* and labels itself wrongly. So a 1.4 finding is never evidence about the piece. The audit proved exactly that: the WP2-named note was WP1's own correct number. What it protects against is a **reader**, in both directions — someone believing a WP2 note on a WP1 sheet, or deleting a correct note because its name looked foreign. That is hygiene, WARN, grade B. One deliberate blind spot: only a token whose letter prefix matches this design's is compared, or `Elevation Top 2` reduces to `TOP2` and every ticket grows a false finding. It can therefore miss a label from an entirely different piece family. For a WARN, silence is the right way to be wrong. ## 2. Dimensions and mass | # | check | grade | | --- | --- | --- | | 2.1 | Printed length / width / depth vs modelled | B — on stemmed pieces the printed length is nominal and runs over by design, so the tolerance is a plant fact | | 2.2 | **Weight, three ways, all three must agree** — see below | A | | 2.3 | Unit weight on the ticket vs the design summary | A | | 2.4 | Cast extras (a ledge is its own family) counted in the weight | A | ### 2.2 — weight is a three-way agreement, not a comparison Two numbers agreeing proves nothing about which one is right. Three independently derived numbers agreeing is evidence the piece is what everybody thinks it is, and **any one of them disagreeing tells you where to look**: | # | number | derived from | | --- | --- | --- | | a | **printed** | the WEIGHT (lbs.) field in the ticket's title block | | b | **reconstructed** | length x width x thickness, less the blockouts, plus the cast extras, x unit weight — the piece as the drawing describes it | | c | **measured** | the actual volume of the modelled solid x unit weight — the piece as it was drawn | What each disagreement means: - **b and c agree, a differs** — the model is consistent with itself and the ticket's weight field is stale or was overridden. The piece is probably fine and the print is wrong. - **a and b agree, c differs** — the stated dimensions and the printed weight tell the same story but the solid does not match them. Something was modelled that the dimensions do not describe: an uncut opening, an unrecorded recess, a ledge, a chamfer taken too far. - **a and c agree, b differs** — our reconstruction is wrong, which means our reading of the geometry is wrong: a blockout missed, a cast extra not counted, a thickness read from the wrong parameter. Suspect the tool first. `vibe.liftexport.weight_check` already computes all three and calls them `stated`, `reconstructed` and `solid`. What it does not yet do is report the **pattern** of the disagreement, which is where the diagnosis is. Both failure directions have been seen on this job. A slab-seat ledge left out of the reconstruction understated one piece by 7.5%, and the gate refused a model that was right — that is the third row. On four other pieces the `_CAST` volume parameter and the real solid differ by 14–15 ft3 in both directions, which is the second row, and it is a model-side problem. Do not report a weight finding without saying which of the three disagrees. ### 2.4 — the extras the ticket draws vs the extras that were counted Built 2026-08-30 (`CAST-EXTRA`, `check_cast_extras` / `ticketpage.cast_extra_agreement`). It is a different question from 2.2 and the difference is the whole reason it exists: **2.2 compares three weights, and all three can be wrong the same way.** A ledge left out of the model AND off the drawing is missing from the printed weight, from the reconstruction and from the solid alike — they agree with each other, 2.2 says OK, and the piece is 7.5% light. 2.4 compares the *set*, not the number. **Telling the two sides apart.** "The extras that were counted" is not a re-derivation: `liftexport.extra_cast_ft3` was split so its member list (`extra_cast_members`) is the shared thing, and 2.4 reads exactly the members whose `Volume` fed 2.2's reconstructed weight. Two implementations of "which family is cast concrete" would drift; there is one. "The extras the ticket draws" is read from the ticket's **words** — a BOM row, or the text on a placed view, naming one of the same `families.cast_include` tokens. That is a proxy and the check says so rather than implying otherwise: the ledge is drawn as *geometry*, and no query answers "does this elevation show a ledge". What 2.4 can honestly assert is that the two sides *name* the same extras. Three findings it produces, none of them reachable from 2.2: - the ticket names an extra the assembly does not carry — **the case above**; - an extra is in the weight that nothing on the ticket names; - a cast member whose `Volume` could not be read: it added **nothing** to the reconstruction, silently. Drawn, and not counted. **The five BOM states are respected, not collapsed** (`ticketpage.bom_state`). OK and PARTIAL are judged, PARTIAL naming what failed. ABSENT is a fact about the ticket, so it is judged from view text and the message says that is all it had. UNREADABLE and UNKNOWN are failures of *ours* — the row that names the ledge may exist and we missed it — so "the ticket does not name it" is not assertable and the comparison stands down to NOT RUN with the BOM's own reason. The unreadable-volume finding still fires there, because it needs no ticket at all. **What it cannot detect, stated in the OK itself:** an extra missing from both the model and the drawing. Nothing on either side is a genuine pass, but the message says out loud that the blind spot was not closed. ## 3. Concrete | # | check | grade | | --- | --- | --- | | 3.1 | f'c on the ticket vs the design summary | A | | 3.2 | f'ci on the ticket vs the design summary | A | | 3.3 | f'ci / f'c release ratio inside the plant's band | B | **The audit's f'c and f'ci findings were false.** Both were raised as "in the summary, not found on the ticket" because the tool was reading only sheet `2 OF 2`. Fix item 0 first or these two produce noise on every ticket. ## 4. Strand | # | check | grade | | --- | --- | --- | | 4.1 | Printed strand count vs strands modelled | B — whether the printed `(n)` is per-piece or a design maximum is a plant fact | | 4.2 | Printed diameter and grade vs the design | A | | 4.3 | Jacking force on the ticket vs the design, within `tolerances.it_lb` (**100 lb**, owner 2026-08-30 — the same 0.1 kips the check always used, now a knob rather than a literal) | A | | 4.4 | Strand positions vs the design's dimension chain, from the stated rail | B — which rail is fixed is plant knowledge and is never inferable | | 4.5 | Debonded strands called out where the design debonds | C | | 4.6 | Row count and spacing plausible for the section | A | | 4.7 | Strand debonded wherever the concrete along it runs short | B — the length is a plant fact | ### 4.4 — what "never inferable" does and does not forbid The rule bars inferring **which rail the design summary dimensions from**. That is a plant fact and guessing it silently reverses every dimension in the chain. It does **not** bar measuring from both edges. Strands get rotated during reinforcing, so the chain legitimately has to be tried against both sides and the fitting one kept. `check_strand_layout` does exactly that and is correct. Ruled by the engineer 2026-08-30, after this was raised as a possible violation. It was not one. ### 4.5 — does the ticket say so? Built 2026-08-30 (`STRAND-CALLOUT`, `check_debond_callout` / `ticketpage.debond_callout_status`). Will's ruling, verbatim: *"4.5 做掉。 如果满足 debond 要求,那么就期待有 debond,没有 debond 给个 warning。"* — where the debond condition is met, expect a call-out; where there is none, WARN. **It does not re-derive the geometry. It consumes 4.7.** Both items now read one `_debond_requirement(t, know)`, cached on the ticket, so there is exactly one place that decides whether a run is short and the two items cannot disagree. Three consequences follow, all deliberate: - **If 4.7 could not judge, 4.5 cannot either.** It returns nothing and its row reads NOT RUN *repeating 4.7's own reason*. Warning that a call-out is missing, when nothing established a requirement, would be inventing the requirement. - If 4.7 judged and found nothing required, 4.5 is an OK that says so — not silence. - A call-out where nothing is required is **not** flagged. Only one direction warns, because that is the ruling. The ticket side is `strand.debond_callout_tokens` — plant nomenclature, empty by default, so a plant that has not written down its own wording gets NOT RUN with that reason and never a pass. Measured: this plant's BED PLAN prints a bare two-word note among the embed callouts, and the manufacturer's standards carry a whole note sheet whose entire content is a longer form of the same words. **Not merged with the manufacturer's own debond standard**, which fixes a length at each end keyed on strand *diameter*. That is a bed standard; the threshold in 4.7 is a geometric trigger. Different rules, and the manual's fixed length must never be substituted for the threshold. **Still grade C, and the OK admits why.** The call-out is a bare note: no station, no strand number. A present call-out is evidence that somebody debonded something, not that they debonded *these* runs — so the OK says, in those words, that it was not verified. Code narrows; a person closes it. ### 4.7 — a short run of concrete cannot develop a strand Not the same question as 4.5. That one asks whether the ticket calls out the debonding the design specified; this one derives from the geometry where debonding is *required*, and can therefore catch a design that failed to specify it. Along the strand direction, measure the runs of continuous concrete between openings. Where a run is shorter than `strand.debond_below_continuous_ft` (4 ft here), the strand crossing it must be debonded. The reason decides the shape of the check, so it is worth stating. Openings leave little column sections. A strand crossing one has nowhere to develop — its transfer length exceeds the concrete available, so it never reaches force and carries essentially nothing. That alone would only be waste; the harm comes at release, when burning the strands drives a shock transfer through a stretch too short to take it, and a flexural crack opens. Bursting steel answers part of the problem — the Poisson expansion as the strand shortens — but not this part. **The instantaneous flexure from the bursting force has no detailing remedy.** Debonding is the answer, which is why this is a rule and not a preference. The geometry is already in hand: `liftexport._openings` returns every void as `(x0, x1)` along the length, and the ROW-FLEX work already cuts the piece at every opening edge. The uncut runs between those cuts are exactly what this measures. On 4.1 the audit found five of the twelve print `(14)` while 10 or 6 are modelled. That is a real discrepancy to put in front of a person; it is not automatically a defect, because the printed count may be the design's maximum. **Where the strand data actually is:** the count is inside the `Elevation Right` detail view (`(14) 0010001 (E.L.)`), and the jacking force is in a TICKET NOTE view. There is **no separate pattern-legend view on any of the 204 WP sheets in this model.** The existing `LGD-MISSING` check looks for a placed view whose name starts with `STRAND PATTERN` or `REINFORCING PATTERN`; it fires on every WP ticket and always will. Either the prefixes are wrong for this plant or the concept does not apply here — it is a knowledge-file question, not 204 defects. **Answered, Phase 2:** the concept does not apply. `legend.pattern_prefixes` is empty for this plant and `LGD-MISSING` now asks about the BOM schedule instead, so the 204 false positives are gone. Two things about it are still open — a "could not read the BOM" that reports as a defect, and the fact that no checklist item owns `LGD-*` so neither verdict reaches the report table. Both are written up in `docs/checklist-code-map.md`. ## 5. Reinforcing and embeds | # | check | grade | | --- | --- | --- | | 5.1 | Every embed on the BED PLAN callouts exists in the model — **two halves, one of them unbuilt and saying so** (2026-08-30) | A | | 5.2 | Every embed in the model appears on the ticket | A | | 5.3 | Face designations (N.F. / F.F. / E.L. / S.I.F.) consistent with the model side | C | | 5.4 | Mesh / WWF designation vs the design | A | | 5.5 | Bar marks **legible** on the drawing | D | | 5.6 | **Bar marks unique, and model and print name the same bars** | **B** (new 2026-08-30) | | 5.7 | **No bar longer than the stock it is cut from** | **B** (new 2026-08-30) | | 5.8 | Connectors present on an insulated panel | B (built 2026-08-31) | | 5.9 | Enough connectors for what the design calls for | B (built 2026-08-31) | | 5.10 | Connector edge distances | B (built 2026-08-31) | | 5.11 | **Every lifter's solid zone is a member of the assembly** | **B** (built 2026-08-31) | | 5.12 | **Solid zones appear on the ticket's insulation layout** | **B** (built 2026-08-31) | | 5.13 | **Solid zone hatch matches the legend swatch** | **B** (built 2026-08-31) | | 5.14 | **Insulation recess regions present where the design calls for them** | **C** (built 2026-08-31) | | 5.15 | **Recess region hatch matches the legend swatch** | **B** (built 2026-08-31) | ### 5.1 asks two questions and only one of them was ever asked (2026-08-30) `docs/checklist-code-map.md` has said for a while that 5.1's owner code does not implement 5.1 as written: `check_bom_members` compares the **BOM's named rows** against the assembly's members, which is a real comparison and not the BED-PLAN one this row describes. The row printed OK off it anyway. The half that was missing has a shape nobody had written down. Not every call-out on the BED PLAN is a part. This plant collects its typical sections onto **detail sheets of their own** and the plan cites one as a bubble — a detail number over a sheet name — whose embeds are drawn *there* and never on the plan. `nomenclature.detail_sheet_prefix` is the plant naming that series, and it is the only thing that tells a bubble from a part call-out. It was declared in the plant file, documented, and **read by nothing** (D5 in `QNA/drift-triage.md`). The owner's ruling was one line: *"那倒是读啊。。"* It is read now, and what it buys is exactly one thing: the ability to see that the plan reaches past itself. **Nothing here opens a detail sheet.** Following a bubble to that sheet and comparing the parts it draws against this piece's members needs the sheets themselves, and that is a job of its own — so `check_detail_callouts` stops at finding the bubbles and reports **NOT RUN naming the sheet nobody read**. There are three outcomes and only one of them is a pass: | the plan | 5.1's detail half | | --- | --- | | no `detail_sheet_prefix` declared | NOT RUN — a bubble cannot be told from a part call-out, so the plan cannot even be read for them | | a prefix, and the plan cites no detail | **OK, and a real verdict** — every call-out on the plan names a part directly, and nothing on this piece reaches the model through a detail sheet | | a prefix, and the plan cites details | NOT RUN, naming the detail and its sheet — those embeds have not been asked about | **5.1 is therefore the first row backed by two checks**, and it is answered only when both spoke. `CHECKLIST_ITEMS` takes a tuple in its check slot and `checklist_states` makes any quiet half NOT RUN, however green the other one is. That direction is deliberate: a row that asks two questions and prints OK because the half which could still have failed it was never asked is the silent pass this whole table exists to prevent. Under-claiming is the safe direction; a green row is a claim. What is still not built, stated plainly so nobody reads the above as more than it is: **the contents of a detail sheet.** Deriving `drawings.called_out_on_detail` — today a hand-kept job-layer map of which family is drawn on which detail — by actually reading the X-sheets is the same missing capability seen from 5.2's side, and it needs the model. ### 5.3 reads FOR ENGINEER, not NOT RUN (fixed 2026-08-30) The owner read a live run and reported "5.3 没跑". It had not run, and it never will — it is one of the two rows this document has counted as *a person's job by design* since it was written. But it printed **NOT RUN**, in red, in the same bucket as FAIL, because `CHECKLIST_FOR_ENGINEER` was a hand-kept tuple holding only `5.5` and nobody added 5.3 to it. That list is now **derived from the table**: a row with no check function, no owner finding code, and a reason spelled out is a row somebody declared a person's. A row missing any of the three is an unbuilt row and stays red. The reason string in the row is also what the cell prints, so 5.3 no longer carries a stock sentence about grade D — it is grade C. ### 5.4 stands down when the design has no mesh, and says which silence it is Also 2026-08-30, also read as "没跑". 5.4 ran; the design it was checking records no mesh at all (`reinforcing.wwf.mesh: null` in its extracted spec — its mild reinforcing is a bar line), so there was no design side to compare and the row was right to stay unjudged. The probe's one sentence, however, covered three different situations and sent the reader to Extract Design in all of them, including the one where re-extracting returns the same nothing. It now distinguishes: **no spec at all** (run Extract Design), **a spec with no `wwf` section** (it predates the field — re-run it), and **a spec that records no mesh** (nothing to re-extract; either the design uses bars, or the summary's mesh line was not parsed, and only the summary view settles which). All three stay NOT RUN. ### 5.6 / 5.7 say what could not be *recognised*, not what the piece has not got Both stand down on the same gather and printed the same paragraph twice, one row under the other, opening with *"no reinforcing found on this piece"*. That is a claim about the piece, and with `drawings.reinforcing_families` empty the tool is in no position to make it — this plant models bars outside any rebar category, so a piece with three hundred members produced the same words as a piece with no steel in it. The reason now says the tool could not **recognise** any, says what each row lost (a mark comparison / a length comparison), and names the key that would make it run. When the key **is** filled in and the search still comes back empty, it says the piece appears to carry none, and names what it looked for. Both rows stay NOT RUN and red either way — naming the switch is not flipping it. ### 5.5 split into 5.5 and 5.6 — the two lines that matter **What moved to code (5.6):** *uniqueness*. Whether one mark names two parts, whether a printed mark exists in the model, and whether a modelled bar is ever printed are all comparisons between the model's marks and the sheet's text — and a comparison is code's job. **What stayed with eyes (5.5):** *legibility*. Whether the printed glyphs can actually be read is a property of the picture, which no query answers. Grade D, still `FOR ENGINEER`, and the vision path (`ticketvision`, `AI-BARMARK`) is unchanged and still escalates it. ### 5.6 — bar marks unique, model against print Three questions, three different defects, each its own WARN under `BARMARK-UNIQUE` (`ticketpage.bar_mark_uniqueness_status`): | | | |---|---| | **one mark, two parts** | two bars sharing a mark but differing in family or length. The mark is what a fabricator cuts to, so this is the expensive one. | | **printed, not modelled** | the sheet calls out a part the model does not carry. | | **modelled, never printed** | the part exists and the drawing never names it. | A bar carrying **no mark at all** is none of the three: it is could-not-judge, reported under `BARMARK-UNREAD`, and is never counted as "never printed" — an absent mark cannot be looked for in the printed text. **The design came from a live read, not from the spec's assumptions**, and the read changed it twice. - **The spec said tags. There are no tags.** Measured on a bar-carrying WP ticket: **zero** `IndependentTag` and **zero** keynote elements on either sheet. Every printed bar call-out is a plain **TextNote on a Detail view**. The tag path is still gathered — a plant that tags must not fall through to silence — but nothing here depends on it. One specimen, one plant: worth re-confirming before any tag path is deleted. - **The printed string contains the mark; it never equals it.** Verbatim off the sheet: `SR324 (F.F.)`, `SR310 (E.L.)`, `P76 W/ SR5602 (S.I.F.)` — a bare mark plus a face designation. An equality comparison would have reported every call-out on every ticket as an orphan. The match is a whole-token containment, and the same reduction runs on both sides so an unrecognised shape goes missing from both and produces silence, never a false finding. - **Note-view pollution: real, and separable by view TYPE.** The note views are Legends carrying headings and the jacking-force paragraph; the call-outs live on Detail views. Legends are excluded — a view-type test, so no plant nomenclature is needed and none was invented. #### Two narrowings, both off the knowledge layers (2026-08-31) Both landed on one live complaint — *"SR4996 是 SP29/30 的附属 rebar,我记得我增加 过这条知识了,为什么还在 WP117 报 warn?"* — and he was right twice over. The shape, the merge law and the refusals are in `docs/knowledge-override.md`; what a reader of the report sees is this. **A bar covered by its parent's callout.** `SR4996` ships inside the SP29/SP30 angles and is never drawn on its own. That was recorded in three places and **5.6 read none of them**, so the "modelled, never printed" leg reported it. It now consults `drawings.annotation_covered_by_parent` (through `knowledge.attached_parents`) and produces one of three answers, never a silence: | the parent | the row | | --- | --- | | **is printed** | the bar is accounted for, and the coverage is **named in the sentence** — *"SR4996 is attached to SP29/SP30"* | | **is not printed** | **still a WARN, and now it is about the PARENT.** The bar is unaccounted for either way; what is missing is the parent's callout | | **cannot be read** | **no claim either way** — nothing in the parent's name is shaped like a part number, so whether the ticket calls it out could not be established | **A printed token that is not a bar mark at all.** 5.6 took every mark-shaped token for a candidate, so WP115 reported `P78`, `P114`, `SP29`, `SP30` and `SP31` as *"printed bar marks matching no bar in the model"*. They are hardware. A token is now classified through the plant's part-number grammar (`part_numbers.prefixes`, `kind: hardware` / `kind: bar_mark`), and `P78 W/ SR5502 (S.I.F.)` splits itself — the part is set aside, **the bar after `W/` still counts**. Three properties hold the honesty, and each is a way this could have gone quiet instead: - **Only `kind: hardware` removes a token from the comparison, so only it is gated** on a `verified: true` prefix. An unverified hardware claim is refused and the token is reported as unplaceable. `kind: bar_mark` needs no gate — the worst it can do is add noise. - **A token the grammar cannot place is its own WARN**, could-not-judge, never a quiet drop. A printed `MK12` says the grammar has a hole and names it. - **A plant that has declared no grammar loses nothing.** Nothing is narrowed and every token stays a candidate, exactly as before — noisy, never quiet. **Everything set aside is stated on the row**, on a green row and on a red one alike. A narrowing you can only see when everything else passed is a narrowing nobody audits on the runs that matter. ### 5.7 — no bar longer than the stock it is cut from Owner's ruling, 2026-08-30: *"rebars come in 40ft stock. unless overwritten by other knowledge, all rebars pieces must be less than 40ft."* Stock is industry data, not a plant habit, so the number is published in `knowledge/catalogue.yaml` as `catalogue.rebar.stock_length_ft` and any higher layer may override it. **With the key null the check does not run and says so — it never assumes 40.** **The length is READ, never measured.** Owner, same day, on a bar's length with bent bars included: *"钢筋的长度,即使是弯筋的长度,也都不要自己测量"* — never measure it yourself; read the parameter. He named the parameter; the name is written down in the plant layer under the `member.length` role, which is where a parameter name belongs. The check reads that role and nothing else. There is no geometry fallback and there must never be one: what a bent bar consumes off the stick is its **developed** length, and a bounding box only sees the envelope it folds into — it understates every bent bar and would pass exactly the pieces this check exists to catch. A bar without the parameter is **could-not-judge, named**. **Three states, not two.** Verified on the live model: the parameter sits on the instance and its internal unit is decimal feet (4.41666… reads as 4'-5"). A length of **0.0** is its own state — 260 elements hold one — and is neither a length nor an absence, so it is neither passed nor flagged. **Grouped by mark, and the reason is arithmetic.** The model carries **2,122** elements at or over 40 ft (max exactly 60.00). As one finding per element that is a wall of noise that buries every other row; as one finding per **mark** it is a short list a person can rule on — and mark is also the grain the owner's ruling will arrive in. **What it does not claim, yet.** A bar over stock is not automatically a defect: a continuous or spliced run is a real detail here (the catalogue records `x CONT. (SEE PLAN) LAP SPLICE`). Whether the 60 ft marks in this model are those **is with the owner and has not been answered**. So the WARN says the ruling is pending, and the answer when it comes is a list in `catalogue.rebar.stock_exempt_marks` — **a config edit, not a code change**. Nothing is pre-exempted, and what *is* exempted is listed in the report rather than silently dropped. The key is matched **exactly**, never as a substring, or an entry for `SR1` would swallow `SR1101`. #### Two fields carry a bar's size, and they cover disjoint populations Every bar is measured against the stock length **for its own size** (`catalogue.rebar.stock_by_size`, per-size, in integer inches). A bar whose size cannot be resolved is **not judged at all** — not against the generic number either, because *"40 ft"* and *"40 ft for a #4"* are different claims and the generic one is wrong in the direction that passes a defect. The size is reached two ways, and **neither is the other's fallback**. Censused over 24,539 rebar members in 13 families, 2026-08-31: | population | field | coverage | | --- | --- | --- | | the one **straight** family | the size parameter, `parameters.member.bar_size` | that family, and no bent one | | all twelve **bent** families | the size at the front of the **family's** part description, `#N BENT BAR ()` | 100% on the type, instance count zero; 7,405 of 7,405 parse | **The two sets are disjoint** — no bar carries both — so the order `knowledge.bar_size_of()` tries them in has never decided anything and must not be read as a ranking. Bent bars are roughly **30%** of the reinforcing in this model, and until 2026-08-31 only the straight half was wired: the size role had landed and `ticketchecks._bar_size` still read that role and nothing else, so every bent bar went unjudged. **Which element the description is read off is load-bearing.** An *assembly's* identity description is the owner's freeform notes; a *family's* is structured plant data describing the part — one parameter name over two kinds of element. `drawings.bar_size_rule.description_read_from` is what keeps them apart, and `docs/knowledge-override.md` records the correction. **The parse stays strict**, and not because the field is unreliable: the shape `#N BENT BAR` **is** the evidence that the leading token is a bar size, so a reworded description stops being evidence and the bar goes **unjudged rather than mis-sized**. A leading *bare* number is refused for the same reason. **The finding says which field answered.** `BAR-LENGTH-SIZE-BASIS` — its own code, owned by no row, so provenance can neither turn 5.7 green nor vanish from the report — names how many bars were sized *read from the size field behind `parameters.member.bar_size`* and how many *parsed from the part description*, with the sizes. A verdict that cannot say what it read cannot be audited. The over-stock line names the size too: *"SR1101 at 60 ft vs its 40 ft stock for a #5"*. **Exempt by mark, not by family** — a deliberate departure from how `_is_reinforcing` identifies a bar. That function asks *is this a bar at all*, where family is the right grain. This asks *may this bar be longer than a stick*, which is a property of the part: one family here carries both 11 ft bars and 60 ft ones, so exempting by family would exempt every bar at the plant and switch the check off. **Today it is dormant here, and that is correct.** Which members are bars is decided by `_is_reinforcing` and by nothing else — Revit's own category, plus `drawings.reinforcing_families`. This plant models its bars as **Specialty Equipment**, so the category signal says nothing, and the families key is **empty**. Until it is filled in, 5.6 and 5.7 report NOT RUN **naming that key**. That is the conservative outcome, and it is also what keeps the length check from opening with 2,122 findings derived from a family list nobody has vetted. #### `drawings.reinforcing_families` is an armed switch — what one edit does Worth writing down before anyone throws it, because it is **not** one key and it does **not** turn on three checks cleanly. Populating it: - **flips 5.6 and 5.7 live.** Neither needs a second key. Expect, on the first run over this model, **2,122** mark-grouped bar-length findings and **260** present-but-zero lengths reported as could-not-judge — the population the "zero is its own state" branch was written for, about 11% the size of the ≥40 ft set. - **does not, on its own, flip 5.2's reinforcing half.** That half is gated by a *second* key, `drawings.full_length_bar_fraction`, which is **also null by default and is deliberately not guessed** — the standard says "full length bar" and never says how short still counts. With bars newly visible but the fraction unset, `bar_drawing_home` returns `None` for **every** bar, so every bar family lands in `unclassified` and 5.2 grows a new `BED-REVERSE-UNCLASSIFIED` line naming them all — and where bars are the only thing left to compare, 5.2 flips from OK to **NOT RUN**. So the honest framing is **two checks go live, and a third changes behaviour in a way that is neither working nor broken.** Throwing both keys together is the edit that actually lands 5.2's reinforcing half; throwing only the first is safe but will visibly change 5.2, and a reader who was told "three checks turn on" would read that change as a regression. 5.1 and 5.2 are worth building: `Elevation Top` on sheet 1 reads as a flat list (`P76 W/ SR5602 (S.I.F.)`, `P106 (S.I.F.)(E.S.)`, `P136 (N.F.)` twice, `DEBOND STRAND`, `SP126 (S.I.F.)`, `SR309 (E.L.)`) and the assembly's member families are directly comparable to it. Nested children need the `SuperComponent` filter or the counts double. ## 6. Handling and lifting | # | check | grade | | --- | --- | --- | | 6.1 | Insert capacity vs the load each carries | A | | 6.2 | Insert edge distances | A | | 6.3 | A current handling result exists and passes | A | | 6.4 | Dunnage not landing under an opening | A | | 6.5 | The DUNNAGE / ERECTION / PICKING-STRIPPING notes actually say something | ~~A~~ **B** (re-graded 2026-08-30) | | 6.6 | An odd count of one part carries the BED PLAN note it requires | B (built 2026-08-31) | | 6.7 | Minimum spacing between lifting anchors | A (built 2026-08-31) | | 6.8 | Printed picking stations match the modelled picking inserts | B (built 2026-08-27) | | 6.9 | Lifting-point count vs panel length (plant rule) | A (built 2026-09-02) | | 6.10 | Lifting-point position vs PCI recommendation (plant rule) | A (built 2026-09-02) | | 6.11 | Dunnage-point count vs stripping-point count (plant rule) | A (built 2026-09-02) | | 6.13 | Lifter accessory (cup/patch) is a member of the assembly | A (built 2026-09-08) | Rows 6.8 - 6.11 were built in the days after this table was last edited and sat unlisted here until 2026-09-08 (issue #26 read the section as ending at 6.7 in the doc and 6.10 in the code; the code and `checklist.master` in `knowledge/catalogue.yaml` both run to 6.11). **The catalogue is the authority for what exists; this table is the narrative.** 6.12 is unused: the `FLEXURAL_CHECKS_ALREADY_ASKED` comment in `ticketchecks.py` records that a "6.12" built to an earlier draft of the flexural table would have been 6.10 twice, which is why issue #26's lifter-accessory membership check (a nested cup / patch that fell off its anchor's assembly) is numbered **6.13** rather than reuse it. **6.9 / 6.10 / 6.11 no longer print as NOT RUN on a plant without the gating keys.** Until 2026-09-08 a job whose knowledge layer had never declared `lifters.lifting_points.two_point_max_length_ft` / `.two_point_absolute_ max_ft` / `.pci_position_tolerance_in` / `.dunnage_matches_stripping` (any plant that is not MC) still showed all three rows inline as red NOT RUN -- indistinguishable from a check that failed on a real piece, and read by the owner on another plant's job as "a pile of MC checks." `ticketchecks.PLANT_GATE_ KEYS` now names, per check, the knowledge key(s) that gate it; when a job has not set them all, `ticketqc_run.py` pulls the row out of the coloured table entirely and lists it, with the missing key(s), in a folded "Not applicable at this plant" section instead -- not counted in the header's OK/WARN/FAIL/NOT RUN tally. A plant that HAS declared the keys (MC today) still gets the rows in the table, judged exactly as before -- nothing about the arithmetic changed, only which plants are asked to answer it. See `ticketchecks.plant_gate_reason` / `plant_scoped_out`. **6.13 is now built** (`check_lifter_accessory_in_assembly`, `LIFT-ACCESSORY-NOT-IN-ASSEMBLY` / `LIFT-ACCESSORY-UNCLASSIFIED`), and it reuses the 5.11 gather rather than adding a second reader over the same parts: `ticketqc._nested_children_of` is the core `_read_nested_solid_zones` (one recognised family) and the new `_read_nested_children` (every family) both walk, so `GetSubComponentIds()` on each recognised lifter is read the one way. Classification is against `lifters.accessories` in the plant layer (`knowledge.lifter_accessories()`) -- a child family listed there and absent from the assembly is a FAIL, an un-listed child also absent from the assembly is a separate WARN, and a read failure is never a quiet pass. Live-validated 2026-09-08 through the bridge: WPA648 (assembly 9141343) FAILs naming its 8 DB_PATCH children nested in LPA pin anchors that never joined the assembly (e.g. child 9521037 under anchor 9521036); WPA655 (9141344) and SMD516 (6113164) are OK. ### 6.7 — a rule that was on a page we already had ALP catalog p53, 02/2024, in a footnote under the capacity table: > Minimum anchor spacing is 2x the published edge distance The owner supplied that page. Its edge-distance column became 6.2 and its capacities became `catalogue.inserts`; **this sentence on the same sheet was read past**, so until 2026-08-31 nothing in the tool asked how far apart two anchors must sit. Two 8-ton anchors 20 in apart passed every row on the board while the page asked for 28. It is 6.2's shape one comparison further — the same insert positions, the same per-part resolution through the plant's alias, one more measurement — and it answers `LIFT-SPACING`. **Where the minimum comes from, in order:** | | | | --- | --- | | `min_spacing_rule.published_in` | a sheet stating a spacing outright. Source `published`. A measured number for the part beats a rule of thumb about it. | | `multiple_of_edge_distance` × the part's own `min_edge_distance_in` | the p53 footnote. Source `derived`, and **the finding prints the arithmetic** — *"28" is 2x QLPD8T8WG's published edge distance of 14""* — because "28 inches" and "twice this part's published 14 inches" are different claims and only the second can be checked by a reader. | | nothing | **could not judge.** There is deliberately no plant-wide fallback: doubling a general number would produce a specific-looking claim about a part nobody measured. | **Its states, and none of them is a quiet pass:** * a pair too close, both ends resolved from the part's own data → **FAIL**, naming the gap, the minimum, and where the minimum came from; * every pair clear → **OK**, naming what they were held to; * an insert the catalogue does not cover, or one whose entry publishes no edge distance to double → that pair is **not judged** and is named *in the same finding*, on the same code — a second `LIFT-SPACING` would be dropped by `checklist_states`, which keeps the last finding per code, and the row would print half the answer; * nothing judged at all → **NOT RUN** with the probe's reason, in red. The probe distinguishes three silences: fewer than two inserts with a readable position (a fact about the *piece*), unreadable geometry (about the *read*), and no resolvable minimum (about the *catalogue*). **A mixed pair is held to the larger of the two minimums.** A 14 in anchor beside a 6 in one does not get to sit at 12 in: taking the smaller passes exactly the pair the check exists for. **The thickness axis is excluded**, for `connector_edges`' reason and one of its own. Anchors in a lifting wythe all sit at one depth, so the depth term contributes nothing on a normal piece — and where it *would* contribute, a three-axis distance is larger, which is the direction that passes a pair the page would refuse. The axis is derived (the span closest to the piece's own thickness), never assumed to be an index. **Not yet run against a live model.** The host tests cover the arithmetic, the resolution order and all four silences; what nobody has done is press Ticket QC on a real piece and read the row. **6.4 could not say why it said nothing, until 2026-08-30.** The check had six silent `return []` paths and **no skip probe**, so `run_all` logged it `RAN-EMPTY` and the row rendered NOT RUN with the reason *"no probe declared"* — a sentence about the tool that told the reader nothing about the piece. Every one of those sites now leaves `_dunnage_silence` behind and a probe reads it, the same shape 5.2 already used. **Zero openings is a pass, and it is checked FIRST — owner's correction, 2026-08-31, reading a live report: "6.4 应该是 OK, 既然没有 opening".** Until this fix the geometry was only ever consulted *after* the printed dunnage insets had been read off the sheet, so a piece with no opening at all still rendered NOT RUN the moment its size notes failed to parse — the row was complaining about a station that could never have mattered. The check now solves the panel geometry **before it ever reads the sheet**: if the piece has zero openings, the row is settled on the spot — *"no openings on this piece — dunnage cannot sit over one; dunnage stations were not needed"* — and the sheet, the notes, and the stations are never touched. The two cases that were never a pass keep behaving exactly as before, now reached only once an opening is actually on the piece: **"no size notes found"** and **"notes found but none parsed into feet"** are failures to read the ticket, and a ticket whose dunnage row could not be read is not a ticket whose blocks are clear of the openings. Both keep reporting NOT RUN. Openings **present** and clear of every station still reach the fall-through pass after the clearance loop — *"every one of the N dunnage station(s) is at least 6" clear"* — the same reasoning as 4.7's flip, unchanged. **The zero-vs-ungathered line that made the fix safe.** `liftexport._openings` hands back an empty list both for a solid with no opening and for a solid it was never given (its own guard: `if solid is None: return []`), so "zero openings" and "openings never gathered" are NOT the same signal on that return value alone — collapsing them would have turned a geometry read failure into a false pass. The check now checks `solid is None` itself, **before** calling `_openings`: the panel's extents are already solved by that point (`Q.concrete_extents`), which walks the same geometry under the same size threshold `liftexport.concrete_solid` does, so a solid missing on the second read is a real read failure, not a real zero. That case keeps reporting NOT RUN with its own reason and is never presented as a clear piece. **One open judgement call, named rather than decided.** When the plant sets `opening_counts_only_if_through_thickness` and every opening on a piece turns out to be a blind recess, a case can be made that the plant's own rule has answered the question and the row should read OK. It is reported NOT RUN instead, with a reason that says exactly what was found — NOT RUN under-claims, which is the safe direction. This is a different case from the zero-openings fix above: here the piece genuinely has openings, just none of them through-thickness, so it is **worth the owner's ruling** on its own. 6.5 is new. On the pieces audited, those three note views contain their own titles and nothing else — `DUNNAGE`, `ERECTION`, `PICKING/STRIPPING`. If the content is graphics rather than text this check must look for graphics too, but a heading printed over nothing is a real ticket defect and nobody was looking. **Re-graded A -> B, 2026-08-30, built the same day** (`NOTE-CONTENT`, `ticketpage.note_view_content_status`). The grade turned on one question that had to be answered rather than assumed: **can the API see this at all?** It can. A note view's contents are ordinary view-owned elements — `FilteredElementCollector(doc, view.Id)` returns the text notes (already used by `_flat_text` for the jacking-force and BED PLAN checks) and everything else drawn there. "Does this view hold anything besides its own heading" is therefore a **counting** question with an exact answer, not something that needs a picture. No vision path is required and none was built. B rather than A for one reason: **which views are the note views is plant nomenclature.** `DUNNAGE` / `ERECTION` / `PICKING-STRIPPING` are this plant's names, they go in `notes.instruction_views`, and the default is empty — so until a plant names them the row reads **NOT RUN with that reason**, never a pass. Mechanical rule, local constant: grade B. Graphics count as content, and that is not defensive padding. A plant that *draws* its stacking instruction instead of writing it would have every one of these views reported empty by a text-only check — the tool would fail every ticket at the plant it was built to help. So the check counts every view-owned element, and says in its own message when the content it found was graphics, so a reader knows what was and was not read. **What was deliberately not built.** A note view holding *something* passes. A note that is present but stale, wrong, or says nothing useful (`N/A`, a leftover line, last year's instruction) is not caught, and no amount of element counting will catch it. That residue is a genuinely different kind of check — its verdict would come from a model reading the rendered sheet, which cannot be unit-tested to a fixed answer and may differ run to run. **It does not belong anywhere on the A/B/C/D ladder**, which grades what it takes to *compute* an answer; it needs its own category before anything is built for it. Left open on purpose. ### 6.6 — an odd count of one part demands a note on the BED PLAN **Built 2026-08-31 on the owner's ruling** (`PARITY-NOTE`, `ticketpage.parity_note_status` / `ticketchecks.check_parity_note`): when the count of one particular part on a piece is **odd**, the ticket's BED PLAN must carry a particular phrase. An odd count with no such note is a defect. **Three plant facts, not one rule.** Which part is counted, which parity demands the note, and what the note says are three separate things, and every one of them is another plant's to answer differently. So none of the three is in the code: they are `drawings.parity_note_rule.family_names`, `.parity` and `.require_text`, and with any one of them unset the row reads **NOT RUN naming the key that is missing**. Nothing is guessed and nothing falls back. **The count comes from the MODEL, and the printed BOM would have been a trap.** It is one scoped `asm.GetMemberIds()` pass, the idiom `check_bom_members` and `_bar_members` already use. Taking it from the ticket's printed BOM looks like the more faithful reading of *"the count on the ticket"* and is not: this plant's lifting hardware is among the model extras that **never appear on a BOM row at all** — `check_bom_members` says so in its own docstring, and skipping them is why that check works. A BOM-sourced count would therefore be zero on every piece, zero is even, and the rule would never once fire. A check that passes everything silently is worse than no check. What the BOM would have caught and this does not: a model and a printed bill that disagree about how many of the part there are. That is real, and it is **5.1's** question. The call-outs drawn on the plan — what a person counting the ticket by eye actually counts — are **5.2's**. Three readings of one number that can disagree with each other is how a tool gets two answers to one question; this row asks only what the piece *is* and what the plan *says*. **One unread member flips the parity, so an unrecognised member stands the whole row down.** Every other check here can afford to report on what it could read and name what it could not. A parity cannot: a census one member short is not a slightly worse answer, it is the **opposite** answer half the time. So a member with no readable family name makes the row NOT RUN, naming how many there were and why. **Zero is an answer, and it is not the same answer as silence.** A piece carrying none of the part has a count of 0, 0 is even, the rule does not apply, and the row is a **genuine OK** — it says so in those words, and prints how many members it read, so a family name that matches nothing is visible instead of silently even. A piece whose members could not be identified is could-not-judge and is never a pass. The two arrive at the verdict function as different arguments so that no later edit can collapse them. **The order of the gates is the design.** Configuration first, then the census, then the parity, and the BED PLAN **last** — because a count whose parity does not demand the note is fully judged without ever opening the plan. Demanding a readable plan before that would print NOT RUN for a piece the tool can answer completely. The plan is not even read on those pieces: the check hands the verdict function a reader rather than the text, and it is called after the parity gate or never. **The phrase match is case-insensitive and survives line breaks**, because a note typed over two lines is the same note. `ticketqc._flat_text` already joins a view's text notes with a single space and collapses runs of whitespace; the phrase is normalised by the same function as the text it is looked for in. The printed call-outs are plain TextNotes on the detail views — established when 5.6 was built, and unchanged. **Grade B rather than A, for two reasons that are worth stating.** The count rests on a family name the **plant** declares, not on anything the model says by itself. And a present phrase proves only that the words are on the plan, not that they are **about this part**: the owner has used the same wording for something else entirely (2026-08-16, on the `*.A` packets — a piece can need a temporary precast beam that exists for the erection and is removed in the field). The OK says exactly what it saw and claims nothing more. #### It ships dormant, and that is the whole safety `drawings.parity_note_rule.family_verified` is **false** until somebody has read the family name off a live document, and while it is false the row reads NOT RUN quoting `family_unverified_because`. This is the guard `reinforcing_rule.parameter_verified` already carries, and here it is doing more work than usual: **a family name that matches nothing counts zero on every piece, and zero is even.** Get the name wrong and the row is a permanent, invisible OK over a whole defect class — the exact failure `families.lifter_include` had before anyone noticed the plant's inserts are not named `LIFT` or `ANCHOR`. The match is on the **whole family name, case-insensitively**, and that is a choice between the two shapes already in the tree rather than a third one. `families.lifter_include` and `drawings.reinforcing_families` match substrings; `drawings.reinforcing_family_overrides` matches the whole name, "or an entry for `SR1` would swallow `SR1101`". A part number is exactly that shape of token, and this job's own piece marks are `WP` — a substring token would swallow the piece named for the same number. A plant whose family name is longer than its part number writes the longer name. **What only a live press can settle:** whether the part is modelled under a family name that *equals* the part number, or under a longer name containing it. The plant layer lists it bare in `families.lifter_include`, which is substring-matched and so cannot tell the two apart. One read of a family name off a real assembly answers it and flips `family_verified`. 6.3's handling findings on these twelve were run on default stages with nobody having picked the inserts. That is enough to find a problem and not enough to sign, and the report has to keep saying so. ## 7. Interference | # | check | grade | | --- | --- | --- | | 7.1 | Interference check | B — the tolerance is a plant fact | Nine of the twelve failed. The recurring pair is `SR5602 x P136` overlapping 0.21–0.23". A pair that repeats across pieces is one modelling pattern, not nine defects — **cluster interference findings by family pair before reporting them**, or the count misleads. **The pairs are a FILE, not a note (2026-08-30).** A WARN writes every pair to `/interference-.csv` — beside `last-ticketqc.txt`, next to the model — with the owner's header `MARK,ID,X,MARK,ID` and then `FAMILY_1, FAMILY_2`; the middle `X` column is the versus between the two sides and holds a literal `x`. The row's note keeps the count, the population, the depth band and a link to that file, because a 180-character cell truncated the evidence and kept the diagnostics. **`THICKNESS_IN` and `VOLUME_IN3` were dropped from the file (2026-08-30)**, on the owner's ruling: keep them only if they could ship hidden by default in Excel, otherwise turn them off. A `.csv` is plain text and carries no column formatting, so a hidden column in one does not exist — both are gone. Thickness was not decoration (it is the quantity the filter tests, so it was the only per-pair answer to *why is this pair here*), so it survives as a band in the WARN note — `shared material 0.13"-0.85", over the 0.125" that flags a pair, deepest row first` — and the file's rows stay sorted deepest-first. Volume had no such duty: depth is the question this check asks, and volume never decided anything. Written only when there are pairs; deleted at the start of every run, so a piece that has been fixed cannot keep the file that says it has not. The population is the ticket's OWN assembly (`member_boxes` → `asm.GetMemberIds()`) and never the model — the note says so in words, because six numbers in one parenthesis, the first counting elements and the rest counting pairs, read as a whole-model sweep to the owner on WP296 (142 members, then 940). ## 8. Text hygiene | # | check | grade | | --- | --- | --- | | 8.1 | No unresolved lines in the design summary | C | | 8.2 | No invisible or non-printing characters in ticket text | A | 8.2 is new and concrete: a `U+200E` left-to-right mark is sitting in the design summary of these pieces. It is one of the five lines the spec reader could not interpret, and it crashed a tool that tried to print it. Invisible characters in text that a person is expected to read are worth refusing outright. --- ## The table itself: nothing is truncated, and a long note folds Owner, 2026-08-30, having read a live 4.5: *"4.5被截断了。我经常在notes里面有截 断。不要有截断,item不要折叠,需要一行就一行,需要两行就两行,但是note做成默认 折叠显示跟item一样的行数。"* **What was cutting it was the data layer, not the styling.** `ticketpage.checklist_states` ran every non-OK message through a 180-character `capped_note()`, so the row tuple itself carried a stump — and the row tuple is what the HTML table, `last-ticketqc.txt` and every other consumer are built from. 4.5 lost its second debond run mid-number (`… 0.958 ft of concrete from 25.875 t…`) in the panel *and* in the file the reader is told to fall back to. **`NOTE_CAP` and `capped_note` are gone, and a test asserts they stay gone.** What replaced the cap is a fold, and it is height-only: - The **item cell is never folded.** One line if it needs one, two if it needs two. - The **note cell collapses to its own row's item height** — `max-height` in `em`, `overflow: hidden`, and the whole note inside it. Nothing is removed, so a collapsed note can still be selected and copied in full. - **Every row folds a long note, red included.** Until 2026-08-31 a red row (FAIL, NOT RUN) never folded — the reasoning then was that the reader must not have to click to read a problem row. The owner overruled that: *"notes太长的话默认折叠"* — long notes fold by default, no status named or exempted. Since the original rule shipped, notes got long on purpose (row probes' full multi-clause reasons, shared-silence cross-references, dated mute reasons), overwhelmingly on NOT RUN — the very status that used to be exempt — and he is now looking at walls of text. What still holds: the state colour and the row's own `item_lines` worth of visible text before any click, on every status; a fold is a clip on height, never a cut on content. - **A file path renders outside the fold**, so 7.1's collapsed note still hands over its link — on any status. - The control **says how much is behind it** (`[+] the whole note (330 characters)`), which is the one thing the old `…` did do. - **The text file has no fold at all** — everything, wrapped, as before. ### …and the text file carries his own words, in his own script (2026-08-31) `last-ticketqc.txt` is documented above as the complete record. A live run wrote it with **every CJK character of the job file's mute sentences removed** — the ASCII fragments and the gaps between them survived, and the same sentences rendered correctly in the panel and in `qc-run.json`. A file promised as complete that silently drops half a sentence is worse than one that fails to write. Two boundaries, both now closed in `ticketpage`: - **`str()` on a note.** Replaced by `_text()`, which is `vibe.compat.as_text` — the project's one answer to this class. Every note, detail line, mute reason and row field goes through it, from the moment the reason is read out of the job file (`muted_checks`) to the moment it is wrapped. - **`textwrap`'s whitespace step.** It munges whitespace by calling `text.translate()` on anything it takes for a `str`. On CPython that table covers every byte; the engine here is IronPython 2.7, whose `str` holds .NET UTF-16 text, so **a character above U+00FF has no entry in the table at all** — which is exactly the set of characters that came back missing. `_wrap_text` flattens the whitespace itself and tells `textwrap` not to do it again. The suite cannot reproduce this: the host interpreter it runs on has neither boundary. So there are two tests — one that a non-ASCII note survives (true on any engine, and the promise itself) and one **tombstone that pins the shape**, because what protects the report here is the absence of two calls. --- ## Every row owes a reason — including the rows no check function owns **The guarantee had a hole one level above where it was installed.** The skip probe (`CLAUDE.md` §5) makes a check that returns nothing explain itself, and `RAN-EMPTY` reports an unexplained silence as the defect it is. All of that is keyed by **check function** — and seven checklist rows name no check function at all. 1.2, 2.1, 2.3, 3.1, 3.2, 4.1 and 4.2 are answered by finding **codes** that `ticketqc.check_ticket` emits directly, so no probe could reach them. On a live run, 2026-08-31, six of them came back like this: ```json {"item": "3.1", "state": "NOT RUN", "judged": false, "checks": [], "reason": null, "note": null, "owner_codes": ["FC"]} ``` `reason` null, `note` null, and in the text report a bare red `NOT RUN` with nothing underneath. Eleven codes waited for, not one emitted. **The tool gave the reader no sentence to disagree with**, and he read the whole page as unnecessary noise — correctly, because a refusal that will not say what it lacks is indistinguishable from one. **And the reasons existed.** The design had no extracted spec, so `intent.source` is `regex` and every column of the intent table is a dash; 2.1's and 4.1's inputs are a printed size and a printed strand count that did not parse — the same parse failure **row 6.4 reports in a full sentence** on the same ticket. What closes it: - **A row probe** — the same contract as a skip probe, keyed by checklist row number instead of by function name. It returns why the row *would* be silent, or `None` when every input is there. `ticketchecks._ROW_PROBES`. - **The answer travels in the run log**, under `row:`. The log is the one channel that already reaches every consumer — the button hands it to `checklist_states`, `ticketvision` writes it into `qc-run.json` — so nothing else had to be edited to carry it. (`missing.reason` in the JSON was *not* the fix: it is a generic catch and it never reaches the text report at all.) - **A last rung that cannot be empty.** With no probe and no declared reason, the row prints a sentence saying exactly that, naming the codes it waited for. Same ruling as `RAN-EMPTY`'s *"no probe declared"*: an unexplained silence is reported, never blanked. - **A mechanical test**, so a new code-answered row with neither a probe nor a declared reason fails in the suite rather than on his screen — `test_every_code_answered_row_can_explain_its_own_silence`, plus one that walks the whole table and asserts no `NOT RUN` row carries an empty reason. **One fact is said once and then cross-referenced.** Rows 4.4 and 4.6 stand down on one sentence, and 3.1 / 3.2 / 2.3 / 4.2 on one missing spec; four red rows carrying one fact read as four problems. Rows whose reason shares its opening clause — everything before the first ` -- `, which is how these sentences are built (the shared half, then what *this* row lost) — now name each other: *"the same missing input also left rows 3.1, 3.2, 4.2 quiet: one fact, not 4 separate problems"*. It **adds**; each row keeps its own words, because a group summary that replaced them would trade one silence for another. **What is not claimed.** A row probe reads the same inputs the emitting code reads, but it is not the emitter, so a future edit could make the two disagree — a row explaining its silence with a reason that is no longer the real one. The guard against that is that a probe must go quiet the moment its input is present, which is pinned by test; a probe *inside* the emitter would be better, and would mean `check_ticket` returning a disposition of its own. **The approximation, stated.** How many lines the item cell occupies is a *rendered* measurement — column width and font, neither of which exists when this code runs — so `ITEM_CHARS_PER_LINE` estimates it from the item's length. Being wrong costs a line of preview, never a character of content. The toggle is the same inline-`onclick` convention every other fold in this tool uses (`foldreport.wtClip`, beside `wtFold`); if the script is dead, a folded note stays visually clipped, which is why a path and the text-file copy stay outside the fold entirely (a red row no longer does — see above, 2026-08-31). --- ## The NOT RUN sweep (2026-08-31) The owner's standing order, verbatim: *"qc别再给我出这么多not-run。看看能跑的, 能做的,现在都做了去。"* Every NOT RUN cause that could be eliminated **without his data and without a ruling from him** was eliminated in one pass — and the honesty invariant governs *how*: **a NOT RUN dies by making the check RUN, never by softening a state.** Nothing below repaints a row; four things were built and one plant layer was armed. ### 1. The design-summary vocabulary — which killed a row's silence *and* seven findings Row 8.1 reported **7 uninterpreted lines out of 7** on a real insulated piece, and row 5.9 (connector sufficiency) stood down on every press saying *"the design states a connector requirement that the summary parser cannot read yet"* — because the requirement it needed **was one of those seven lines**. `ticketpage.parse_summary_line` is the grammar. It **categorises and does not evaluate**: a line it places is a line whose *shape* is known, and nothing in it decides whether a piece honours anything. Four productions, each drawn from a corpus rather than from the one line that prompted it — (a) the live design summary's 9 distinct unresolved lines, (b) 54 distinct `… @ O.C. …` call-outs over 75 occurrences in the plant-manual extractions, both counted 2026-08-31: | production | shape | corpus | | --- | --- | --- | | `spacing` | ` @ O.C. ` | 2 live lines, all 54 manual call-outs | | `placement` | `CL [@ ]` — centred, optionally in a named wythe | 3 live lines | | `conditional` | ` NEEDED WHEN ` | 1 live line | | `label` | an all-letters heading naming the group below it | 1 live line | **The first `@` wins and everything after it is a qualifier.** `#3S @ 18" O.C. +(2)@3" EA. END` states one *field* spacing and then an end densification, and the measured piece honours exactly that reading. A reader that took the last `@` would report the end pair as the requirement and fail every correct piece. **Three refusals are the load-bearing part.** They are what stops the row going green by shrugging: - **A line the grammar cannot place stays an 8.1 finding**, verbatim, in the shape it arrived in. `REINFORCING PER DESIGN` is three capitalised words with no digits — exactly the shape of a heading — and it is a *deferral*, so the label production carries a word list that refuses it. - **A line placed but not *valued* is not a line that was read.** The corpus writes a non-numeric spacing two ways: `@ "S" O.C.` (a column of a table printed on the sheet — 7 occurrences) and `@ __ O.C.` (a literal blank — 1). Both are recognised as spacing requirements carrying `spacing_in: None` and a reason, and 8.1 names them in a clause of their own. - **A conditional is stated, never evaluated.** `CL #7 BARS NEEDED WHEN THERE ARE OPENINGS` is recorded as a condition with the requirement it qualifies parsed inside it, and 8.1 prints it saying the tool does not evaluate it. **The verbatim record is untouched.** `spec['unresolved']` on disk is the *extractor's* own account of what its rules could not place; rewriting it with a later reader's opinion would stop it being evidence. So the grammar runs at **report** time, over whichever list 8.1 was handed, and 8.1 re-derives the split every press — an intent assembled by hand cannot smuggle a claim past it. ### 2. Row 5.9 now runs, and its design side is joined through the plant's own alias The join is the whole trick, and no product name is in the code: ``` the summary says HK ST100'S @1'-4" O.C. the plant declares connectors.marks holds R82 connectors.catalogue_alias.R82: HK Composites (ST100) so the two join on ST100 ``` `knowledge.connector_spacing_requirement` does it, **exactly, on a code token** — one carrying both a letter and a digit — after the summary's possessive `'S` is dropped. Matching on `HK`, or on any substring, would let `ST50` answer for `ST100`: both are *HK Composites* and they are not the same part. That is `connector_product`'s exact-match lesson one join along. Three refusals again, and each has a different repair: **the summary states no spacing**; **it states one and no declared connector is named in it** (a bar's pitch read as a connector's is a confident wrong answer, and 5.9 FAILs on a shortfall); **it names a connector and the spacing is not a number**. **The tolerance, and where it came from.** The comparison was `widest > required + 0.5` with the `0.5` a literal inside the function. It is now `tolerances.connector_spacing_in`, passed in, **and a run that cannot resolve one compares nothing** rather than falling back on a constant — a borrowed tolerance is the same class of mistake as a borrowed edge distance, and 5.10 already paid for that lesson. The number itself is honest about its own standing: **it is a tool default and not a plant ruling.** The one calibration that exists needed no tolerance at all — measured 2026-08-31, the connectors sit at exactly 16.0 in across the field against a design stating `1'-4"`, so this allowance has **never been exercised**. 0.5 in was chosen to sit below the finest spacing the corpus prints (½ in) and above the rounding of a printed dimension, and the finding prints the value and the key it came from, pass as well as fail. **End densification is normal and is never flagged**: the comparison is against the *widest* gap, because the same measured piece carries 9.5 in and 6.5 in pairs at its ends. ### 3. The seven checks with no skip probe `RAN-EMPTY → "no probe declared"` is a whole *class* of NOT RUN and the worst one: a sentence about the tool, printed in red, on a row that may well be fine. Seven checks could still produce it — re-derived rather than copied from a document (`CHECKS` minus the keys of `_SKIP_PROBES`, 37 − 30). All seven now have one, and **the class is closed by test**, not by prose: `test_every_registered_check_has_a_skip_probe` walks `CHECKS`, and two siblings catch a probe keyed by a renamed function and a probe returning `''` (falsy, so `run_all` would read it as *"ran and found nothing"* — a probe meant to explain a silence turning that silence green). | check | row | what the probe says | | --- | --- | --- | | `check_lifter_capacity` | 6.1 | no insert recognised — **and it names both readings**: the piece carries none, or none matches `families.lifter_include` | | `check_handling_result` | 6.3 | three paths: no `panel_id`, `handling.lifter_groups` empty, no mark | | `check_member_interference` | 7.1 | `interference.check` is off, or no `panel_id` — and it goes **quiet on the clean path**, or a piece with no clashes would read NOT RUN | | `check_picking_match` | 6.8 | no sheet / no length / no picking families / **the printed band did not parse**, which is the tool's reading failure and never a defect in the piece | | `check_sheet_name` · `check_ticket_set` · `check_invisible` | 1.1 · 1.3 · 8.2 | *these three have no silent path at all* | The last three are a second shape of probe and it is deliberate. `sheet_name_matches`, `ticket_set_status` and `invisible_chars` answer with a severity and a sentence for **every** input, including no sheets at all — a piece with no sheet gets `FAIL "no sheet found for this piece"`, not silence. So their probes return a reason *always*, saying so: they never fire while the checks behave, and the day a refactor makes one return `[]` the row names the check and says the silence is **a defect in the tool, not a verdict on the piece**. That beats *"no probe declared"* by naming the place to look. ### 4. The two checks that ran and could reach no row `check_bom_strand_count` (`BOM-STRAND`) and `check_picking_match` (`PICK-STATIONS`) had been registered and running with **no master entry naming them** — so their findings landed in `qc-run.json` under a code no row owned, and `BOM-STRAND` can FAIL. They were recorded as a reasoned exemption (`ROWLESS_CHECKS`) rather than wired, because giving a check a row is a decision about what the report claims and that is the owner's. He made it. They are now master ids **4.8** and **6.8**, on the plant board in place (46/46, contiguous, nothing off-board). **Why each got a row of its own rather than joining a neighbour.** 4.1 already asks *printed strand count vs modelled* and 6.5 already asks whether the PICKING note view says anything — so the multi-check idiom (5.1) was the obvious move and it is the wrong one **here**, for a reason worth writing down: > A row is answered only when **every** check it names has spoken. 5.1 pairs two > checks because they are two halves of **one** question and half an answer is > not an answer. 4.8 and 4.1 are two **independent** answers to a similar > question — a BOM linear-feet row and a printed section call-out, different > inputs, different failures, different repairs — and either alone is a real > verdict. Hanging 4.8 off 4.1 would have made 4.1 read NOT RUN on every ticket > whose BOM could not be read: **a row that had a verdict turned red over a > silence about a different question.** That is the opposite of the order. `ROWLESS_CHECKS` stays, empty, and so do its three guards — an allowlist with one test that fails if a check reaches no row and another that fails if a listed check gains one. An empty allowlist under a working pair of guards is what *"there are none"* looks like; deleting it would delete the mechanism that found these two. ### 5. Rows 5.13 / 5.14 / 5.15: the gather that was a comment These three shipped with their arithmetic built and their inputs deliberately **un-gathered** — `read_ticket` set no `solid_zone_pattern`, `recess_patterns`, `recess_regions` or `legend_patterns`, and a comment in `ticketqc.py` said why: writing a Revit read nobody has run and letting the first live press find out is worse than three rows standing down loudly. The reads have since been taken on the live model, id by id, so `ticketqc._read_insulation_hatch` now ships the path those measurements exercised. Every call in it is individually guarded and every failure leaves a **sentence** in `insulation_hatch_notes` that the three rows print behind their own — a gather that can take Ticket QC down is worse than three rows that stand down. Three things in it are not incidental: - **The filters are read off the view *template*, not the view.** The override that hatches a solid zone sits on the template; a reader that asked the view would find no filter and report the hatch as un-overridden, on a job whose defect **is** the override. - **Legend views are excluded from the recess count.** A swatch is a *picture* of a recess. The only four recess regions in the model are swatches, and they sit across **two** legend views — counting them would make every piece look as though it drew two recesses. The plant layer names the token (`LEGEND`) rather than a second view name, because the next one will be called something nobody predicted. - **A type-level pattern read is authoritative only under a condition, and the condition is checked every run.** A filled region is a Detail Item; a filter selecting that category beats the type, and then the type says what the region *would* have drawn with rather than what it drew. Measured 2026-08-31: no filter on this template carries the category — a fact about today's template, not a property of Revit. The day one gains it, 5.15 stands down naming the filter instead of quietly comparing the wrong thing. **What each row does now.** 5.13 **runs and FAILS**, id-exact: the zones draw the pattern the template's filter sets and the legend's own swatch for a solid zone is a different pattern element — and the one they wear resolves *by element id* to the 2-inch-recess swatch, so the finding can say the zones render with the recess's own pattern rather than merely *"the wrong hatch"*. 5.15 **runs clean** for both recess types. 5.14 **stands down honestly**, and the sentence is the sharpened one: no recess is drawn on any ticket view, the legend views are excluded on purpose so a zero is a real zero — **and this reader has never seen a job that does draw one**, so it cannot tell a piece that needs no recess from a piece whose recesses were forgotten, and it claims neither. One question came off the owner's list with a measurement rather than a ruling: **`VOID_REVEAL_WIDE` is not a modelled recess** — 22 instances, all on one piece, zero on any insulated piece, none an assembly member. The drafted-region path is the whole path, `insulation.recess.modelled_path_verified` is now true with `modelled_family` settled empty, and 5.14 has stopped printing the *"the modelled path was NOT consulted"* caveat. A tool that keeps hedging about a closed question is noise on a red row. ### What is left, and it is all his After the sweep, a row can still read NOT RUN five ways, and **only the first two are eliminable by anybody but him**: 1. a check raised (`CHECK-ERROR`) — a bug, and it names the check; 2. an input this run could not read — the probe names the ticket key or the gather; 3. **a plant fact no layer states** — the probe names the key: `1.4` `nomenclature.design_number_re`, `6.5` `notes.instruction_view_sets`, `5.6` / `5.7` `drawings.reinforcing_rule.scopes`; 4. **a datasheet nobody has supplied** — `5.10` for any connector but ST100 (`catalogue.connectors.products.*.min_edge_distance_in`), `5.7` the per-size stock table's reader; 5. **a ruling he has not given** — `5.7`'s bar-size parameter, `5.14`'s design side, `5.15`'s legend-text-vs-type-name size discrepancy. Three rows are not NOT RUN at all and must not be counted as such: **5.3 and 5.5 are FOR ENGINEER by design** (yellow, a person's job, with the reason that makes them so), and **3.3, 5.4 and 1.4 are muted on this job** with his own dated sentence — muted still runs, still shows, and a mute can never silence a FAIL. --- ## Can this be code? **Most of it, yes — but not as one gate.** Counting the grades: A dominates. Those are comparisons between two values that both exist in the model, and they belong in `ticketchecks.CHECKS` alongside what is already there. The B items are the same shape but need a constant that is genuinely local — the dimension tolerance on stemmed pieces, whether a printed strand count is per-piece, which rail the dimension chain starts from, the interference tolerance, the release band. Those belong in the knowledge file, and the audit shows why they cannot be defaulted: a wrong default reads as a missing value and the check quietly does not run. The C and D items are the residue, and they are not the interesting part. What this audit actually shows is that **the failure mode worth engineering against is not "the check is impossible" — it is "the check ran against the wrong input"**: - reading one sheet of a two-sheet ticket produced 60 false findings on twelve pieces, and would produce roughly a thousand across the model - one missing knowledge entry (the legend prefixes) fires a FAIL on all 204 WP tickets - one root cause fanned out into five distinct codes, so the report's shape suggested five problems A checklist that a person walks through would not have caught any of those. Code would, and did. **So: build it as code, and let the skill be the thin part.** A skill is the right home for the C and D items — the reading-the-drawing judgement, the shortlist triage, deciding whether a repeated interference pair is one problem or nine. It is the wrong home for anything a comparison can settle, because a person walking a checklist has no way to notice that the tool handed them half a ticket. Before either: fix item 0, and re-run. Every number in this document was measured against a ticket the tool had only half-read. --- ## 8bis. Checks a model has to look at a picture to make Ruled by the engineer 2026-08-30, prompted by the residue in 6.5: a note view that *does* say something may still say the wrong thing -- stale, `N/A`, last year's instruction. That verdict cannot come from a query. It comes from a model reading the rendered sheet, it cannot be pinned to a fixed answer by a test, and it therefore does not sit anywhere on the A/B/C/D ladder, which grades what it takes to *compute* an answer. **The ruling: such a check reports NOT RUN, and raises a WARN alongside it.** Not a pass, not a fail, and never silent. NOT RUN says honestly that nothing was computed; the WARN says a human still has to look. The two together are the only combination that does not lie: a pass would claim a verdict nobody reached, a fail would condemn on a guess, and silence would be read as a pass by every person who ever skims the report. ~~Nothing on this footing has been built.~~ What the report may *claim* on such a check's behalf is settled by the above and by nothing else -- it may say what it could not judge and that someone should look. It may not paraphrase the content, score it, or imply it was reviewed. ### Built 2026-08-30 — `vibe.ticketvision`, `tools/vision_read.py` The escalation path exists. **One** check uses it: **5.5**, bar marks legible and unique — grade D, the row that has always read FOR ENGINEER because there was nothing else it could read. Legibility is a property of the *picture*, so a picture is the only thing that can answer it. **How a sheet becomes an image, and why the resolution is what it is.** It does not. **A whole sheet is never rendered**, and that is forced by a hard ceiling at the far end: the API downscales any image whose long edge exceeds 1568 px, so the model receives about 1.1 megapixels however large a file is sent. A 34 x 22 in sheet inside that budget is **44 dpi** — and no bigger render helps, because the extra pixels are discarded on arrival. So **one placed view** is exported (`Document.ExportImage`, read-only, no transaction) at **1500 px on the long edge**, just under the ceiling. The effective resolution then follows from how much paper that view occupies: effective dpi = 1500 / the view's long side, in inches on the sheet A 6-inch note view is 250 dpi. A 20-inch bed plan is 75 dpi, and is **refused** — `render_plan` returns NOT RUN with the arithmetic in the reason below a **150 dpi floor**. That floor is the QNA evidence, not a preference: a text layer returned `1316"` for both `13/16"` and `1 3/16"`, a phantom strand position survived four sheets until someone rendered at 450 dpi, and stamps invisible to text search were found only by looking. A render nobody can read is a NOT RUN, never a question. **How a model-read finding is told apart from a computed one**, in four independent places, because one of them will eventually be dropped by an edit and the other three still have to hold: | | | |---|---| | the finding | `Finding.source == 'model-read'` (everything else defaults to `'computed'`) | | the code | `AI-*`, a prefix no computed check uses and **no `CHECKLIST_ITEMS` row owns** — so a read cannot set, flip or satisfy a computed row | | the list | its own, `result['model_read']`, never merged into `findings` | | the report | its own section, its own table, and a column that says *a model looked at a picture*; every message opens `[read by a model, not computed]` and ends *NOT a verdict* | | the JSON | two keys, `findings` and `model_read`, each entry carrying `source` and a boolean `computed` | **Every outcome is a WARN**, `clear` included. A model that looked at one view and saw nothing wrong is a reason for a person to look, not a reason to skip looking — which is exactly the shape 8bis requires, and there is no branch in the code that can emit OK or FAIL. **Cost, and why it is opt-in.** Run option `vision_escalation`, **default OFF** — the opposite of `expensive_geometry`, deliberately: geometry spends seconds on a machine nobody bills, a model call spends money and leaves the building. `vision_escalation_max_calls` (default 25) is a hard per-run bound, enforced on both sides of the file boundary, and an escalation past it reports NOT RUN naming the bound rather than being dropped. A full run over all 1105 sheets would be roughly **1100 calls, about US$5.40, and 1.5–2 hours** — about 4,300 input tokens a call (2,200 of skill, 2,000 of image) and ~120 out, at Haiku 4.5's $1/$5 per MTok, so **$0.005 a sheet**; the wall clock is dominated by the per-view image export, not the model. Which views carry bar marks is plant nomenclature (`vision.barmark_views`, default empty), so an unconfigured plant gets NOT RUN with that reason and never a pass. **Credentials** are read from the environment by the SDK in the CPython child (`ANTHROPIC_API_KEY`, or an `ant auth login` profile). No key is in the repo, in a knowledge file, in the request, in the response, or in any log — nothing in either half ever reads one into a variable. **What is not test-pinnable, said plainly.** The verdict. The same sheet may come back `clear` on one run and `cannot_tell` on the next, and no test asserts otherwise. What *is* pinned, in `test_ticketvision.py`: a refusal renders as NOT RUN; a failure to reach the model renders as NOT RUN and never a pass; every outcome carries its provenance; the two kinds never share a list, a table or a key; an `AI-` code owns no checklist row; annotation never changes a row's status; and the resolution arithmetic refuses a render nobody could read. **What was proposed and left.** `NOTE-SENSE` — 6.5's residue, whether a note that says *something* says the *right* something — is defined in the skill and not wired: it needs `notes.instruction_views` populated to know what to render, and no plant has populated it, so it would report NOT RUN on every ticket today. 5.3 (face designations) and 8.1 (unresolved lines) were considered and **not** proposed: both need a second source the picture does not carry, so every answer would be `cannot_tell` by the skill's own rules. The title block, the BOM and the handling dimensions were **not** proposed either, and that is the one worth stating — every number on them is already read exactly by code, and asking a model to re-read them off a picture would replace an exact answer with a guess. **Escalation goes down the ladder, never up it.** ## 9. Deliberately not checked Recorded so nobody proposes them again as oversights. Each was considered against real tickets and left out on purpose. **Shear-cone overlap between adjacent erection anchors.** Two anchors close enough that their breakout cones intersect share concrete, and the pair is worth spreading. On two pieces in the reviewed set the engineer moved a pair from 1'-0" to 1'-6" apart for exactly this reason. It is still not a check: the spacing that matters depends on embedment, edge distance, what the anchor is rated for and what the piece is actually being asked to do, and reasonable engineers argue about it. **The verdict was: show the geometry, do not judge it.** A tool that fails a ticket over a debatable margin trains people to ignore its failures. The general form of that decision is worth keeping: **a check needs a criterion that is not in dispute.** Where the criterion is a judgement call, the honest product is a number on the page, not a verdict. ## What the review measured The twelve tickets were audited, then reviewed by hand, and four were corrected. This document's items caught **one** of the four: | piece | corrected | caught here | why not | |---|---|---|---| | WP199 | dunnage `4'-9"/4'-9"` → `3'-0"/5'-0"` | yes | support sat 2.5 ft inside an opening | | WP213 | dunnage `4'-7"/4'-7"` → `4'-0"/5'-0"` | no | support sat **5 inches** from the opening edge — outside it, so a containment test passed | | WP219 | `3'-3"/1'-0"` → `2'-9"/1'-6"` | no | anchor spacing, deliberately not checked (§9) | | WP220 | `3'-3"/1'-0"` → `2'-9"/1'-6"` | no | same | WP213 is the one to learn from. The check ran, returned, and said the ticket was fine — and by its own definition it was right. **A criterion looser than reality is more dangerous than a missing check**, because a missing check leaves an obvious hole and a loose one produces a clean report. ### The criterion, after the fix Dunnage against openings is now a clearance test: the distance from each support to the nearest opening edge, with the thresholds in the plant file (`handling.dunnage.opening_clearance_fail_in` / `_warn_in`, 3 in and 6 in here). Inside an opening stays a fail on its own. Re-run against the same geometry, both versions of both tickets: | piece | version | far support | clearance | verdict | |---|---|---|---|---| | WP199 | before | 18.17 | inside OPENING 1 | FAIL | | WP199 | after | 19.92 | 11.5 in | OK | | WP213 | before | 17.38 | 5.0 in | **WARN** | | WP213 | after | 17.96 | 8.0 in | OK | **Two of two in-scope corrections, where it had been one of two.** The other two the engineer made are anchor spacing, which §9 leaves alone on purpose. The part worth noticing is that the two thresholds were given as plain numbers by the engineer, before any of this was run, and they land exactly on the line he had already drawn by hand: the piece he treated as a defect comes back FAIL, the piece he merely improved comes back WARN. Nothing was tuned to make that happen. **When a threshold reproduces a judgement nobody encoded, that is the evidence the threshold is real** — and it is also the reason to leave it in the plant file rather than in the code, because it is his number, not the tool's. --- ## What is left for a person, counted The point of grading each item A to D was to find out how much of this a program can do. With the A and B items written, the report renders 29 rows per ticket (27 before 1.4 and 6.5 landed on 2026-08-30) and they fall out like this: | | rows | | |---|---|---| | covered by code | **22** | OK / WARN / FAIL / FOR ENGINEER, all produced by a check that ran | | code not written yet | 0 | was 1.1, 5.4, 6.5, 8.2. All four are now written; 1.4, which this tally never listed, is written too (2026-08-30) | | missing an input | 1 | 3.3 — the spec carries no f'c/f'ci to compare against | | a person's job by design | 2 | 5.3 narrows to a shortlist, 5.5 is reading the drawing | **Amended 2026-08-30, second pass.** `CHECKLIST_ITEMS` now carries **31** rows, not 29: 2.4 and 4.5 were rows-without-checks when that count was written, and both are now built (`CAST-EXTRA`, `STRAND-CALLOUT`), so "covered by code" is **24**. The residue is unchanged — 3.3 still lacks its input, 5.3 and 5.5 are still a person's job — and no row has moved *into* the residue. The column arithmetic above never summed to the row count and still does not; it was not re-derived here, because doing that honestly means re-running the report, not editing a table. **Not one row needs a model to look at a picture.** The four unwritten ones are code nobody has typed; the missing input is data, not judgement; and the last two are work that should stay with a person — a tool that took them over would be pretending to a competence it does not have. That answers a question that had been open on cost grounds: whether the drawing-reading part should move to a local model on the shop's own hardware. It should not, because after the A and B items landed there is no drawing-reading part left to move. **Amended 2026-08-30.** That still holds for every row as written, and 6.5 — the item most likely to have needed a picture — turned out not to: a note view's contents are queryable elements, so emptiness is counted, not seen. But building it exposed a residue the ladder has no rung for: whether a note that *does* say something says the **right** thing. A verdict from a model reading the rendered sheet is not grade A however valuable it is, because it cannot be pinned to a fixed answer by a test. Before any such check is built, it needs a category of its own and a rule about what a report may claim on its behalf. Nothing has been built on that path. **The reason the question became answerable is worth more than the answer.** While the report was a list of findings, "how much is left for a person" could only be estimated, because an item nobody checked produced no line and looked exactly like an item that passed. Once every item occupied a row — including the ones marked NOT RUN, with the reason — the question became arithmetic. **A system that cannot say what it did not do cannot be measured.** --- ## A row can also be *not a question about this piece* (2026-08-31) Everything above grades a row by **who answers it**. This section is about a different axis the ladder has no rung for: **whether the question applies at all** — and the answer to that is plant knowledge, conditioned on what the piece is, not a property of the row. Owner, 2026-08-31, reading insulated panels: *"ticketqc 检查的内容需要进行分级。 根据 plant 和 ticket 的具体内容进行分级 ... 让 ticketqc 工具读取厂知识来得知啥需要 检查,啥不用检查。"* The mechanism is `checks.not_applicable` / `checks.only_applies_to`; the shape, the merge law, the classifier and the worked non-examples are in **`docs/knowledge-override.md`**. What belongs here is what a **reader of the report** sees. ### The five states, and the colour each one is printed in Colours are pinned by tests. A new state gets its own entry, never a reuse of an existing one. | state | colour | did the check run? | what it means | | --- | --- | --- | --- | | `OK` | green | yes | judged, and it is fine | | `WARN` / `FOR ENGINEER` | yellow | yes / by design not | a person must look | | `FAIL` | red | yes | the piece is wrong | | `NOT RUN` | **red, exactly like FAIL** | no | **nobody checked.** An unrun check tells you nothing about the piece, so it is never quieter than a failure | | `MUTED` | grey | **yes** | it ran, and this job asked for it quietly. The note says what it really reported, and a FAIL is never muted | | `NOT APPLICABLE` | **blue** | **no — not at all** | a knowledge layer declares this question is not about this kind of piece | Blue is calm on purpose, and calm is only safe because the declaration is loud elsewhere: **every run prints, per piece, which profile ran** — > **PNL101** classified as **insulated** — 3 row(s) declared not applicable: > 3.3, 5.4, 6.6 — and then every switched-off row with its reason, its date, its declarer and the layer that declared it. That section **opens itself whenever anything was switched off**, unlike the muted section, which folds when it is tidy. A mute leaves the check running; this stops it, and a mistaken rule here is a safety check disabled in silence. ### Reading a blue row * The check **did not run**. There is no verdict behind the row and there is not meant to be — which is exactly what separates it from grey. * `judged` is **false** in `qc-run.json`, the same as `NOT RUN`. The difference between them is *why nobody asked*, and that is the state word and the reason, never a truth value. * **A row that FAILED is never blue.** The declaration is recorded as DECLINED and the row keeps its red — the same ruling as a mute over a failure. This is not theoretical: 4.4 and 4.6 both name `check_strand_layout`, so a check kept alive for the row that still applies can put a finding on the row that does not. ### Reading a red row that a *scoped* declaration produced A row declared with `only_applies_to` exists only for one kind of piece. On a piece nothing could classify it reads **NOT RUN, in red**, saying so — never OK and never blue. Running it would answer an insulated question about a piece that may not be one; calling it not applicable would claim the piece is not that type, which is precisely what could not be established. It is cured by making the classification work, not by a ruling. ### The boundary a plant may not cross A plant declares applicability **over** the rows in this document. It cannot define a new one. A new *question* is a code change, and the reason is the ladder at the top of this file: a row is a claim the tool makes, and a claim needs a check, a skip probe, a grade and a test — the things that let it be *wrong in a reviewable way*. A YAML-defined row would have a number, a sentence, and no way to fail. That is a checklist row that always looks fine. ### What is seeded today: nothing Both tables ship **empty**, and a test pins that. The first evidence run (2026-08-31, four live presses on insulated pieces) found **no row whose noise was applicability** — the "nearly useless on insulated panels" wall was already gone, and it went by running Extract Design and by the face-lifter knowledge fix, not by switching anything off. Of the remaining non-OK rows, four trace to known bugs (a weight sum that misses a wythe on 16" insulated bodies, an 8.1 parser with no vocabulary for `TOP WYTHE`, a 5.6 parser reading hardware part numbers as bar marks), and **declaring any of those inapplicable would bury a real defect under a legitimate-looking declaration.** The first genuine insulated-only *question* arrived the same day and from the owner, not from the noise: every lifter on an insulated panel must have a solid zone through the insulation. On a solid panel that question is meaningless. It is the worked example of `only_applies_to` and it is **not built** — the population is still being measured and what it would compare is undetermined. --- ## The checklist is now data, and a plant numbers its own (2026-08-31) Everything above describes the questions. Where they *live*, and what the numbers on a report mean, changed on 2026-08-31 — the mechanism, the merge law and the reference grammar are in **`docs/knowledge-override.md`**; what a reader of the report needs is here. ### Three layers | layer | owns | | --- | --- | | **public** (`checklist.master` in `knowledge/catalogue.yaml`) | **what the questions are** — id, wording, grade, owner codes, which check answers it | | **plant** (`checklists.profiles`) | **which of them this plant asks of which kind of member**, in what order, under what numbers | | **job** (`checks.muted`) | the mute buttons | The check **functions** stay code. A row is a claim the tool makes, and a claim needs something that can be wrong in a reviewable way — a probe, a code, a grade, a test. So **what exists is publishable knowledge; what answers it is code**, and a new *question* is still a code change. ### Two numbers on every row, and which is which * the **master id** is the identity. It never changes. Every mute, every applicability rule and every ruling names it, and a **bare number in any document written before 2026-08-31 is a master id** and still means exactly what it meant. * the **display number** is what this plant's board calls it. On the **page**, the master id prints small under the plant's number and only when the two differ — a plant that has not renumbered sees one number, not the same number twice. In **`last-ticketqc.txt`** it is always spelled out (`2.1 [3.3]`), because the file is the fallback a reader is told to trust and it has no hover text. In **`qc-run.json`** (now `qc-run/3`) every row carries `item`/`master_id` **and** `display_number`, plus `renumbered` so a consumer never has to work out which it is holding. A citation that needs to be unambiguous is **qualified**: `sample_plant-wall_panel 3.3`. ### The report header says which board ran > **PNL101** — sample_plant checklist **Wall panel** *(profile `wall_panel`, > chosen on member kind)* · member kind: **wall_panel** and beside it the classification line. A renumbered table with neither has shown the reader a checklist without telling him whose. The member kind comes from the **mark prefix**; a piece whose prefix nobody has declared gets **no profile and the full master checklist** — more questions, not fewer. If the board does not name a question that exists in the code, **it is asked anyway** and the header says so. A board is a snapshot of what existed when somebody wrote it; the alternative would let the next check anybody builds run nowhere. --- ## 5.8 / 5.9 / 5.10 — connectors on an insulated panel Added 2026-08-31: *"insulated panel 加一个 check,检查 connector 是不是存在, 以及够不够"*, and *"再增加 connector edge distance 的 check"*. A **connector** crosses the insulation of a sandwich panel and ties the two concrete wythes together. All three rows are **insulated-only**. | | | | |---|---|---| | **5.8** | connectors present | **zero is a FAIL**, not a WARN — untied wythes are a structural condition. It is only ever reached after the recognition gate has confirmed the tool can see connectors at all, so a zero here is a zero in the piece. | | **5.9** | enough of them | compares against what the design calls for. Stands down today: the summary line `HK ST100'S @1'-4" O.C.` is one 8.1 cannot parse. | | **5.10** | edge distances | product-first minimum; a plant-wide fallback is named as such in the finding. | **What each says when it cannot answer**, because none of them may go quiet: * recognition unverified → all three stand down with the plant's own reason (one gate, three rows, never three copies of it); * on a **solid** panel → `NOT APPLICABLE`, blue: there is no insulation for a connector to cross, so the question is meaningless rather than quiet; * on a piece nothing could **classify** → `NOT RUN`, **red**. Calling it not applicable would claim the piece is not insulated, which is exactly what could not be established; * no published minimum for 5.10 → stands down naming the missing datum, and **never** borrows another product's number; * no readable requirement for 5.9 → stands down naming the count it did not judge. A count on its own is a number, not a judgement. **Today all three read NOT RUN on every piece**, because `panel_types.detect_verified` is false — the connector recognition is measured and verified, but the panel-type classifier has never been run against the model. One press confirming it arms all three. Two calibrations worth keeping, both measured 2026-08-31 on one insulated piece: the field spacing is **16.0 in**, which *is* the design's 1'-4" — and the tighter 9.5/6.5 pairs at the ends are **normal**, so the comparison is against the widest gap. And the smallest in-plane edge distance actually built is **6.0 in**, on work the owner has not questioned — so a published minimum above 6 is a finding to **surface**, not a reason to set the limit at 6. --- ## 5.11 – 5.15 — solid zones and insulation recesses (2026-08-31) Added on the owner's **second** asking, which is the first thing worth recording: > "没有 solid zone 相关的检查。lifter 自带 solid zone,没检查这些 solid zone > 是不是被加入了 assembly,是不是被显示在 ticket 上面,hatch pattern 对不对。 > 这些都没有啊" > "不光是 solid zone,insulation recess 也得加上。你看看 page 2 里面的那些 > hatch pattern 就知道了。" A **solid zone** is a block of concrete that replaces the insulation locally so a lifting insert has solid material to pull against. On this job every lifter *nests* one inside its own family. A **recess** is a partial-depth pocket in the insulation, and on this job it is **not a model element at all** — it is a drafted filled region on the ticket. All five rows are **insulated-only**, through `checks.only_applies_to` in the plant layer. ### The measurements everything below rests on All bridge-verified 2026-08-31, over the 27 insulated pieces: | | | | --- | --- | | nested zones | **192 of 192** lifters nest exactly one, via `GetSubComponentIds()` | | standalone zones | 82 more, at the openings, nested in nothing | | assembly members | **21 of 192**. 17 of the 27 pieces have **zero** | | rendered on a ticket | **0 of 192**. The layout view shows only the standalone ones | | the live hatch defect | the `SOLID ZONE` view filter on the insulation view template overrides the surface foreground pattern to **`TRIANG`** — the *recess* pattern | ### Why this is five rows and not one They are five different failures with five different repairs, and a single row would tell a modeler something is wrong without telling him what to do. | | | | |---|---|---| | **5.11** | the zone is modelled but left out of the assembly | add it to the assembly | | **5.12** | it is out of the assembly, so it never reaches the layout view | *the same repair* — which is exactly why the row says so | | **5.13** | it is drawn, with the wrong hatch | fix the view filter | | **5.14** | a recess the design calls for is not drawn | draw it | | **5.15** | a recess is drawn with the wrong hatch | fix the region type | **5.11 and 5.12 share a repair and are still two rows**, because they do not share a *cause*: a zone can be an assembly member and still fail to render the day somebody changes the view. Merging them would make the second failure invisible for as long as the first one holds. **And 5.12 is not enough on its own.** Nested and standalone zones share **one type**, so making the nested ones visible without fixing the filter would put **eight more wrongly-hatched zones on every piece** rather than none. That is why 5.13 exists as a row rather than as a footnote on 5.12. ### The reference for a hatch is the legend's own swatch Not a table in a YAML file. The legend printed on the sheet is the sheet's own statement of what each hatch means, and it is **the only reference that updates itself** when the plant redraws a swatch. So `expected_hatch_pattern` resolves the legend **first** and the plant's `insulation.legend.expected_patterns` **second** — and every finding says which it used, because *"the legend on this ticket says `ANSI35`"* and *"a YAML file written on 2026-08-31 says `ANSI35`"* are different claims and only the first can be trusted next year. **The plant map disagrees with the manufacturer's manual on two entries**, and that is recorded rather than resolved: the manual (p706 / 8207.2, already in `face_designations.insulation_hatch`) says `HONEY` for a 3in recess where this legend draws `TUCKPOINT`, and calls `STARS` a 1in cap where this legend labels it 2in. The checks are held to **the legend printed on the ticket**, because that is what the shop reads. Which of the two governs is the owner's ruling. ### What 5.14 does not claim, and why the row exists anyway There is **no model counterpart** to compare a drafted region against, and **nothing parses the design side** — no line anybody has taught a reader to turn into "this piece should have two 2in recesses". So 5.14 stands down today: it prints the inventory it found, states that nothing was compared, and names **both** unblockers (a design-summary reader, or a ruling that the recesses are modelled after all). A presence count printed as agreement would be the alternative, and it would be a green row over a question nobody asked. The row is still worth having under this architecture's own reasoning: the inventory is real and written down, the unblocker is in the sentence a reader actually sees, and the day either half arrives the comparison is already written — `recess_presence_status` takes the requirement argument today and simply has not been given one. **One candidate complication, asked and unresolved.** `VOID_REVEAL_WIDE` (`MC='VOID REVEAL'`) might be a *modelled* recess. The drafted-region path is built; the modelled path is a **named dormant slot** (`insulation.recess.modelled_path_verified: false`) and 5.14's own finding says the modelled path was not consulted. Naming it rather than leaving it implicit is what makes turning it on a YAML edit. ### The size question 5.15 refuses to answer The legend's **text** says 1" and 2"; the region **type names** say 2in and 3in. Both are on the same sheet. 5.15 compares **patterns**, which have an exact answer, and carries a sentence saying the size discrepancy is the owner's question and that he has been asked. A row that quietly picked a side would launder a question into a verdict. ### What each row does when it cannot answer None of the five may go quiet, and every empty return leaves a sentence a skip probe reads — the shape 6.4 had to be retrofitted with, built in from the first commit here. * **the solid-zone family unset, or set and unverified** → all three solid-zone rows stand down with the plant's own reason. This is the 6.6 guard and it is doing more work than usual: a family name that matches nothing counts **zero** zones on every piece, and zero zones reads as *"no lifter is missing one"* — a permanent invisible pass over the whole defect class the owner just commissioned. * **no lifter recognised** → nothing to look inside; the existing connector/lifter recognition is reused and **no fifth recogniser was built**. * **no sheet issued** → **NOT RUN, "no sheet issued"**, and it is neither a pass nor a failure. 12 of the 27 insulated pieces have no sheet yet. * **the layout view unnamed at this plant** → stands down naming `drawings.insulation_layout`, which is landed beside `drawings.bed_plan` for exactly the reason that key exists. * **no legend read and no plant map** → the *reference itself* is missing, and it is named rather than filled in. **No neighbouring concept's pattern is ever borrowed** — one concept drawn with another's hatch is the whole defect. * **the rendered pattern not gathered** → a named silence, never a pass. **Zero and ungathered never merge.** `solid_zones` reaches the check as `[]` when the gather ran and found none, and as `None` when it never ran, and the two produce different sentences. That is 6.4's lesson, landed the same day and built in here rather than retrofitted. ### The gather half, and what is dormant **5.11 and 5.12 run today.** The gather is `GetSubComponentIds()` on each recognised lifter plus `AssemblyInstanceId` against the assembly — the exact reference the measurements were taken with, in `ticketqc.read_ticket`. Membership is also how 5.12 answers: the layout view is assembly-scoped, so a member reaches it and a non-member cannot. **That relationship is an inference, not a rendered-geometry read, and 5.12's finding says so in its own words** — because the repair follows from the basis, not from the verdict. **5.13, 5.14 and 5.15 are dormant and loud.** Four keys are not gathered, and `ticketqc.py` carries a block naming exactly what each one costs to fill: `recess_regions` (a view-scoped `FilledRegion` collector), `recess_patterns` (`FilledRegionType.ForegroundPatternId`, **then** any view filter, because reading only the type would report every pattern correct on a job whose defect is precisely a filter override), `solid_zone_pattern` (`View.GetFilterOverrides(...).SurfaceForegroundPatternId`, resolved against the view **template**), and `legend_patterns` (the legend view's own swatches). None of those API paths has been run against a live document, and guessing an enum or a property name is how Revit dies without an exception. One bridge session lands all four. ### And the edge that 5.10 measures to changed the same day > "pin edge distance = 3". ST100 的知识。写。检测 edge 检测的不是 concrete > edge,而是 insulation edge,所以如果 insulation 有 solid zone 被 cut > through 而不只是 recess 的话,那么 edge 就是被 cut 之后的 edge。如果只是 > recess,则按照原来的 edge 来算。" Two things landed, and the second is the larger one. **The number.** `HK Composites (ST100).min_edge_distance_in: 3.0`, source *stated by the owner*. ST50 and the rest stay null with their reason: he gave one product's number, and borrowing it across designations is exactly what that reason field forbids. **The semantics.** The edge is the **insulation** edge, not the concrete one. A solid zone that **cuts through** the insulation redefines it — concrete has replaced the insulation, so the zone's border *is* an insulation edge. A **recess** does not: insulation still runs underneath, and the original boundary governs. So 5.10's reference geometry is *the insulation extent minus the through-cut solid zones*, which means it consumes the very population 5.11–5.13 recognise. **One recognition, several consumers** — a second reader for the same parts is how two rows come to disagree about what is on a piece. **Through-cut versus partial is an inference from names**, and it is labelled one everywhere. Depth is not a field; it is text in the type's description (`3" SOLID ZONE`, `4" SOLID ZONE`) beside an insulation type named for its own thickness. Equal depths read as through-cut. **A zone whose depth cannot be parsed stands the pins near it down** rather than defaulting either way — `False` would quietly measure to the old edge, which is the permissive direction, and `True` would invent an edge that may not exist. **The 6.0 in calibration has been withdrawn**, and withdrawing it is the point. It was measured pin-to-**panel**-edge, and he has just defined the measurement as pin-to-**insulation**-edge. Those are different measurements of different things, and carrying the number across would have set an expectation that the first live run then looked like a regression against. The insulation-edge distances on this job are **unmeasured**; whatever the first run prints is new information.