Files
willtools-demo/docs/ticket-checklist.md
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

114 KiB
Raw Permalink Blame History

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 (<code>) 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<number> — 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 <qc root>/interference-<mark>.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(<a 256-entry table>) 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:

{"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:<number>. 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 … @ <spacing> O.C. … call-outs over 75 occurrences in the plant-manual extractions, both counted 2026-08-31:

production shape corpus
spacing <product> @<spacing> O.C. <qualifiers> 2 live lines, all 54 manual call-outs
placement CL <items>[@ <zone>] — centred, optionally in a named wythe 3 live lines
conditional <requirement> NEEDED WHEN <condition> 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.5with the0.5a literal inside the function. It is nowtolerances.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.