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

69 KiB
Raw Blame History

Plan — panellift as pre / engine / post, and the numbering that survives it

Status: proposed. Nothing here is built. No module was changed to write it.

The owner's shape, in his words:

"你见过科学软件吗?就是 pre-prossing, engine, post-processing 分开的那种软件。 整个这个 toolkit 最终的样子,我希望就这样。engine 全程不变,但是每个客户, 每个 job,都有自己的 pre/post processing 的程序。事实上就是每个 plant 单独写 一套,然后每个 job 用户手动进去 project config 里面东调调西调调。"

"三阶段的拆分是要的,但是类似 ticketqc 那种做法。每个 check,每个功能,每个 模块,都给编号。engine 也给编号,大不了 pre 跑完不用这部分,但是鲁棒性不能 太差,不能说我没有这个阶段整个软件都趴窝。该报错报错"

"我希望这玩意儿能跟积木一样自由拼装,并且每个阶段都有 mute(mute 是通过 job 级别的知识配置来实现的)"

"用厂和 member 来默认几种拼装形式,但是用户可以用 mute 来决定更加细节的东西。 project_config 也要做成更自由的样子,只显示加载了的模块,等等。现在基本上只有 wallpanel 和 plant_a。没有其他的。"

(One substitution in that last quote: he named the plant, and this repository carries no plant names. plant_a is the placeholder — §0.)

The model is FEA software. A preprocessor builds the input deck; a solver runs invariant physics; a postprocessor renders. The solver never knows which plant it is running for. Every decision below is measured against that one sentence.


0. Names, and the one combination that exists

No plant or client name appears in this repository. Two placeholders are used throughout:

placeholder what it means here
plant_a the producer in production use today. Wall panels.
plant_b the second producer, being onboarded. No job yet.

There is exactly ONE (plant, member kind) combination in existence today: plant_a × wall panel. Everything in this document that looks like a taxonomy is a mechanism for a taxonomy. It ships with one entry. Filling it in by guessing is this project's own error pattern #2 — a fact extended past its coverage — wearing architecture as a disguise, and the same mistake in CLAUDE.md's "WP panels" record.

So: the mechanism is designed here, one preset is named here, and how a second one would be added is shown here. No second one is written.


1. What the numbering is for, and why it is not a function name

Copied from docs/checklist-code-map.md and ticketpage.CHECKLIST_ITEMS, because that scheme has already survived a renumbering, a mute file, a vision escalation table and four documents referring to it. Its four properties, and what this scheme has to reproduce:

  1. The number is a catalogue row, not a function name. 1.4 existed as a row and read NOT RUN for weeks before check_view_name_design existed. That is the point: a unit nobody has built can still be named, listed, and reported as unbuilt. The numbering outlives the code, and code that is deleted does not take its row with it.
  2. Each number owns finding code(s) — VIEW-DSN, WGT-*.
  3. Codes come in pairs. CALLOUT-OVERLAP and CALLOUT-OVERLAP-UNREAD. "Found nothing wrong" and "could not judge" are different outputs. This is the project's cardinal rule and it is the whole reason for the numbering: a number with only one code cannot say why it is quiet.
  4. The id is frozen; the display number is not. knowledge.py's profile block already carries this and states the reason: the owner's mutes, the QNA records, qc-run.json and its consumers all refer to checks by id, and "a number that became profile-relative would either break or — far worse — keep working while pointing at a different question."

1.1 The id grammar

<tool>/<SEGMENT>-<group>.<n>   handling/PRE-2.5   handling/ENG-4.2   handling/POST-1.3   handling/INT-1.1

Four segment tags: PRE, ENG, POST, INT.

The tag is mandatory and it is not decoration. checks.muted today is a map keyed on bare numbers — '3.3', '5.4' — and those are Ticket QC master ids. If handling units were numbered bare, a mute of 3.3 would be ambiguous between two tools forever. knowledge.resolve_check_reference already parses a qualified reference (<plant>-<member kind> <number>) and returns (None, why, problem) for anything it cannot place; a segment tag extends that grammar rather than fighting it, and a bare number keeps meaning exactly what it means today.

RESOLVED, one tool earlier than this section expected — the tool prefix is part of the id from day one, not deferred. This paragraph used to defer the prefix until a third tool arrived; docs/plan-strands-auto-layout.md §5.1 hit the same ambiguity one tool sooner — a second segment-tagged tool (strands/PRE-1.1) already makes a bare PRE-2.5 ambiguous between it and handling, and checks.muted is exactly the map that ambiguity would land in silently. The coordinator ruled it there, owner informed, no objection: handling's ids carry handling/ from the start. Elsewhere in this document an id is still written bare (PRE-2.5) where only one tool is in scope, purely for brevity — read every one as carrying the handling/ prefix. Nothing about the grammar changes; one level of namespace for one tool was never actually free to defer, it just looked that way until a second numbered tool existed. See open question 10, closed.

1.2 What a number owns

Every unit, built or not, owns:

  • a title — what question it answers, in a sentence;
  • a segment — PRE / ENG / POST / INT;
  • at least two finding codes: a verdict code and a could-not-judge partner. A unit registered with only one is refused at registration. This is the pair rule turned from a convention into a gate — the convention version produced WGT-MODEL / WGT-READ / WGT-MIXED as dead code for weeks, and produced LGD-MISSING, which still reports "could not read the BOM" as a defect;
  • a skip probe, on the ticketqc contract exactly: returns the reason it was quiet, or None meaning "it ran and found nothing", and never '';
  • consumes / produces — §6;
  • settings — the config fields it exposes, §8.

2. The unit catalogue

Status vocabulary is checklist-code-map.md's, because the same words will have to be read against both documents: built / partial / not built / hole (named deliberately with nothing behind it — §9).

Finding codes below are the ones that exist today where they exist, and proposed names where they do not. Proposed codes are marked (new). Nothing in this table is a rename of a live code: renaming a code is how a mute stops mating with the thing it mutes.

PRE — the plant's preprocessor. liftexport.py, sweep_in_revit.py, the knowledge layers.

# unit codes status
PRE-1.1 Which pieces are in scope SCOPE-* (vibe.scope) built
PRE-1.2 Member kind from the mark prefix KIND / KIND-UNREAD (new) — wraps knowledge.member_kind_of, which already returns None for NOT TOLD APART partial — the resolution exists, no finding is emitted
PRE-1.3 Panel type from measured signals PTYPE / PTYPE-UNREAD (new) — classify_panel, which already carries basis and verified partial, same shape
PRE-1.4 Design intent for this piece DSN / DSN-UNREAD (new); designspec partial
PRE-2.1 Axes and bounding frame AXES / AXES-UNREAD (new); liftexport._axes built, silent
PRE-2.2 Which end is up once erected UP-END / UP-END-ASSUMED (new); erected_orientation, MIN_VERTICAL_SHARE built — and it already declares its own fallback basis on the deck. This is the model the rest of PRE should copy.
PRE-2.3 Which solid is the concrete CONCRETE / CONCRETE-UNREAD (new); choose_concrete, DEFAULT_CONCRETE_MIN_PCF built, writes a _sum_sentence
PRE-2.4 Layup — solid or sandwich LAYUP / LAYUP-UNREAD (new); layup_of, _solid_parts built
PRE-2.5 Openings: through-blockout vs blind recess OPENING / OPENING-UNREAD (new); _openings, opening_penetration, split_openings built — the 2026-09-01 recess split
PRE-2.6 Cast extras that are not the panel rectangle CAST-EXTRA (already a Ticket QC code — do not reuse; mint PRE-CAST (new)) ; extra_cast_members built
PRE-2.7 Weight gate: does the geometry weigh what the model says WGT-GATE / WGT-GATE-UNREAD (new); weight_check built, refuses closed
PRE-3.1 Which insert plays which part LIFT-GROUP / LIFT-GROUP-UNREAD (new); _group_of, handling.lifter_groups built
PRE-3.2 Where an insert acts LIFT-POS / LIFT-POS-UNREAD (new); snap_to_edge, _fast_box built
PRE-3.3 Insert ratings and their basis CAP-BASIS / CAP-UNRATED (new); knowledge.lifter_capacity, capacity_basis, capacity_why built — provenance already rides to the engine
PRE-3.4 Strand read: position, pull, area STRAND / STRAND-UNREAD (new); read_prestress built
PRE-3.5 Concrete strengths f'c / f'ci FC-IN / FC-IN-UNREAD (new) built
PRE-3.6 The stage list STAGE / STAGE-UNREAD (new); default_stages off handling.stages built
PRE-3.7 Dunnage support lines DUN-CONFIG (new) + its problems list; dunnage_supports built
PRE-3.8 Did each stage get every insert it was entitled to STAGE-AUDIT (new); stage_audit built, advisory
PRE-3.9 The engineer confirms the picks — built (button click-through)
PRE-4.1 Declare what this pre does NOT produce DECLARE / DECLARE-MISSING (new) not built — §5

ENG — the solver. Plant-blind, and stays that way.

# unit codes status
ENG-1.1 Deck schema gate DECK-SCHEMA (new); analyse_job's schema compare built
ENG-1.2 Deck field read and validate DECK-FIELD (new); read_panel / read_prestress / read_rigging / read_stage built, raises JobError
ENG-1.3 Declaration reconciliation DECK-DECLARE / DECK-DECLARE-MISSING (new) not built — §5
ENG-1.4 Section idealisation is valid for this body SANDWICH-SECTION built (refuses)
ENG-1.5 Transpose for a width-spanning stage SPAN / SPAN-UNREAD (new); transpose built
ENG-2.1 Net section at a station SEC / SEC-UNREAD (new); section.NetSection built
ENG-2.2 Edge snap — a sliver is not a fibre — (internal); EDGE_SNAP_FT built
ENG-2.3 Which strands a blockout cuts CUT / CUT-UNREAD (new); prestress.cuts_for, bonded_run built
ENG-2.4 Transfer length LT / LT-UNREAD (new); transfer_length_in built
ENG-2.5 Prestress resultant at a station PRE-STATE / PRE-STATE-UNREAD (new); state_at built
ENG-3.1 Group inserts into support lines LINE / LINE-UNREAD (new); group_lifters built
ENG-3.2 Load profile, scaled to the stated weight LOAD / LOAD-UNREAD (new) built
ENG-3.3 Rolling solve SOLVE-ROLL / SOLVE-ROLL-FAILED (new) built
ENG-3.4 Static (stiffness) solve SOLVE-STATIC / SOLVE-SINGULAR (new); linalg.solve already raises on a singular system partial — the raise exists, nothing catches it per-unit. §4 row 3.
ENG-3.5 Moment and shear diagram DIAG / DIAG-UNREAD (new) built
ENG-3.6 Sling force from the reaction SLING / SLING-UNREAD (new); sling_forces built
ENG-3.7 Reactions on support lines (dunnage) SUPPORT / SUPPORT-UNREAD (new) built
ENG-3.8 A stage solved at more than one attitude — hole — §9
ENG-3.9 A carrying-set handover inside a stage — hole — §9
ENG-4.1 M_cr MCR / MCR-UNJUDGED (new); already has a per-column why dict built
ENG-4.2 φM_n, strand only PHIMN / PHIMN-UNJUDGED (new) built
ENG-4.3 φV_n, concrete alone PHIVN / PHIVN-UNJUDGED (new) built
ENG-4.4 Insert derate at the concrete you have DERATE / DERATE-UNKNOWN (new); pci.insert_derate built
ENG-4.5 Ratio formation, and the 0/0 stand-down RATIO / RATIO-UNJUDGED (new); _negligible_ratio_reason built
ENG-5.1 Is a station installable at all — (internal); station_is_placeable built
ENG-5.2 Search along the piece REC-X / REC-NOT-SEARCHED (new); NOT_SEARCHED_REASON already exists built
ENG-5.3 Level hang across the piece REC-Y / REC-Y-ECCENTRIC (new) built
ENG-6.1 Sandwich section bounds LAYUP-BOUNDS (new); section_bounds built
ENG-6.2 Composite ratio COMPOSITE / COMPOSITE-UNJUDGED (new) built
ENG-6.3 Connector demand CONNECTOR / CONNECTOR-UNJUDGED (new) built

POST — the plant's postprocessor. Verdicts and rendering.

# unit codes status
POST-1.1 Cracking verdict CRACK, CRACK-NOFC, CRACK-NOSECTION built
POST-1.2 Compression verdict CRUSH (+ CRUSH-UNJUDGED (new)) built, no partner
POST-1.3 Flexural demand/capacity verdict FLEX-CAP (+ FLEX-CAP-UNJUDGED (new)) built
POST-1.4 Shear demand/capacity verdict SHEAR-CAP (+ partner (new)) built
POST-1.5 Insert capacity verdict INSERT, INSERT-UNRATED, INSERT-NO-FOS built — the pair rule already honoured, twice
POST-1.6 Standing plumb on the anchors PLUMB, PLUMB-UNRATED built
POST-1.7 Pick layout / sling angle LAYOUT, SLING-ANGLE built
POST-1.8 A dunnage line under an opening DUNNAGE-OPENING (+ partner (new)) built
POST-1.9 Aspect ratio ASPECT (+ partner (new)) built
POST-1.10 Weight reconciliation WEIGHT (+ partner (new)) built
POST-1.11 Edge anchor load path EDGE-PATH, EDGE-PATH-UNRATED built
POST-1.12 Row transverse flexure ROW-FLEX, ROW-FLEX-UNRATED, ROW-FLEX-INTERRUPTED, ROW-FLEX-NOT-A-ROW built
POST-2.1 Render the report — ; report.py built
POST-2.2 The basis block — every number's provenance — built
POST-2.3 Machine summary — ; summarise built
POST-2.4 Where the report goes, whether it opens — built
POST-2.5 The unit table: every unit, its state, its reason UNIT-TABLE (new) not built — the deliverable of migration phase 1
POST-2.6 The mute roll-up: how many, whose, why, and which are stale MUTE-COUNT, MUTE-STALE (new) not built — §7
POST-3.1 The handling result read back by Ticket QC HANDLE-* (a large existing family) built

INT — the boundary. Nobody's judgement lives here.

# unit codes status
INT-1.1 The deck file: schema, provenance, versioning DECK-STALE (new) partial — §5
INT-1.2 Finding the CPython that runs the engine NO-PYTHON (exists as HANDLE-NO-PYTHON) built
INT-1.3 The runner and its timeout HANDLE-CALC-ERROR, HANDLE-UNAVAILABLE built
INT-1.4 The sweep's per-piece meta file — built

3. Segment assignment, module by module

module segment reasoning, and where the seam runs
liftexport.py PRE, with an INT tail Everything up to writing the deck is measurement of one model at one plant. The seam is at strip_private(job) / the write: below it, find_python, runner_script, run, open_file are INT, and weight_check's refusal is POST policy sitting in the PRE file (see §4).
sweep_in_revit.py PRE A batch driver over PRE. Its scope declaration (scope.json) is already the right shape — out of scope is a third state, never an absence.
model.py INT It is the deck's in-memory shape. Every default value in it is policy hiding in the boundary, and every one of them is in §4's list.
cli.py INT + ENG driver read_* are the deck reader (INT). analyse_job is the engine's driver (ENG). main and summarise are POST. Three segments in one 1,020-line file; this is the file the split actually costs something to make.
linalg.py ENG Pure. No seam.
section.py ENG Section properties are mechanics. EDGE_SNAP_FT is arguably a statement about the fidelity of the model that produced the deck, i.e. a PRE fact — see §4 and §12.2.
prestress.py ENG Bond and development are mechanics. The ×1.3 multiplier that a plant may apply to them is not — §4.
statics.py ENG Reactions and diagrams. Its discretisation settings must be deck-recorded for reproducibility (§5), not because anybody edits them.
pci.py ENG, fed from the deck The closed forms stay. The published constants (φ, MODULUS_OF_RUPTURE_SQRT_FC, CONCRETE_SHEAR_SQRT_FC, γ_p, the 0.5 f_pu gate, 0.80h) become deck fields under a declared code edition. pci.py's own comment already proposes exactly this and names the catalogue keys.
capacity.py ENG, with a POST seam M_cr, φM_n, φV_n and the ratios are mechanics. RATIO_FAIL_ABOVE and RULING are a verdict and its ruling text: POST. The seam runs at the ratio. The engine produces a ratio or a documented could-not-judge; the word FAIL is post's.
checks.py SPLIT — mostly POST stress_profile and _corner_stresses compute stress: ENG. Every check_* turns a number into OK/WARN/FAIL: POST. The seam is the Finding constructor — nothing above it names a severity, nothing below it computes a stress. Two functions do not obey that seam today (check_edge_anchor_path, check_row_transverse_flexure, ~250 lines between them, geometry and verdict interleaved); for those the seam is a rewrite, not a line. §12.1.
layout.py ENG It is a search over the same mechanics, and its answer is a position, not a verdict. Its "installability" test consumes min_edge_distance_in, which arrives per insert on the deck — so it is already plant-blind.
sandwich.py ENG for the bounds, POST for the refusal The bounds, the composite ratio and the connector demand are mechanics. REFUSAL_CODE / refusal_text — refuse rather than report bounds — is a policy decision. Moving it must not weaken it: §11 sequences the move so the refusal survives the whole migration unchanged.
report.py POST Whole.

One thing the split does not change. PRE runs in Revit's IronPython 2.7; ENG runs on host CPython. That boundary already exists and is docs/architecture-migration.md's subject. The three-stage split is a different axis from the in-process/host split and the two must not be conflated: POST is on the host today (it is report.py), and parts of PRE will stay in-process forever because they touch the API. Where they agree is that both are crossed by a versioned file, and neither invents a second style of file.


4. The plant knowledge and the policy sitting inside the engine

The highest-value section, and the one to argue with first. The test applied to each: is it mechanics, or is it judgement? A verdict threshold is not physics.

4.1 Judgement — moves to POST

where it lives now value verdict
capacity.RATIO_FAIL_ABOVE 1.0 POST. The owner's ruling, verbatim in the module: "M/pMn 对我而言>1.0是可以的,我能盖章。你不行,大于1.0你就报fail,我做最后决策。" His stamp lets him accept over 1.0; the tool may not. That is a statement about who decides, not about concrete. It moves to post policy with a floor: a plant may make it stricter, never looser, and the floor is not editable from any knowledge layer. RULED, not presumed — owner, 2026-09-01: "对的" ("correct"), against the question put to him verbatim. See open question 1, answered.
checks.DUNNAGE_VOID_FRACTION 0.5 POST. "How much of the width has to be missing before a support is bearing on a hole" is judgement, derived from one measured piece (464 in³ against ~1900). Plant-tunable, reported on the page.
checks._EDGE_REACH_TOL_FT, _ROW_REACH_TOL_FT 6 in POST. "Near enough to count" is judgement.
check_plumb_hang's ratio > 0.9 → WARN; check_cracking's "near" band inline literals POST, and they must be named constants before they move. A bare 0.9 deciding a severity is indistinguishable from a fudge factor six months later — pci.py makes exactly this argument about a bare 0.9 and then checks.py writes one.
sandwich.REFUSAL_CODE / the refuse-vs-bound decision — POST. The engine can compute bounds and say the section is not one prism; whether that is a refusal or a reported range is policy.
liftexport.weight_check's gate, handling.weight_tolerance 0.02 PRE policy (it decides whether a deck is written at all), already config-surfaced, already fails closed. Correct where it is; named here so nobody moves it into the engine.

4.2 Mechanics — stays in ENG

where value why it stays
capacity.NEGLIGIBLE_MOMENT_LBFT / NEGLIGIBLE_SHEAR_LB 1.0 each Not a verdict threshold — a numerical-validity floor. It says 0/0 is not a number, and it fires only where the demand and the capacity have both vanished. The engine is entitled — required — to say "I produced no number here", and that is precisely the could-not-judge the whole design is built on. It is measured (58 stations of 1,017,806 on the reference sweep, the next smallest capacity eleven orders of magnitude away) and it must never grow into a development-length exclusion. Stays, with its measurement, in the engine.
pci.OPTIMAL_TWO_LINE_FRACTION, beta1, fps_approximate_ksi, insert_derate, concrete_shear_capacity_lb, elastic_modulus_psi, the Fig. 8.3.2 forms — Closed forms. Mechanics.
section.EDGE_SNAP_FT, checks._ROW_SNAP_FT 1/16 in Noise floors, not judgements — but see §12.2: I am not certain these are engine-side rather than a statement about the model's fidelity, and I would deck-record them either way.
statics.MIN_ELEMENT_FRACTION, _nodes(target=120); layout._COARSE_FT, _SEARCH_STATIONS, _SWEEPS, _MAX_COMBOS, _FALLBACK_STEPS — Discretisation and search settings. Engine-owned, not user-editable, and recorded on the deck anyway — a recommendation that moved because the grid changed is not reproducible, and reproducibility is the deck's whole job (§5).

4.3 Published data — moves ONTO THE DECK, under a declared code edition

pci.py already proposes this and names the keys (codes.aci318_19.phi.flexure_tension_controlled, .phi.shear, .modulus_of_rupture_sqrt_fc, .concrete_shear_sqrt_fc), stating the reason it has not happened: "panellift has no YAML reader and is fed only by the job file". Under this architecture that is not an obstacle, it is the design.

PHI_FLEXURE_TENSION_CONTROLLED 0.90 · PHI_SHEAR 0.75 · MODULUS_OF_RUPTURE_SQRT_FC 7.5 · CONCRETE_SHEAR_SQRT_FC 2.0 · GAMMA_P_LOW_RELAXATION 0.28 · MIN_FSE_OVER_FPU_FOR_APPROXIMATE_FPS 0.5 · MIN_D_OVER_H_PRESTRESSED 0.80 · TENSION_CONTROLLED_STRAIN 0.005 · CONCRETE_CRUSHING_STRAIN 0.003 · WHITNEY_STRESS_INTENSITY 0.85 · LAMBDA_* · TABLE_8_3_1

All of them are published, industry-wide, and identify nobody. They belong in knowledge/catalogue.yaml as their source, and on the deck as the record — the deck states the code edition it was run under and the values it used. A deck re-run in five years under a later code must reproduce its own answer; that is only possible if the numbers are in it.

Two homes for one number is how they drift. Say it here plainly, because the lifters.capacity_lb incident of 2026-08-31 is exactly this failure: catalogue.yaml is the source, the deck is the frozen record, and the engine reads only the deck. The engine must never reach for a catalogue.

The two sevens must survive the move. MODULUS_OF_RUPTURE_SQRT_FC = 7.5 and Stage.tension_limit_sqrt_fc = 7.5 are different quantities that coincide numerically at plant_a only because this owner picked the no-margin school. They land under different deck keys, with the warning attached, and any tool that "unifies" them changes M_cr on every panel.

4.4 A plant method that is not code, and is not in the engine at all

The Eriksson-derived development multiplier, ×1.3. Strand normal 1.3, debonded 2, cut 1.3, mild 1.3 — recorded in the library notes with a page cite and one internal disagreement already flagged (mild reads 1.3 in one source and 1 in another). It scales both l_t and l_d, so it moves φM_n at every station within a development length, which is where every measured capacity exceedance on the reference sweep sits.

Verdict: not mechanics. ACI gives l_t and l_d; multiplying them by 1.3 is a plant's method for how much bond it will credit. It is a deck field (prestress.development_multiplier, with source and the plant that declared it), the engine honours whatever the deck states, and the default is 1.0 with source: 'default' — never a silent 1.3.

RULED on the general question, 2026-09-01: it is plant knowledge. His words: "长度乘子是厂知识。我比较明确的知道的是plant_b是1.3" ("the length multiplier is plant knowledge; the one I know with confidence is plant_b at 1.3"). The design above is confirmed rather than merely proposed — prestress.development_multiplier is a deck field, a plant turns it on for its own decks by declaring the key, and the default stays 1.0 with source: 'default' for every plant that has not declared it. plant_b's figure lands in plant_b's own knowledge layer, stated_by/since recorded there — this document names no plant's numbers, only the mechanism.

Still open, narrowed: the per-condition figures from the study (debonded 2, cut 1.3, mild 1.3, with the one source disagreement on mild already flagged) are the study's, not an owner ruling, and remain unruled. So does whether plant_a — whose reference sweep is where adopting a multiplier would move φM_n on every piece and look, on the page, exactly like a change in the panels — declares any of this at all. It must not be wired for plant_a before he rules on that separately. Open question 2, partially answered.

4.5 Defaults in model.py that are policy in the boundary

These are the ones that will bite, because a default in the deck reader is a value that appears when nobody said anything.

field default problem
Stage.tension_limit_sqrt_fc 5.0 Correct today, and correctly provenance-carrying (tension_limit_source = default/job/stage, printed beside every allowable). This is the model every other policy field should copy. Under the deck rule it should become required, because absence currently reads as a value — a schema-bump-worthy change, sequenced in §11.
Stage.compression_limit_ratio 0.6 Same shape as the tension limit — a handling allowable, a real choice — with no source field and no config surface at all. Inconsistent. Cited, 2026-09-01: ACI 318-19 Table 24.5.3.1 (p.465), "all other locations", 0.60·f'ci at transfer, and §24.5.4.1 (p.466), 0.60·f'c at service under prestress + total load — knowledge/library/notes/standards/ACI318-19-ch24.md. ACI's other row (0.70·f'ci at the ends of a simply-supported member) is ruled out, deliberately — his words, "先不用实现0.7. 跑个lifting别搞这么复杂" — so the flat 0.6 stays, everywhere, as a decision with an owner and a date rather than an unexamined constant. Configurable, 2026-09-01: "0.6保持可调" — it gets the tension limit's full treatment: source field and a Project Config surface. Open question 4, closed.
Rigging.group_tolerance_ft 0.25 in model.py / cli.read_rigging; 1.0 in knowledge.DEFAULTS and liftexport Two different defaults for one number, on two sides of the boundary. No real deck hits it (the exporter always writes the key) — which is exactly what makes it dangerous: a hand-written deck, or a plant layer that omits it, is analysed on 0.25 while every upstream audit used 1.0, and the two lines it merges or splits are a whole support line. Under §5 the deck states it or the engine refuses.
Material.* — phi_flexure 0.9, fy_ksi, fyt_ksi, prestress_loss_at_strip/yard 0.10/0.15, handling_load_factor 1.4 — Material is never constructed from a deck. cli has no field that fills it; checks.run_all takes material=None and its body never reads it. It is a second, dead copy of numbers that live elsewhere (pci.PHI_*, Prestress.loss_pct). Two homes, one dead — delete or wire it, but do not leave it. handling_load_factor is the one live question inside it and it is already open in docs/lift-check.md (does 1.4 stack on the PCI multiplier?). Open question 3.
Rigging.tension_fos / shear_fos None Correct, and correct for the right reason — the comment records that a global 2.67 with no published source reached every insert in the catalogue for twelve days. None means the insert is not judged, and INSERT-NO-FOS says so by name. The model for every other absent value.
Prestress.fpu_ksi 270.0, fpu_source: 'default' Defensible because it is self-checking: f_pi/f_pu is computed from the measured jacking force and printed, so a Grade 250 strand announces itself. Keep, and note the pattern — a default is acceptable where the deck can detect it is wrong.
liftexport.MIN_VERTICAL_SHARE 0.7, DEFAULT_CONCRETE_MIN_PCF 50.0, rows_of(tolerance=0.4) — PRE policy. MIN_VERTICAL_SHARE already declares its own basis on the deck; the other two do not.

5. The deck

The job JSON is already a proto-deck. It becomes the whole contract.

5.1 The rule this reverses, and why

A survey on 2026-09-01 recommended that panellift resolve insert capacities live from the catalogue instead of consuming frozen ones. Under this architecture that is wrong and it is rejected here. Material properties belong in the deck, exactly as they do in an .inp. A deck must reproduce its own result forever; a deck that reaches out to a file that has changed underneath it reproduces nothing.

The real defect the survey was looking at was never that the values were frozen. It was that nothing recorded which knowledge version froze them, and nothing could detect that the freeze had gone stale. That is what gets designed, not live lookup.

(The same reasoning already runs the other way and confirms it: the reason Lifter.capacity_why exists on the deck at all is that "panellift never re-reads the catalogue", so the sentence explaining a with-bar/without-bar rating has to travel with the number or it does not exist anywhere.)

5.2 Shape

Additive to today's file. Three new top-level blocks; nothing existing moves.

{
  "schema": "panellift/2",

  "deck": {
    "written_at": "2026-09-01T14:02:11",
    "written_by": "PanelLiftCheck 0.x / liftexport <sha>",
    "doc_key": "<stable model key>",
    "assembly_id": 123456,
    "preset": {"id": "wall_panel@plant_a", "basis": "member_kind",
               "member_kind": "wall_panel", "panel_type": null,
               "why": "<one sentence>"},
    "knowledge": {
      "layers": [
        {"layer": "defaults",  "version": "<vibe.knowledge build>"},
        {"layer": "catalogue", "sha256": "...", "path_kind": "repo"},
        {"layer": "plant",     "id": "plant_a", "sha256": "...", "mtime": "..."},
        {"layer": "job",       "sha256": "..."},
        {"layer": "config",    "sha256": "...", "path_kind": "external"}
      ],
      "merged_sha256": "..."
    },
    "codes": {
      "concrete": "ACI 318-19",
      "handling": "PCI MNL-120, 8.3",
      "values": {"phi.flexure_tension_controlled": 0.90, "phi.shear": 0.75,
                 "modulus_of_rupture_sqrt_fc": 7.5,
                 "concrete_shear_sqrt_fc": 2.0, "...": "..."}
    },
    "engine": {"version": "<panellift build>",
               "solver": {"nodes_target": 120, "min_element_fraction": 0.1},
               "search": {"coarse_ft": 1.0, "stations": 400, "sweeps": 3}}
  },

  "declares": { "PRE-2.5": {"state": "produced", "payload": "panel.blockouts", "count": 3},
                "PRE-3.7": {"state": "none_by_design",
                            "why": "this plant runs no transportation stage",
                            "declared_by": "plant:plant_a"},
                "...": "..." },

  "panel":   { "..." : "as today, plus recesses, solid_parts, layup" },
  "rigging": { "..." : "as today" },
  "stages":  [ { "..." : "as today; §9 adds phases" } ]
}

liftexport.strip_private keeps doing its job: nothing in deck may carry a model path, a client name or a user name. path_kind rather than path for the external config, and the sha is the identity.

5.3 The versioning rule

Three rules, and the first two are already written down in cli.py's SCHEMA comment — they are restated here so this document is arguable on its own.

  1. A schema mismatch discards the deck whole. No partial read, ever.
  2. A key may be added without a bump only when its absence has an explicit stand-down — that is, when absence reads as "nobody said", not as a value. "enabled": false and solid_parts both passed that test. declares passes it in migration phase 3 (advisory) and fails it in phase 4 (absence becomes NOT RUN), which is where the bump to panellift/2 happens and why it happens there.
  3. NEW: the report records the engine version and the deck's merged_sha256, and a re-run compares. A deck re-run against knowledge that has since changed produces DECK-STALE — named, with which layer's sha moved — not a silently different answer and not a silent re-freeze. This is the whole of what the survey was actually asking for.

What the bump costs, honestly. Every archived deck (184 on the reference sweep alone) is panellift/1. A panellift/2 engine reading one has no declarations, so under phase 4 every unit reads NOT RUN. That is correct — an old deck genuinely cannot say what its pre did not produce — but it means docs/lift-check.md's promise that any number in a report can be re-run from the deck beside it stops holding for those files under the new engine, unless "re-run" is read the way the owner has now ruled it should be.

RULED, 2026-09-01 — option A. His words: "原来的其实也不算错。不是同一种 算法罢了。" ("The old one isn't really wrong either. It's just not the same algorithm.") That is the versioning philosophy stated plainly, and it is better than reaching for an outside analogy to explain it: a v1 deck is re-run with the v1 engine that wrote it, forever — the version recorded on the deck (rule 3, above) is exactly what makes that possible. No byte-parity requirement across a schema bump, and no expectation that an old result should agree with a new engine's answer — they are two algorithms, and the old one standing is not the old one going unchecked. Open question 9, closed.


6. Composability — what "积木" requires, concretely

Free assembly needs a declared interface. Each unit registers as data:

UNIT = {
    'id': 'ENG-4.2',
    'segment': 'engine',
    'title': 'phi*M_n, strand only',
    'consumes': ('deck.panel.prestress',
                 'deck.codes.values.phi.flexure_tension_controlled',
                 'ENG-2.4:transfer_length_in',
                 'ENG-2.5:prestress_state'),
    'produces': ('phi_mn_lbft', 'development_note'),
    'codes':    ('PHIMN', 'PHIMN-UNJUDGED'),
    'settings': (),                     # what it exposes to Project Config
    'probe':    _probe_phi_mn,          # reason, or None. Never ''.
}

Seven rules, each of which is a test, not a convention:

  1. A unit consumes only deck paths and other units' produces. Never a module global, never an import for data. This is what lets a plant swap its PRE while the engine stands.
  2. produces names are namespaced by unit id and are unique. Two units producing one name fails registration.
  3. An unsatisfied consumes is NOT RUN with the missing name printed — never a silent skip, never a default substituted.
  4. Every unit has a skip probe, on ticketqc's exact contract. The test that enforces it already exists there (test_every_registered_check_has_a_skip_probe) and is copied verbatim.
  5. Every unit owns at least one verdict code and one could-not-judge partner. Registration refuses a unit that can only say OK and FAIL.
  6. Cycles are refused at registration.
  7. The engine registry may not import from the pre or post registries. One-directional, test-enforced. This is the sentence "the solver never knows which plant it is running for" written as code.

6.1 Composition is two-level, and mute is the only per-job lever

Per the owner: (plant, member_kind) selects a named default assembly; the job then refines with mute. This is a Lego box, not loose bricks.

The mechanism already exists for Ticket QC and is reused rather than reinvented — knowledge.checklist_profiles / profile_for, selected on the pair (member kind from the mark prefix via member_kind_of; panel type from measured signals via classify_panel; most specific wins; every step may answer "I could not tell" and each time that resolves towards asking more).

pipelines:
  profiles:
    wall_panel_plant_a:
      member_kind: wall_panel        # from nomenclature.member_kinds
      panel_type:                    # absent = any
      title: 'Wall panel handling'
      because: '<why this plant asks this of this member kind>'
      since: '2026-XX-XX'
      declared_by: '<who>'
      unlisted: ask                  # ask | not_applicable
      units:                         # ORDERED list; lists REPLACE across layers
        - {id: 'PRE-2.5'}
        - {id: 'ENG-4.2'}
        - {id: 'POST-1.3'}

The plant axis is implicit, exactly as it is for checklist profiles: the profile is declared in that plant's layer. resolve_check_reference's <plant>-<member kind> <number> grammar already refuses a reference qualified for a plant this run is not using.

The one preset that exists is wall_panel_plant_a, and it contains exactly the units that run today. A second plant adds a second profile in its own layer. No member type is invented here and plant_b's preset is not written here.

RULED, 2026-09-01 — this two-level split is exactly right, and it is the whole answer to open question 8. A plant does not get a mute lever of its own; its composition rights are the preset (this section) plus the display mapping it may declare over that preset (§6.3). See §8.1.4.

6.2 Is an enable needed? RULED — no, never.

Mute is a volume control: the unit runs and reports quietly. It cannot turn a unit on. So the original worry here was that if a preset could omit a unit, a job wanting it back would have no lever and we would be forced to add one.

Owner, 2026-09-01: "预设不能关单元,单元是由厂知识决定的" ("a preset cannot turn a unit off — which units run is decided by plant knowledge"). Read against §6.3's renumbering ruling, this settles the question by removing its premise: the preset is not a second authority that could need an enable to override it — it IS the plant layer's declaration, exactly as checklists. profiles already works for Ticket QC (a profile lives in that plant's own layer, §6.1). There is no separate switch anywhere else in the system that could turn a unit off against the plant's own knowledge, so there is nothing for an enable to re-open. unlisted: ask stays the default (a unit the profile does not name still runs, under its own id, so new code is never silently unasked); unlisted: not_applicable (a closed board) is still available per profile — that, too, is just the plant declaring its own knowledge, not a second lever. If a plant later wants a unit it once excluded, that is an edit to the plant's own preset, the same way naming a preset at all is an edit to the plant's own knowledge — not a runtime toggle exposed anywhere in Project Config or a job layer. Mute remains the only per-job lever, full stop. Open question 5, closed.

6.3 Display numbering — a plant renumbers by mapping, never by moving the id

Open question 8 asked "is a plant-level mute legal", and the owner's answer reshaped the question rather than answering it as asked: a plant does not compose by muting at all. It renumbers.

His example: the master board checks 1.1–1.6. A plant with a special lifting method does not run 1.4. That plant's own numbering shows 1.1– 1.3 unchanged, 1.5 displayed as 1.4, 1.6 displayed as 1.5. Asked whether that means a per-plant display mapping over immutable internal ids, he confirmed, verbatim: "是的。编号永不变是最底层的编号不变,但是显示出 来的不一定是最底层的编号。就像我说的那个不变的是engine,但是pre/post是灵活可 变的。怎么做到?就是映射。内部ID永远不变。内部ID永远不变。" ("Right. The number that never changes is the bottom-level id — what's shown is not necessarily that id. Like I said, what's invariant is the engine; pre/post are free to vary. How? Mapping. The internal id never changes. The internal id never changes.")

Nothing new needs inventing here — the second user of a mechanism the first one already tests. knowledge.checklists.profiles already carries exactly this split for Ticket QC, in its own words: "A CHECK'S IDENTITY IS ITS STABLE ID AND THE PROFILE ONLY SUPPLIES A DISPLAY NUMBER" — a profile's items list is [(display number, stable check id), ...], authored in full by whoever writes the plant's board (compaction is not computed, it is written — the same as ticking 1.5 down to 1.4 is a line someone typed, not an algorithm run). Handling's preset gains the identical field:

units:                              # ORDERED list; lists REPLACE across layers
  - {id: 'handling/PRE-2.5'}
  - {id: 'handling/ENG-4.2'}
  - {id: 'handling/POST-1.3', display: '1.4'}   # this plant's board calls it 1.4

display is optional; an id absent from the map (or a unit with no display key) shows under its own id's number, unchanged.

What is immutable, what is not, and who owns each:

  • Internal ids never move. Already argued in §1.2.4 and restated here because it now carries the owner's own words, twice, for exactly this reason: the mutes, the finding codes, cross-plant comparisons, logs, and every deck field refer to a unit by its internal id and only its internal id. Nothing machine-facing ever reads a display number.
  • The display mapping is plant knowledge. It lives in the same layer as the preset (§6.1) — a plant that renumbers is doing the same kind of thing as a plant that omits a unit, and both are authored where the preset is authored.
  • Recoverability on rendered surfaces reuses Ticket QC's own idiom, exactly. ticketpage.row_display already does this: the display number is the # cell; the internal id prints as a small sub-line beneath it (wt-ck-master, title="master checklist id") only when the two differ, and not at all when they agree — in that module's own words, "the master id is recoverable on the page, printed under a profile number it differs from, and not at all when the two are the same word." Handling's report adopts the same rule rather than inventing a legend table: a legend is a second place for the same fact to drift out of, and the per-row sub-line has already shipped and been tested.

Two hazards, named because the mechanism makes both possible:

  1. A number quoted outside the report is ambiguous without the plant. "Check 1.4 failed" means a different internal unit depending which plant's board printed it. The per-row sub-line resolves this on the page itself; a conversation that strips the row out of its report — a text message, a phone call — has to name the plant too, the same discipline resolve_check_reference's <plant>-<member kind> <number> grammar already assumes (§1.1).
  2. A wrong mapping is a knowledge defect, not a code defect, and is knowledgelint-able the way a stale mute is (§8.1.3). The map must be injective — knowledge.py's own profile validation already refuses two checks sharing one display number, the identical rule this mapping needs. Every mapped id must actually exist as a registered unit; an id that no longer does is a stale mapping and should be flagged by name, on the same principle as MUTE-STALE.

7. The four states, and the two that look identical

This is the sharpest problem in the design, so it is stated flatly.

situation state word colour judged?
the plant's pre by design produces no input for this unit NOT APPLICABLE blue no
the pre should have produced input and did not NOT RUN red no
the engine ran and could not solve (singular, non-convergent) FAIL, that unit only red yes
a job muted it MUTED grey yes — it ran
the whole program stops never acceptable

None of these words is new. ticketpage.STATE_VOCABULARY already carries all of them, each with a stable machine key and a judged flag, and state_color's red fallthrough — an unrecognised word renders red, never grey — is what makes it safe to have a grey state at all. The handling report adopts that vocabulary as it stands. Building a second one would be the MODULUS_OF_RUPTURE mistake in a new place.

7.1 Rows 1 and 2 present identically, and the declaration is the only thing that tells them apart

At the input, "the plant has no rotation stage" and "the rotation measurement failed" are the same thing: there is nothing here. No amount of looking at the deck's payload distinguishes them, because both produce an absence.

They can only be told apart if the pre DECLARES what it does not produce.

That is the same distinction as capacity_lb: {} (looked, none declared) versus a bare capacity_lb: (a value went missing) — the one that cost five real panels a false FAIL/PLUMB on 2026-08-31 and took a git dig across two repositories to explain, because the two shapes merge to the identical value and nothing said the value had gone.

7.2 The declaration mechanism

Every unit in the active preset gets exactly one row in declares, and the row carries a state word, not a shape:

word means required with it engine's reading
produced the pre made this input payload (a deck path), count where countable run the unit
none_found the pre looked; the piece has none payload, count: 0 run the unit — an empty input is a fact about the piece
none_by_design this plant's pre does not produce this at all why, declared_by (layer + id) NOT APPLICABLE, blue, never OK
not_measured the pre tried and could not why NOT RUN, red
row absent nobody said anything — NOT RUN, red: "the pre declared nothing about this unit"
unrecognised word — — NOT RUN, red, by the state_color fallthrough rule

Why a word and not a shape. The capacity_lb incident happened because shape carried the meaning: {} and null merge to the same value and a restructure emptied one without anyone being able to see it. A word cannot be emptied by accident without becoming an unrecognised word — and an unrecognised word already goes red in this codebase, by a rule that is documented as load-bearing and that nobody is allowed to "tidy". The declaration inherits that protection for free.

Note the deliberate inversion from the knowledge layer: there, {} is the legitimate "nobody declared" and null is the suspicious event. Here neither shape means anything and only the word does. Say so at the top of the schema, or somebody will apply one convention to the other file.

7.3 Robustness — "不能说我没有这个阶段整个软件都趴窝"

Three rules, and the third is the one that is not built today.

  1. A unit's failure is that unit's. Every unit runs inside its own try/except; an exception becomes that unit's FAIL with a *-ERROR code naming the unit, exactly as run_all → CHECK-ERROR does in Ticket QC. The run continues.
  2. A stage's failure is that stage's. ENG-3.4 on a singular system (linalg.solve already raises ValueError with a real sentence — "the supports do not restrain it") becomes SOLVE-SINGULAR FAIL on that stage, and the other stages still report. Today that exception escapes analyse_job and takes the whole piece down; that is the concrete robustness defect this section exists to fix.
  3. A refusal is not a fault. SANDWICH-SECTION and the weight gate stay hard refusals — the piece is not analysed, loudly. Those are the two places where stopping is the right answer, and this design does not soften either.

8. Mute, and the bar it has to clear

Mute is the single most dangerous feature here, because a muted unit and a passing unit look the same. The project has already solved this twice, and the design is to reuse both solutions rather than write a third.

From tools/knowledge_drift_allow.txt, whose header argues its own design:

  • a mute without a stated reason is refused, and the run exits 2 — "an unexplained suppression is how a checker quietly stops checking";
  • every run prints how many findings it suppressed, so a shrinking report is never mistaken for a shrinking problem;
  • every run lists entries that no longer match anything, so a key that was fixed does not leave a permanent blindfold behind.

From ticketpage.apply_muting / configform.add_mute:

  • the key is a map, so the layers deep-merge and a job mutes one more unit without restating the others;
  • the reason is mandatory, refused by configmodel.validate, and is typed by a person — no default text, no placeholder;
  • a muted row stays on the table, repainted MUTED, carrying what it really reported ("ran and reported OK, muted for this job because …");
  • muting never softens a FAIL — a failed unit keeps its word and its red, and the muted section says the mute was declined and why;
  • a mute naming something that is not a unit is reported as muting nothing;
  • a mute on a unit already declared none_by_design is reported as redundant — two mechanisms doing one job, one of which should go.

8.1 What is new for handling units

  1. Namespaced keys. checks.muted: {'ENG-3.4': '<reason>'}. A bare number keeps meaning a Ticket QC master id, so nothing existing moves.
  2. POST-2.6, the roll-up, which does not exist today. Every handling report prints, in one line that is never folded: how many units were muted, by which layer, and how many mutes matched nothing. The Ticket QC page lists mutes; it does not print the count as a headline. The drift file's rule is the stricter one and it is the one to adopt.
  3. Stale mute detection. A mute whose unit no longer exists, or which matched nothing on this run, is reported by name. MUTE-STALE.
  4. declared_by on the mute — RULED, 2026-09-01: job-level, full stop, and a plant-level entry is refused rather than merely discouraged. Open question 8 asked whether a plant-level mute (legal today, because the map deep-merges across five layers) was intended. The owner's answer reshapes the question rather than answering it as asked: a plant does not compose by muting at all. Its lever is the preset — which units it runs (§6.1) — plus the display mapping it may declare over them (§6.3), never a checks.muted entry in its own layer. His original words stand exactly as written: "mute 是通过 job 级别的知识配置来实现的" — job-level is not merely the intended home, it is the only legal one. A checks.muted entry declared above the job layer is a knowledge defect, refused the way any other layer-scope violation in this design is refused, not a merge quirk to be tolerated.

9. Room for the second plant, without designing the second plant

What is known, and it is all that is known: the second producer rotates the panel on face lifters alone to some angle, then attaches edge lifters, then cuts the lower lines.

So the load path changes WITHIN a stage. The current model cannot express that: one sling_angle_deg for the whole job (configmodel's own note: "One angle for the whole job — the solver reads it once"), one lifter set per stage, solved once. check_plumb_hang is the only non-flat condition in the package and it is hard-coded to plumb and to on_edge inserts.

The vocabulary must be able to say it. This document does not say what it says. Guessing a lifting sequence builds a wrong load path, and a wrong load path that runs is worse than one that refuses.

9.1 The hole, shaped

A stage becomes a sequence of phases. Today's Stage is a stage with exactly one phase, and that reading must be exact so every existing deck remains a valid deck with no rewriting.

A phase carries, and nothing here is defaulted from another phase:

field what it is today
lifters the carrying set at this instant per stage
attitude_deg the piece's angle from horizontal implicit: 0 (flat) or 90 (check_plumb_hang)
rig_mode rolling / static per stage (Stage.rig_mode)
sling_angle_deg line geometry job-wide — must become per phase
fc_psi strength at this instant per stage
span which way gravity runs per stage

Two phase kinds beyond today's:

  • a sweep — attitude: {from_deg: a, to_deg: b, steps: n}. The engine solves at each step; the unit's verdict is the worst step, named by angle. ENG-3.8.
  • a handover — two consecutive phases whose carrying sets differ. The deck states both sets and the attitude at which the handover happens. The engine never infers it. ENG-3.9.

And one reporting rule: a report may not print one number for a stage that had three phases. The unit table states the phase count and the governing phase.

9.2 What is deliberately absent

At what angle the handover happens · which lines are cut and when · whether the face lifters carry anything after the edge lifters are on · whether the sling length is fixed while the piece rotates (which is a moving geometry, not an angle sweep, and would need more vocabulary than §9.1 has) · every member kind other than wall panel · every preset other than wall_panel_plant_a.

Those are the owner's to supply. ENG-3.8 and ENG-3.9 exist as numbered holes so that a preset which needs them can name them and the report can say NOT RUN — not built, which is exactly what property 1 of the numbering is for.


10. Project Config, generated from the registry

Today: configmodel.SETTINGS is a hand-maintained table; configform.MODULE_OF and NOT_SHOWN are a hand-maintained mapping; unmapped() plus tests/test_configform.py fail on anything in neither. That last part is the good half and it survives: a setting added cannot quietly fail to appear.

The change: settings is declared by the unit that reads it, and SETTINGS becomes the concatenation over the units the active preset loaded, plus a small unit-independent set (report folder, python path, open-on-finish). A job that loaded no transportation unit shows no dunnage box.

Three hazards, each of which is the same staleness disease in a new place:

  1. A field absent because no unit loaded it is not a field nobody set. The full view (configpage) must still list it, greyed, saying "no loaded unit reads this" and naming its current value and layer. Otherwise the generated form becomes a way to hide a live YAML value — which is precisely the vanished-check-row failure, one screen over.
  2. A job-layer value for a unit no longer loaded is a stale setting and is reported by name, the same way a stale mute is.
  3. The window is opened without a piece selected, and the preset needs a member kind. RULED, 2026-09-01 — neither of the two options above. His words: "没选构件的时候project config至少还能显示ticketqc等等的内容 啊……" ("even with no member selected, Project Config should still be able to show Ticket QC's settings and the like"). A third shape: the config surface is not all member-keyed. Job-wide modules — Ticket QC's settings among them — render regardless of selection; member-keyed sections (a preset's fields) render once a member kind is known, from a selected piece or an explicit choice. Neither "ask first" (which would block the job-wide half for no reason) nor "union of every preset" (which answers a question about member-keyed fields that does not apply to the job-wide ones). Open question 6, closed.
  4. Every section collapsible, folded by default — RULED, 2026-09-01, and not waiting for this redesign. His words: "project config现在有点儿太 复杂了……给弄成每个部分都可以折叠的吧。需要调啥,再把那个部分给打开,调整, 完事儿。" ("Project Config is a bit too complicated right now — make every section collapsible. Whatever needs adjusting, open that section, adjust it, done.") This is an interaction model on the current, hand-maintained form, going in immediately — it does not wait on the registry-generated rebuild above. The rebuild inherits it: a generated section (unit-declared or job-wide) is exactly the unit a fold/unfold applies to, so nothing here is at odds with the sections becoming registry-generated later; the two changes are independent and this one lands first.

NOT_SHOWN survives as the deliberate-exclusion list with its reasons, and unmapped() is extended: a setting declared by a loaded unit that lands in no module fails the suite.


11. Migration — every intermediate state runnable

plant_a is in production and the owner runs it while this work happens. He was broken mid-flight once already by a half-applied contract change. So:

The rule for the whole migration: no phase may both move code and change a number. Each phase does one or the other, and each is committed separately.

phase what lands proves itself by
0 The catalogue: this document's tables as a registry module that is data only, imported by nothing that runs. Both suites green. Report byte-identical.
1 POST-2.5, the unit table. Every unit listed with its state, derived by mapping today's finding codes onto unit ids. Nothing moves. Every unit that ran shows a state; the reference sweep's verdicts do not move. The first thing that can go wrong becomes visible before anything is refactored.
2 Skip probes for every unit, plus the test that fails the build when one is missing. A unit that finds nothing is distinguishable from one that could not judge. Verdicts unchanged.
3 declares written by liftexport and read advisory by the engine: disagreements are WARN. Additive; absence has an explicit stand-down; no schema bump by cli.py's own rule. Old decks read unchanged. New decks carry declarations.
4 The declaration becomes load-bearing: none_by_design → NOT APPLICABLE, missing row → NOT RUN. Schema bump to panellift/2, because absence now reads as a value. A panellift/1 deck under a v2 engine reports every unit NOT RUN with the reason "written before declarations existed" — never silently green. Reports record the engine version so v1 decks stay re-runnable with a v1 engine.
5 Policy extraction, one constant per commit, each moved to the deck at its current value. Parity gate: the reference sweep's --json summary does not move. Only after parity does anyone get to change the value.
6 Mute for handling units + POST-2.6 roll-up. A muted unit is grey, listed, counted; a stale mute is named; a FAIL is never quietened.
7 Presets. wall_panel_plant_a declared containing exactly the units that run today, unlisted: ask. Report identical to phase 6's. A preset that changes nothing is the only safe first preset.
8 Generated Project Config from the registry; unmapped() extended. Every field on today's screen still appears; the full view lists the unloaded ones greyed.
9 Phases / rotation vocabulary (ENG-3.8, ENG-3.9). Only when the second plant's rules arrive. Until then the two units read NOT RUN — not built, which is honest and is why they are numbered now.

Every phase, without exception: both suites green (panellift and WillTools.extension), python tools/scrub.py clean, commit separately, push nothing.


12. What I am not sure of

Stated because a design that hides its soft spots gets built and then discovered.

  1. Whether the seam through checks.py is a line. check_edge_anchor_path and check_row_transverse_flexure are ~250 lines that interleave geometry with verdict; I read their signatures and their finding calls, not every branch. For those two the ENG/POST split may be a rewrite rather than a cut, and if so phase 5 is longer than it looks.
  2. Whether EDGE_SNAP_FT and the two 6-inch reach tolerances are really plant-tunable. They read as noise floors, but a noise floor is a statement about the fidelity of the model that produced the deck, and that varies by plant. I lean toward deck-recorded and plant-editable — but a knob that can silence a check is the mute problem without the mute discipline, and it may need the reason-mandatory treatment too.
  3. Whether §9.1's vocabulary is enough for a rotation. I do not know how the second plant rigs. If the slings are fixed-length while the piece turns, the geometry moves continuously and an angle sweep with a per-phase sling angle is not the same thing. I have deliberately not guessed, so this may be under-designed rather than wrong.
  4. Whether Material is dead. It looks dead — never constructed from a deck, run_all's material argument never read in its body. I did not run the suite to prove it and I changed nothing.
  5. Whether Project Config can be generated before a piece is chosen — ruled, 2026-09-01, see §10.3: job-wide modules render regardless of selection, member-keyed sections wait on a member kind. No longer a soft spot.
  6. The code-constant move may collide with the catalogue layer. pci.py proposes catalogue keys for the φ values; this document proposes the catalogue as the source and the deck as the record. If both end up readable at run time we have recreated the two-homes condition that produced the capacity_lb incident. The rule has to be stated once, loudly, wherever it is implemented: the engine reads only the deck.
  7. The unit catalogue's PRE half is coarser than its ENG half. I derived PRE units from liftexport's function boundaries, which are where the code happens to be cut, not necessarily where the questions are. A pass over it with the owner is worth more than a pass over it by me.

13. Open questions — only the owner can answer these

  1. RATIO_FAIL_ABOVE. It moves to post policy. May a plant make it stricter? (Presumed yes.) May any layer ever make it looser? (Presumed no, hard floor, not editable from any knowledge layer — confirm, because the whole ruling is that the tool may not bless it.) ANSWERED, 2026-09-01: "对的" ("correct"). Confirmed as put: a plant may tighten, no layer may loosen, hard floor, not editable from any knowledge layer. See §4.1.
  2. The ×1.3 development multiplier (and debonded 2, cut 1.3, mild 1.3 — with one source disagreement already recorded on mild). Is this a method he wants the engine to honour? At which plants? He should know before ruling that adopting it moves φM_n on every piece at whichever plant declares it, and that the change would look, on the page, exactly like a change in the panels. PARTIALLY ANSWERED, 2026-09-01. General question ruled: "长度乘子是厂 知识。我比较明确的知道的是plant_b是1.3" — it is plant knowledge, and plant_b's figure is landed in plant_b's own layer. Still open: the per-condition figures (debonded 2, cut 1.3, mild 1.3, with the source disagreement on mild) are the study's, not his, and remain unruled; so does whether plant_a declares any of this at all. See §4.4.
  3. Does a strength-design load factor (1.4) stack on the PCI Table 8.3.1 multiplier? Still open from docs/lift-check.md. Material.handling_load_factor holds it and nothing applies it.
  4. compression_limit_ratio = 0.6 — same provenance treatment as the tension limit (a source word printed beside every allowable, a Project Config field)? Or is it not a choice he wants exposed? ANSWERED IN FULL, 2026-09-01. Provenance found, at his direction ("0.6我没记错的话PCI跟ACI里面都有吧?...搜一搜ACI"): ACI 318-19 Table 24.5.3.1 (p.465) and §24.5.4.1 (p.466), knowledge/library/notes/standards/ ACI318-19-ch24.md — cited in §4.5. A finer two-row table (0.70·f'ci at the ends of a simply-supported member) was found alongside it and put to him; he ruled it out deliberately: "先不用实现0.7. 跑个lifting别搞这么复杂" ("don't bother implementing the 0.7 for now — don't make a lifting check this complicated"). The flat 0.6 is now a decision with an owner and a date, not an unexamined constant. Configurability: "0.6保持可调" ("keep 0.6 adjustable") — it gets the tension limit's full treatment, a source field and a Project Config surface. Nothing left open. See §4.5. (He separately consented to display-once dedup on the compression note, reading an early framing of this question as asking about that; the dedup is uncontroversial and stands alongside the rest of this ruling.)
  5. unlisted: ask vs a closed board. Should a preset be able to switch a unit off? Recommending no — mute stays the only lever — but a closed board would need an enable, and that is his call to make in advance rather than ours to add quietly. ANSWERED, 2026-09-01: "预设不能关单元,单元是由厂知识决定的" ("a preset cannot turn a unit off — which units run is decided by plant knowledge"). No enable, ever: the preset is not a second authority a job or a screen could toggle, it IS the plant layer's own declaration. See §6.2.
  6. Project Config with no piece selected: the union of every preset, labelled, or ask for a member kind first? ANSWERED, 2026-09-01: "没选构件的时候project config至少还能显示ticketqc 等等的内容啊……" Neither option as posed — a third shape: job-wide modules (Ticket QC's settings and the like) always render; member-keyed sections render once a member kind is known. See §10.3.
  7. The second plant's member-kind vocabulary and its preset. Not invented here. Needed before phase 7 can add a second profile, and before ENG-3.8 / ENG-3.9 can be built at all.
  8. Is a plant-level mute legal? His words say job-level. The map deep-merges across five layers, so a plant can mute today. Either that is intended, or the writer refuses a plant-level entry and says why. ANSWERED, 2026-09-01 — the important one, and it reshapes §6 and §8 rather than merely settling this question. The plant layer does not mute, it renumbers: master checks 1.1–1.6, a plant with a special lifting method does not run 1.4, that plant's board shows 1.1–1.3 unchanged, 1.5 displayed as 1.4, 1.6 displayed as 1.5. Confirmed, verbatim, that this is a per-plant display mapping over an immutable internal id: "是的。编号永不变是最底层的编号不变,但是显示出来的不一定是 最底层的编号。就像我说的那个不变的是engine,但是pre/post是灵活可变的。怎么 做到?就是映射。内部ID永远不变。内部ID永远不变。" So: internal ids are frozen forever (§1.2.4, restated with this quote in §6.3); a plant's composition rights are its preset (§6.1) plus this display mapping (§6.3), never a plant-level checks.muted entry — mute stays job-level, full stop (§8.1.4).
  9. Archived decks across the schema bump. Accept "a v1 deck is re-run with a v1 engine" (the FEA convention), or is byte-parity across the bump required? The second is expensive and I do not think it buys anything. ANSWERED, 2026-09-01 — option A. "原来的其实也不算错。不是同一种算法 罢了。" ("The old one isn't really wrong either. It's just not the same algorithm.") A v1 deck is re-run with the v1 engine that wrote it, forever; no byte-parity requirement across a bump. See §5.3.
  10. Namespacing now or later — PRE-2.5 today, or handling/PRE-2.5 against a future second numbered tool. Renaming an id later is the one thing this scheme is built to avoid. RESOLVED, 2026-09-01 — one tool earlier than expected. docs/plan-strands-auto-layout.md §5.1 hit the collision first (a second segment-tagged tool, not a third); the coordinator ruled the prefix in from day one, owner informed, no objection. See §1.1.