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.
104 KiB
When a higher layer contradicts a lower one
Ruled by the engineer 2026-08-30:
我需要不同级别的知识之间矛盾的碾压。高级别的知识(job)总是修正低级别 (plant)的知识。
The precedence itself already works: DEFAULTS, catalogue, plant, job, project config, each overriding the one before. What does not exist is any way to tell a contradiction apart from a completion, and the two mean opposite things.
The case that forced this
plant.rails.left_rail_is_fixed = null "this plant has a fixed rail,
but which side is not stated"
job.rails.left_rail_is_fixed = false the current job
The plant manual documents a fixed rail on its beam beds (section 6141.0 / 6142.0, left rail labelled FIXED RAIL). Its wall-panel beds never say. This job's panels are not cast on any bed in that manual, and have no fixed rail at all.
So the job value is not filling in a blank. It is overruling the plant's own
practice, for a stated reason -- different beds -- and a reader of either file
alone cannot tell. Today the merged tree just says false and no output
mentions that anything was overridden.
Correction, same day: no fourth state is needed
This document first argued that left_rail_is_fixed needed a fourth value
meaning "neither rail is fixed", on the reasoning that false asserts the
right rail is fixed instead. That reasoning was mine and it was wrong.
Will: "为啥左轨不是固定的,就是右轨是固定的?...这个行业里压根就没有固定右轨
的说法。" There is no such thing as a fixed right rail. So false already
means exactly what this job needs -- the left rail is not fixed, full stop,
and there is nothing else for it to imply.
The code was already right. aiextract._rail_sentence renders false as
"That rail is NOT the bed's fixed rail on this job, so do not assume
dimensions accumulate away from it" -- no inference about the other side.
the current job's false is correct as it stands and needs no change.
What was genuinely wrong was a comment, and it sat in the worst possible
place: knowledge.py's PLANT_STARTER template, which is what every new
plant reads when its configuration is first written. It said some plants cast
against a fixed left rail "and others do the opposite" -- stating a fixed
right rail as ordinary practice. Corrected 2026-08-30, along with a note that
false and null stop the same reasoning for opposite reasons: null is
"we do not know", false is "we know, and there is none".
And a second correction, from the same exchange. I also wrote that false
and null both stop the reasoning. They do not. Will, quoting his own earlier
ruling: "false 不应该让推理停手。false 的意思是允许 bed_offset,并且需要读
max_bed_width,等等的一系列逻辑." Found in the transcript of an earlier session,
in his words:
left rail 不一定是 fixed rail,所以实际 wall 的 strand pattern 会有出入, 但是所有的出入都是 bed offset.
这个参数用就是 false。厂层面添加一个 max_bed_width 参数,并且这个厂 这个参数是 12'-0"。这个参数决定了我最大的 offset。如果我的 panel_width 是 11'-11",那么我的 offset 就不能超过 1".
So false starts a reasoning rather than stopping one: with no fixed rail
the piece may sit anywhere across the bed, the pattern slides, and the slack is
exactly max_bed_width_in - panel width. The spacings between strands must
still be reproduced exactly -- only the rail-to-first-strand distance moves.
null is the only value that stops anything.
All of this is already implemented and correct: _check_bed_offset in
ticketchecks.py runs it, and the plant file carries max_bed_width_in: 144.
Only the comment was wrong.
Known gap found while checking this -- CLOSED 2026-08-30. As first
written, _check_bed_offset never read left_rail_is_fixed and allowed the
pattern to slide unconditionally: right for false, wrong for true, and a
plant that set true would have been quietly ignored.
That is no longer the case, and the two paragraphs that followed here said
so wrongly until 2026-08-31. The flag is wired and it does three different
things. _check_bed_offset (in ticketchecks.py, called from 4.4's
check_strand_layout) reads it and hands it to ticketpage.bed_offset_status,
which branches on all three values:
| value | what BED-OFFSET does |
|---|---|
false |
the bed has no fixed rail: the piece may sit anywhere across it, and the ceiling is bed width - panel width. Over that is a FAIL, under it an OK. |
true |
the left rail IS fixed, so the slack is not slack. Reports the offset as a WARN and says plainly that it was not judged -- because what a fixed-rail plant should tolerate is still an open engineering question (see "Not settled" below). It deliberately does not fall through to the false path, which would spend slack that is not there. |
null |
nobody recorded it. Verdicts are the false ones, but every message carries "rails.left_rail_is_fixed is unrecorded; this reads the bed as having no fixed rail", so the assumption is visible rather than silent. |
So the flag is read by a check, not only by a prompt. aiextract still
renders it into prompt text as well (_rail_sentence), but that is now the
second reader, not the only one. tools/knowledge_drift.py grades it the same
way -- its header says this key "HAD a reader ... and was still drift", in the
past tense, and cites it as the shape a surface-only key takes rather than as
a current example.
What has not changed: check_strand_layout still measures the strand chain
from both edges and keeps the better fit. It does not use the flag to choose
an edge; it uses it, through _check_bed_offset, to decide whether the
resulting offset is slack or a finding.
What is required
- A value that means "no fixed rail on this job." Distinct from null.
- An override must be able to declare itself -- carry a note saying what
it sets aside and why.
knowledge.py's own module docstring has argued for this since it was written: an override "should have to declare itself ... and the tool should say, in its output, that one is in force. Not refuse it. Just never let it happen silently." - The report must say an override is in force, and say which. A merged value that contradicts the layer below it is a fact about the run, and a reviewer asking what a piece was checked against deserves to see it.
Note the mechanism must survive the merge's own rule: maps deep-merge, lists replace. A declared override on a list key is a replacement whether or not its author knew it -- which is how this job silently dropped five of the plant's exempt families earlier today.
Not settled
Two questions, both engineering rather than coding, and both the owner's:
- What a check should do when the answer is "neither rail is fixed" -- stand down and report NOT RUN, or run in some reduced form. It decides whether 4.4 runs at all on this job.
- What a fixed-rail plant should tolerate. With
left_rail_is_fixed: truethe pattern cannot slide, so the bed-width ceiling is not the right test and no other number has been chosen. BED-OFFSET therefore reports the offset as a WARN and says it was not judged (chosen 2026-08-30). Until a tolerance is given, that WARN is the honest answer; do not invent one.
One plant layer per plant, and the job names which
Added 2026-08-30, with the face-designation and rebar-stock work.
Until now the plant layer was a single file per machine --
%APPDATA%\WillTools\knowledge.yaml, or whatever WILLTOOLS_KNOWLEDGE
pointed at. One machine, one plant, forever. It is now one folder per
plant, and a job says which plant it is produced at:
# <model>/_ticketqc/knowledge.yaml -- the JOB layer
job:
plant: northgate
resolving to <root>/northgate/knowledge.yaml. A second plant is a new
folder, never an edit to the first one's file.
Where <root> is, and why nothing had to be reconfigured
The root has always been "the folder the machine-wide knowledge.yaml is
in", and that relationship is what makes the variable keep meaning what it
always meant:
WILLTOOLS_KNOWLEDGE |
root | no plant named | plant named |
|---|---|---|---|
| unset | %APPDATA%\WillTools |
<root>\knowledge.yaml |
<root>\<plant>\knowledge.yaml |
| a file | that file's directory | that file -- untouched | <root>\<plant>\knowledge.yaml |
| a directory | that directory | <root>\knowledge.yaml |
<root>\<plant>\knowledge.yaml |
Reading the root backwards off a variable that names a file is the definition read backwards, not a guess. So a machine already pointing at one file picks up per-plant folders as siblings of the file it already names, with no change to the variable -- and a machine that never names a plant reads exactly the file it always read. That backwards compatibility is not a nicety: the single-file arrangement is what every machine but the author's is set up for, and a silent break there would cost more than the feature is worth.
The per-plant folder and the machine-wide file share a root and cannot
collide: one is a directory, the other is called knowledge.yaml.
The ordering trap, and the two-pass that solves it
knowledge.load() merges DEFAULTS then catalogue then plant then job
then project config. But job.plant -- the key that chooses the plant file
-- lives in the job layer, which merges after the plant layer. The
dependency runs backwards through the merge order and no reordering of those
lines fixes it.
So the job file, and the project config above it, are read once, on their
own, before anything is merged, purely to answer "which plant".
knowledge.plant_id(doc) does that and nothing else.
Do not simplify it back into one pass. Deleting the pre-pass makes the
tool read the machine-wide plant file while the job file plainly says which
plant it is for, and every report still looks entirely normal. There is a
tripwire for exactly that: after the merge, the plant the file was chosen by
is compared against the plant the finished tree names, and a disagreement is
recorded as _plant_layer_stale and printed by config_problems.
What is loud, what is quiet, and why
| situation | behaviour |
|---|---|
| job names a plant with no file | _plant_missing, a sentence from config_problems, and no fallback. Running one plant's job on another plant's rules is worse than not running. |
job names a plant whose id is not a folder name (../x, a/b) |
refused outright, _plant_missing with a null path. A malformed name scrubbed into a nearby folder is how a run ends up on the wrong plant looking normal. |
WILLTOOLS_KNOWLEDGE set, file gone |
_plant_env_missing and a sentence. Somebody pointed the tool at a file and the tool did not read it; nothing else would say so. |
| job names no plant at all | quiet, deliberately. The machine-wide file is read, exactly as before this key existed, and that is the state every install that predates it is in. A warning on every run of every install is a warning that stops being read. It is still on the record: _plant_id is None and knowledge.sources(doc) names the file that was read. |
Face designations: a library published once, selected per plant
knowledge/catalogue.yaml -- the public layer -- now carries a library of
face-designation vocabularies: the parenthetical suffix on a call-out,
SR324 (F.F.), SR310 (E.L.), P76 W/ SR5602 (S.I.F.). The abbreviations
are the trade's and identify nobody, which is exactly why they are
publishable.
Which set a producer uses is plant knowledge, so the plant layer selects by id and never copies the tokens down:
face_designations:
use: [near_far, in_form] # a LIST -- it replaces, it does not merge
use being a list is the right semantics, not an accident: what a layer names
is exactly what is in force. A vocabulary left quietly selected from the layer
underneath would resolve tokens in a frame nobody chose. A plant using a token
nobody published adds its own under vocabularies -- a map, so it
deep-merges and the published ones survive.
Why each vocabulary declares its frame of reference
Two families of terms, measured from different things, not interchangeable:
frame: view-- near / far. Measured from the viewing direction. It flips when the view flips. An elevation that looks at the panel from the opposite side than was assumed makes everyN.F./F.F.on that sheet backwards, and each one individually still reads perfectly normal. This is the published near/far vocabulary and it is not universal -- see "Two corrections" at the end of this file: at the plant in front of usN.F.is anchored to the FORM and isT.I.F..frame: form-- top / bottom in form. Measured from the casting bed. Absolute for the piece; a flipped view does not touch it.
Flattening both into one token-to-meaning map throws away exactly the
distinction that makes checklist 5.3 decidable. So the frame is structural,
and knowledge.face_designation() returns it with every lookup: side
alone is not an answer, because 'near' only means something once you know it
is read off the view.
A plant commonly uses both at once -- near/far names a layer position and
in-form names a casting orientation, and one ticket prints both. Two selected
vocabularies claiming the same token in different frames is the one
genuinely undecidable case, and config_problems reports it.
Rebar stock: per size, with "not stocked" as its own fact
catalogue.rebar.stock_length_ft is one number for every size, and
checklist 5.7 ("no bar longer than the stock it is cut from") compared against
it. Real stock is per bar size and the small sizes are commonly nothing like
40 ft, so a 30 ft #4 at a plant that stocks #4 in 20 ft lengths passed.
A false OK on a real defect class.
The per-size table is catalogue.rebar.stock_by_size and it is plant
knowledge -- what a producer stocks is that producer's, so it is never in
this repository. Resolution, in knowledge.rebar_stock():
- the per-size entry, if there is one --
basis: 'by_size' - otherwise the generic number --
basis: 'generic', and the sentence says the number is not size specific - otherwise nothing --
basis: None, both lengthsNone, and the check must stand down and printwhy. It never assumes.
Three shape decisions worth keeping:
- A map keyed by the printed size (
'#4'), so the layers deep-merge: a job that special-orders one size adds one key and inherits the rest. As a list, a job adding one bar would delete the plant's whole stock table with nothing anywhere saying so. The cost, stated rather than hidden: a higher layer can override a size but cannot remove one -- and removal is not what anyone means, because a size the plant stopped stocking isstocked: false, which is a fact worth printing. - Integer inches. Two common stock lengths are not whole feet -- 23 ft 4 in
is 23.3333... ft -- and a bar cut to exactly that would fail a comparison
against a decimal-foot copy of its own stock length.
rebar_stock()returns both units so no caller ever converts. stockedis its own fact, separate from the length. A size can be listed with a length and marked not stocked: the length is what would arrive if it were ordered, and "not stocked" is why a ticket calling for one deserves a human. Neither is modelled as the absence of the other.
An ASTM designation is not a grade
catalogue.rebar.astm_grades is public -- it is what the standard says. It
exists to stop anything reading a designation as a strength:
| A615 | Grades 60, 80 and 100 (historically 40 and 75). default_fy_ksi: null -- no default is defensible. |
| A706 | Grades 60 and 80. default_fy_ksi: 60, recorded as a default, with who ruled it and when. |
knowledge.rebar_fy() always returns where its answer came from --
'stated', 'astm default', or None -- so a default can never be read as a
measurement. Nothing anywhere quietly falls back to 60.
Present-but-empty is a third state
A producer's stock section may name a designation and list nothing under
it. That is a fact, and a different fact from never mentioning it, so
catalogue.rebar.astm_designations and knowledge.rebar_astm() keep three
states apart: sizes_listed: true, sizes_listed: false (named, nothing
under it -- not "the plant has none"), and no entry at all.
Which families are reinforcing: a rule, not an enumeration
drawings.reinforcing_families is a list of family names, and checklist
5.6 and 5.7 rest entirely on it. An enumeration goes stale in silence: the day
a bar family is added and nobody re-lists it, both checks stop seeing those
bars and report nothing -- which reads exactly like nothing being wrong.
So the same fact can now be stated as a rule: a category to look in, plus a token to find in the family name.
drawings:
reinforcing_rule:
category: Generic Models # VERIFY IT AGAINST A MODEL FIRST
category_verified: true
name_contains: [rebar]
knowledge.reinforcing_family_verdict(know, family, category) resolves it and
returns True / False / None with a sentence. None is a real answer
and callers must keep it: it means nothing here can judge, which is not the
same as False. Reporting "not a bar" for a family nobody has described is
how these checks went quiet in the first place.
Order of resolution:
drawings.reinforcing_family_overrides-- exact name, case-insensitive, mapped totrue/false. Definitive and first, so a job adds or excludes one family without restating a list or a pattern. A map, so a job's correction is added to the plant's rather than replacing them.drawings.reinforcing_families, if set -- substring match, existing semantics unchanged. It wins where it is set: a plant that would rather name its families one by one must be able to, and being overtaken by a pattern it did not write would be worse than the pattern is worth. Empty the list to hand over to the rule.- the rule -- the category must match and the family name must carry a token. Both, never one: a token swept across every category would call anything named that way a bar, mesh and detail components included.
- otherwise
None.
SUPERSEDED THE SAME DAY — the rule is a PARAMETER, not a category. The section below is kept because the reason it was wrong is worth more than the text was; the correction is at the end of this file under "The reinforcing rule was the right shape around the wrong field".
category: null is not "no category"
It means nobody has verified what that category is really called in the model, and that has to stay a distinct state, because guessing it wrong un-recognises every bar at once -- silently -- which is the exact dormancy the rule exists to end. So a category that has only been described in conversation is written down as described and left unverified:
reinforcing_rule:
category: null
category_as_stated: <what you were told>
category_verified: false
category_unverified_because: <what is missing>
name_contains: [rebar]
knowledge.reinforcing_mode() then reports the rule as unrunnable and names
the reason, quoting the words that were used. Dormant, out loud.
Two corrections, same day, and what they changed in the shape
Both of these arrived after the sections above were written. They are kept as corrections rather than edited away, because the reason the schema survived them is the point: each one changed a value, and neither changed the shape.
1. Near/far is anchored to the FORM at this plant
The section above says near/far is view-relative. That is the published
near_far vocabulary, and it is not universal. Owner, 2026-08-30:
NF 就是 top in form, ticket detail view: elevation top 里面, 如果是 embed, 那么就应该是实线, 如果 embed 但是 FF, 那么应该是虚线. rebar 无论如何都是 虚线, 因为 rebar 不会 expose, 总在水泥里面.
So at this plant N.F. is T.I.F. — near/far names the casting
orientation, not the viewing direction. The two vocabularies are not two
frames here; they are two names for one anchor.
What that proves about the schema, and what it changed:
framestays, and is now doing exactly the job it was added for. A producer that anchors near/far to the view is still a real possibility, and only the frame field can say which one you are looking at. Had near/far been hard-coded as view-relative — the obvious flattening — this correction would have needed a code change instead of a data change.- The plant declares its own form-anchored vocabulary and selects that.
It does not half-edit the published
near_far: flipping a published vocabulary'sframetoformwhile leavingside: nearbehind produces something that parses, resolves, and answers nonsense.framesnow publishes the sides each frame can name, andconfig_problemsreports a token whose side does not belong to its vocabulary's frame — that exact half-edit. - The equivalence is recorded structurally, not in a comment. A face's
identity is the pair
(frame, side): this plant'sN.F.is(form, top)and the trade'sT.I.F.is(form, top), soknowledge.same_face()answers "yes, one face" from the data, and neither vocabulary has to mention the other. - The equivalence is not generalised into the catalogue. It is this plant's convention; the public layer must assume nothing.
same_face() returns None — not False — when either token cannot be
resolved, and False across two different frames. Two frames is not a
failure: the question genuinely has no answer until something resolves the
view against the form, and that is a fact about the piece, not the vocabulary.
Kept apart, deliberately, in the plant file: N.F. = top in form was
stated outright. F.F. = bottom in form follows from the same sentence's
drawing rule (an F.F. embed is dashed in the elevation-top detail — hidden
— therefore down in the form). Both are written down; which is which is
written down too.
2. A706 60 ksi is the stock; A615 is a job-level exception
The manual's page 5301 files every stock length under A615 and shows an A706 heading with nothing under it. Owner, 2026-08-30:
备的,默认就是 A706,60ksi。
老手册了。。。实际上现在已经没有 A615 了。如果又有了,那也是特例了,应该是 job 级别知识,不应该继续放在 plant 级别了。
This is the "present but empty" state paying for itself. A706 with
sizes_listed: false said nothing is known about A706 here. Had that been
collapsed into "the plant has no A706" — the tempting reading — the data would
now be flatly wrong. Read as written, it was right, and the owner's sentence
simply answers it.
What changed, and what did not:
- The lengths survive; the ASTM label on them does not. The size rows now
carry no
astmfield at all. Binding a live stock length to a retired designation is how a stale label outlives the page it came from. - The material is stated once, in
catalogue.rebar.stock_material:{astm: A706, fy_ksi: 60, basis: stated}, with who said it and when.basis: statedoutranks the catalogue's published A706 default, andrebar_fy()reports'plant stock'rather than'astm default'when it answers from it. Same number, different strength of claim, and the basis is what keeps them apart. - A615 leaves the plant layer entirely. An A615 bar is now a job-level
exception, and
stock_by_sizebeing a map is what makes that expressible: the job adds one size (or overrides one, with its ownastm) and every plant size survives. As a list, declaring the exception would have deleted the plant's stock table. That case is no longer hypothetical. - A615 stays in the public
astm_gradestable, which is published standards data, not a plant fact: it is what makes a job's A615 exception stand down correctly and say why (A615 covers 60, 80 and 100 ksi — the designation does not imply a grade). Removing it would leave A615 an unknown designation, which stands down too, but with a weaker sentence. - The manual's headings are kept as provenance, each marked
superseded: true. The page said what it said, and a record that changes silently is not a record.
3. Mesh is absent on this job — declared, not silenced
Owner: WWF 在这个项目里不存在,别纠结了。这玩意儿在这个 job 里面应该默认不检查。
Checklist 5.4 is muted in the job layer, through the existing
checks.muted map, with his sentence as the mandatory reason and the date.
Job layer and not plant: other jobs at this plant may well use mesh.
Muting is the declared third state, not a silence — a muted item stays on
the checklist table, repainted grey as MUTED with the note saying what the
check really reported, because the check still runs every time and only the
presentation changes — and the existing invariant that a mute can never
silence a FAIL is untouched.
Note what this does not settle: families.wwf_prefix has two readers
disagreeing about what unset means ('' in takeoffgather._is_mesh,
'WWF' in ticketqc). A job having no mesh is not a statement about what a
null prefix should mean plant-wide, so that inconsistency is deliberately left
alone and stays an open question.
A stored reason RENDERS, so it is written in the report's language (2026-08-31)
Owner, after reading a live run: "ticket qc 3.3 notes are still in
chinese." That closes a question this file had left implicit — a mandatory
reason is not an annotation, it is product text. It reaches the checklist
row itself, the folded muted section, last-ticketqc.txt and
qc-run.json, verbatim and uncut, on every press.
The report surface is English, so a reason is stored in English. That
applies to every reason string in the knowledge layers, not only
checks.muted: checks.not_applicable's not_applicable_because,
checks.only_applies_to's scoped_because, scope.note, and any
*_because / *_unverified_because sentence a layer supplies.
And the verbatim ruling is still kept, because this project keeps provenance — as a YAML comment above the entry, dated:
checks:
muted:
# Will 2026-08-30, verbatim: 本项目所有 panel 都没有 release ratio 要求 ...
'3.3': "no panel on this job carries a release-ratio requirement, so the
row would read NOT RUN forever (Will 2026-08-30)"
A comment never reaches the report; a value always does. That is the
whole rule, and it is the reason the two can be different without either one
being lost. The job layer's 3.3, 5.4 and 1.4 mutes were rewritten this
way on 2026-08-31; a scan of both YAML files found no other rendered value in
another language.
Still open, and it is not in this file's gift: the Project Config window stores what the user types into the mandatory-reason box, and it is the natural place for him to type Chinese. Nothing there says the box is product text. Either the box says so, or something normalises on the way in — and which of those is right is a decision about the editor, not about the knowledge shape.
Which end of a piece is up: a fact that moved LAYERS, not a fact that changed
Ruled by the owner 2026-08-30, when he was asked whether to wire or delete two plant keys:
哪头朝上取决于 assembly origin。也就是 TOT。写入公共知识。assembly origin 里面有一个朝上的头。
Which end is up depends on the assembly origin -- he calls it TOT -- and that is public knowledge. The origin carries an up end.
Why the placement is the whole ruling
The plant layer carried:
orientation:
top_is_higher_global_z: true
top_end_families: [<an embed family>] # a cross-check, not the definition
Both were settable, displayed by the config screen, and inert:
liftexport.erected_orientation() solved which end was up from geometry and
never read either. That is the shape rails.left_rail_is_fixed had before
it was wired on 2026-08-30 (see the correction above), and the obvious repairs
are "wire it" or "delete it".
Neither was the answer. The fact is not a producer's to declare. An
assembly origin is placed as part of making the assembly, and placing it is
the statement of which end goes up -- so it is how precast is built, not a
house habit, and it belongs in knowledge/catalogue.yaml beside the face
designations and the ASTM grades: the layer that ships in the repository and
identifies nobody. A key that asks a plant to declare it is not an unread key.
It is the wrong question, and wiring it would have made every plant answer
a question that has one answer everywhere.
What a higher layer may override, and what it may not
orientation.up_end is a map, so it deep-merges: a producer whose
assemblies genuinely carry no usable origin corrects one field and inherits the
rest. That is the ordinary override this document is about.
What a layer may not do is name a different mechanism. And that is
exactly what top_is_higher_global_z was: not a different answer to "which
end is up" but a different way of finding out -- compare the two ends'
elevations in the model. The two are not equivalent, which is why the ruling
matters rather than merely tidying:
- global elevation answers this for a wall panel erected on end;
- it is meaningless for a piece erected long-ways, where both ends sit at the same height and the winner is whatever the model's rounding happens to be;
- an origin has a direction either way.
So a mechanism is code, and a layer that could swap it could quietly reroute
every answer in the tool. up_end_rule() reads exactly the fields the
catalogue documents and stands down on a source nobody implemented, quoting
it back rather than falling through to a guess.
top_end_families was not an override either. Its own comment said it was "a
cross-check, not the definition" -- and there was no check. An inert
cross-check is precisely the shape the drift report flagged, so it was deleted
too, with the family name kept in a comment in the plant file so the day
somebody builds the cross-check it comes back as data.
Both keys are gone. liftexport.erected_orientation() is no longer the only
place in the codebase that knows the answer.
Dormant and loud, then live -- as data
The rule shipped with its model-reading half null, unverified, and saying
why, which is the drawings.reinforcing_rule idiom reused rather than
reinvented: origin_up: null, origin_up_verified: false, plus the words the
owner used and the reason nobody had checked them.
It went live the same day on bridge evidence:
AssemblyInstance.GetTransform().BasisY is the erected up direction,
correlated against extents unioned from GetMemberIds() member boxes across
282 assemblies. Turning it on changed no code -- two fields in a YAML file.
That is the return on writing a dormant fact as a shape instead of as an if.
What did need code is the three ways the reading is wrong on real pieces, and all three decline rather than clamping or picking a nearest axis:
| one assembly in 282 | its BasisY points sideways and its face normal is up. On that piece every reading off the frame is wrong, so the check is an assertion, not a preference. |
| vertical panels | the frame is confirmed for horizontal panels; the owner has said vertical ones work differently and has not yet said how. Standing down costs one fallback; assuming costs an erection. |
| near-square pieces | long-versus-up separates cleanly at aspect ratios 1.7 and up, and two pieces sit at 1.00 and 0.92. The threshold is 1.25, chosen to sit clear of both edges of the evidence. |
The fallback is named, every time
When the origin cannot answer, erected_orientation() reads which end is up
the way it always has -- world up in the panel's own axes -- and says so.
basis and basis_why come back with every call and ride out in the job
file's source block as up_end_basis / up_end_basis_why.
That is the same discipline _plant_id: None gets: quiet on the console,
never absent from the record. Falling back is not the failure; falling back
where no report can say it happened is. A reviewer asking what a piece was
checked against can now see which of the two mechanisms answered, which was
impossible while the answer lived only inside one function.
dimension_lines: deleted, and what went with it
Owner, 2026-08-30, asked to keep or delete: 不要.
The job layer carried counts of dimension strings per ticket page, measured off
139 archived tickets, so that an outlier check could one day flag a page with
too few. Nothing read it. It was declared in the job layer only -- not in
DEFAULTS, not in a starter, not on the drift allowlist -- so the deletion is
the key, its commented section, and nothing else.
What is lost, stated rather than quietly kept: page 1 min 30 / max 71 /
median 38, page 2 min 21 / max 55 / median 37, n=139 each, and the observation
that neither distribution is a single hump (page 1 clusters at 37 and again at
43; page 2 at 37-38 and again at 42). The 5th-95th percentile bounds that were
written into the key were derived from those numbers. Two clusters usually mean
two kinds of piece or two drafting habits, and which it is was always a
question for the detailer -- the key's own source line said so.
Worth keeping in view as a general point about this layer stack: a measurement is not knowledge until somebody rules on what it means. These numbers described what the plant ships, not what it should ship, and a check built on them would have flagged unusual rather than wrong. That is a WARN at best, and the owner decided it was not worth carrying a declared key for.
The reinforcing rule was the right shape around the wrong field
Corrected 2026-08-30, hours after the section above was written. It is the sharpest example in this document of the difference between a shape being wrong and a value being wrong, because this time the shape really was wrong -- and the way it failed is the argument for everything else here.
What it said, and what was actually true
The rule was a Revit CATEGORY to look in plus a TOKEN to find in the FAMILY
name. The owner's words were "family 里面找到 manufacture_component,里面写着
rebar 的,就是 rebar" -- and manufacture_component was read as naming a
category. It could never be verified, so the rule shipped dormant with
category: null, category_verified: false, and the reason.
It is a shared parameter. It carries a short phrase per element -- REBAR CUSTOM REINFORCING, BEARING TIE, BLOCKOUT, EMBED -- and the census that
failed to verify it read FamilySymbol types only. This plant writes the
value on the instance, so the census saw no reinforcing at all. Scoped to
real assemblies the bars were never hiding: 112 members on one piece, 84 on
another.
Three facts, measured, that the new shape has to carry:
- the parameter exists on both the instance and the type, mutually
exclusively per element -- where the instance has a value the type is
empty, and vice versa.
read_from: [instance, type]is that, and a reader that takes only one of the two finds nothing on half the model and returns a result that looks perfectly clean; - the plant has already encoded this rule in the model. Thirteen view
filters key on the same parameter, and every string rule among them is
FilterStringContains-- substring, not equality.value_containsis not an invention, it is a transcription; - the parameter is named in this repository as a role
(
member.manufacture_component), so the office's own parameter name stays in the plant layer. Optional, so a plant without it is not nagged: the rule stands down with its own sentence.
What the dormancy bought, and what it did not
category_verified: false did its job exactly: the rule never ran, so nothing
was ever mis-decided, and the report said the rule was unrunnable and named the
reason. Had the category been guessed, every bar in the job would have been
silently un-recognised -- which is what the flag exists to prevent and what it
prevented.
What the flag could not do is notice that the field was the wrong kind of thing. A verified-false key says "nobody has checked this"; it cannot say "this is not a category at all". The only thing that catches that is somebody reading the model, and the shape's contribution is that when they did, the fix was an edit rather than an excavation.
The part that stays undecided, on purpose
The token is not a clean boundary in either direction, and both exceptions are real pieces:
REBAR CUSTOM LIFTINGandREBAR CUSTOM ERECTIONcarry the token and are bar-formed hardware -- one is a strand loop. It has no bar mark and no stock length, so 5.6 ("every bar is marked") and 5.7 ("no bar exceeds its stock") may want it out;BEARING TIE,RA EMBED CUSTOMandTRUSS CUSTOMare reinforcement and carry no token at all.
So 5.6 and 5.7 may legitimately want different populations. scopes is keyed
by checklist item so they can differ, and it ships empty with the reason
recorded -- with nothing in it both rows see the same population, which is
today's behaviour exactly. Guessing a scope here would answer an open
engineering question silently, which is the one thing this whole document is
against.
The merge cost is stated rather than hidden, as it must be for every list in
this stack: scopes is a map so a job can add one row's scope without
restating the other's, but also and except are lists inside it, and a
layer that sets one replaces it whole.
A bar's size: two structured fields, and the trap of calling one weaker
Landed 2026-08-31, closing the open question that had kept checklist 5.7's per-size stock table dormant since the table was built.
Two readers, two populations — not a fallback chain
Censused over a whole job (282 wall panels, 38,662 members, 24,539 of them rebar in 13 families):
| population | field | coverage |
|---|---|---|
| the one straight family | a dedicated String parameter, on the type, holding exactly the printed size | that family, and no bent one |
| all twelve bent families | the family's part description, shape #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 bar_size_of() tries
one first only because something has to be first, and that order has never
decided anything. Reading it as a ranking is the mistake this section exists
to prevent — see below.
Both are declared through roles, so no parameter name enters this
repository: parameters.member.bar_size for the first and
drawings.bar_size_rule (naming member.identity_description) for the
second.
The straight half needed no code change. ticketchecks._bar_size already
read the instance and then el.Symbol, and the value is on the Symbol. That
is the return on having written the reader as a role lookup instead of a
parameter name: the fact arrived and one line of YAML turned the row on.
The correction that is worth more than the code
This document, and the plant file, said for part of one day that a size parsed out of a description was a weaker claim than one read from a dedicated parameter — on the grounds that the description field is the owner's freeform notes, the standing example being a fixed token sitting inside otherwise free text.
That is true of the ASSEMBLY's field and false of the FAMILY's. Owner, 2026-08-31: "assembly 的 identity description 是我的随笔。family 的不是。" Two different fields, on two different kinds of element, sharing one name — the exact collision this codebase keeps paying for. The assembly's is running notes; the family's is structured plant data describing the part.
The measured evidence had already said so and was overridden by the bad
premise: 7,405 of 7,405 members parsing in one uniform shape is not what
freeform prose looks like. So description_read_from: [type] is now a
declared key rather than an assumption, because reading the wrong one of the
two gets prose where structure was expected and looks fine doing it.
What survived the correction unchanged is the strictness, and the reason
for it changed rather than the rule: the parse requires the #N BENT BAR
shape and returns nothing otherwise — not because the field is unreliable, but
because the shape IS the evidence that the leading token is a bar size. A
description that stops having the shape stops being evidence, so the bar goes
unjudged rather than mis-sized. A leading bare number is refused for the
same reason: nothing marks it as a size.
basis is still recorded — 'parameter' / 'description' / None — not
because one is weaker but because a verdict that cannot say what it read
cannot be audited. Same discipline as rebar_stock's basis and rebar_fy's.
A live defect the census turned up, in the strong source
The size parameter's distinct values included one that is not a bare size:
a size with a parenthesised grade after it. rebar_size() required the whole
string to be the size, so it returned None, and every bar of that size
went unjudged by 5.7 — silently. An unreadable size is a stand-down, and a
stand-down on one value out of five looks exactly like a working check. It was
also, almost certainly, the one bar the owner had specifically told us existed
on this job.
The fix is deliberately narrow: a parenthesised trailing group is a qualifier and is tolerated; anything else trailing is not a size. Widening it to "a size followed by whatever" would make a part description read as a bare size, and a plant that mapped the size role at that field would then be parsing prose with the strong reader.
And the qualifier is kept rather than discarded, because at least one of
them is a grade. rebar_fy()'s provenance chain has 'stated' as its
strongest rung and had nothing in the model to fill it from — every bar
answered from a plant or ASTM default even where the model said otherwise.
bar_size_of() now hands the number out as qualifier_fy_ksi; nothing
applies it behind a caller's back, and a number outside every published rebar
grade is not read as a strength at all.
The alternative, considered and rejected — with the reason
A bar-diameter parameter plus a diameter-to-size map covers straight and bent bars in one reader, and it was the obvious answer before the census. It is not universal: four bent families (534 members, 7.2% of bent bars) carry no diameter on any member, and all four parse cleanly from their description. So the map would have had a hole the description route does not — on top of needing nominal diameters, a tolerance band per size, and a ruling for a diameter landing between two bands.
Recorded as rejected rather than merely not built, because "why not just read the diameter" is the first thing the next reader will ask.
What stayed open
The straight family's shape token is a single letter. If that letter denotes a bent shape then 17,134 members are bent in fact and described as straight, and the split this reader rests on is not the split it thinks it is. Nobody guessed; it is with the owner.
The face a part goes on: the model records it, and the drawing renders it
Landed 2026-08-31. It is the field the face-designation vocabulary work of
2026-08-30 was waiting for, and it landed as data plus lookups, with no
check built — ticketchecks and ticketpage belong to other work.
Why not read the line on the sheet
The plant's rule is about the drawing: on the elevation-top detail an embed on the near face draws solid and one on the far face draws dashed. So the first instinct is to read the line, and the question "what makes the line dashed" sat open for two days. The answer is Object Styles — per subcategory, project wide, no view filter and no view override involved.
But the line is a rendering of a fact the model already holds, one layer further from the data, and reading a render to recover a fact is not merely the long way round. It is unsound here: the same view carries 57 hand-drafted detail curves with line styles of their own, so some of what reads dashed on a sheet is not derived from any model member at all.
The face is a String shared parameter on the element instance. That is what was landed.
Four shape decisions, each one a failure avoided
1. The join is its own key, not an alias on a published token. The model
writes a token without dots where the ticket prints one with them. The obvious
move is to add an alias to the published vocabulary — and a vocabulary's
tokens is a list, so lists replace: a plant adding one alias would
replace the entire published vocabulary. That is precisely the half-edit
face_designation_problems already refuses. So the plant states a map from
what its model writes to what its ticket prints, the map deep-merges, and the
printed token then resolves through the machinery that was already there. Two
facts, two keys.
2. It reads the INSTANCE, and read_from is [instance], not
[instance, type]. This is the one place the shape differs from
drawings.reinforcing_rule, where both really are right because the two are
mutually exclusive per element. Measured over 180 real tickets: the same
part code carries different face tokens on different sheets — 24 codes do,
six of them carry three. The face is a property of the placement. A type
read is therefore not a weaker version of an instance read; it collapses
distinct facts onto one answer and returns something that looks perfectly
clean.
3. A modifier is not a face. A call-out can stack two parenthesised
groups, the second saying something orthogonal to the face axis — it does not
say which face, so it has no (frame, side) and cannot be a vocabulary
entry at all. A reader expecting exactly one parenthesised group mis-parses
the pair. Modifiers are their own declared set; split_face_callout()
strips them and resolves the face underneath, which needs no meaning at all —
and the meaning is recorded as an inference, unverified, with the owner.
The negative is declared beside it: one group pattern-matches a face designation exactly and qualifies a dimension instead. Without somewhere to record "this is not one" it returns forever as a false positive or an unexplained unknown, and neither is a state anyone can act on.
4. Which member kinds a token may attach to is DATA. Measured over the
whole ticket corpus without exception: the side-in-form token attaches to an
embed 177 times out of 177 and never to a bar; the each-layer token attaches
only to bars. So one on the wrong kind is wrong by construction, which is
a real finding waiting for somebody to write the check — and a fact that would
rot inside one if it were written there. face_attachment_verdict() answers
True / False / None, where None is "no rule recorded" and is never False.
Absence is not a value, on either side, and no key may say it is
Measured in the model: the kinds that use the field write the near-face token explicitly 163 times and still hold 1,304 blanks. If blank meant near face, near face would never need writing. Measured on paper: 933 call-outs carry a token and 461 do not, and 219 of those 461 use a code that is tagged elsewhere on the same sheet — an unmarked call-out is annotation that was not repeated.
So there are three states, and a report owes each a different sentence:
| a recorded token | resolves to a (frame, side) |
| blank on a kind that does record faces | unknown, and not near |
| blank on a kind that never records one | the field does not apply |
Telling the second from the third needs the list of kinds that record a face.
The census established that exactly five do and that every other kind is 100%
blank, but not which five — so recorded_on ships empty with the reason
written down, and the reader says a blank cannot be told from a
does-not-apply rather than picking one. same_face() returning None rather
than False is the instrument for all of this and it already did the right
thing.
Lifting hardware is its own stand-down, and it is not a corner case
The ticket prints the near-face token 829 times — the most common face call-out in the job — and the part those call-outs most often name is the face lifter, whose face field is blank on all 1,931 of its instances, with no face token on any other string parameter on the instance or the type. A part that is on a face by definition, with a model that does not say which, is evidence to decline on and never a disagreement.
face_from_model_token() takes the family name and stands down on anything
families.lifter_include matches, with its own sentence. That is deliberately
in the knowledge layer rather than left to the check: a stand-down a check has
to remember is a stand-down that gets forgotten in the next check.
The per-piece fact that would have inverted half the job
The design output carries, per panel, which building face ends up on top
in the form — and it varies panel to panel. This does not contradict the
owner's near face = top in form; it confirms the frame is form-anchored and
supplies the bridge that was missing. But it means the top-in-form face is
not a fixed building face, and a check that resolves a printed designation
to interior or exterior without reading this piece's own value is not slightly
wrong. It is inverted on every panel of the opposite handedness, one
normal-looking verdict at a time.
It is neither plant nor job knowledge — it is a value to be read off each
piece, like a design number — so what a knowledge layer holds is only the
label to look for, and it ships dormant and loud: verified: false
with the reason, the drawings.reinforcing_rule idiom. building_face_of()
refuses without the value and quotes that reason back. It also refuses to
invert a stated face: a piece has more than two faces, so "the opposite of
interior" is not derivable from "interior is top in form". Read it, do not
invert it.
What the paper cannot settle
There is no abbreviation legend on any ticket in the corpus — the tokens are never spelled out. What they mean rests entirely on the owner's statement of 2026-08-30, which is quoted verbatim above the vocabulary in the plant file. If that is ever in doubt, the tickets cannot resolve it, and the vocabulary's provenance now says so.
What a 5.3 check would call
Not built here, deliberately. Per member:
- read the role
member.face_designationoff the instance (never the type —face_read_from()says so as data); - hand the raw value to
face_from_model_token(know, value, component=..., family=...), which returns the resolved face or awhynaming exactly which of the states it stopped in; - split the ticket's call-out with
split_face_callout(), which strips modifiers, reports a declared non-face as such, and keeps anything unrecognised rather than dropping it; - compare with
same_face(know, printed, model_token)— True / False / None, where None is never a disagreement; - check
face_attachment_verdict()independently: a token on the wrong kind of member is its own finding and does not need the comparison to succeed; - never resolve to a building face without the piece's own top-in-form value.
A face of None owes the row a NOT RUN carrying the why.
Which part plays a role depends on the panel type
Landed 2026-08-31, urgently, and it is the second time the same failure has happened in this file's history.
What happened
The owner said the face lifter on an insulated wall panel is a different part
from the one on a solid wall panel. That part was named in no knowledge
layer at all — not in families.lifter_include, not in
handling.lifter_groups.face.families. So on every insulated panel the tool
found zero face lifters, and:
- 6.1 insert capacity found nothing to price and passed;
- 6.2 insert edge distance found nothing to measure and passed;
- 6.3 and the handling result behind it were computed for a piece the tool believed had no face lifters;
- Panel Lift Check had nothing to analyse in the face-lifting stages.
All green, all meaningless, all on safety checks. And it is not a marginal population: a ticket census found 15 of 107 pieces are insulated.
The plant file already carried the post-mortem of the first occurrence, directly above the list that was wrong again: the built-in lifter list matched none of this plant's inserts, so the same checks found zero lifters on every piece and passed in silence. That comment was kept because the lesson was worth more than the fix. It was kept, and it happened anyway — in a population nobody had thought to separate.
Why the fix is a shape and not two more list entries
Adding one family name would close today's hole and leave the next panel type to rediscover it the same way. So what landed is the general fact: which family plays a role depends on the panel type, expressed once.
panel_types:
types: {solid: {}, insulated: {...}}
default: null # and it must stay null -- see below
detect_verified: false
detect_unverified_because: <what is missing>
handling:
lifter_groups:
face:
families: [<both>] # the union: what every reader uses today
families_by_panel_type: # the same fact, narrowed
solid: [<one>]
insulated: [<the other>]
families_for(know, path, panel_type) resolves the pair and returns
(families, basis, why). A third panel type is one added key in one map,
and a job may add one without restating the plant's — which is the only test
that matters for a shape landed under time pressure.
The asymmetry the whole design rests on
The two ways of being wrong here are not symmetric, and which one is safe depends on what the caller is doing with the answer:
| a family left OUT | a family wrongly IN | |
|---|---|---|
| recognising a part ("is this a face lifter?") | the check finds nothing and passes in silence — the failure above | a visible finding about a real insert |
| counting one (row 6.6's parity) | wrong count | wrong count, and a wrong parity is a wrong verdict with nothing on the report to show for it |
So an unclassified piece resolves to the union, which is right for
recognition and wrong for counting — and basis comes back with every answer
precisely so the two callers can differ. A counting caller must refuse
anything but 'panel_type', and the why says so in as many words rather
than leaving it to be inferred.
That is why row 6.6's parity_note_rule ships dormant with the reason
written down, even though the per-type breakdown beside it is fully stated:
nothing can yet establish a piece's panel type from the model, and whether the
two parts ever co-occur on one piece is unmeasured. A flat list of both would
have been the obvious fill-in and would have been a silent miscount.
panel_types.default is null and should stay null
A default is the tempting fix and it is the one that caused this. Reading an
unclassified piece as the ordinary type is exactly how an insulated panel
came to be checked for a lifter it does not carry — on every lifting check at
once, all of them green. panel_type_of() returns None with the reason
instead, and None is a real answer here, not a missing one.
What was deliberately NOT extended, and why the direction is the argument
The face lifter on solid panels is exempt from interference twice over — the crew rotates the disk anchor on the form, and its own bars are found by a symmetry rule whose tolerances were measured on a piece carrying those anchors. The insulated panel's lifter is modelled with a family name that says outright that it has bars, so the temptation to extend both exemptions is real.
Neither was extended. Nobody has said the other insert has the same rotation freedom, and the symmetry tolerances are measurements of a different part. Extending them would suppress findings; leaving them alone at worst produces noisy ones. Suppression is silent and noise is not, so noise wins until somebody measures it.
The same reasoning governs the missing catalogue alias. The new lifter has no published ratings behind it, so capacity resolves to nothing (the insert is named, nothing is claimed) and edge distance falls back to the plant-wide minimum with a WARN saying the number may not be that part's. Both are louder than yesterday, when the answer was nothing at all — and a guessed alias would have been quieter and wrong, which is the standing lesson from the part that was listed as a lifter and was not one.
Matching mode: the part that had to be got right
Both lists that recognise a face lifter are matched as case-insensitive substrings, and the job contains two different families carrying the same part number — the wall-panel lifter, and a job special plate on slab pieces. A bare token would sweep the second in and treat it as something a piece hangs from, which is the standing cautionary tale in that very file happening again.
Scope saves it today, because the slab pieces are outside the declared prefix. A matcher that is correct only because scope rescues it is not correct, so the token is written long enough to be unambiguous — which is what the file already did for the edge anchor, for exactly this reason, and what it does not need to do for the solid-panel lifter because that token appears in only one family name in the whole job. The two long names are irregularly formatted against each other, so no rule derives one from the other; stopping the token before the differing suffix is what makes one form work for both.
Row 6.6 uses whole-name EXACT matching instead, and the difference is the point. Counting is not recognising: an over-broad match in a recognition list produces a visible finding about a real insert, while an over-broad match in a count is a wrong parity — a wrong verdict with nothing on the report to show for it. Exactness also makes the "a longer name contains this token" trap impossible rather than merely absent today.
The part-number grammar, and why it is data
Owner, 2026-08-31: P is a standard plate, looked up in plant knowledge; SP
is a special plate made for one job. A layer-placement rule expressed in the
part number itself — everything else in this stack has to be told which
layer owns a fact, and here the name says it. It corroborates the manual's own
custom-numbering panel, which files the special prefixes under "Non Standard
Material Job Specific": the same rule stated from the other direction.
It is a key rather than a comment because of what it makes decidable. A part
printed on a ticket that nothing can resolve is two different problems with
two different owners: a standard-plate number nobody can look up is a gap in
plant knowledge, and a special-plate number nobody can look up is a gap in the
job file. Reporting both as "unknown part" is right half the time and sends
the other half to the wrong desk. part_defining_layer() answers whose
question it is; nothing here decides a checklist row.
Longest prefix wins, and that is the safety rather than a tie-break: read
the short way round, a job's special plate is filed in the plant's
standard-plate namespace — precisely the merge a substring match on a bare
part number makes, and precisely what it cost on the face lifter. A prefix
whose meaning came off a manual carries verified: false and names the
source, because a reading is not a ruling. A prefix nobody can describe at all
is left out rather than given a placeholder: an entry in this table is a
claim about whose desk a missing definition belongs on.
A note that is present and worded wrongly
Row 6.6 demands one phrase, and one real piece carries a near-miss of it. The tempting fix is to accept both wordings; that would quietly overrule an owner who named one phrase, so it was not done — the piece fails.
But it fails for a different reason than a piece with no note at all: one needs a wording corrected and the other needs a note written, and a report that reads identically for both sends the reader to the wrong job. So the near-miss wording is recorded in its own key, reported and never accepted, whose entries must be wordings actually seen in a model rather than guesses at what somebody might type. Accepting one is a one-line change and an owner's decision, which is the state it should be in.
Which checks apply is itself plant knowledge
Commissioned 2026-08-31. The owner, reading insulated panels:
"ticketqc 检查的内容需要进行分级。根据 plant 和 ticket 的具体内容进行分级。。。 我正在查 insulated panels,发现这个工具在 insulated panels 几乎没用。我估计换另 一家 plant 也是无用。所以把 ticketqc rules 写进厂知识,让 ticketqc 工具读取厂知识 来得知啥需要检查,啥不用检查。"
Two claims, and the design serves both. Applicability varies along two axes — the plant, and the piece's own nature — and neither belongs in code.
The fifth state, and where it sits among the four that existed
This project had already accumulated four ways for a checklist row to be quiet. The commission is a fifth, and the whole value of writing it down is that none of the five may drift into another's job.
| what it says | granularity | does the check run? | voluntary? | |
|---|---|---|---|---|
checks.muted |
"I know, stop telling me" | per job, per row | yes — repainted grey | yes, stated |
declared scope (scope.mark_prefixes) |
"that piece is somebody else's" | per piece, never a row | the piece is never read | yes, stated |
| dormant check | "the fact that would enable me is unset" | per check | no — stands down with a reason | no — cured by supplying a fact |
| grades A–D | "who answers this" | per row | yes | n/a |
checks.not_applicable |
"this question is not about this kind of piece at this plant" | per row × panel type, per plant | no — not at all | yes, stated and dated |
The three distinguishing features of the new one, each chosen against one of the other four:
- Voluntary and stated, unlike dormancy. A dormant check is an admission; this is a ruling, so it carries a reason, a date and a declarer.
- Plant-level and conditional on the piece, unlike a job mute. A mute is one job saying "not here"; this is a plant saying "not on this kind of thing", and the condition is a measurable piece fact.
- The check does not run, unlike a mute. Muting is a volume control; this is the question not being asked.
The nearest precedent in spirit is the 3.3 mute — "no panel on this job ever has a release ratio" is an applicability fact wearing a mute's clothes. It was not migrated, and the reason is in its own words: it says 本项目, this job. A plant-level declaration would be a claim the owner did not make. The same question now stands over the 1.4 mute landed 2026-08-31 (a shared jacking-force legend carries another design's name): that may well be a plant fact rather than a job annoyance, and moving it is his call, not a reader's.
The shape, and the wipe-the-table trap
checks:
not_applicable: # the row is NOT asked on this kind of piece
'3.3': # a ticket-checklist number
insulated: # a declared panel type id, or `any`
applies: false
not_applicable_because: <why the question is not about this piece>
not_applicable_since: '2026-08-31'
declared_by: <who ruled it>
only_applies_to: # the row is ONLY asked on this kind of piece
'6.7':
insulated:
scoped: true
scoped_because: <why the question does not exist elsewhere>
scoped_since: '2026-08-31'
declared_by: <who ruled it>
There is not a list anywhere in it, and that is the whole design. Lists replace across the layers and maps deep-merge (§2.3). A list of switched-off row numbers in the job file would delete the plant's whole table the moment a job wanted to add one entry — the rebar stock-length trap, on a safety switch this time. With maps all the way down:
- a job narrows one entry by writing
applies: trueunder exactly that(row, panel type)pair, and - a job extends by adding a pair,
and in both cases every other entry the plant declared survives untouched. The wipe is not guarded against; it is unspellable.
Why the reason key is named for the value it justifies
After the merge, nothing can tell which layer wrote which key. A single
because: would let a job flip applies: true and silently inherit the
plant's sentence written for the opposite ruling — a reason that now
reads backwards, over a re-enabled safety check. So turning a row back on
needs applicable_because, which the plant's switch-off entry does not
carry: the narrowing states its own case, dated, or it is refused.
declared_by says who ruled. The layer is computed separately, from
configdump.layer_values(doc), and printed on every line — because a plant
rule copied into a job file still names the plant's engineer, while the file
somebody has to open to change it is the job file.
The two axes, and what each one costs
any is the plant axis: "we do not do that here". It needs no
classification, so it works the day it is written.
A key that names a panel type is the piece axis, and it needs a
classification. While panel_types.detect_verified is false, such a
declaration is REFUSED and the row RUNS, and the refusal is printed —
because an owner who wrote a rule believes that row is off, and a refusal
nobody reports is that person never finding out it is on.
How a piece is classified, and when it declines
panel_types.types.<id>.signals is a map of measurable facts, evaluated
by knowledge.classify_panel(know, facts). The prose model_signals beside
it is for a reader and is never evaluated — a sentence somebody wrote is not
a predicate, and a file that let one masquerade as the other would classify a
piece off a description.
signals:
body_family_contains: [<token>] # case-insensitive substring
thickness_in: {min: <n>, max: <n>} # inclusive, INCHES
member_family_contains: [<token>] # a member only this type has
min_signals: 2
The rule, and each half of it was chosen against a specific failure:
- Every signal that could be evaluated must agree, and at least
min_signalsof them must have been evaluable. Two, not one: the plant file's own note says any single signal could be a coincidence of how one job was modelled, and that it is three mutually independent ones that make it a convention. - A fact nobody measured makes its signal unevaluable, never
False. - A type that declares no signals can never be classified into.
Recognising
solidas "not insulated" is the default this branch exists to refuse — reading an unclassified piece as the ordinary type is exactly how an insulated panel came to be checked for a lifter it does not carry. - Two types satisfied at once, or none, is a decline — said out loud, naming every signal and what it said.
A piece that declines gets the full checklist. The asymmetry is the whole argument: running a check that turns out not to apply is noise; skipping one that did apply is a silent pass, and those are not symmetric.
classify_panel takes a plain dict built by knowledge.panel_facts(), and
is deliberately Revit-free so a second caller measuring the same facts —
liftexport, which needs "sum both wythes on an insulated body" — hands over
the same dict rather than inventing its own key names. thickness_in says
its unit in the key: the ticket carries Revit's internal feet, and a 16
that meant feet would match no band any plant writes, on every piece, and
report a whole job as unclassifiable with no error anywhere.
The one place "decline → full checklist" needed refining
A row declared with only_applies_to exists only for one kind of piece —
the owner's insulated-only solid-zone check is the live example: on a solid
panel there is no insulation for a solid zone to interrupt, so the question
is not quiet, it is meaningless. Such a row has three answers, and the third
is the one that had to be thought about:
| the piece | the row |
|---|---|
| is one of the scoped types | asked, normally |
| is another type | NOT APPLICABLE, in blue, naming both types |
| could not be classified (or nothing can classify) | NOT RUN, in red |
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 — precisely what could not be established. So nobody could ask it, which is NOT RUN, which is red and loud, and which is cured by making the classification work rather than by anybody's ruling. That is the dormant-check shape arrived at from the other side, and it keeps §3.1 intact in both directions: silence is never a pass.
What the report renders, and why it is as loud as the scope line
NOT APPLICABLE is its own state word, its own machine key
(state_key == 'not_applicable'), judged: false, and its own colour —
blue, never a reuse of the muted grey. A reader who has to work out which
kind of quiet a grey row is has stopped trusting the table.
Blue is calm, and calm is only safe here because the declaration is loud somewhere else. The report carries, once per run per piece:
PNL101 classified as insulated — 3 row(s) declared not applicable: 3.3, 5.4, 6.6 this piece is insulated. insulated: 2 signal(s) agree (…)
and then every switched-off row with its reason, its date, its declarer and the layer that declared it. The section opens itself whenever anything was switched off — unlike muting, which folds when it is tidy — because a mute leaves the check running and this stops it. A mistaken applicability rule is a safety check disabled in silence, and the only thing between that and a signed report is this section.
Two invariants survive intact:
- A row that FAILED is never switched off. The declaration is recorded as
DECLINED, exactly as a mute over a failure is. The case is real rather than
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. - A check is skipped only when no row it answers can be asked. Skipping
check_strand_layoutbecause 4.4 was switched off would take 4.6 with it — a row nobody disabled, going quiet, because of a declaration about a different row.
qc-run.json is bumped to qc-run/2 and carries all of it: the new state
in the published vocabulary, a panel_type block per piece (the type, the
basis, every signal's verdict, the facts, and used_for_applicability so
nobody may infer that an unverified classification switched something off),
and a not_applicable block on any row it switched off. The evidence file is
bumped to ticketqc/2 and carries the facts and the classification as
what they are — inputs. Rule 1 of the file contract is discard whole on
mismatch, so a shape change is a bump and never a migration.
What was seeded, and what was deliberately left empty
Nothing is switched off. Both tables ship empty, in DEFAULTS and in the
plant file, and a test pins that. The plant file carries the template and the
evidence beside it.
The first evidence run (2026-08-31, four live presses on insulated pieces) found the opposite of the intuition that commissioned this, and the template says so, so that whoever fills it in starts from the measured truth:
- the "nearly useless" wall was already gone, and not by applicability — running Extract Design took NOT RUN from 15 to 0 and OK from 11 to 22 between two presses an hour apart, and the face-lifter knowledge fix took 6.1, 6.2 and 6.6 from NOT RUN to OK in the same run;
- of the 13 remaining non-OK rows, four trace to known bugs, two are declared mutes, two are FOR ENGINEER by design, and five are genuine judgements with real arithmetic;
- not one non-OK row fired because the check does not apply to an insulated panel.
The four things that must never be declared here
Each was a real row on that run, and each would look perfectly reasonable as an entry:
- A check that is broken on a panel type. Row 2.2's weight sum reads ~65% of the printed weight on 16" insulated bodies — a wythe or an insulation layer missing from the sum, which also gates 6.3.
- A parser missing vocabulary. Row 8.1 has no words for
TOP WYTHEorHK ST100'S. - A category confusion. Row 5.6 read hardware part numbers as bar marks (fixed 2026-08-31 — see below).
- Landed knowledge with no reader. Row 5.6's other half: the owner had
recorded that
SR4996ships inside SP29/SP30 and is covered by their callout, in three places, and the check read none of them. "我记得我增加过 这条知识了,为什么还在 WP117 报 warn?" — and he was right.
The test is: does the question stop being about this kind of piece, or does the tool stop being able to answer it? Only the first belongs here. The second is a bug, and a bug behind a declaration is a safety check switched off with a signature on it — the exact failure the report contract exists to surface.
The fourth is the one to watch, because it is the most persuasive. A row that fires on knowledge the owner has already written down looks exactly like a row that does not apply — the difference is that somebody has already made the ruling and the tool cannot see it. Declaring it inapplicable would "work", and it would put a second, weaker copy of a fact next to the good one. Before writing an entry here, grep the knowledge layers for the thing the row is complaining about. If it is already in there, the bug is the missing reader.
And the counterexample arrived the same day, which is the cleanest possible
illustration: the owner's insulated-only solid-zone check is a question
that only exists for a panel type. It is reserved as the worked example of
only_applies_to and is not built — the population is still being
measured and the comparison it would make is undetermined.
A new question is still a code change, and why the boundary is there
A plant declares applicability over the rows in
ticketpage.CHECKLIST_ITEMS. It cannot define a new row.
The rows are the universe because each one is a claim the tool makes about a
ticket, and a claim needs code that can be wrong in a reviewable way: a check
function, a skip probe that says why it stayed silent, a grade recording who
answers it, an owner finding code, a place in the numbering that a reader
navigates by, and a test. A YAML-defined row would have a number and a
sentence and no way to be wrong — it could never fail, never stand down
with a reason, and never appear in qc-run.json as anything but present.
That is a checklist row that always looks fine, which is this project's
cardinal failure with a friendly editor on top.
So the split is: what to ask is code; whether to ask it, of what, is knowledge. A plant that needs a genuinely new question gets a code change — and gets the probe, the grade and the test that come with it.
A bar that ships inside another part, and a token that is not a bar
Landed 2026-08-31, on a live complaint:
"SR4996 是 SP29/30 的附属 rebar,我记得我增加过这条知识了,为什么还在 WP117 报 warn?"
He was right on both counts. The knowledge existed and one check could not see it.
Landed knowledge with no reader
SR4996 is the "(4) #4 X 1'-6" BENT BARS" written into the SP29 and SP30
descriptions — the angles arrive with them. He had recorded that three times:
in interference.exempt_families with his dated comment, in
interference.component_groups, and in
drawings.annotation_covered_by_parent. The plant file's
rebar_must_appear_on block states the rule in full, and it is the
specification of the fix nearly verbatim:
"a bar that is PART OF another item on the BOM is covered by that item's callout and is never drawn on its own… Looking for SR4996 as an independent bar and failing the ticket when it is not found would be the checker misreading the BOM, not the ticket being wrong."
Checklist 5.2 read the key. The takeoff read the key. 5.6 did not — its "modelled, never printed" leg compared modelled marks against printed text with no concept of a covered bar. So a bar that is by design never drawn on its own was reported as never printed, on every piece carrying one.
That is the drift detector's oldest category — a key that is written, merged, and read by nobody — showing up live on a check rather than in a report. It is worth naming as its own failure mode, because it does not look like one: the report was wrong in a way that reads exactly like the tool working.
The fix is a named accessor, not a third inline get().
knowledge.attached_parents() returns {BAR MARK: [parent, ...]}, uppercased
and shape-normalised, and 5.6 takes it as an argument. Three readers spelling
one fact three ways is how the fourth comes to disagree with the other three.
What the coverage produces — two outcomes, never a silence
The narrowing touches only the never-printed leg, and it never removes a row's answer:
| the covered bar's parent | 5.6 |
|---|---|
| is printed | the bar is accounted for. Said out loud: "SR4996 is attached to SP29/SP30", in the sentence, on the report and in qc-run.json |
| is not printed | still a finding, and now it is about the PARENT. The bar is unaccounted for either way; what changed is whose callout is missing |
| cannot be read at all | 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 |
The second row is the one that keeps this honest. A narrowing that turned "bar not printed" into silence would be muting with extra steps; what it actually does is re-address the finding to the right desk. A report that named the bar would send the reader to fix the wrong thing.
The third exists because of a real inconsistency, found by running the merged
layers rather than by reading them: a parent is named in two vocabularies.
The plant file names one by its FAMILY (<vendor>_EMBED_P76); the job file
names one by its PART NUMBER (SP29); the ticket prints the part number. So
the part number is extracted from whatever the key holds — the same thing
check_bed_plan_reverse has done with family names since 08-28. Demanding one
spelling would have turned every plant-declared parent into a finding nobody
could act on: a false finding invented by the fix.
That extraction needs its own regex and cannot reuse mark_tokens, and the
difference is exactly one \b. In regex _ is a word character, so
\bP76\b never matches <vendor>_EMBED_P76 — the same blindness that let the
scrub gate miss PREFIX_TERM (§3.4). mark_tokens keeps its boundary because
it reads printed call-outs, where it is what stops SR1 being read out of
SR1101. Two functions, two regexes, and part_numbers_in() carries the
reason so the third caller does not reinvent it with a \b in it.
Should interference.exempt_families derive from this key?
Recommendation: no — and the fact that it looks like it should is worth writing down rather than acting on. Not rewired; 7.1 is untouched.
The two keys are close enough that the job file already tells a reader to keep them in sync: "These two facts travel together… if it appears in only one of the two lists, one of the checks is wrong about it." But they are not the same claim, and deriving one from the other would assert the second wherever somebody stated the first:
annotation_covered_by_parentis about drawing: this bar is never called out on its own, because its parent's callout covers it.exempt_familiesis about geometry: this bar does not count as an interference, because it ships fixed to its parent and the crew can work it clear.
A bar can be the first without being the second. A bar drawn only as part of its parent may still be modelled somewhere it genuinely clashes — and 7.1 exists to find exactly that. Deriving would make every drawing declaration silently suppress a clash, which is a suppression, and this file's standing rule is that suppression is silent while noise is not.
There is also a shape argument: exempt_families is a list, so it
replaces across the layers and the job file carries a restated block for the
plant's entries; annotation_covered_by_parent is a map and deep-merges.
Deriving a replacing list from a merging map would give the derived half
different override semantics from the half beside it, and the two would
disagree the first time a job overrode either.
What is worth building instead is a consistency check, not a derivation: something that reports a bar present in one and absent from the other, as a question for a person. That is the job file's own instruction turned into code, and it neither suppresses nor invents. It is not built here.
Is a printed token a bar mark at all?
The same run's other defect. 5.6 took every mark-shaped token on the sheet
for a candidate bar mark, so WP115 reported P78, P114, SP29, SP30 and
SP31 as "printed bar marks matching no bar in the model". They match no
bar because they are hardware.
The grammar to tell them apart landed the day before —
part_numbers.prefixes, longest-prefix-wins — but it records what a prefix
MEANS as prose (means: standard plate). Prose is not a predicate, and
parsing means to decide would be the same mistake as classifying a panel off
model_signals. So the population is its own declared field:
part_numbers:
prefixes:
P: { means: standard plate, kind: hardware, verified: true }
SP: { means: special plate, kind: hardware, verified: true }
SR: { means: reinforcing mark, kind: bar_mark, verified: false }
MK: { means: erection / site mark } # no kind: nobody has said
The two values are not symmetric, and only one of them is gated
| effect | if it is wrong | |
|---|---|---|
kind: bar_mark |
adds the token to the comparison | a visible finding about something that is not a bar |
kind: hardware |
removes the token from the comparison | a real printed bar mark stops being compared — 5.6 goes quiet on the exact defect it exists to find |
So kind: hardware is honoured only on an entry that is
verified: true. An unverified hardware claim is refused at run time and
the token is reported as unplaceable instead. A reading is not a ruling, and
a reading may not switch a comparison off. bar_mark needs no such gate,
which is why SR carries it although the SR entry is itself an unverified
reading — the worst that direction can do is add noise.
Three more properties, each chosen against a way this could have gone quiet:
- A token the grammar cannot place is its own finding — could-not-judge,
never a quiet drop. A printed
MK12says the grammar has a hole and names the hole, which is whyMKdeliberately carries nokind. - When no layer declares the grammar at all, this stands down to ungoverned and every token stays a candidate — exactly what 5.6 did before any of this existed. Standing down here has to mean do not narrow; the other reading would empty the comparison for every plant that has not written a grammar yet. Noisy, never quiet.
- "Is this mark printed anywhere" is answered from every token, not from the narrowed candidates. A bar mark the grammar misfiled as hardware is still ink on the sheet, and the never-printed leg must not go on to report it as unprinted — a finding invented by the narrowing itself.
And no W/ splitter
P78 W/ SR5502 (S.I.F.) (E.S.) carries both populations. It needs no special
parsing: the grammar answers per token, so the part is set aside and the
bar after it is compared. A W/ splitter beside the grammar would be a
second, weaker mechanism that can disagree with the first — and the two would
be discovered disagreeing on a ticket, not in a test.
What this looked like, measured
Against the real merged layers, on the WP115/WP117 call-out shape:
Before — two WARNs, both wrong:
5 printed bar mark(s) match no bar in the model: P114, P78, SP29, SP30, SP31 1 modelled bar mark(s) are never printed on this ticket: SR4996
After — one OK, with everything it set aside named in it:
all 3 modelled bar mark(s) are accounted for … ; 1 bar(s) are never drawn on their own because a parent item callout covers them, and that parent IS printed: SR4996 is attached to SP29/SP30; 5 printed token(s) are hardware part numbers by the plant part-number grammar, not bar marks, and were not looked for among the bars: P114, P78, SP29, SP30, SP31
Both narrowings are stated, not silent. They are named on a green row and on a red one — a narrowing visible only when everything else passed is a narrowing nobody audits on the runs that matter.
One checklist per plant-member, and the identity that survives renumbering
Landed 2026-08-31, on the owner's ruling:
"按照 plant-member 这种级别,给 ticketqc 的 item 重新编号,让 ticketqc 自动识别 plant-member, 并且做正确的 check。"
and, on where each half lives:
"就是得重新编号。以后引用需要前面加 plant-member。静音系统应该是 job 级别的东西, plant 级别的东西就是对应的 member 有不同的 check-list. 我之前没意识到不同的 member 需要不同的 check-list 这件事儿。做一个总的 checklist 放 public knowledge 里面, 然后 plant 里面做一层映射,job 里面用静音按键去控制"
Three layers, and each owns exactly one thing
| layer | owns | where |
|---|---|---|
| public | what the questions ARE — the universe, trade-wide | checklist.master in knowledge/catalogue.yaml |
| plant | which of them it asks of which kind of member, in what order, under what numbers | checklists.profiles |
| job | the mute buttons — presentation, per job | checks.muted |
What did not move is the check functions. A row is a claim the tool makes, and a claim has to be able to be wrong in a reviewable way: a skip probe that says why it stayed silent, a finding code, a grade, a test. A YAML row with no function behind it would have a number, a sentence and no way to fail — a checklist row that always looks fine.
The trap the whole design is built around
CHECKLIST_ITEMS was a tuple in ticketpage.py. Every number in it is
named by something else: the owner's mutes (3.3, 5.4, 1.4),
checks.not_applicable, checks.only_applies_to, the QNA records,
qc-run.json and its consumers, docs/checklist-code-map.md, and the
suite. If a number became profile-relative, every one of those would
either break or — far worse — keep working while pointing at a different
question.
So: the number is frozen as the STABLE ID, and the profile supplies a DISPLAY NUMBER. Internally everything resolves through master ids; the qualified number is how humans and reports say it.
Why the id is the old number rather than a new slug: minting slugs means
translating every existing reference at once, and a mass rename is exactly
the orphaning risk this design exists to remove — paid on day one, for a
tidier spelling. The number is already unique, already tested unique, and
already what everyone writes. The cost is that an id looks like a display
number, which is how it would get renumbered by accident — so
test_the_master_ids_are_frozen compares the table against a typed-out
golden. A pin that recomputes both sides pins nothing.
The reference grammar
Two forms, and only two:
| written | means |
|---|---|
3.3 |
a master id. Every ruling written before 2026-08-31 is this form and every one still means what it meant. |
sample_plant-wall_panel 3.3 |
qualified: <plant>-<member kind> <number>, a position on that plant's board for that member kind. |
The qualifier and the number split on whitespace (a display number has dots, a member kind has underscores; a space is the one character neither has). The qualifier splits on its last hyphen, so a plant whose own id carries one still resolves.
A qualified reference is resolved at read time and stored as a master id.
That is the whole reason the grammar exists rather than the numbers simply
changing: a mute written as sample_plant-wall_panel 3.3 becomes master id
3.3 the moment it is read, so everything downstream speaks about the same
question whatever was typed.
The limit, stated rather than papered over
A qualified reference is a position, resolved against the board as it is
now. If the plant moves that question from 2.1 to 7.4, an old
reference to 2.1 has two possible fates:
- silently resolve to whatever sits at
2.1today — a ruling now quietening a different question. This is the ambush, and it is impossible: the resolution is honest, so it returns whatever2.1genuinely is now, and the report prints the mute's resolved target on every run. - fail to resolve, and be reported as a reference nobody can place.
So a renumbering can cost a reference and can never corrupt one — and a bare master id, which is what every existing mute uses, cannot even be cost. That is why the durable form is the bare id and the qualified form is for citing.
A rule for whoever writes the next example
The qualified form contains a plant name by construction. That is fine
in the gitignored layers and in QNA, where real references live, and never
fine in tracked code, docs or comments. Every example in a tracked file uses
a placeholder plant (sample_plant-wall_panel 3.3).
This was learned the direct way, the day the grammar landed: the first
examples used the real plant's name and took the scrub gate from 2 hits to
7 across three tracked files. The tension is permanent — every future
example writer meets it — so it is written beside the grammar in
knowledge.py as well as here. (CLAUDE.md's placeholder table has rows
for a client and a job; it needs one for a plant, and that file is not
this work's to edit.)
Recognising the member, and the panel type within it
Two signals, and they compose rather than compete:
- member kind ← the mark prefix (
nomenclature.member_kinds, a map). The prefix is read byscope.prefix_of— the whole leading letter run, exact match — and that is the reconciliation:scope.pyalready owns "what is the prefix of this mark" and argues at length against matching a declared prefix as a prefix. Two readers with two rules would disagree about which pieces the declared scope covers versus which checklist they get, silently, and only on the pieces where it matters. - panel type ← measured signals (
classify_panel), refining within a member kind. An insulated wall panel is a wall panel first. - the profile is selected on the pair, and a profile naming both beats one naming only the kind.
A piece whose member kind cannot be read gets no profile, and therefore the full master checklist under its own numbering — more questions, not fewer, on a piece nobody has calibrated for. That is also what a column gets, honestly, if somebody runs one through: the owner's scope is cladding only, so the first real profiles are wall-panel ones.
Compatibility, and the hole a closed board would open
No profile declared → the full master checklist, in master order, under master numbers, byte-identical to what the tool printed before profiles existed. A test pins it, comparing the rendered text both ways.
And within a profile: unlisted defaults to ask. A board names the
questions that existed when somebody wrote it; the code universe grows. With
a closed list and no policy, the next check anybody builds would be
silently unasked at every plant that has ever written a profile — a new
safety check, shipped, running nowhere, with nothing on the report saying
so. A check the board does not name is asked anyway, under its own master
id, and the report says the board has not caught up with the code.
unlisted: not_applicable is the deliberate closed board and needs a reason
and a date, because it is a standing promise about checks nobody has written
yet.
Precedence: the board, then the switch
checklists.profiles says what is on the board;
checks.not_applicable says whether a question that is on the board
applies to this piece. A question that was never put up cannot be
switched off, and does not need to be — so a profile should not restate what
not_applicable already says. Both render as the same NOT APPLICABLE
state and source on the decision names which of the two did it.
The new failure mode, and the guard that replaces the tuple
A tuple could not disagree with the functions beside it. A YAML list can, in two directions, both silent:
- a master entry naming a function nobody wrote — the row is NOT RUN forever and reads like an unbuilt feature rather than a typo;
- a registered check function no master entry names — the check runs, spends the time, and can never reach a row.
Three tests pair them. The second one found two real orphans on the day it
was written: check_bom_strand_count (emits BOM-STRAND) and
check_picking_match (emits PICK-STATIONS/PICK-UNREADABLE). Neither is
named by a master entry and neither emits a code any entry owns, so both run
and can never turn a row red — check_bom_strand_count can even FAIL while
row 4.1, which asks the same question through a different code, stays green.
They are recorded in ROWLESS_CHECKS with that reasoning; wiring them up is
a decision about what the report claims, and it is the owner's.
And the loader refuses rather than degrades: a missing or malformed catalogue takes the button down with a sentence naming the file, because the alternative is a tool that starts with a shorter checklist and reports every ticket it looks at as fine.
5.10 is not 5.1: a coercion trap that cost a row
_coerce gives YAML scalars their intended types, and it runs on values.
A number in a value position is numified — so a board line written
{number: 5.10, check: 5.10} arrives as the float 5.1, which is a
master id that really exists. The board silently lost a question and gained
a duplicate. It was caught by reading the resolved board back, not by
review.
It is the leading-zero lesson (0010001 is a part mark, not a number) with
the zero at the other end, and it is worse: a leading zero at least
changes the text, while 5.10 → 5.1 lands on something real. Quoting does
not save it, for the reason _coerce's own docstring gives — coercion
happens after parsing.
So a board line is written '2.1 = 5.10', which never parses as a
number whatever anybody quoted; and the map form refuses a non-string
number rather than accepting a value that may already have been mangled,
because nothing can tell 5.1 from a flattened 5.10 after the fact.
What was seeded
The plant file declares one member kind (the cladding prefix — the only one
whose checklist has been validated, because every check here was written
against wall panels) and one board: the identity board, every master id
in master order under its own number, unlisted: ask. So nothing about any
report changes, and the thing that gets edited is visible in one place.
A second board for insulated wall panels is deliberately not declared,
and the reason is the asymmetry: it would have to restate every question the
first one asks, and any question missed in the copying would go quiet on
exactly the pieces the owner is reviewing right now. The insulated-only
questions do not need one — they sit on the wall-panel board like
everything else and checks.only_applies_to scopes them.
Connectors: the first questions added under the three-layer architecture
Commissioned 2026-08-31:
"insulated panel 加一个 check,检查 connector 是不是存在,以及够不够。增加一个公共 知识,connector 指的是穿越 insulation, 把两层混凝土链接起来的东西 ... 公共知识应该有 connector 包含:HK Composites (ST50),而厂知识应该有 R80=HK Composites (ST50)。"
and, the same day: "再增加 connector edge distance 的 check".
The procedure, because every future question follows it
Adding a question — not a plant's opinion about a question — took exactly four things:
- Three entries in the public master (
checklist.master):5.8exist,5.9sufficient,5.10edge distance. New ids are minted, never reused, and added to the frozen golden in the same commit. - Three check functions in
vibe.ticketchecks, bound by those ids, each with a skip probe. The pairing tests fail loudly if either half is missing — which is what makes step 1 safe now that the list is data. - Public product knowledge where it belongs: what a connector is, and which products exist.
- Plant knowledge: this plant's part numbers, the mapping onto those
products, and one
only_applies_toentry scoping the rows.
Nothing about the questions is plant-specific except the part numbers, which
live in the plant layer where part numbers belong. No board had to be
edited: unlisted: ask means a new master entry is asked everywhere until
somebody decides otherwise, which is exactly the property that policy exists
for — and this was its first real exercise.
Public: the concept and the products
A connector crosses the insulation of a sandwich panel and ties the two concrete wythes together. Without enough of them the wythes are two slabs travelling in company — a structural condition, not a drafting one.
catalogue.connectors.products carries HK Composites (ST50) and
HK Composites (ST100), each with capacity_lb: null and
min_edge_distance_in: null and a reason beside each null. The owner
said he will supply the datasheets; a rating nobody has read is not a
rating. ST50 and ST100 are not folded together — a capacity that arrives
for one may not be read onto the other.
The minimum edge distance sits beside the capacity, in the public layer, because it is a property of the product. Resolution is product-first through the plant's alias; a plant-wide fallback is second-class and the finding names which it used, because "6 inches" and "6 inches for this product" are different claims. That is 6.2's lesson carried one part along. No published minimum and no fallback → the row stands down naming the missing datum, and never borrows another product's number.
Plant: recognition by mark on the type, and two traps that shaped it
Measured over the whole model, 2026-08-31: 1,951 connector instances, every one on the 27 insulated pieces, zero on the five solid controls, per-piece counts 15–115 tracking piece size.
Two traps, either of which a family-name rule falls into:
- The part number is absent from the family name entirely — the exact inverse of the face-lifter lesson, where the family name was the only place the part number appeared. A family-name key finds nothing.
- A decoy family exists, literally named for the part number, carrying
the same mark and the same component value, with zero instances. A
family-name matcher finds the decoy and misses all 1,951 real pins — a
silent zero on the one check whose entire job is to notice zero, and
5.8FAILs on zero. That is the worst outcome available here.
So the mark is read off the type, through the ordinary member.mark
role, and the check counts instances. Counting instances is immune to the
decoy, which contributes none; anything enumerating types would
double-count. An optional component_gate narrows a match the mark already
made — and what it excluded is counted and named in the failure, because
"this piece has untied wythes" and "your gate is wrong" are different
problems for different people.
Two product pairings, and they are recorded differently on purpose:
R80 = ST50 is the owner's own words; R82 = ST100 is measured off the
model's own type description. The other nine numbers are null with a
reason — a guessed pairing would give a real connector another product's
capacity and another product's edge distance.
Edge distance: the thickness axis is excluded, and that is not a detail
Every pin sits at exact mid-depth. A three-axis minimum therefore reports half the panel thickness as an "edge distance" on every connector of every piece — a number that is real, constant, and about nothing. An edge distance is a distance to an edge; the two faces are not edges. A first pass made exactly that mistake and would have reported 8.0 in where the true in-plane minimum is 6.0.
The thickness axis is derived — the local span closest to the panel's own
thickness — rather than assumed to be an index. That is strand_layout_of's
idiom, reused for the reason it exists there: a nearly square piece defeats
any shortcut. And the positions come from the same measurement path the
lifters already use; there is no second geometry reader.
The calibration to keep: the smallest edge distance actually built on the measured piece is 6.0 in (four pins; median 23 in) and the owner has raised nothing about that piece. So a published minimum above 6 would fire on work he accepts — a finding to surface when the datasheet lands, never a reason to quietly set the limit at 6.
Sufficiency: a real requirement the parser cannot read yet
The design summary states it — WP115 carries HK ST100'S @1'-4" O.C., and
8.1 lists that line among the ones it cannot interpret, the same wythe
vocabulary gap that also costs 8.1 TOP WYTHE. The model honours it exactly:
measured 16.0 in field spacing, which is 1'-4".
So 5.9 has a calibrated example and no readable requirement. It returns
(no verdict, a reason) rather than an OK — a count on its own is a
number, not a judgement, and printing OK because nobody stated a requirement
is the silent pass this table exists to refuse. What it needs is one
parser extension: the design-summary reader learning the connector line
(<product> @<spacing> O.C.) and writing connector_spacing_in into the
intent. That is not built here; it belongs with the wythe-vocabulary work on
8.1, and doing it blind would be inventing a grammar for one observed line.
End densification is normal and must not be flagged. The measured piece runs 16.0 in in the field with tighter 9.5/6.5 pairs at the ends, so the comparison is against the widest gap: a gap tighter than required is never the defect.
Why all three read NOT RUN today, and what arms them
All three are scoped insulated-only through checks.only_applies_to. On a
solid panel the question is not quiet, it is meaningless — there is no
insulation to cross. On a piece nothing could classify they read NOT RUN,
in red, because calling them not-applicable would claim the piece is not
insulated, which is precisely what nothing has established.
And nothing has: panel_types.detect_verified is false. The connector
recognition is verified (measured, and the rule was written from the
measurement), but the panel-type classifier has still never been run
against the model. So on every piece today these three rows are red with the
reason spelled out.
That is one press from green. Confirm the classifier against one known
insulated and one known solid piece, set detect_verified: true, and all
three arm at once — 5.8 immediately (recognition is verified and a zero is
a FAIL), 5.10 when a datasheet lands, 5.9 when the summary parser learns
the line.
One fact recorded and deliberately unread
The insulation type describes itself as EPS with holes for non-composite
pins. That is the plant's own words saying these panels are not designed to
act as a composite section — a fact with engineering weight that Ticket QC
does not act on. It is quoted and dated in the plant layer under
connectors.insulation_note, with an allowlist entry saying it is read by
nothing on purpose, and it must not become a check without the owner's word.