8e6ee31596
ci / test (push) Successful in 6m18s
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.
2037 lines
104 KiB
Markdown
2037 lines
104 KiB
Markdown
# 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
|
||
|
||
1. **A value that means "no fixed rail on this job."** Distinct from null.
|
||
2. **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."
|
||
3. **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:
|
||
|
||
1. 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.
|
||
2. **What a fixed-rail plant should tolerate.** With `left_rail_is_fixed: true`
|
||
the 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:
|
||
|
||
```yaml
|
||
# <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:
|
||
|
||
```yaml
|
||
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 every `N.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 us
|
||
`N.F.` is anchored to the FORM and *is* `T.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()`:
|
||
|
||
1. the per-size entry, if there is one -- `basis: 'by_size'`
|
||
2. otherwise the generic number -- `basis: 'generic'`, and the sentence says
|
||
the number is not size specific
|
||
3. otherwise **nothing** -- `basis: None`, both lengths `None`, and the check
|
||
must stand down and print `why`. 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 is `stocked: 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.
|
||
* **`stocked` is 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.
|
||
|
||
```yaml
|
||
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:
|
||
|
||
1. **`drawings.reinforcing_family_overrides`** -- exact name, case-insensitive,
|
||
mapped to `true`/`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.
|
||
2. **`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.
|
||
3. **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.
|
||
4. 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:
|
||
|
||
```yaml
|
||
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:**
|
||
|
||
* `frame` **stays**, 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's `frame` to `form` while leaving `side: near` behind produces
|
||
something that parses, resolves, and answers nonsense. `frames` now
|
||
publishes **the sides each frame can name**, and `config_problems` reports
|
||
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's `N.F.` is
|
||
`(form, top)` and the trade's `T.I.F.` is `(form, top)`, so
|
||
`knowledge.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 `astm` field 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: stated` **outranks** the catalogue's published A706 default, and
|
||
`rebar_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_size` being a **map** is what makes that
|
||
expressible: the job adds one size (or overrides one, with its own `astm`)
|
||
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_grades` table**, 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**:
|
||
|
||
```yaml
|
||
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:
|
||
|
||
```yaml
|
||
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_contains` is 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 LIFTING` and `REBAR CUSTOM ERECTION` carry 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 CUSTOM` and `TRUSS CUSTOM` are 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:
|
||
|
||
1. read the role `member.face_designation` off the **instance** (never the
|
||
type — `face_read_from()` says so as data);
|
||
2. hand the raw value to `face_from_model_token(know, value, component=...,
|
||
family=...)`, which returns the resolved face or a `why` naming exactly
|
||
which of the states it stopped in;
|
||
3. 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;
|
||
4. compare with `same_face(know, printed, model_token)` — True / False /
|
||
**None**, where None is never a disagreement;
|
||
5. 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;
|
||
6. 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.
|
||
|
||
```yaml
|
||
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
|
||
|
||
```yaml
|
||
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: true` under 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.
|
||
|
||
```yaml
|
||
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_signals` of 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 `solid` as "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_layout` because 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:
|
||
|
||
1. **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.
|
||
2. **A parser missing *vocabulary*.** Row 8.1 has no words for `TOP WYTHE` or
|
||
`HK ST100'S`.
|
||
3. **A *category confusion*.** Row 5.6 read hardware part numbers as bar
|
||
marks (fixed 2026-08-31 — see below).
|
||
4. **Landed knowledge with no reader.** Row 5.6's other half: the owner had
|
||
recorded that `SR4996` ships 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_parent` is about **drawing**: this bar is never
|
||
called out on its own, because its parent's callout covers it.
|
||
* `exempt_families` is 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:
|
||
|
||
```yaml
|
||
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 `MK12` says the grammar has a hole and names
|
||
the hole, which is why `MK` deliberately carries no `kind`.
|
||
* **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.1` today — a ruling now quietening
|
||
a **different** question. This is the ambush, and it is impossible: the
|
||
resolution is honest, so it returns whatever `2.1` genuinely 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 by **`scope.prefix_of`** — the whole leading letter
|
||
run, exact match — and that is the reconciliation: `scope.py` already 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:
|
||
|
||
1. **Three entries in the public master** (`checklist.master`): `5.8` exist,
|
||
`5.9` sufficient, `5.10` edge distance. New ids are **minted**, never
|
||
reused, and added to the frozen golden in the same commit.
|
||
2. **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.
|
||
3. **Public product knowledge** where it belongs: what a connector *is*, and
|
||
which products exist.
|
||
4. **Plant knowledge**: this plant's part numbers, the mapping onto those
|
||
products, and one `only_applies_to` entry 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:
|
||
|
||
1. **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.
|
||
2. **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.8` FAILs 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.
|