Files
willtools-demo/docs/ticket-check.skill.md
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
2026-09-09 08:32:15 -05:00

427 lines
22 KiB
Markdown

---
name: ticket-check
description: Check one precast shop ticket for errors by cross-reading three
sources that must agree — the printed page, the Revit model, and the design.
Encodes what went wrong when this was done by text extraction, and what a
fresh reader got wrong when handed the same page.
when_to_use: >
Someone asks whether a ticket is right, or asks to audit a batch of tickets.
Also the reference for building the coded checks: everything here that is a
comparison between two numbers belongs in ticketchecks eventually.
---
# Checking a shop ticket
Three sources have to agree: **the page**, **the model**, **the design**. A
finding is a disagreement between two of them, named with both values and both
sources. Anything else is not a finding.
## 0. Read the page. Do not read the page's text.
Render the PDF and look at it.
```python
import pymupdf # pip install --target <scratch> pymupdf
doc = pymupdf.open(path)
for i, page in enumerate(doc):
page.get_pixmap(dpi=200).save('p%d.png' % (i + 1))
```
200 dpi on a 1224x792 pt sheet gives 3400x2200, which is legible.
**If something looks too small to read, that is not an answer — crop it and
render that region larger.** A reader who reports "unreadable in the rendered
image" has skipped a step, and on this ticket format everything it is likely to
skip is exactly what matters: the bill of materials, the handling diagrams and
the title block are all small type in the corners.
```python
page = doc[0]
r = page.rect
regions = { # fractions of the sheet, this format
'bom': (0.66, 0.02, 1.00, 0.40), # top right
'handling': (0.63, 0.05, 0.78, 0.42), # the three diagrams
'title': (0.80, 0.60, 1.00, 1.00), # bottom right
}
for name, (x0, y0, x1, y1) in regions.items():
clip = pymupdf.Rect(r.x0 + x0 * r.width, r.y0 + y0 * r.height,
r.x0 + x1 * r.width, r.y0 + y1 * r.height)
page.get_pixmap(dpi=400, clip=clip).save('%s.png' % name)
```
400 dpi on a crop is worth several times the whole page at 200. Re-render and
look again before writing the words "could not read".
**Extracting the PDF's text stream does not work and will produce confident
wrong answers.** On this project it silently dropped every dimension inside the
handling diagrams, which led to a written finding that the tickets printed no
handling positions at all. They print them clearly. The same extraction decoded
nothing usable from four of twelve files because of their font encoding, and
those four were nearly reported as missing their notes.
Text extraction is for searching a page you have already looked at. It is not
for deciding what is on it.
## 1. What is on a ticket
Per piece, two sheets, both named after the mark and numbered `1 OF 2` and
`2 OF 2`. **A ticket is the union of them.** Tools that resolve a mark to one
sheet get whichever one wins and then report everything on the other as missing.
**Sheet 1** — BED PLAN with the embed callouts and their dimension chains; the
**bill of materials** (QTY / MARK / DESCRIPTION); three handling diagrams
(PICKING/STRIPPING, ERECTION, DUNNAGE) each with dimensions; production notes;
the title block.
**Sheet 2** — sections. Section A-A carries the strand count for this piece and
the transverse strand chain; section B-B carries the bar spacings.
The title block is the densest checkable block on the ticket: mark, length,
width, thickness, weight, f'c, f'ci, quantity, product code, base and face mix.
## 2. The comparisons, in the order that finds the most
1. **Title block against the model.** Length, width, thickness, weight. Weight
agreeing to a few pounds is a strong signal the geometry is right.
2. **Handling diagram dimensions against the piece's geometry. This one is not
optional.** Convert each printed dimension to a station along the piece and
ask what is at that station: an opening, an insert, nothing. It is the only
comparison that has ever found a real defect on these tickets, and both
readers who skipped it reported the ticket as passing. See §4.
**How to get a station.** `ticketqc.read_ticket` gives each lifter a `local`
tuple in the panel's own frame — there is no `x` or `y` key, and asking for
one returns None and looks like missing data. `local[0]` is measured from the
panel **centre**, so:
```python
station_ft = t['length'] / 2.0 + lift['local'][0]
```
Checked: WP200's four P136 are at `local[0] = ±7.208` on a 22.917 ft piece,
giving 4.25 ft from each end, and its PICKING/STRIPPING diagram prints
`4'-3"`. Exact agreement. Openings come from the same read.
3. **Bill of materials against the assembly's members**, family by family.
4. **Strand count in section A-A** against the model and against the design.
5. **Anything on the page against anything else on the page.** A production note
naming a bar mark that is not in the bill of materials is a finding and
needs no other source.
## 3. Traps, each of which has bitten
**The BOM's strand row is a length, not a count.** `321 | 0010001 | M392 - 1/2"
270ksi Low Lax Strand` on a 22'-11" piece is 321 linear feet. 321 / 22.917 =
14.007, and section A-A prints `(14)`. On the next piece, 249 / 24.917 = 9.993
against a printed `(10)`. A fresh reader compared 321 against 14 modelled
strands and reported a 23x error as the ticket's most critical defect.
**Nested families double-count.** An element whose `SuperComponent` is not None
belongs to another member. `SR5602` sits inside the `P76` embed and
`RL_OVAL_PATCH_4T` inside `QLPD4T5WG`. Count them as their own BOM rows and the
totals are wrong in both directions.
**A count printed in the design summary is not this piece's count.** The design
covers a family. WP1's summary says 14; the pieces print 10, 6 or 14 according
to what each carries. Comparing the summary's number to a piece's model is a
guaranteed false finding — that is what `legend.printed_strand_count_is_per_piece`
is for, and on this plant it is False.
**`ValueString` is not what prints.** Dimensions here carry a `ValueOverride` of
`U+200E`, an invisible character, and still print their value. Read the page to
learn what the page says.
**There is no legend view.** 0 of 204 sheets carry one. The information the
`LGD-MISSING` check looks for is the BOM table plus the section counts.
**Read the digits carefully.** A fresh reader read 24,345 as 23,245 and reported
a 4.7% weight error. The real difference was 3 lb.
**Close every dimension chain before you compare a station to anything.** Add
the printed segments and compare the total against the piece's own length. If
they do not match, the difference is a segment the ticket does not dimension,
and you recover it by subtraction:
missing = panel_length - sum(printed segments)
**A blank segment in the PICKING/STRIPPING, ERECTION or DUNNAGE chain is
correct. It is how these tickets are drawn, not a defect — do not report it.**
The crew locates the points from the ends; the interior span is derived and is
deliberately left off.
What the blank segment WILL do is produce a false finding if you assume the
printed segments are contiguous. WP213 prints `2'-0" | 6'-0" | 7'-4" | 2'-0"`,
summing to 17'-4" on a 21'-11" piece. Reading those as consecutive put the third
pick at 15'-4", the model had it at 12'-7 1/2", and a 2.7 ft discrepancy was
reported that does not exist — the missing 4'-7 1/2" closes the chain exactly
and the model was right all along. WP412 is the same shape: printed segments sum
to 20'-0" on a 24'-11" piece, the recovered 4'-11" closes it, and the four
stations then land on the four modelled inserts to the inch.
So: close the chain, recover the blank, then compare. The recovered station is
as good as a printed one.
## 4. The finding this process exists to catch
WP199's DUNNAGE diagram prints `4'-9"` from each end. The piece is 22'-11", so
the ticket puts dunnage at 4.75 ft and **18.17 ft**. `OPENING 1` runs 15.60 to
18.96 ft. The ticket instructs the yard to set a dunnage block where 74% of the
width is missing — S of 403 in3 against 1,525 in3 for the full section.
Nothing about that is visible from the model alone, or from the page alone. It
needs the printed position and the modelled geometry together, which is the
whole argument for this being a cross-read rather than a checklist.
## 4b. The coverage table — fill this in and return it, always
Three readers have now been told that comparison 2 is mandatory. Three skipped
it and issued a verdict anyway. Saying "not optional" in prose does not work,
because **an omission is invisible**: a report that simply never mentions a
comparison reads exactly like a report where it passed.
So the report opens with this table, every row filled, before anything else:
| # | comparison | done? | result |
| --- | --- | --- | --- |
| 1 | title block vs model — length, width, thickness, weight | | |
| 2a | **picking** stations vs model | | |
| 2b | **erection** station(s) vs model | | |
| 2c | **dunnage** stations vs model | | |
| 3 | bill of materials vs assembly members, family by family | | |
| 4 | strand count in section A-A vs model vs design | | |
| 5 | page against itself: notes, callouts, marks | | |
**Why the handling row is three rows.** It used to be one, and a reader did the
picking comparison, marked the row **yes**, and never touched erection or
dunnage — which is where that ticket's most interesting question was. A row that
can be half-done will be half-done and reported as whole. **Split any row you
are tempted to mark "yes, mostly" until it cannot be half-done**, and mark the
pieces separately.
Row 1 stays one row because its four values are read from one block in one
glance — but its `result` must name all four numbers. A `result` that says
"matches" without the numbers is not evidence that the comparison happened.
`done?` is **yes** or **no**. There is no third value, and there is no partial
**yes**. "Major items match, the rest was incomplete" is a **no** — a row is
**yes** only when every item in it was compared. Writing **yes** and then
describing what you did not finish is the same omission in a different place.
A row marked **no** carries its reason in `result`, and by §5 a single **no**
means **no verdict of any kind** — not "pass", not "conditional pass", not "no
defect found". Report the findings you have and stop. "Conditional pass" is a
verdict wearing a hedge, and it will be read as a pass.
**There is no separate "could not check" section. The table is the only place
it goes.** An earlier version of this skill had one, and it became the place to
confess what the table was denying: a reader marked row 1 **yes** with
"weight not available in the model" beside it, and listed the weight under
"could not check" further down. Same for erection: **yes**, and "unable to
locate which dimension corresponds" in the same cell, and again in the appendix.
Marking **yes** and describing a failure in the same breath is not a slip. It
happens because the box and the prose have different audiences — the box gets
skimmed and the prose gets written to be safe. Removing the appendix leaves the
confession nowhere to go but the `done?` column, which is where it is read.
**Test before you write `yes`:** could you state the two values and the two
sources for this row, right now, without hedging? If not, the row is **no**.
Filling a row **yes** means you did the arithmetic. For row 2 that means: each
printed dimension converted to a station along the piece, and each station
looked up against the model — what is there, an opening, an insert, nothing.
A row is not **yes** because you looked at the diagram.
## 4c. What to look for at each handling station
Once you have the stations, ask what is there. Three things have mattered:
- **An opening under a dunnage point.** WP199's ticket puts dunnage at 4.75 and
18.17 ft; `OPENING 1` runs 15.60 to 18.96 ft, so the ticket sets a block where
74% of the width is missing. This is the one real defect this method has
found. Check every dunnage station against the openings, every time.
- **Which insert belongs to which stage. Read it, do not infer it.** The
knowledge file already says:
lifter_groups:
face: families: [P136] at: measured
edge_top: families: [SAMPLE_CLIENT_EMBED_P76] at: top
stages:
stripping groups: [face] # P136 only
transportation groups: [face] # P136 only
erection groups: [face, edge_top] # P136 AND P76
**Stripping and transportation use P136 alone. P76 appears only at erection.**
**The ERECTION diagram has two points and only one carries a dimension.** The
dimensioned one is a lower-row **P136**; the other is the **P76 along the top
edge**, drawn as a line with a jogged leader, and it is not dimensioned along
the length because it is an edge, not a station. That is correct drawing, not
a missing dimension.
On WP412 the printed `2'-6"` lands at 22.417 ft, which is a P136 station —
and that is exactly right. An earlier reader assumed a dimensioned erection
point had to be a P76, compared it against the P76 stations, and reported a
1'-0" discrepancy on three pieces that do not have one. Two of those three
readings were also copied from someone else's report and never verified.
Note also `at: top` on the P76 group: its modelled box centre sits about 7 in
inboard of the panel end because it is welded to a bar, but the anchor acts at
the end. Never compare a printed dimension against a P76's measured centre.
- **Whether a station has any insert at all.** A printed dimension that lands on
bare concrete is a finding on its own — for picking and erection. **Not for
dunnage.**
**Dunnage points are not inserts and nothing in the model marks them.** The
piece rests on blocks on a trailer; there is no family, no element, no station
to compare against. So there is no such thing as a dunnage station "not matching
the model", and a reader who compares a printed dunnage dimension against the
nearest lifter and reports the difference has invented a finding — that has
happened, and the reported discrepancy was one inch between two things that are
not the same kind of thing.
What a dunnage station IS checked against is the **geometry**: is there an
opening there, and how much section is left. That is the whole of row 2c.
## 4d. "The tool returned nothing" is not "there is nothing"
This is the most common error on this task, and everyone who has attempted it
has made it at least once, including the author of this file.
- A reader asked `read_ticket` for a lifter's `x` and `y`. Those keys do not
exist; the positions live under `local`. It got `None`, wrote up a tool
defect, and put it in two documents.
- A reader reported the ticket's dimensions as unreadable because the render
looked small. They were legible; nobody had cropped.
- A reader reported "model returns empty BOM", "weight not available",
"strand count not available" for a piece whose model carries eleven family
rows, a weight of 15,371 lb matching the ticket to the pound, and ten
strands. Its query had failed.
Empty is a claim about your query, never about the model, until you have shown
otherwise. **Before writing that something is absent:**
1. Run the same query against a piece you already know has that data. The
worked example `_scratch/claude_wp199_verify.py` returns length, width,
weight, strand count and lifter positions for a real piece — if it works and
yours does not, the difference is in your script.
2. Check the response is actually yours. Result payloads can arrive from a
previous timed-out request; confirm the JSON carries the keys your script
returns.
3. Say which it is. "My query returned nothing and I could not determine why"
is a fair `no`. "The model does not have it" is a claim about the model and
needs the evidence above.
The cost of getting this backwards is not a wasted row. It is a defect filed
against a model that is correct, and an engineer sent to look for it.
## 4e. A criterion that is missing a dimension
§4d says a tool returning nothing is not the same as there being nothing. This
is the sharper version: **a tool returning an answer is not the same as the
answer being about what you asked.**
Every one of these came out of a single afternoon on a single check — whether a
dunnage support is safe against an opening — and every one of them produced a
confident, well-formed, wrong result:
| the criterion asked | what it left out | what happened |
|---|---|---|
| is the support *inside* an opening? | the margin | a support 5 in. from a 3'-4" hole passed |
| is the support inside the opening's *length* range? | the width | a spandrel's 11-in. top notch failed a sound piece |
| is the remaining concrete wider than N feet? | that pieces differ | 3.9 ft of solid on a 4.8 ft piece read as failure |
| does the opening take the support? (asked in one branch) | the other branch | a 2.5-in. corner pocket still fails a support beside it |
Four faces, one shape: **the check compared the right things along too few
axes, and every time the missing axis was invisible in the output.** Nothing
said "I only looked lengthwise". It said FAIL, with a number.
What to do about it, in order:
1. **Say out loud what the criterion measures, and in what units.** "Distance
from the support to the nearest opening edge, in inches, along the length."
Written out, a missing axis is usually obvious — the sentence has no word
for width in it.
2. **Find the case where the criterion and the physics disagree.** Not a case
where it gives the wrong answer — one where it gives the *right* answer for
the wrong reason. A support beside a notch is fine, and a length-only test
also says fine, until the notch gets longer.
3. **Test on two objects that differ in the axis you are unsure about.** A wide
piece and a narrow one. A big hole and a small notch. One object cannot tell
you whether your threshold is absolute or proportional — that specific
mistake was made here, with a threshold that worked on an 11'-11" piece and
condemned a 4'-10" one.
4. **When a fix adds a guard, ask which branches got it.** Three of the four
above are the same guard arriving in one place at a time.
And the reason this matters more than the individual check: **a criterion
looser than reality produces a clean report.** A missing check leaves a visible
hole and someone eventually asks about it. A check that runs, returns, and
quietly means something narrower than its own name is the one that gets
trusted.
## 4f. An exclusion never raises
§4d says an empty result is not an empty world; §4e says an answer can be about
the wrong question. This is the third and the quietest: **something that was
excluded produces nothing at all.**
No finding, no exception, no red test. The tool runs, the report renders, the
row says OK. Everything looks the way it looks when the work is fine.
Three of these landed in one afternoon on this project:
- A whole check switched off in the plant file, on a misread instruction. The
findings did not become wrong; they stopped existing.
- `min_remaining_width_ft` unset, so the "support sits in a hole" case could
not fire. The support that sat 2.5 ft inside an opening came back **OK, with
a comfortable 9.5 inch clearance** -- it measured the distance to the edge of
the hole it was standing in.
- One line, `for p in parents: covered.add(p)`, excluding parent parts along
with the children they cover. A ticket that had been reporting two missing
callouts silently became clean. Forty tickets would have followed.
**Every exclusion has to point at a sentence that authorises it.** Not "this is
cleaner", not "these are always noise" -- an actual rule someone stated. The
knowledge said `SR4996 is covered by SP29/SP30`; it did not say SP29 and SP30
need not be drawn, and reading it that way removed the very thing the rule
depends on.
Two habits that catch these:
1. **After adding any exclusion, re-run and count what stopped being
reported.** If the number is larger than you expected, you excluded more
than you meant to. This is the only symptom an exclusion has.
2. **When a threshold is absent, do not treat that as permission to pass.**
Absent means unknown. An unknown that renders as OK is a lie the tool tells
with a straight face; NOT RUN with a reason is the same fact, told honestly.
And the reason this one is worth its own section: §4d and §4e leave evidence --
an empty list, a wrong number. **An exclusion leaves a clean report**, which is
indistinguishable from the outcome everybody hopes for.
## 5. How to report
- Both values and both sources, or it is not a finding.
- **Name the source of every number correctly.** Insert stations read out of the
model are the model's, not "the design spec". Getting the provenance wrong on
a number that happens to be right is how a wrong number survives the next
review — the reader checks the source you named, finds nothing, and moves on.
- **A single `no` in the coverage table forbids a verdict.** If any comparison
did not happen, the ticket has not been checked and no sentence beginning
"this ticket passes" may appear. Report the findings you have and stop. A pass
issued over an open row is worse than a false finding, because a false finding
gets argued with and a false pass gets filed.
- **"The sources disagree" and "I could not find it" are different things**, and
the second belongs in a `no` row, not in a finding. Do not pad it either: a
reader listed f'c, f'ci and the jacking force as uncheckable because the
model's spec fields were empty. All three are printed on sheet 1 in plain
text. Another reported the weight as unavailable from the model; it was there,
and it matched the ticket to the pound.
- One root cause fanning into several codes is one finding. Reading one sheet of
a two-sheet ticket produced five codes across twelve pieces — sixty findings,
one cause.
- A pair of families clashing on nine pieces is one modelling pattern, not nine
defects. Cluster by family pair before counting.
- Say which pieces were read from the rendered page and which were not. A
conclusion from text extraction is provisional until someone looks.