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

5826 lines
266 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Pure arithmetic and parsing for the ticket-page checks.
WHY THIS MODULE EXISTS, AND WHY IT IS FREE OF REVIT.
Phase 2 of the ticket refactor repoints the legend-dependent checks at the
information a ticket really carries. The `read_ticket` half of that lives in
ticketqc.py, which imports the Revit API at module scope -- correct for the
extension, fatal for tests. The arithmetic half -- recover a strand count
from the BOM's linear-feet row, decide whether a title-block parameter
tolerances a strength comparison, compare BOM/length against model -- needs
no Revit at all, and it is where a misjudged number turns into a false
finding. So the numbers live here, tested on plain CPython the way panellift
is; ticketqc.read_ticket feeds the raw values in and ticketqc.check_ticket
calls the comparison functions back.
Everything here is IronPython 2.7 compatible: no f-strings, no pathlib.
Two traps this module is designed around, both measured on real tickets:
* The BOM's strand row is a LENGTH IN FEET, not a count. WP210 prints 249 on
a 24'-11" piece, and 249 / 24.9167 = 9.993 -- the section prints (10).
Reading the row as a quantity produced a "23x error" report on a correct
ticket once. The count is recovered by division, never read directly.
* A count printed in the DESIGN SUMMARY is the design family's figure, not
this piece's. The summary says (14); individual pieces print 10, 6 or 14
according to what they carry. Comparing the summary's count against a
piece's model is a guaranteed false finding. The ticket's own strand
number is the only one a ticket check may be held to.
"""
# THE ONLY MODULE-SCOPE IMPORTS THIS FILE HAS, AND THEY ARE STDLIB ONLY.
# `ticketpage` is the Revit-free half by design (CLAUDE.md): the whole test
# suite reaches it on host CPython with no licence, and nothing here may
# import the Revit API, `vibe.ticketqc`, or anything that does. `os` and `io`
# arrived with the master checklist, which is read off disk at import.
import io
import os
def strand_count_from_linear_ft(linear_ft, length_ft, tolerance=0.05):
"""Strands implied by the BOM's linear-feet row.
linear_ft -- the BOM strand row (a length of strand, in feet).
length_ft -- the panel length, in feet.
tolerance -- how far the ratio may sit from a whole number before the
row is treated as unusable. Measurement, not invention:
12 real tickets' remainders span 0.0032 .. 0.0226, so
0.05 is a clean gap above them. A row that is actually a
COUNT or a misread length sits far outside it (10 / 24.9 =
0.40; 249 / 2.49 = 99.93), and a confident wrong count
from such a row is worse than an honest "unusable".
Returns the count, rounded to the nearest whole strand, or None when
either input is unusable or the ratio is not close to a whole number.
The round is deliberate: 321 / 22.9167 = 14.007 and 249 / 24.9167 =
9.993 -- the printed section counts are the rounded values, and half a
strand is not a thing.
"""
if linear_ft is None or not length_ft:
return None
try:
ratio = float(linear_ft) / float(length_ft)
except (TypeError, ValueError, ZeroDivisionError):
return None
nearest = round(ratio)
if abs(ratio - nearest) > tolerance:
return None
return int(nearest)
def strengths_agree(summary_psi, ticket_psi, tolerance=0.5):
"""Whether the ticket's printed strength matches the design's.
summary_psi -- f'c or f'ci from the reviewed spec / summary.
ticket_psi -- the strength printed on the ticket (title block), or None
when the ticket does not carry it.
Returns (agree, message). When ticket_psi is None the answer is
(False, ...) and the caller decides what that means -- an unmapped role
(this plant never prints it) is not the same as a missing value.
"""
if summary_psi is None:
return True, ''
if ticket_psi is None:
return False, 'ticket carries no value to compare against'
try:
want, got = float(summary_psi), float(ticket_psi)
except (TypeError, ValueError):
return False, 'non-numeric value on the ticket: {0!r}'.format(ticket_psi)
if abs(want - got) <= tolerance:
return True, '{0:,.0f} psi'.format(got)
return False, 'summary {0:,.0f} psi, ticket {1:,.0f} psi'.format(want, got)
# ------------------------------------------------------- BOM disposition
#
# `LGD-MISSING` used to be the one place that said anything about a ticket
# with no bill of materials, and it was wrong three ways (retired
# 2026-08-30):
#
# * no checklist item owned LGD-*, so neither its OK nor its FAIL ever
# reached the table -- an orphan finding nobody could act on;
# * it could not tell "this ticket has no BOM" from "the BOM could not be
# read", because the gatherer swallowed every per-schedule failure, and
# it called both a FAIL -- an unreadable input reported as a defect;
# * its premise is contradicted by the plant standard. Section 8000 of the
# manufacturer's manual (p713) says "Label all rebar - no description
# under material list", so an empty BOM description is CORRECT here.
#
# What replaces it: every check that wants the BOM asks these functions and
# reports its own NOT RUN with its own reason. A BOM-less ticket is named by
# everything that wanted one, and "absent" never reads as "unreadable".
BOM_OK = 'ok' # a BOM was read and it carries content
BOM_PARTIAL = 'partial' # content read, but some schedule failed
BOM_ABSENT = 'absent' # read fine; the ticket simply has no BOM
BOM_UNREADABLE = 'unreadable' # the ticket may have one; we could not read it
BOM_UNKNOWN = 'unknown' # nothing was gathered -- we never even looked
def bom_state(bom):
"""What can be said about a ticket's BOM. Returns (state, reason).
`bom` is what ticketqc._read_bom_schedules returned:
{'parts': [...], 'strand_linear_ft': x or None,
'schedules': [names read],
'unreadable': [(name, why), ...]}
reason is None only for BOM_OK. For every other state it is a sentence a
reader can act on, and ABSENT and UNREADABLE never share one: the first
is a fact about the ticket, the second is a failure of ours.
"""
if bom is None:
return BOM_UNKNOWN, ('no BOM was gathered for this ticket at all, so '
'nothing about it could be judged')
unreadable = list(bom.get('unreadable') or [])
read = [str(n) for n in (bom.get('schedules') or [])]
has_content = bool(bom.get('parts')) or bom.get('strand_linear_ft') is not None
if has_content:
if unreadable:
return BOM_PARTIAL, bom_unreadable_sentence(unreadable)
return BOM_OK, None
if unreadable:
return BOM_UNREADABLE, bom_unreadable_sentence(unreadable)
if read:
return BOM_ABSENT, (
'the ticket carries {0} schedule(s) ({1}) and every one of them '
'was read, but none is a bill of materials -- this ticket has no '
'BOM'.format(len(read), ', '.join(read)))
return BOM_ABSENT, ('this ticket carries no schedule at all, so it has '
'no bill of materials to read')
def bom_unreadable_sentence(unreadable):
"""The 'we could not read it' sentence, naming what failed and why.
Deliberately says out loud that this is OUR failure, not the ticket's:
the ticket may well carry a perfectly good BOM.
"""
bits = []
for entry in list(unreadable)[:4]:
if isinstance(entry, (list, tuple)) and len(entry) >= 2:
bits.append('{0}: {1}'.format(entry[0], entry[1]))
else:
bits.append(str(entry))
more = '' if len(unreadable) <= 4 else ' (+{0} more)'.format(
len(unreadable) - 4)
return ('{0} schedule(s) on this ticket could not be read -- {1}{2}. That '
'is an unreadable input, not a missing BOM: the ticket may carry '
'one'.format(len(unreadable), '; '.join(bits), more))
def bom_not_run_reason(bom, what):
"""Why a BOM-reading check did not run, or None when it can run.
`what` names the comparison, so a BOM-less ticket is named once by every
check that wanted the BOM rather than judged once by an orphan code.
"""
state, reason = bom_state(bom)
if state in (BOM_OK, BOM_PARTIAL):
return None
return '{0} could not be judged: {1}'.format(what, reason)
def count_agrees(bom_count, model_count, section_count=None):
"""Three-way agreement on strands: BOM/length vs model vs section print.
bom_count -- strand_count_from_linear_ft(...) -- the BOM row's
implied count, or None if the row was unusable.
model_count -- strands actually in the assembly.
section_count -- the (n) printed in section A-A, or None to compare two
ways only.
Returns (agree, what). The check must not fail a ticket when the BOM
row was unusable and only whisper the model agrees with itself.
"""
if bom_count is None:
return False, ('BOM strand row unusable -- count cannot be '
'recovered from it')
if model_count is None:
return False, 'strand count in the model is unavailable'
if not bom_count and not model_count:
return True, 'no strands on this piece'
if bom_count != model_count:
return False, ('BOM implies {0} strands but the model has {1}'
.format(bom_count, model_count))
if section_count is not None and section_count != bom_count:
return False, ('section prints {0}, BOM implies {1}'
.format(section_count, bom_count))
return True, '{0} strands (BOM {1} / model {2} agree)'.format(
bom_count, bom_count, model_count)
# ------------------------------------------------------------- handling
def parse_feet_text(text):
"""Parse a size string like '5'-0\"' or '4'-9\"' into decimal feet.
Sheet TextNotes carry the handling sizes as bare strings; this is the
boundary parse. Returns None when the text is not a size.
"""
import re as _re
if not text:
return None
m = _re.match(r"^\s*(\d+)'-(\d+)(?:\s+(\d+)/(\d+))?\"?\s*$", text.strip())
if not m:
return None
frac_feet = 0.0
if m.group(3):
frac_feet = (int(m.group(3)) / float(m.group(4))) / 12.0
return int(m.group(1)) + int(m.group(2)) / 12.0 + frac_feet
def stations_from_ends(length_ft, printed_ft):
"""Stations of a handling diagram whose segments run from BOTH ends.
The PICKING/STRIPPING diagrams print segments from each end with a blank
in the middle -- WP412 prints 2'-6" 7'-6" [blank] 7'-6" 2'-6" on a
24'-11" piece. The crew locates points from the ends; the interior span
is derived and deliberately left off. So the first half of the printed
segments places points FROM THE LEFT, the second half FROM THE RIGHT:
2'-6" -> 2.5, 7'-6" -> 10.0 (from the left end)
2'-6" -> 22.417, 7'-6" -> 14.917 (from the right end)
which are exactly the four modelled stations. printed_ft is in DRAWING
order (left-to-right on the diagram); the diagram is symmetric, so the
chain is split at the middle and mirrored. An odd count means a centre
segment that belongs on neither side -- it is the unprinted blank half.
Returns the stations as a sorted list (excluding 0 and length).
"""
n = len(printed_ft)
if n == 0:
return []
half = n // 2
left = printed_ft[:half]
right = printed_ft[half:] if n % 2 == 0 else printed_ft[half + 1:]
stations = []
acc = 0.0
for seg in left:
acc += seg
stations.append(round(acc, 4))
acc = 0.0
for seg in reversed(right):
acc += seg
stations.append(round(length_ft - acc, 4))
seen = set()
uniq = []
for s in stations:
if 0 < s < length_ft and s not in seen:
seen.add(s)
uniq.append(s)
return sorted(uniq)
def dunnage_stations(inset_ft, length_ft=None):
"""Dunnage blocks: insets from the ends, PRINTING ORDER.
The ticket prints the insets left-to-right on the diagram -- WP199's
NEW version prints 5'-0\" (left) and 3'-0\" (right). The FIRST value is
from the LEFT end, the SECOND from the RIGHT. Reading them mirrored
(3 left, 5 right) puts the far block back over the opening on a ticket
Will already fixed -- the mirror reading is the false one (measured:
the mirrored station 17.92 falls inside OPENING 1 on the corrected
ticket).
A SINGLE value is the symmetric '4'-9\" each end' form -- one inset,
both ends.
inset_ft -- the printed insets, in drawing order.
length_ft -- piece length, to compute the right-end station.
Returns the stations.
"""
if not inset_ft:
return []
out = [inset_ft[0]]
if length_ft is not None:
if len(inset_ft) > 1:
out.append(length_ft - inset_ft[1])
elif inset_ft[0] * 2 < length_ft:
out.append(length_ft - inset_ft[0]) # 'each end' form
return sorted(out)
def openings_hit(stations, opening_spans):
"""Every station that falls strictly inside an opening span.
opening_spans -- [(x0, x1), ...] along the length, in feet.
Returns [(station, (x0, x1)), ...]. A station exactly at an opening's
edge is a graze, not a hit -- 74% of the width missing under a block is
the finding, an edge sitting on the jamb is not.
"""
hits = []
for station in stations:
for (a, b) in opening_spans:
if a < station < b:
hits.append((station, (a, b)))
return hits
# Owner, verbatim (08-28): "这类问题全都走 warning 吧" -- everything of this
# kind goes out as WARN.
def dunnage_clearance(station, openings, relevant, fail_in=3.0,
warn_in=6.0):
"""Clearance of one dunnage station against the openings that matter.
station -- the support point, in feet along the length.
openings -- full opening rectangles [(x0, x1, y0, y1)] in feet, in
the piece's local frame (x along length, y across width).
relevant -- indices into `openings` that count at all. The dunnage
judgement is torque: only a through-thickness opening
matters (a support across a hole leaves the piece partly
unsupported; a shallow recess does not). The caller
measures penetration (opening_penetration) and passes
the through openings here. An opening NOT in `relevant`
is neither INSIDE nor a distance source -- the measured
false pattern was 1.7%-wide corner pockets reporting WARN
at 5 inches and FAIL at 1 inch because the distance
branch did not gate on relevance.
fail_in, warn_in -- plant edge-clearance thresholds, inches.
Returns (severity, name, distance_in):
'FAIL', 'INSIDE' -- station is inside a relevant opening.
'FAIL', 'OPENING <n>'-- within fail_in of a relevant opening edge.
'WARN', 'OPENING <n>'-- within warn_in.
'OK', None -- clear of every relevant opening.
The FAIL here is the inside-the-judgement reading, NOT the reported
severity: William's ruling (08-28) is that every dunnage finding goes
out as WARN -- "everything of this kind goes out as warning", INSIDE
included. The
3"/6" grading still decides WHETHER anything is reported; the caller
(check_dunnage_openings) flattens FAIL and WARN both to WARN. fail_in
is INERT as a severity knob -- it exists to record the plant's
grading and set the boundary with warn_in, but can never surface as a
FAIL. See the comment beside the caller.
"""
best = None
for idx in relevant:
(x0, x1, y0, y1) = openings[idx]
a, b = x0, x1
if a < station < b:
return 'FAIL', 'INSIDE', 0.0
edge = min(abs(station - a), abs(station - b))
name = 'OPENING {0}'.format(idx + 1)
if best is None or edge < best[0]:
best = (edge, name)
if best is None:
return 'OK', None, 0.0
distance_in = round(best[0] * 12.0, 1)
if best[0] * 12.0 < fail_in:
return 'FAIL', best[1], distance_in
if best[0] * 12.0 < warn_in:
return 'WARN', best[1], distance_in
return 'OK', None, distance_in
# ---------------------------------------------------------- strand debond
def strand_debond_runs(length_ft, through_openings, strand_positions_in,
threshold_ft):
"""Checklist 4.7: runs of concrete too short to develop a strand.
length_ft -- piece length along the strand direction, feet.
through_openings -- THROUGH openings only, as [(x0, x1, y0, y1)] in
feet, in the piece's local frame (x along the
length, y across the width, same convention as
dunnage_clearance). Passing the raw
liftexport._openings() list is wrong: that
function cannot tell a hole from a blind
recess -- both present a side face with an
in-plane normal -- so the caller must first
filter with liftexport.opening_penetration and
pass only the ones marked 'through' (see below).
strand_positions_in -- one entry per strand: its distance across the
width, in inches, measured from the same edge
the openings' y0/y1 are measured from (this is
ticketqc.strand_layout_of()['across']).
threshold_ft -- strand.debond_below_continuous_ft, read from
plant knowledge by the caller. Never defaulted
here -- a wrong default would silently decide
what is this plant's rule to set.
Returns:
None -- could not judge at all: length, strand positions or the
threshold is missing. Distinct from an empty list on
purpose (Claude 08-30: three of this project's real bugs
were a check returning OK on missing input; the caller
must not read None as "ran clean").
[] -- it COULD judge, and nothing needs debonding: either the
piece has no through opening at all, or every strand's
run past its openings is at least threshold_ft. The
caller does not report a finding for an empty result --
checklist item 4.7 has nothing to say about a piece with
no openings, and "ran clean" is not silence either.
[{'x0':, 'x1':, 'length_ft':, 'strands_in': [...]}]
-- one entry per run shorter than threshold_ft, sorted by
x0. strands_in lists (in inches, across the width) every
strand whose path crosses that run; strands that land on
the identical run are merged into one entry rather than
one finding each.
WHY A LENGTH-ONLY TEST IS NOT ENOUGH, AND WHY THIS ONE DOES NOT NEED
ITS OWN HOLE-VS-NOTCH THRESHOLD THE WAY DUNNAGE AND ROW-FLEX DO:
Those two checks ask a SECTION question -- how much of the width is
left to carry a load -- so a void that only nicks a sliver of the
width has to be told apart from one that spans most of it, and both
checks carry an explicit fraction for exactly that (DUNNAGE_VOID_
FRACTION, the ROW-FLEX void-fraction test). This check asks a
per-strand question instead: does concrete run out along THIS
strand's own path. That is answered exactly by whether the strand's y
position falls inside the opening's y0..y1 band (tested below, with a
hair of tolerance for a strand sitting on a jamb) -- not by how far
the opening reaches across the width in the abstract. A notch a fifth
of the width wide still correctly fails this check for the one or two
strands whose y happens to sit inside it, while a strand two feet
away never even gets a cut built at its position and is untouched.
A separate notch/hole threshold would be answering a question this
check does not ask.
WHY A BLIND RECESS DOES NOT COUNT (the caller's filtering job, stated
here because it drives what "through_openings" must already exclude):
a recess takes concrete off one face but leaves concrete behind it in
the thickness direction, so a strand's run through that stretch is
still against real, continuous concrete -- there is no burst-prone
stub the way there is across an actual void. That is the same
judgement dunnage_clearance and ROW-FLEX already make (both act only
on full-depth / through openings), so this function is written to
receive the through set pre-filtered rather than repeat the
raycast here.
PIECE ENDS COUNT AS BOUNDARIES TOO: a run bounded by one opening and
the piece's own end is measured exactly like a run bounded by two
openings. The same transfer-length argument applies to a strand that
has to develop in a short stub between an opening and the piece's saw
cut -- the end of the piece is a real boundary to the concrete THIS
piece has, not a placeholder meaning "no more openings to consider".
"""
if (length_ft is None or length_ft <= 0.0 or not strand_positions_in or
not threshold_ft or threshold_ft <= 0.0):
return None
if not through_openings:
return []
runs_by_key = {}
for y_in in strand_positions_in:
y_ft = (y_in or 0.0) / 12.0
gaps = []
for (x0, x1, y0, y1) in through_openings:
lo_y, hi_y = (y0, y1) if y0 <= y1 else (y1, y0)
if lo_y - 1e-6 <= y_ft <= hi_y + 1e-6:
lo_x, hi_x = (x0, x1) if x0 <= x1 else (x1, x0)
gaps.append((max(0.0, min(length_ft, lo_x)),
max(0.0, min(length_ft, hi_x))))
if not gaps:
continue # this strand crosses no opening at all
# The runs are the CONCRETE between the openings, not the spans the
# cut points bound -- an opening's own (x0, x1) is where concrete is
# ABSENT, so it must be excluded, not reported as a short run of it.
gaps.sort()
merged = [list(gaps[0])]
for (a, b) in gaps[1:]:
if a <= merged[-1][1] + 1e-9:
merged[-1][1] = max(merged[-1][1], b)
else:
merged.append([a, b])
cursor = 0.0
runs = []
for (a, b) in merged:
if a > cursor:
runs.append((cursor, a))
cursor = max(cursor, b)
if cursor < length_ft:
runs.append((cursor, length_ft))
for (a, b) in runs:
run_len = b - a
if run_len <= 1e-9:
continue
if run_len < threshold_ft:
key = (round(a, 3), round(b, 3))
runs_by_key.setdefault(key, set()).add(round(y_in, 3))
out = []
for (x0, x1), strands in sorted(runs_by_key.items()):
out.append({
'x0': x0, 'x1': x1, 'length_ft': round(x1 - x0, 4),
'strands_in': sorted(strands),
})
return out
def _n(value):
"""A number as a person would write it. Local to this module on purpose:
ticketqc._fmt_num is the same function, but ticketqc imports the Revit
API at module scope and this file must stay host-testable."""
if value is None:
return '-'
try:
if abs(value - round(value)) < 1e-9:
return '{0:,.0f}'.format(value)
return '{0:,.2f}'.format(value)
except Exception:
return str(value)
def _runs_phrase(runs):
"""The runs 4.7 found, said as 'what and where', never as a count."""
bits = []
for run in runs[:4]:
strands = ', '.join(['{0}"'.format(_n(s))
for s in (run.get('strands_in') or [])])
bits.append('{0} ft of concrete from {1} to {2} ft along the piece '
'(strand(s) at {3} across the width)'
.format(_n(run.get('length_ft')), _n(run.get('x0')),
_n(run.get('x1')), strands or 'unknown position'))
if len(runs) > 4:
bits.append('+{0} more'.format(len(runs) - 4))
return '; '.join(bits)
# Owner, verbatim (2026-08-30): "4.5 做掉。如果满足 debond 要求, 那么就期待有
# debond, 没有 debond 给个 warning."
def debond_callout_status(required_runs, callouts, threshold_ft):
"""Checklist 4.5: does the ticket CALL OUT the debonding 4.7 requires?
Owner's ruling, 2026-08-30, verbatim: "Get 4.5 done. If the debond
requirement is met, then expect a debond call-out; if there is no
debond, give a warning." Where the debond condition is met, expect a
call-out; where there is none, WARN.
This is deliberately NOT a second derivation of the geometry. 4.7
(`strand_debond_runs`) is the only place that decides where debonding is
required; 4.5 consumes its answer and asks the ticket the question. So:
required_runs -- EXACTLY what strand_debond_runs returned.
None -- 4.7 could not judge. 4.5 cannot either, and returns None so
the caller reports NOT RUN. Warning about a requirement
nobody established would be inventing the requirement.
[] -- 4.7 judged and nothing requires debonding.
[..] -- the runs that require it.
callouts -- where the ticket says so: [{'where': view or BOM row,
'text': the words found}]. Empty means the ticket is silent;
the caller must only pass this after actually reading the
ticket, because "we did not look" is not "it does not say".
threshold_ft -- strand.debond_below_continuous_ft, for the message only.
Returns (severity, code, message), or None when it could not judge.
NOT MERGED WITH THE MANUFACTURER'S OWN DEBOND RULE, and that is a
decision, not an oversight. The manual's plank section (QNA, the
1000-2000 extraction, A4) debonds a fixed length at each end keyed on
strand DIAMETER. That is a bed standard; the threshold here is a
geometric trigger. They are different rules and the manual's fixed
length must never be substituted for this threshold.
WHAT THIS CANNOT SEE, said out loud because a reader will assume
otherwise: the call-out found on these tickets is a bare note (measured:
the literal words sitting in the BED PLAN's text alongside the embed
callouts). It carries no station and no strand number, so a present
call-out is evidence that SOMEBODY debonded SOMETHING -- not that they
debonded these runs. That residue is why 4.5 is grade C: code narrows to
a shortlist, a person decides.
"""
if required_runs is None:
return None
where = '; '.join(['{0}: "{1}"'.format(c.get('where') or 'ticket',
(c.get('text') or '').strip())
for c in (callouts or [])[:3]])
if not required_runs:
if callouts:
return ('OK', 'STRAND-CALLOUT',
'no run of concrete along a strand is shorter than the '
'{0} ft a strand needs to develop, so no debond call-out '
'is required here -- the ticket carries one anyway ({1}). '
'That is not judged: 4.5 only asks whether a required '
'call-out is present'.format(_n(threshold_ft), where))
return ('OK', 'STRAND-CALLOUT',
'no run of concrete along a strand is shorter than the {0} ft '
'a strand needs to develop, so this ticket is not expected to '
'call out any debonding, and it does not'
.format(_n(threshold_ft)))
if not callouts:
return ('WARN', 'STRAND-CALLOUT',
'debonding is required here -- {0} -- but no debond call-out '
'appears anywhere on this ticket. Either the call-out is '
'missing from the drawing or the strands were never debonded'
.format(_runs_phrase(required_runs)))
return ('OK', 'STRAND-CALLOUT',
'debonding is required here -- {0} -- and the ticket calls out '
'debonding ({1}). The call-out names no station and no strand, '
'so THAT IT COVERS THESE RUNS WAS NOT VERIFIED -- a person has '
'to read the drawing to close it'
.format(_runs_phrase(required_runs), where))
# ---------------------------------------------------------- cast extras
def cast_extra_agreement(counted, ticket_mentions, bom_pair, tokens):
"""Checklist 2.4: are the cast extras the ticket draws the ones counted?
2.2 compares three weights, and all three can agree while all three are
wrong the same way -- a ledge left out of the model AND off the drawing
agrees with itself and 2.2 stays quiet. 2.4 asks the different question:
the SET of cast extras on each side, not the weight they add up to.
"The extras that were counted" is exactly what fed 2.2's reconstructed
weight: `liftexport.extra_cast_members` -- the assembly members whose
family name matches a `families.cast_include` token, and the Volume that
`extra_cast_ft3` summed out of each of them. Same function, same rule,
so 2.4 cannot drift from what 2.2 actually added.
"The extras the ticket draws" is read from the ticket's own words: a BOM
row or a placed view's text naming one of those same tokens. The ledge
IS drawn as geometry, and no query answers "does this elevation show a
ledge", so the ticket's text is the proxy -- stated here so nobody reads
a pass as "the drawing was examined".
counted -- [{'family', 'mark', 'volume_ft3' (None when it could
not be read), 'id'}], or [] for none modelled.
ticket_mentions -- [{'where', 'text', 'token'}] found on the ticket.
bom_pair -- bom_state(...)'s (state, reason). Respected as five
distinct states, never collapsed: OK and PARTIAL can
be judged (PARTIAL says so in the message), ABSENT can
be judged from view text alone and says so, while
UNREADABLE and UNKNOWN mean a row naming the extra may
have been missed -- so "the ticket does not name it"
is not assertable and the comparison stands down.
tokens -- the families.cast_include tokens, upper-case.
Returns a list of (severity, code, message), or None when it could not
judge at all (the caller then reports NOT RUN with the reason).
"""
if not tokens:
return None
state, why = bom_pair if bom_pair else (BOM_UNKNOWN, None)
out = []
# Model side first, and it needs no ticket at all: an extra whose volume
# could not be read contributed ZERO to 2.2's reconstructed weight,
# silently. That is the extra being drawn and NOT counted.
for item in counted:
vol = item.get('volume_ft3')
if vol is None or vol <= 0.0:
out.append((
'WARN', 'CAST-EXTRA',
'{0}{1} is cast concrete of this piece (it matches '
'families.cast_include) but no usable volume could be read '
'from it, so it added {2} to the weight 2.2 reconstructed -- '
'the extra is drawn and not counted'
.format(item.get('family') or 'an unnamed family',
_where_id(item),
'nothing' if vol is None else '0 ft3')))
if state in (BOM_UNREADABLE, BOM_UNKNOWN):
if not out:
return None # nothing assertable; probe says why
out.append(('WARN', 'CAST-EXTRA',
'the cast extras were NOT compared against what the '
'ticket names, because {0}'.format(why or 'the BOM '
'could not be read')))
return out
caveat = ''
if state == BOM_PARTIAL:
caveat = ' (note: {0})'.format(why)
elif state == BOM_ABSENT:
caveat = (' (this ticket has no BOM, so the only ticket-side evidence '
'is the text drawn on its views)')
model_tokens = {}
for item in counted:
fam = (item.get('family') or '').upper()
for tok in tokens:
if tok and tok in fam:
model_tokens.setdefault(tok, []).append(item)
break
ticket_tokens = {}
for m in (ticket_mentions or []):
ticket_tokens.setdefault((m.get('token') or '').upper(), []).append(m)
for tok in sorted(model_tokens):
if tok in ticket_tokens:
continue
items = model_tokens[tok]
total = sum([i.get('volume_ft3') or 0.0 for i in items])
out.append((
'WARN', 'CAST-EXTRA',
'the assembly carries {0} ({1} ft3 of cast concrete, counted '
'into 2.2\'s reconstructed weight) but nothing on this ticket '
'names it{2}'
.format(_families_phrase(items), _n(total), caveat)))
for tok in sorted(ticket_tokens):
if tok in model_tokens:
continue
m = ticket_tokens[tok][0]
out.append((
'WARN', 'CAST-EXTRA',
'the ticket names a cast extra -- {0}: "{1}" -- that the '
'assembly does not carry: no member matches "{2}", so nothing '
'was added to the reconstructed weight for it. 2.2 cannot see '
'this: an extra missing from the model is missing from all '
'three of its weights, which then agree with each other{3}'
.format(m.get('where') or 'ticket',
(m.get('text') or '').strip(), tok, caveat)))
if out:
return out
if not counted and not ticket_mentions:
return [('OK', 'CAST-EXTRA',
'no member of this assembly matches families.cast_include '
'and nothing on the ticket names one, so there is no cast '
'extra to be missing from the weight. An extra omitted from '
'BOTH the model and the drawing cannot be detected here and '
'is not claimed to have been{0}'.format(caveat))]
total = sum([i.get('volume_ft3') or 0.0 for i in counted])
return [('OK', 'CAST-EXTRA',
'{0} ({1} ft3) went into 2.2\'s reconstructed weight and the '
'ticket names {2}{3}'
.format(_families_phrase(counted), _n(total),
_mentions_phrase(ticket_mentions), caveat))]
def _where_id(item):
ident = item.get('mark') or item.get('id')
return ' ({0})'.format(ident) if ident else ''
def _families_phrase(items):
counts = {}
for i in items:
fam = i.get('family') or 'an unnamed family'
counts[fam] = counts.get(fam, 0) + 1
return ', '.join(['{0} x {1}'.format(counts[f], f)
for f in sorted(counts)])
def _mentions_phrase(mentions):
if not mentions:
return 'none'
return '; '.join(['{0}: "{1}"'.format(m.get('where') or 'ticket',
(m.get('text') or '').strip())
for m in mentions[:3]])
# ------------------------------------------------------------- weight
# Owner, verbatim (2026-08-30): "weight 有关的现在都变成 warn。我有点儿想不
# 清楚,就一律变成 warn。我尝试用过以后可能会再改。"
def weight_three_way(printed_lb, measured_lb, reconstructed_lb,
tolerance=0.02):
"""Which of the three weights disagrees, and what that means.
printed_lb -- the ticket's title-block weight (what is printed).
measured_lb -- the modelled concrete solid x unit weight.
reconstructed_lb -- length x width x depth less blockouts plus cast
extras, x unit weight.
Returns (severity, code, message):
'OK' -- all three agree (within tolerance).
'WARN', 'WGT-PRINTED' -- printed != measured but reconstructed ==
measured: the ticket's weight field is
stale, the geometry reads clean.
'WARN', 'WGT-MODEL' -- printed == reconstructed but measured
differs: something is modelled that the
dimensions do not describe.
'WARN', 'WGT-READ' -- printed == measured but reconstructed
differs: OUR reading of the geometry is
wrong. Suspect the tool first.
'WARN', 'WGT-INCOMPLETE' -- a value is missing; nothing concluded.
ALL weight findings are WARN, not FAIL. Will, 2026-08-30: "everything
related to weight now becomes warn. I'm not entirely sure about it, so
it's warn across the board for now. I may change it again once I've
tried using it." Provisional and deliberately so -- the severities go back on the
table once he has used it on real tickets. Until then a weight
disagreement is something to look at, never something that condemns a
ticket on its own.
The pattern is the diagnosis (plan 4.2): which pair agrees says which
side is wrong -- the ticket, the model, or our read.
"""
def off(a, b):
return abs((a - b) / b) if b else None
missing = [v is None for v in (printed_lb, measured_lb,
reconstructed_lb)]
if any(missing):
return 'WARN', 'WGT-INCOMPLETE', ('weight comparison incomplete '
'(printed/model/reconstructed)')
try:
p = float(str(printed_lb).replace(',', ''))
m = float(str(measured_lb).replace(',', ''))
r = float(str(reconstructed_lb).replace(',', ''))
except (TypeError, ValueError):
return 'WARN', 'WGT-INCOMPLETE', (
'a weight value is not a number -- nothing compared')
pm = off(p, m)
rm = off(r, m)
rp = off(r, p)
if (pm is not None and pm <= tolerance) and \
(rm is None or rm <= tolerance):
return 'OK', 'WGT-OK', '{0:,.0f} lb agrees three ways'.format(m)
if rm is not None and rm <= tolerance and (pm is None or pm > tolerance):
return 'WARN', 'WGT-PRINTED', (
'ticket prints {0:,.0f} lb but the geometry weighs {1:,.0f} lb '
'-- the printed weight is stale'.format(p, m))
if rp is not None and rp <= tolerance and (rm is None or rm > tolerance):
return 'WARN', 'WGT-MODEL', (
'geometry reads {0:,.0f} lb but the model weighs {1:,.0f} lb -- '
'something modelled that the dimensions do not describe'
.format(r, m))
if pm is not None and pm <= tolerance and (rm is None or rm > tolerance):
return 'WARN', 'WGT-READ', (
'our reading of the geometry ({0:,.0f} lb) does not reconcile '
'with the model ({1:,.0f} lb) -- suspect the tool first'
.format(r, m))
return 'WARN', 'WGT-MIXED', (
'all three weights differ: printed {0:,.0f}, geometry {1:,.0f}, '
'model {2:,.0f}'.format(p, r, m))
# The three weights of checklist 2.2, in the order the checklist lists
# them, each with the words a report uses for it. The role names are this
# module's vocabulary; where the numbers come from is the caller's problem.
WEIGHT_SOURCES = (
('printed', "the ticket's printed weight"),
('reconstructed', 'the weight rebuilt from length x width x depth'),
('measured', "the modelled solid's own weight"),
)
def weight_lb(value):
"""A weight as a float, or None when it is not a number."""
if value is None:
return None
try:
return float(str(value).replace(',', '').strip())
except (TypeError, ValueError):
return None
def weight_agreement(printed_lb, measured_lb, reconstructed_lb,
tolerance=0.02, unavailable=None):
"""Checklist 2.2 as it is written, including when it cannot be run.
Returns (severity, code, message), or None when fewer than two of the
three weights could be had. None is 'could not judge' -- the caller
turns it into NOT RUN with a reason. It is never a pass.
With all three: `weight_three_way` decides, and its verdict NAMES which
of the three is the odd one out, which the checklist requires ("do not
report a weight finding without saying which of the three disagrees").
With exactly two: the pair is still worth comparing, but the result
says out loud that it ran TWO-way, which two, and why the third was not
available -- `unavailable` maps a role name to that reason. It is never
OK, even when the two agree, because a two-way that reads as a clean
2.2 is exactly the silent degradation this function exists to stop.
"""
labels = dict(WEIGHT_SOURCES)
values = {'printed': weight_lb(printed_lb),
'measured': weight_lb(measured_lb),
'reconstructed': weight_lb(reconstructed_lb)}
have = [role for (role, _label) in WEIGHT_SOURCES
if values[role] is not None]
if len(have) >= 3:
return weight_three_way(values['printed'], values['measured'],
values['reconstructed'], tolerance)
if len(have) < 2:
return None
first, second = have
missing = [role for (role, _label) in WEIGHT_SOURCES
if role not in have][0]
reason = (unavailable or {}).get(missing) or 'it could not be read'
a, b = values[first], values[second]
base = b if b else a
off = abs(a - b) / base if base else 0.0
head = ('only two of the three weights could be read, so this ran '
'two-way: {0} {1:,.0f} lb vs {2} {3:,.0f} lb'
.format(labels[first], a, labels[second], b))
tail = ('. {0} was not available -- {1} -- so which of the three is the '
'odd one out cannot be said'
.format(labels[missing][0].upper() + labels[missing][1:], reason))
if off <= tolerance:
return 'WARN', 'WGT-TWO-WAY', (head + ', and they agree' + tail)
return 'WARN', 'WGT-TWO-WAY', (
head + ', and they disagree by {0:.1%}'.format(off) + tail)
def weight_agreement_no_reconstruction(printed_lb, measured_lb, tolerance,
reconstruction_reason):
"""Checklist 2.2 with the reconstruction leg stood down as NOT
APPLICABLE, never as a WARN of its own.
`liftexport._reconstructed_ft3` can find a wythe whose own solid
`Volume` disagrees with the plan-projection reconstruction by more than
the wythes's own openings can explain -- a stepped or tapered wythe the
method has no way to represent (WP100, 2026-09-08). That is a known
LIMITATION of the tool, not a missing input on the piece, and reporting
it as `WGT-READ` -- "our reading of the geometry does not reconcile
with the model, suspect the tool first" -- would be exactly that: a
warning that blames a fine piece for a hole in the code. So this is a
SEPARATE function from `weight_agreement`, not a branch inside it:
`weight_agreement`'s two-way path is deliberately never OK, because a
missing INPUT is still worth a person's eyes; this one only fires when
the tool itself has already named why the third number cannot exist,
and here two-way agreement really is the whole answer.
Returns (severity, code, message), or None when printed or measured
could not be read either -- the caller falls back to NOT RUN.
"""
p = weight_lb(printed_lb)
m = weight_lb(measured_lb)
if p is None or m is None:
return None
tail = (' -- the reconstruction is not applicable: {0}'
.format(reconstruction_reason))
off = abs(p - m) / m if m else None
if off is not None and off <= tolerance:
return 'OK', 'WGT-OK', (
'{0:,.0f} lb (measured) agrees with the ticket ({1:,.0f} lb)'
'{2}'.format(m, p, tail))
return 'WARN', 'WGT-TWO-WAY-NA', (
'ticket prints {0:,.0f} lb but the geometry weighs {1:,.0f} lb, '
'{2:.1%} apart{3}'.format(
p, m, off if off is not None else 1.0, tail))
# The run-time option `expensive_geometry` (configmodel.default_run_options,
# DEFAULT ON) decides whether 2.2 is allowed to read the panel's concrete
# solid -- 30-60 s on the first read of an untouched piece, per ticket.
#
# Will, 2026-08-30: "可以做成开关,但是默认是开的". Off-by-default would print
# NOT RUN on every ticket forever, and a permanently grey row is one people
# stop reading. So: ON unless somebody turned it off, and when it IS off the
# check reports NOT RUN naming the switch. Never a silent pass, and never a
# quieter comparison dressed up as the real three-way.
EXPENSIVE_GEOMETRY_OFF_REASON = (
'the run-time option expensive_geometry is OFF for this run, so the '
"panel's concrete solid was not read -- turn it back on in the project "
'config (run-options.yaml) to run this check')
def expensive_geometry_off_reason():
"""The sentence a check prints when the geometry switch is off.
A function rather than a bare constant so the reason is one thing, said
one way, wherever a check has to explain itself.
"""
return EXPENSIVE_GEOMETRY_OFF_REASON
def weight_param_note(stated_lb, measured_lb, printed_lb, tolerance=0.02):
"""The model's stored weight parameter against the weights that are
evidence. Returns (severity, code, message), or None when there is
nothing to say.
The stored parameter is NOT one of 2.2's three: it is a number somebody
wrote down, not a number derived from the piece. It is worth reporting
only when it disagrees with a derived weight, because it is what the
handling weight gate uses as `stated` -- a parameter that was never
recalculated stops a healthy piece there, and that has happened.
"""
stated = weight_lb(stated_lb)
against = weight_lb(measured_lb)
what = "the modelled solid"
if against is None:
against = weight_lb(printed_lb)
what = 'the ticket'
if stated is None or against is None or not against:
return None
if abs(stated - against) / against <= tolerance:
return None
return 'WARN', 'WGT-PARAM', (
"the model's stored weight parameter says {0:,.0f} lb but {1} says "
'{2:,.0f} lb -- the parameter is not a derived weight and is what '
'the handling weight gate reads, so it should be recalculated'
.format(stated, what, against))
def pick_stations_match(printed_stations, model_stations,
tolerance_ft=1.0 / 12.0):
"""Printed pick stations against the modelled insert stations.
printed_stations -- stations recovered from the ticket (ends-closed).
model_stations -- insert stations from the model (along the length).
tolerance_ft -- how close is a match. The ticket is nominal; an inch
is slack, not a defect.
Returns (matched, unmatched_print). Every printed station needs a
modelled insert within tolerance.
"""
matched = []
for station in printed_stations:
nearest = min([abs(s - station) for s in model_stations]
if model_stations else [1e9])
if nearest <= tolerance_ft:
matched.append(station)
unmatched = [s for s in printed_stations if s not in matched]
return matched, unmatched
# ------------------------------------------------------------- invisible
_INVISIBLE_RE = None
def _invisible_re():
"""The compiled pattern, built lazily so the module stays import-clean."""
global _INVISIBLE_RE
if _INVISIBLE_RE is None:
import re as _re
_INVISIBLE_RE = _re.compile(
u'[\u200b-\u200f\ufeff\u00ad\u2028\u2029]')
return _INVISIBLE_RE
def clean_text(text):
"""Model text as a string safe to print and to parse.
Strips the zero-width and format characters (U+200B..U+200F, U+FEFF,
U+00AD, line/paragraph separators) that Revit parameters can carry.
Measured: a model parameter whose ValueString was exactly U+200E (a
lone left-to-right mark) reached evidence/WP1.json and crashed a tool
that printed it. Everything that scrapes model text goes through here
at the boundary. None passes through as None; a value that was
NOTHING but invisible characters becomes '', which callers treat as
blank.
"""
if text is None:
return None
return _invisible_re().sub('', text)
# ------------------------------------------------- Phase 5: checklist table
# What docs/ticket-checklist.md asks, in order. Status words (Will/Claude
# 08-28, mutually exclusive): OK / WARN / FAIL / NOT RUN (with reason) /
# FOR ENGINEER (grade D). The status is decided by the CHECKS' own
# disposition (run_all's log), never guessed from missing findings --
# see checklist_states.
# ============================================================================
# THE MASTER CHECKLIST, READ FROM THE PUBLIC LAYER
# ============================================================================
#
# Owner, 2026-08-31: "做一个总的 checklist 放 public knowledge 里面,然后 plant
# 里面做一层映射,job 里面用静音按键去控制。"
#
# This WAS a tuple in this file. It is now `checklist.master` in
# `knowledge/catalogue.yaml` -- the one knowledge layer that is in this
# repository, because it identifies nobody. What exists, how it is worded and
# what grade it carries is publishable knowledge about shop tickets and is
# the same at every plant; a plant declares which of them it asks of which
# kind of member (`checklists.profiles`), and a job mutes.
#
# WHAT DID NOT MOVE: 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 test. A YAML row with no
# function behind it would have a number, a sentence and no way to fail.
#
# ---- THE NEW FAILURE MODE, AND THE GUARD ---------------------------------
#
# A tuple could not drift from the code beside it. This can, in two
# directions, and both are silent: a master entry naming a function nobody
# wrote, and a registered check function no master entry names -- the second
# being a check that RUNS and can never reach a row.
# `test_ticketchecks_host.py` pairs them and fails on either.
#
# ---- AND WHY THIS RAISES RATHER THAN DEGRADES ----------------------------
#
# If the catalogue is missing or unreadable this refuses to import, which
# takes the button down with a sentence naming the file. The alternative is
# a tool that starts with a shorter checklist, or none, and reports every
# ticket it looks at as fine. NOT RUN is never a pass, and a checklist that
# silently lost rows is that failure at the largest scale this tool can
# manage. A loud refusal is the cheap outcome.
def _text(value):
"""`value` as the interpreter's own text type -- never through `str()`.
THE TEXT REPORT LOST THE OWNER'S OWN WORDS THROUGH THIS BOUNDARY.
`last-ticketqc.txt` is documented as the complete record -- "the whole
report, nothing folded, long notes wrapped rather than cut" -- and a run
on 2026-08-31 wrote it with every CJK character of the job file's mute
sentences REMOVED, leaving the ASCII fragments and the gaps between them.
The same sentences rendered correctly in the panel and in `qc-run.json`.
A file promised as complete that quietly drops half a sentence is worse
than a file that fails to write.
`vibe.compat` is the project's one answer to this class: it returns the
engine's text type, decoding bytes as UTF-8, and the button encodes once
on the way to disk. IMPORTED LAZILY, and that has not changed even though
this module now carries two stdlib imports: `vibe/__init__` reaches for
the Revit API the moment it runs, so nothing here may import a `vibe`
module at module scope, which is what makes this file host-testable.
DEFINED UP HERE, ABOVE EVERYTHING, since 2026-08-31: the master checklist
is read off disk AT IMPORT and its refusals are sentences, so this has to
exist before the first line of module-level work rather than beside the
renderers that used to be its only callers.
"""
from vibe import compat
return compat.as_text('' if value is None else value)
MASTER_KEY = 'checklist.master'
# ---- A YAML NULL, HOWEVER THIS ENGINE'S READER SPELLED IT -----------------
#
# pyRevit's YAML reader is a YamlDotNet wrapper whose ENTIRE scalar
# conversion is `return ynode.Value`, so every scalar arrives as a STRING and
# `null` arrives as the four characters 'null'. `knowledge._merge_file` has
# always known this and runs `_coerce()` after every read; `_read_yaml` below
# did not, and it is the only other call site of that reader in the tree.
#
# WHAT THAT COST, MEASURED 2026-08-31 on the button engine (invisible to host
# pytest, which reads through PyYAML and therefore types its scalars):
#
# * `CHECKLIST_FOR_ENGINEER` derived EMPTY -- `owner_codes: null` came back
# as the tuple `('null',)` and `checks: null` as the string `'null'`,
# both truthy, so `_for_engineer_rows()` matched nothing and 5.3 and 5.5
# printed red NOT RUN. That is the 2026-08-30 defect resurrected verbatim,
# and the owner read it off a live report.
# * 37 of 39 rows carried the WORD 'null' as their `not_run_reason`, which
# is a non-empty string, so it WON over the derived probe sentence and
# SATISFIED the guard that walks the table demanding a sentence from every
# row. The no-silent-row contract broke exactly where its guard is blind.
#
# NOT `knowledge._coerce`, and the reason is a row: that one also NUMIFIES,
# and coercion runs after parsing, so `id: '5.10'` loses its quotes, becomes
# the float 5.1, collides with the real id 5.1 and `load_master` refuses the
# whole checklist -- the button dies rather than mis-renders. Null-only, and
# local to the checklist, is the whole fix.
_NULL_TEXT = ('null', 'none', '~', '')
def _is_absent(value):
"""True for a YAML null however this engine's reader spelled it.
Applied to the OPTIONAL slots only -- `grade`, `owner_codes`,
`not_run_reason`, `checks` -- and to list members one level down, because
`owner_codes: [null]` is the same trap wearing a bracket. `id` and `name`
deliberately do NOT get it: they already refuse an empty value loudly,
which is the right behaviour and not something to soften.
"""
if value is None:
return True
try:
return _text(value).strip().lower() in _NULL_TEXT
except Exception:
return False
class ChecklistUnavailable(RuntimeError):
"""The master checklist could not be read. Never caught in this package."""
def master_path():
"""Where the published master checklist lives."""
try:
from vibe import knowledge as K
return K.catalogue_path()
except Exception:
# The same two-level walk `knowledge.catalogue_path` does, kept as a
# fallback so a broken import in one module cannot take the
# checklist with it.
here = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
return os.path.join(os.path.dirname(here), 'knowledge',
'catalogue.yaml')
def _read_yaml(path):
"""The catalogue as a dict, through whichever YAML reader this engine has.
pyRevit's first, because that is the reader the rest of the knowledge
stack uses in Revit and IronPython 2.7 has no PyYAML; PyYAML second,
because that is what the host test runner has. If neither is importable
the caller raises -- guessing at YAML by hand is how a checklist comes
back one row short.
"""
errors = []
try:
from pyrevit.coreutils import yaml as pyyaml
return pyyaml.load_as_dict(path)
except Exception as ex:
errors.append('pyrevit.coreutils.yaml: {0}'.format(ex))
try:
import yaml as pyyaml_host
handle = io.open(path, encoding='utf-8')
try:
return pyyaml_host.safe_load(handle)
finally:
handle.close()
except Exception as ex:
errors.append('yaml: {0}'.format(ex))
raise ChecklistUnavailable(
'no YAML reader could open {0}, so the master checklist could not be '
'read and this tool has no checklist at all. Tried: {1}'
.format(path, '; '.join(errors)))
def _master_entry(entry, index, path):
"""One master row as the tuple the rest of this module speaks.
Every refusal here names the entry and the file. A checklist row that
arrived malformed and was skipped would be a question nobody asks and
nobody is told about.
"""
where = '{0}, {1}[{2}]'.format(path, MASTER_KEY, index)
if not isinstance(entry, dict):
raise ChecklistUnavailable(
'{0} is {1!r}, not a map of id/name/grade/owner_codes/'
'not_run_reason/checks.'.format(where, entry))
ident = entry.get('id')
ident = _text(ident).strip() if ident is not None else ''
if not ident:
raise ChecklistUnavailable(
'{0} has no `id`. An id is a check\'s STABLE IDENTITY -- every '
'mute, every applicability rule and every ruling names it -- so '
'one cannot be invented here.'.format(where))
name = entry.get('name')
name = _text(name).strip() if name is not None else ''
if not name:
raise ChecklistUnavailable(
'{0} (id {1!r}) has no `name`, so the row would print blank.'
.format(where, ident))
# EVERY OPTIONAL SLOT BELOW GOES THROUGH `_is_absent`, never `is None`.
# On the button engine the reader hands back 'null' as text; `is None`
# sees a present value and the row is corrupted rather than absent.
grade = entry.get('grade')
grade = '' if _is_absent(grade) else _text(grade).strip()
owners = entry.get('owner_codes')
if _is_absent(owners):
owners = None
elif isinstance(owners, (list, tuple)):
owners = tuple([_text(o).strip() for o in owners
if not _is_absent(o)]) or None
else:
owners = (_text(owners).strip(),)
reason = entry.get('not_run_reason')
reason = None if _is_absent(reason) else _text(reason).strip()
reason = reason or None
checks = entry.get('checks')
if _is_absent(checks):
checks = None
elif isinstance(checks, (list, tuple)):
checks = tuple([_text(c).strip() for c in checks
if not _is_absent(c)]) or None
else:
checks = _text(checks).strip() or None
return (ident, name, grade, owners, reason, checks)
def load_master(path=None):
"""The master checklist as the tuple-of-tuples this module has always used.
Shape unchanged on purpose: every consumer of `CHECKLIST_ITEMS` -- and
there are many -- keeps working, because what moved is where the data
lives and not what it is.
"""
path = path or master_path()
if not os.path.isfile(path):
raise ChecklistUnavailable(
'the published checklist {0} is not on disk, so this tool has no '
'checklist at all. It ships with the extension; a run without it '
'would report every ticket it looked at as fine.'.format(path))
data = _read_yaml(path)
node = data if isinstance(data, dict) else {}
for part in MASTER_KEY.split('.'):
node = node.get(part) if isinstance(node, dict) else None
if not isinstance(node, (list, tuple)) or not node:
raise ChecklistUnavailable(
'{0} in {1} is {2!r}. It must be a non-empty ORDERED LIST of '
'checklist entries -- an empty one is a tool with no questions.'
.format(MASTER_KEY, path, node))
items, seen = [], {}
for index, entry in enumerate(node):
row = _master_entry(entry, index, path)
if row[0] in seen:
raise ChecklistUnavailable(
'{0}[{1}] reuses the id {2!r}, which is already entry {3}. An '
'id is an identity: two rows under one id means every ruling '
'that names it points at both.'
.format(MASTER_KEY, index, row[0], seen[row[0]]))
seen[row[0]] = index
items.append(row)
return tuple(items)
# ---- knowledge-less distribution: absence of the FILE is not corruption ---
#
# Everything above this line is unchanged: a catalogue.yaml that IS present
# and fails to parse, is empty, or carries a malformed row still raises
# ChecklistUnavailable exactly as before -- that is real corruption of a file
# that was supposed to be trustworthy, and the owner's 2026-08-31 ruling
# ("why this raises rather than degrades", above) stands for it unchanged.
#
# The one case handled specially: the file is not on disk AT ALL. That is
# the ordinary state of a colleague trial install built by
# tools/make_dist.py, which ships no knowledge -- including this one
# repository-resident layer -- on the owner's own explicit instruction
# (2026-09-02). It is the same "ordinary absence" the rest of the knowledge
# stack already treats as unremarkable rather than corrupt
# (`knowledge._merge_file`'s docstring calls out "no catalogue on a stripped
# install" by name), and import has to keep succeeding here or every test
# file and every button that so much as imports this module dies before
# anything runs -- which is a far larger and less honest failure than an
# empty checklist naming why it is empty.
#
# `CHECKLIST_UNAVAILABLE_REASON` is None on every machine that has the file
# -- which is every machine that has always mattered -- and is the one thing
# a caller that is about to render a report (not just import the module) is
# expected to check first and refuse loudly on, the same shape
# `knowledge.config_problems()` already uses for `_config_missing` /
# `_plant_missing`. See TicketQC.pushbutton/script.py for that check.
CHECKLIST_UNAVAILABLE_REASON = None
def _load_master_or_absent():
global CHECKLIST_UNAVAILABLE_REASON
path = master_path()
if not os.path.isfile(path):
CHECKLIST_UNAVAILABLE_REASON = (
'no published checklist at {0} -- this is a knowledge-less '
'install with no knowledge/catalogue.yaml (see '
'tools/make_dist.py). Ask the owner for it; '
'knowledge/catalogue.yaml.template shows the shape the real '
'file has.'.format(path))
return ()
return load_master()
CHECKLIST_ITEMS = _load_master_or_absent()
# The universe as bare ids, in master order. What "every row" means to any
# consumer that must not let one vanish.
CHECKLIST_IDS = tuple([item[0] for item in CHECKLIST_ITEMS])
def _check_names(slot):
"""A row's check slot as a tuple of names.
A row names one check (a string), several (a tuple), or none. Kept in
one place so every reader of the slot agrees on what it holds --
`isinstance(slot, tuple)` scattered over three call sites is how the
second shape becomes a bug.
"""
if not slot:
return ()
if isinstance(slot, (tuple, list)):
return tuple([n for n in slot if n])
return (slot,)
def rows_owned_by(names):
"""{checklist row number} for every row owned ENTIRELY by a check
named in `names` (any container answering `in` -- a dict keyed by
check name, same shape as `ticketchecks.REINFORCING_EXCLUDED` /
`plant_scoped_out()`, or a plain set).
A row naming two checks where only one is named in `names` still
belongs in the coloured table -- it is honestly NOT RUN because half
of it was never asked, which is a true statement about this run, not a
stage- or plant-scope exclusion. Shared by `ticketqc_run.py` for both
"not checked at this stage" (Reinforcing QC's ticket-only checks) and
"not applicable at this plant" (a check gated on a knowledge key this
job's layer has not set) -- the same shape of pull-out-of-the-table
logic, so it lives here once rather than twice.
"""
nums = set()
for (num, _name, _grade, _owners, _reason, check_name) in CHECKLIST_ITEMS:
owned = _check_names(check_name)
if owned and all(n in names for n in owned):
nums.add(num)
return nums
# A ROW A PERSON OWNS BY DESIGN, DERIVED FROM THE TABLE AND NOT KEPT BY HAND.
#
# Shown as FOR ENGINEER (yellow), never NOT RUN (red). The three conditions
# below are exactly what "nobody wrote a check for this ON PURPOSE" looks
# like in a row of CHECKLIST_ITEMS:
#
# * no check function -- nothing computes it,
# * no owner finding code -- and nothing else is going to claim it,
# * a reason SPELLED OUT -- someone wrote down why it is a person's.
#
# The third is what separates it from a row that simply has not been built
# yet: an unbuilt row has no reason recorded, stays NOT RUN, and stays red.
#
# THIS WAS A HAND-KEPT TUPLE HOLDING ('5.5',) AND THAT IS THE 5.3 DEFECT.
# 5.3 (face designations) is a person's job by design -- docs/ticket-
# checklist.md has counted it as one under "what is left for a person" since
# it was written, next to 5.5 -- and it carries the reason to prove it. It
# was never added to the tuple, so it fell through to the "no check at all"
# branch and printed a red NOT RUN on every ticket. The owner read that as
# "5.3 没跑" (2026-08-30), which is exactly what a red NOT RUN means
# everywhere else in this table, and he was right to.
#
# Deriving it means the next grade-C/D row that a person owns cannot be
# forgotten in a second list: the reason string in the row IS the
# declaration.
def _for_engineer_rows():
return tuple([item[0] for item in CHECKLIST_ITEMS
if not _check_names(item[5]) and not item[3] and item[4]])
CHECKLIST_FOR_ENGINEER = _for_engineer_rows()
def checklist_states(findings, log=None):
"""The ticket-checklist table: item -> status.
log is run_all's (check_name, state, note) list -- the checks' own
disposition. Returns rows (number, name, grade, status, note).
Statuses are never guessed from missing findings:
- owner check RAN and found nothing -> OK (removing its OK finding
must not flip the row to NOT RUN; Claude 08-28 self-test).
- owner check SKIPPED (probe said why) or FAILED -> NOT RUN, the
check's own note.
- owner check RAN-EMPTY (no probe, silent) -> NOT RUN, "no probe".
- no owner check at all -> NOT RUN, the item's reason ("not
implemented").
- a row a person owns by design -> FOR ENGINEER, in ITS OWN words.
See CHECKLIST_FOR_ENGINEER for what makes a row one of those.
A ROW MAY NAME MORE THAN ONE CHECK (a tuple in the check slot), and
then it is answered only when EVERY one of them spoke. Any quiet half
makes the row NOT RUN and the note carries each quiet half's own
reason. The rule is deliberately the strict one: a row that asks two
questions and prints OK because the half that could still fail it was
never asked is the silent pass this whole table exists to prevent.
Under-claiming is the safe direction; a green row is a claim.
A ROW ANSWERED BY FINDING CODES ALONE GETS THE SAME GUARANTEE, and
until 2026-08-31 it did not. Seven rows name no check function at all --
they are answered by codes `ticketqc.check_ticket` emits -- so the skip
probes, which are keyed by check function, could not reach them. On a
live run six of them (2.1, 2.3, 3.1, 3.2, 4.1, 4.2) came back NOT RUN
with `reason: null` and printed as a bare red row with nothing under
it: the tool gave the reader no sentence to disagree with, and he read
the lot as noise. `ticketchecks` now runs a ROW PROBE for each of them
and puts the answer in the log under `row:<number>`; `_row_note` reads
it here, and when even that says nothing the row still gets a sentence
saying so. **No row may print NOT RUN with an empty reason.**
"""
row_notes = {}
for entry in (log or []):
nm, _st, nt = (list(entry) + [None, None, None])[:3]
nm = '' if nm is None else str(nm)
if nm.startswith(ROW_NOTE_PREFIX) and nt:
row_notes[nm[len(ROW_NOTE_PREFIX):]] = nt
by_code = {}
for f in findings:
by_code[f.code] = f
for prefix in ('WGT-', 'HANDLE-', 'SIZE-MAX-', 'SIZE-MIN-',
'SIZE-LENGTH', 'SIZE-WIDTH'):
if f.code.startswith(prefix):
by_code.setdefault(prefix, f)
log_by_name = dict((name, state) for (name, state, note) in (log or []))
rows = []
for (num, name, grade, owners, reason, check_name) in CHECKLIST_ITEMS:
if num in CHECKLIST_FOR_ENGINEER:
# The row's OWN reason, not a sentence about grade D written
# here: 5.3 is grade C and a stock "(grade D)" line on it would
# be false. The fallback survives only for a row that declares
# itself a person's without saying why, which the derivation
# above cannot actually produce today.
rows.append((num, name, grade, 'FOR ENGINEER',
reason or 'person reads the drawing'))
continue
check_names = _check_names(check_name)
quiet = []
for cn_want in check_names:
if log_by_name.get(cn_want) not in ('SKIPPED', 'RAN-EMPTY',
'FAILED'):
continue
note = None
for (cn, st, nt) in (log or []):
if cn == cn_want:
note = nt
break
quiet.append(note or reason or 'no probe declared')
if quiet:
rows.append((num, name, grade, 'NOT RUN', '; '.join(quiet)))
continue
owned = []
if owners:
for o in owners:
f = by_code.get(o)
if f is not None:
owned.append(f)
if not owned:
# Every named check ran. One that is missing from the log
# never ran at all, and that is not a pass either.
if check_names and all(log_by_name.get(cn) == 'RAN'
for cn in check_names):
rows.append((num, name, grade, 'OK', None))
continue
rows.append((num, name, grade, 'NOT RUN',
_row_note(num, owners, reason, row_notes)))
continue
ranks = {'OK': 0, 'WARN': 1, 'FAIL': 2}
worst = max(owned, key=lambda f: ranks.get(f.severity, 1))
# note is what the user reads to FIND the thing (Will 08-28:
# '凡是有 N 条的地方,都要能让他找到那 N 条是什么' -- a number alone is
# not information).
#
# NOTHING IS CUT HERE ANY MORE. This line used to read
# `capped_note(worst.message)` and it is where 4.5 lost its second
# half on a live run (2026-08-30) --
#
# debonding is required here -- 3.083 ft of concrete from 12.771
# to 15.854 ft ... ; 0.958 ft of concrete from 25.875 t...
#
# -- and with it the second run, the strands in it, and any hint of
# whether more followed. The owner: "4.5被截断了。我经常在notes里面
# 有截断。不要有截断". A cap at the DATA layer cannot be fixed by any
# amount of styling, and it took the file copy down with it: the
# same short string was written to `last-ticketqc.txt`, so the
# fallback a reader is told to trust held exactly the same stump.
# The full message now reaches every consumer, and the only limiter
# left is VISUAL -- see `note_cell_html`, which folds a long note in
# the panel without removing a character of it.
note = worst.message if worst.severity != 'OK' else None
rows.append((num, name, grade, worst.severity, note))
return name_shared_silences(rows)
# The log key a ROW-level reason travels under. `run_all` writes
# `('row:3.1', 'NO-CODE', <why>)` beside the check dispositions, because the
# log is the one channel that already reaches every consumer -- the button
# passes it here and `ticketvision` writes it to `qc-run.json` -- so the
# reason arrives without a new argument on a function three callers use.
ROW_NOTE_PREFIX = 'row:'
NO_REASON_AT_ALL = (
'no finding under {0} reached this row and nothing in this run recorded '
'why, so this row was NOT judged. That is a defect in the tool, not a '
'verdict on the piece: a row answered by finding codes owes the same '
'explanation a check owes through its skip probe, and this one has '
'none. Register a row probe for it in `ticketchecks`')
def _row_note(num, owners, declared, row_notes):
"""A code-answered row's reason, and NEVER None.
Three rungs, strongest first: what this run's row probe said, the row's
own declared reason from `CHECKLIST_ITEMS`, and -- when there is neither
-- a sentence saying exactly that. The third rung is the same ruling as
`RAN-EMPTY`'s "no probe declared": an unexplained silence is reported as
the defect it is rather than printed as a blank.
"""
said = row_notes.get(num)
if said:
return said
if declared:
return declared
return NO_REASON_AT_ALL.format(
', '.join(owners) if owners else 'this row\'s code(s)')
def name_shared_silences(rows):
"""One missing fact, said once and then cross-referenced.
Rows 4.4, 4.5, 4.6 and 4.7 all stand down on *no strand geometry on
this piece*, and four red rows carrying one fact read as four problems
-- the owner's word for the page was "unnecessary". Rows whose NOT RUN
reason shares its opening clause (everything before the first ` -- `,
which is how these sentences are built: the shared half, then what THIS
row lost) are named to each other.
IT ADDS, IT NEVER REPLACES. Each row keeps its own sentence, including
its own half of the consequence; the cross-reference is appended. A row
that would lose its own words to a group summary would be trading one
silence for another.
"""
stems = {}
for row in (rows or []):
stem = _reason_stem(row)
if stem:
stems.setdefault(stem, []).append(row[0])
out = []
for row in (rows or []):
stem = _reason_stem(row)
family = stems.get(stem) or []
if len(family) < 2:
out.append(row)
continue
others = [n for n in family if n != row[0]]
note = ('{0} [the same missing input also left {1} {2} quiet: one '
'fact, not {3} separate problems]'
.format(row[4], 'rows' if len(others) > 1 else 'row',
', '.join(others), len(family)))
out.append(tuple(row[:4]) + (note,) + tuple(row[5:]))
return out
def _reason_stem(row):
"""The shared half of a NOT RUN reason, or None when there is not one."""
if len(row) < 5 or row[3] != 'NOT RUN' or not row[4]:
return None
return _text(row[4]).split(' -- ')[0].strip().lower() or None
# ------------------------------------------------ sheet name vs mark (1.1)
def wwf_designation_status(mesh_design, mesh_fams):
"""Checklist 5.4 core: mesh designation vs modelled families.
mesh_design is the design's wwf_mesh token ('' when the spec has
none); mesh_fams is the modelled family names. Pure logic, host-
testable. Returns (severity, message); a missing design side is the
probe's job (NOT RUN), never a verdict here.
"""
up_design = (mesh_design or '').upper().replace(' ', '')
if not up_design:
return 'NOT RUN', 'no design-side mesh designation to compare'
matched = any(up_design in (fn or '').upper().replace(' ', '')
for fn in (mesh_fams or []))
if not mesh_fams:
return 'WARN', ('design calls for mesh "{0}" but no mesh family is '
'modelled'.format(mesh_design))
if matched:
return 'OK', ('mesh designation "{0}" is modelled'
.format(mesh_design))
return 'WARN', ('design calls for mesh "{0}" but modelled families '
'are {1}'.format(mesh_design, ', '.join(sorted(mesh_fams))))
# ------------------------------------------------- jacking force (4.3)
#
# UNITS, SPELLED OUT, BECAUSE THE TWO SIDES DO NOT AGREE ON THEM.
#
# the ticket prints KIPS 'JACKING FORCE: 0.70 fpu = 28.9 kips'
# the design carries POUNDS reinforcing.strand_groups[].it_lb = 28920
# tolerances.it_lb is POUNDS -- the key is named _lb and means it
#
# 1 kip = 1000 lb. The comparison below is done ENTIRELY IN POUNDS, so
# there is exactly one conversion in the whole path and it is on the line
# that reads the ticket's number. This is the class of bug the unit
# discipline exists for: a tolerance of 1 in the wrong unit is a check a
# thousand times stricter than anyone asked for, and it fails quietly, on
# correct tickets.
def jacking_force_status(printed_kips, design_it_lb, tolerance_lb):
"""Checklist 4.3 core: printed jacking force vs the design's tension.
printed_kips -- what the ticket's note prints, in KIPS.
design_it_lb -- the design's initial tension per strand, in POUNDS.
tolerance_lb -- how far the two may sit apart, in POUNDS. It is
`tolerances.it_lb`, resolved by the caller.
Returns (severity, message). A side that is missing entirely is the
skip probe's business (NOT RUN on the table), never a verdict here;
'NOT RUN' comes back only for a value that is present and unusable,
and the caller turns that into a probe reason rather than a Finding.
"""
try:
printed_lb = float(printed_kips) * 1000.0
except (TypeError, ValueError):
return 'NOT RUN', ('the jacking force printed on the ticket is not '
'a number: {0!r}'.format(printed_kips))
try:
design_lb = float(design_it_lb)
except (TypeError, ValueError):
return 'NOT RUN', ('the design\'s strand initial tension is not a '
'number: {0!r}'.format(design_it_lb))
try:
tol_lb = float(tolerance_lb)
except (TypeError, ValueError):
return 'NOT RUN', ('tolerances.it_lb is not a number: {0!r}'
.format(tolerance_lb))
gap_lb = abs(printed_lb - design_lb)
design_kips = design_lb / 1000.0
if gap_lb > tol_lb:
return 'WARN', (
'ticket prints {0} kips but the design calls for {1:.1f} kips '
'-- {2:,.0f} lb apart, past the {3:,.0f} lb allowed by '
'tolerances.it_lb'.format(printed_kips, design_kips, gap_lb,
tol_lb))
return 'OK', (
'{0} kips matches the design ({1:.1f}) -- {2:,.0f} lb apart, within '
'the {3:,.0f} lb allowed by tolerances.it_lb'
.format(printed_kips, design_kips, gap_lb, tol_lb))
# --------------------------------------- detail sheets: 5.1's other half
#
# 5.1 asks whether every embed the BED PLAN calls out exists in the model.
# A call-out on the plan is not always a part. This plant collects its
# typical sections onto DETAIL SHEETS of their own and the plan cites one
# as a bubble -- a detail number over a sheet name, `06/X1` -- whose
# embeds are drawn THERE and never on the plan.
#
# `nomenclature.detail_sheet_prefix` is the plant naming that series (the
# letter its detail sheets are numbered from). It is the only thing that
# tells a bubble apart from a part call-out, and without it the two are
# the same shape of token.
#
# WHAT IS NOT BUILT, AND IS NOT PRETENDED TO BE: nothing anywhere reads a
# detail sheet's CONTENTS. Following `06/X1` to X1 and comparing the
# parts it draws against this piece's members needs the sheets themselves,
# and reading them is a job of its own. So the half below stops at finding
# the bubbles and saying plainly what is behind them that nobody looked at.
def detail_sheet_refs(text, prefix):
"""The detail bubbles cited in a piece of ticket text, e.g. '06/X1'.
`prefix` is nomenclature.detail_sheet_prefix -- the letter this plant
numbers its detail sheets from. Returns the references sorted and
de-duplicated, or **None** when no prefix is declared: with the plant's
detail-sheet naming unwritten a bubble cannot be recognised at all,
which is could-not-judge and not an empty answer.
"""
pre = (prefix or '')
try:
pre = pre.strip().upper()
except AttributeError:
return None
if not pre:
return None
import re
pattern = re.compile(
r'\b(\d+[A-Z]?)\s*/\s*(' + re.escape(pre) + r'\d[\d.]*)')
out = []
for num, sheet in pattern.findall(clean_text(text or '').upper()):
ref = '{0}/{1}'.format(num, sheet.rstrip('.'))
if ref not in out:
out.append(ref)
return sorted(out)
def detail_sheets_of(refs):
"""The sheet names inside a list of bubbles: '06/X1' -> 'X1'."""
out = []
for ref in (refs or []):
sheet = str(ref).split('/')[-1].strip()
if sheet and sheet not in out:
out.append(sheet)
return sorted(out)
def detail_callout_status(bed_text, prefix):
"""Checklist 5.1's detail-sheet half, taken as far as it goes today.
`bed_text` is the BED PLAN view's flat text, or None when it could not
be read. `prefix` is nomenclature.detail_sheet_prefix.
Returns (findings, reason) -- `findings` a list of (severity, message)
and `reason` a could-not-judge sentence for the skip probe, None when
the half was actually answered. Exactly one of the two is ever filled.
THREE OUTCOMES, AND ONLY ONE OF THEM IS A PASS:
* no prefix declared -> could not judge. This plant's detail-sheet
naming is not written down, so a `06/X1` bubble on the plan
cannot be told from a part call-out and the plan cannot even be
read for them.
* a prefix, and the plan cites no detail -> OK, and it IS a verdict.
Nothing on this piece reaches the model through a detail sheet, so
every call-out on the plan is a part named directly on it and 5.1's
part-by-part half is the whole question.
* a prefix, and the plan cites details -> could not judge, naming the
sheets. The parts those details bring are not read by anything, so
whether they exist in the model has not been asked.
"""
refs = detail_sheet_refs(bed_text, prefix)
if refs is None:
return [], ('no nomenclature.detail_sheet_prefix in plant knowledge '
'-- this plant\'s detail sheets are not named, so a '
'detail bubble on the BED PLAN cannot be told from a '
'part call-out and 5.1\'s detail-sheet half could not '
'be looked at')
if bed_text is None:
return [], ('the BED PLAN could not be read on this ticket, so the '
'details it cites could not be looked for')
if not refs:
return [('OK',
'the BED PLAN cites no {0}-sheet detail, so every call-out '
'on it names a part directly and nothing on this piece '
'reaches the model through a detail sheet'
.format((prefix or '').strip().upper()))], None
sheets = detail_sheets_of(refs)
return [], ('the BED PLAN reaches {0} part(s) through a detail bubble '
'({1}) and nothing reads what detail sheet {2} draws, so '
'whether those embeds exist in the model has not been '
'asked'.format(len(refs), ', '.join(refs),
', '.join(sheets)))
# ----------------------------------- opening diagonal edge bars (wall rule)
#
# ONE CHECK THAT JUDGES TWO THINGS about a piece's door openings / blockouts:
#
# (1) BAR TYPE -- the bars at the openings carry the plant's opening flag
# (a parameter named like 'BAR_TYPE_BLOCKOUT' reading 'Yes'). Whether
# ANY opening bars are modelled at all is this half's question.
# (2) DIAGONAL -- among those flagged bars, the plant's diagonal edge bar
# is present in number. WHAT that bar must be is plant knowledge --
# `reinforce.panel_standard.opening.diagonal_edge_bar`, a string like
# '#3 x 3'-0" DIAG. E.L.' -- never written here.
#
# The bars are MODELLED assembly members of a scheduled rebar family -- not a
# bar-list line and not a drawing text note; these tickets carry no bar list
# at all. The rule applies to solid and insulated walls alike; this file
# neither knows nor needs the distinction, it is told how many openings the
# piece has.
#
# BOTH FINDINGS ARE WARN AT WORST, NEVER FAIL. Owner, verbatim: "很多时候开口
# 太小我给不了这些钢筋" -- a small opening often cannot take the bars. The
# flagged-bars half is a pass/fail-shaped fact about the model, but it is
# still reported at the plant's severity because a person weighs whether an
# opening too small for bars is at fault; the diagonal half is an upper-bound
# shortlist. `short_severity`, default 'WARN', is applied to both, and the
# function never emits 'FAIL' on its own.
#
# THE SIZE IS DERIVED FROM THE SPEC, NEVER HELD NEXT TO IT. The modelled
# diameter parameter spells the size as a fraction ('3/8"'), and `bar_size_
# fraction` maps the '#N' in the plant's spec onto that fraction, so a plant
# that maps a different bar size needs no code change and a wrong-size bar is
# never counted as one of the diagonals.
# '#3' -> '3/8", '#8' -> '1": the rebar sizes as the fraction a diameter
# parameter prints. Sizes above #8 are deliberately absent: nothing maps them
# yet, and a spec naming one leaves the flagged bars counted for the BAR TYPE
# half alone while the DIAGONAL half reports its shortfall instead of judging
# a wrong size (see `opening_diagonal_status`).
BAR_SIZE_FRACTIONS = {
3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8', 8: '1',
}
def bar_size_fraction(required_spec):
"""The rebar-size fraction a '#N' spec names, '#3 x 3'-0"' -> '3/8'.
Pure and host-testable: the plant maps the required bar as '#<size>' and
the modelled diameter parameter spells that size as a fraction, so the
two are joined here and nowhere else. None when the spec carries no
'#<digits>' or the number is not one of the sizes the map knows -- the
caller then cannot tell a diagonal from a wrong-size bar, so the flagged
bars are counted for the BAR TYPE half and the DIAGONAL half reports its
shortfall rather than guess a size.
"""
if not required_spec:
return None
import re as _re
m = _re.search(r'#(\d+)', required_spec)
if not m:
return None
try:
return BAR_SIZE_FRACTIONS.get(int(m.group(1)))
except (TypeError, ValueError):
return None
def opening_diagonal_status(opening_count, flagged_total, diag_count,
members_readable, required_spec,
short_severity='WARN'):
"""Judges a piece's openings twice: flagged opening bars, and the diagonals.
Pure decision, host-testable: how many openings a piece carries, how many
of its members carry the opening bar-type flag, and how many of THOSE are
the plant's diagonal edge bar go in, sentences come out. `(findings,
reason)` -- findings a list of (severity, message), reason a could-not-
judge sentence for the skip probe, exactly one of the two ever filled,
the same contract `detail_callout_status` and `parity_note_status` use.
opening_count -- how many door openings / blockouts the piece has, or
None when the geometry could not be read. Doors and
blockouts both count; whether the void is
through-thickness is not asked.
flagged_total -- how many of the piece's members carry the opening
bar-type flag (a parameter named like
'BAR_TYPE_BLOCKOUT') reading 'Yes', ANY size. None
when the member walk never produced a count.
diag_count -- how many of those flagged members are the plant's
diagonal edge bar (their diameter matches the size
`required_spec` names). None when the member walk
never produced a count. Counted by the caller off
the assembly's members; this function only compares.
members_readable -- whether the member walk actually happened. False is
could-not-judge, never a pass and never a "no bars"
verdict -- the caller must only pass True after the
assembly members were really read, because this
function cannot tell "we did not look" from "the
assembly models none".
required_spec -- reinforce.panel_standard.opening.diagonal_edge_bar
from plant knowledge: the diagonal the openings
need, e.g. '#3 x 3'-0" DIAG. E.L.'. Falsy means the
plant has not mapped it, which is could-not-judge --
what to count is the plant's to say, and nothing is
guessed.
short_severity -- the severity of a shortfall. The owner's ruling is
that a small opening often cannot take the bars, so
the count is a shortlist a person resolves and the
function never emits 'FAIL' on its own.
THE EXPECTATION IS AN UPPER BOUND. One diagonal per corner would be
4 x the opening count; an opening that runs to an edge of the piece has
fewer free corners, so a count under 4 x openings is expected on a real
piece and is exactly what a person weighs. Length is deliberately not
asked: a modelled bar's length parameter includes its bends.
THE DIAGONAL HALF IS ONLY ASKED WHEN THERE ARE FLAGGED BARS. With none,
"which of them are diagonals" has no answer -- a single finding says the
opening bars are missing or their bar type is not set, and nothing else.
"""
if not required_spec:
return [], ('the plant has not mapped '
'reinforce.panel_standard.opening.diagonal_edge_bar, so '
'the diagonal edge bars could not be judged')
if opening_count is None:
return [], ('the openings on this piece could not be read, so the '
'diagonal edge bars could not be judged')
if not members_readable:
return [], ('the assembly members could not be read, so the '
'diagonal edge bars could not be counted')
if flagged_total is None or diag_count is None:
return [], ('the assembly members could not be counted, so the '
'opening bars could not be judged')
if opening_count == 0:
return [('OK', 'no door opening or blockout on this piece -- no '
'opening bars required')], None
if flagged_total == 0:
return [(short_severity, '{0} opening(s) but no bar on this piece '
'carries the opening bar-type flag -- the opening bars are '
'missing or their bar type is not set; a person decides'
.format(opening_count))], None
findings = [('OK', '{0} opening(s); {1} bar(s) carry the opening '
'bar-type flag'.format(opening_count, flagged_total))]
# The messages name the spec AS THE PLANT WROTE IT -- the raw string is
# what a reader matches against the modelled bars on the piece.
spec = required_spec
expected = 4 * opening_count
if diag_count >= expected:
findings.append(('OK', '{0} x {1} diagonal bar(s) among them '
'(>= {2})'.format(diag_count, spec, expected)))
elif diag_count == 0:
findings.append((short_severity, 'none of the flagged opening bars '
'is a {0} diagonal -- a small opening often cannot '
'take them; a person decides'.format(spec)))
else:
findings.append((short_severity, 'only {0} x {1} diagonal bar(s) '
'among them against up to {2} (one per free '
'corner) -- an edge opening needs fewer; a person '
'decides'.format(diag_count, spec, expected)))
return findings, None
# ------------------------------------- 6.6: an odd count wants a bed note
#
# THE RULE, AND ALL THREE OF ITS PARTS BELONG TO THE PLANT. Owner,
# 2026-08-31: when the count of one particular part on a piece is ODD, the
# ticket's BED PLAN must carry a particular phrase.
#
# WHICH part, WHICH parity and WHICH phrase are three separate plant facts
# and not one rule. Another plant's part number is different, its wording is
# different, and a plant whose rule bites on the EVEN count is a plant and
# not a bug. So not one of the three is written here: all three come out of
# `drawings.parity_note_rule`, and with any one of them unset this stands
# down naming the one that is missing.
#
# ZERO IS AN ANSWER, AND IT IS NOT THE SAME ANSWER AS SILENCE. A piece
# carrying none of the part has a count of 0, which is even, so the rule
# does not apply and the row is a genuine OK. A piece whose members could
# not be recognised at all is could-not-judge, and never a pass. The two
# arrive here as different arguments -- `count == 0` against a non-empty
# `unknown` -- so that no later edit can collapse them into one.
#
# ONE UNREAD MEMBER FLIPS THE PARITY. That is why an unrecognised member
# stands the whole check down instead of being noted and worked around: a
# census one member short is not a slightly worse answer to an odd/even
# question, it is the opposite answer half the time.
#
# WHAT A PRESENT PHRASE DOES AND DOES NOT PROVE. It proves the words are on
# the bed plan. It does not prove they are ABOUT this part, and this is not
# a theoretical worry: the owner has already used the same wording for
# something else entirely (2026-08-16, on the `*.A` packets -- a piece can
# need a temporary precast beam that exists for the erection and is removed
# in the field). The phrase this finds may be that one. So a present phrase
# is an OK that says exactly what it saw and claims nothing more, and it is
# one of the two reasons the row is graded B rather than A.
PARITY_WORDS = ('odd', 'even')
def squash_phrase(text):
"""Ticket text as one upper-case line with whitespace runs collapsed.
The phrase has to be findable through a note typed over two lines, or
with two spaces where a reader sees one. `ticketqc._flat_text` already
joins a view's text notes with a single space and collapses runs, so on
the live path this restates a guarantee rather than adding one; it is
here so that text arriving from anywhere else is normalised the same
way, and so the PHRASE is normalised by the same function as the text
it is looked for in.
None in, None out: "there was no text" is not "the text was empty".
"""
if text is None:
return None
import re
return re.sub(r'\s+', ' ', clean_text(text) or '').strip().upper()
def parity_note_status(count, unknown, bed_text, family_names, parity,
phrases, verified=False, unverified_because=None,
gather_reason=None, bed_reason=None, total=None):
"""Checklist 6.6: does an odd part count carry its BED PLAN note?
Pure decision, host-testable, no Revit. Returns `(findings, reason)` --
`findings` a list of (severity, message) and `reason` a could-not-judge
sentence for the skip probe. Exactly one of the two is ever filled, the
same contract `detail_callout_status` uses.
count how many members of this piece are the part, or None.
unknown members that could not be identified at all, as
{'family', 'why'} dicts. NON-EMPTY STANDS THE CHECK
DOWN -- see the block comment above.
bed_text the BED PLAN view's flat text, None for could-not-read,
or a CALLABLE returning `(text, reason)` -- see below.
family_names drawings.parity_note_rule.family_names.
parity 'odd' or 'even' -- which parity demands the note.
phrases the wording(s) the BED PLAN must carry.
verified has the family name been confirmed against a model.
unverified_because why not, printed when it has not.
gather_reason why the count could not be made.
bed_reason why the BED PLAN could not be read.
total how many members the piece has, printed alongside a
count of zero so a wrong family name is visible rather
than silently even.
THE ORDER OF THE GATES IS THE DESIGN. Configuration first, then the
census, then the parity, and the BED PLAN LAST -- because a count whose
parity does not demand the note is fully judged without ever opening the
plan, and demanding a readable plan there would print NOT RUN for a
piece this can answer completely.
"""
names = [text for text in
[str(n).strip() for n in (family_names or [])] if text]
wanted = [p for p in [squash_phrase(p) for p in (phrases or [])] if p]
word = (str(parity).strip().lower() if parity is not None else '')
if not names:
return [], ('no drawings.parity_note_rule.family_names in the '
'knowledge files -- WHICH part this plant counts for the '
'parity note is the plant naming its own hardware, and '
'the tool will not guess a part number. Name it and this '
'row runs')
listed = ', '.join(names)
if word not in PARITY_WORDS:
return [], ('drawings.parity_note_rule.parity is {0} and not one of '
'{1} -- WHICH parity of {2} demands the note is the '
"plant's rule and not this tool's to assume"
.format(repr(parity), ' / '.join(PARITY_WORDS), listed))
if not wanted:
return [], ('no drawings.parity_note_rule.require_text in the '
'knowledge files -- the exact words the BED PLAN must '
'carry are this plant wording its own drawing, so '
'nothing was looked for')
if not verified:
# THE SAME GUARD `reinforcing_rule.parameter_verified` CARRIES, for
# the same reason. An unconfirmed family name that matches nothing
# counts zero on every piece, zero is even, and every row reads a
# permanent, invisible OK -- a false pass over a whole defect class.
# Standing down until somebody has read the name off a model is the
# only outcome that cannot lie.
return [], ('drawings.parity_note_rule.family_verified is false: '
'nobody has confirmed that this plant\'s part is modelled '
'under the family name(s) {0}. {1}'
.format(listed, unverified_because or
'Read the family name off a live document and set '
'the flag.'))
if gather_reason:
return [], gather_reason
if unknown:
seen = []
for member in unknown:
why = (member or {}).get('why') or 'no reason recorded'
if why not in seen:
seen.append(why)
return [], ('{0} member(s) of this piece could not be identified '
'({1}), so the count of {2} is not trustworthy -- one '
'unread member flips an odd count to even, so this is '
'not judged rather than judged from a partial census'
.format(len(unknown), '; '.join(seen), listed))
if count is None:
return [], ('the count of {0} on this piece was not established and '
'no reason was recorded -- treat as not run, not as a '
'pass'.format(listed))
want = ' or '.join([repr(p) for p in wanted])
other = 'even' if word == 'odd' else 'odd'
if (count % 2 == 1) != (word == 'odd'):
if count:
return [('OK',
'this piece carries {0} x {1}, an {2} count, and the BED '
'PLAN note {3} is required only on an {4} count -- the '
'rule does not apply here'
.format(count, listed, other, want, word))], None
return [('OK',
'this piece carries no {0} at all ({1} member(s) read, none '
'of them named {0}), so the count is 0 -- an {4} number, '
'and the BED PLAN note {2} is required only on an {3} '
'count. Judged, and the rule does not apply'
.format(listed,
total if total is not None else 'an unrecorded '
'number of', want, word, other))], None
if callable(bed_text):
# THE ONE DEFERRED LINE, AND WHY IT IS WORTH THE SHAPE. Reading the
# BED PLAN means walking every sheet's placed views and flattening
# a view's text notes, and the gate above means an EVEN count never
# needs any of it. A caller holding the text passes the text; a
# caller that would have to go and fetch it passes something that
# will -- returning `(text, reason)` together, because "no view of
# that name is placed here" is the reason for the silence and is
# not known until after the look.
bed_text, bed_reason = bed_text()
if bed_text is None:
return [], (bed_reason or
'the BED PLAN could not be read on this ticket, so the '
'note an {0} count of {1} requires was not looked for'
.format(word, listed))
blob = squash_phrase(bed_text)
for phrase in wanted:
if phrase in blob:
return [('OK',
'this piece carries {0} x {1}, an {2} count, and the BED '
'PLAN carries {3} as that requires. The words are there; '
'whether they are about this part is not claimed'
.format(count, listed, word, repr(phrase)))], None
return [('FAIL',
'this piece carries {0} x {1}, an {2} count, so the BED PLAN '
'must carry {3} -- it does not. {4} character(s) of BED PLAN '
'text were read and none of them say it'
.format(count, listed, word, want, len(blob)))], None
def _n(x):
"""Trim a float for a message: 1.0 -> '1', 1.250 -> '1.25'."""
s = '{0:.3f}'.format(float(x))
s = s.rstrip('0').rstrip('.')
return s or '0'
def bed_offset_status(offset_in, width_in, bed_width_in, left_rail_is_fixed):
"""Checklist 4.6 (BED-OFFSET): may the strand pattern slide, and how far.
Pure logic, host-testable. Returns (severity, code, message), or None
when there is nothing to judge (no bed width, no panel width).
`left_rail_is_fixed` is the plant fact, and it decides the whole shape
of the answer (see the `rails` block in knowledge.py, corrected
2026-08-30):
False -- the bed has NO fixed rail. It does not mean the RIGHT rail
is fixed; a fixed right rail does not exist in this
industry. So the piece sits anywhere across the bed, the
pattern slides with it, and the whole difference is bed
offset. How far it may slide is not a judgement call:
|offset| <= bed width - panel width
True -- the left rail IS the bed's fixed rail. Against a fixed rail
the pattern CANNOT slide freely, so an offset is a finding
rather than slack to be spent -- but WHAT a fixed-rail plant
should tolerate is an engineering question nobody has
answered. Rather than invent a tolerance, or (worse) fall
through to the False behaviour and spend slack that is not
there, this reports the offset as a WARN and says plainly
that it was not judged. Chosen 2026-08-30; the owner still
has to decide what a true plant's tolerance is.
None -- nobody has recorded it. The verdicts are unchanged from the
False path (the bed-width ceiling holds under any reading of
which rail is fixed), but the message SAYS the flag is
unrecorded, so the assumption is visible rather than silent.
"""
if bed_width_in is None or width_in is None:
return None
slack = round(bed_width_in - width_in, 3)
offset = abs(round(offset_in, 3))
if slack < 0:
return ('FAIL', 'BED-WIDTH',
'piece is {0}" wide but the bed is only {1}"'
.format(_n(width_in), _n(bed_width_in)))
if left_rail_is_fixed is True:
return ('WARN', 'BED-OFFSET',
'strand pattern sits {0}" off the design chain. The left '
'rail is this bed\'s FIXED rail (rails.left_rail_is_fixed '
'is true), so the pattern cannot simply slide and the {1}" '
'the bed leaves over is NOT slack to spend -- but no '
'fixed-rail tolerance has been recorded, so this offset is '
'reported, not judged.'
.format(_n(offset), _n(slack)))
unknown = ''
if left_rail_is_fixed is None:
unknown = (' (rails.left_rail_is_fixed is unrecorded; this reads '
'the bed as having no fixed rail)')
if offset > slack + 1e-6:
return ('FAIL', 'BED-OFFSET',
'strand pattern sits {0}" off the design chain, but a {1}" '
'piece in a {2}" bed can only move {3}"{4}'
.format(_n(offset), _n(width_in), _n(bed_width_in),
_n(slack), unknown))
return ('OK', 'BED-OFFSET',
'pattern offset {0}" within the {1}" the bed leaves{2}'
.format(_n(offset), _n(slack), unknown))
# ------------------------------------- where a bar is DRAWN (checklist 5.2)
#
# 5.2 used to hold ONE rule about reinforcing: "anything not shown in the
# A-A / B-B sections must appear on the BED PLAN". That is a RESIDUAL --
# it derives the bar's home by subtraction, so every bar the plan does not
# name is a finding.
#
# The manufacturer's own standards state it as an ENUMERATION instead
# (section 10000, PDF p804 / 10301.1 notes 25, 26, 40, 47; p805 / 10301.2
# note 15C; recorded with verbatim in the section-10000 extraction in QNA/
# A-23). Positively, by class of bar:
#
# full-length longitudinal bar -> Section A-1
# partial-length bar -> plan
# transverse bar -> never in Section A-1
# full-width transverse bar -> Section B-1
# additional trim / corner bar -> plan
# return insulation -> BED PLAN
#
# The residual and the enumeration disagree in exactly one direction, and
# it is the expensive one: a full-length bar lives in Section A-1 ONLY and
# a full-width transverse bar lives in Section B-1 ONLY -- neither is
# drawn on the plan, and neither MAY be. The residual reports both as
# missing from the BED PLAN. That is over-flagging, and an over-flagging
# check is read once and ignored after.
#
# JUDGEMENT CALL 1 -- "Section A-1" is this project's "A-A".
# The manual uses three forms for the same two cuts (QNA A-23 / C-5): the
# templates head the notes `SECTION A-1` / `SECTION B-1` and bubble the
# cuts `A over 1` / `B over 1`; the worked wall-panel example bubbles the
# same two cuts `A over A` / `B over B`; the prose says "the A-A section".
# What settles it is not the name but the PAIRING: p819 / 10315.1 says cut
# a section EACH WAY through the panel, and notes 26 and 40 use the two
# names as that same pair -- transverse steel is kept OUT of the first and
# shown in the second. There are only two orthogonal cuts to be had, so
# `A-1` is the along-the-length cut and `B-1` the across-the-width cut,
# which is what `A-A` and `B-B` are here. Recorded as a conclusion, not an
# assumption -- and note that NOTHING BELOW MATCHES A SECTION NAME. The
# code only needs to know that a bar belongs in *a section rather than the
# plan*, so if this conclusion is ever overturned the check does not move.
#
# JUDGEMENT CALL 2 -- trim/corner bars need not be told apart from
# partial-length bars. Geometry cannot separate them (a trim bar beside an
# opening and a short partial-length bar are the same short bar), but the
# standard sends BOTH to the plan, so the distinction has no consequence
# here and is not attempted. If a future rule ever treats them
# differently, this is where that stops being true.
#
# JUDGEMENT CALL 3 -- a partial-WIDTH transverse bar is UNPLACED by the
# standard. Note 26 only forbids it from Section A-1; note 40 places the
# full-width one in Section B-1. Nothing says where the partial one goes.
# The tool therefore does not know, says so, and does not flag it.
BAR_HOME_PLAN = 'plan'
BAR_HOME_SECTION_A = 'section_A'
BAR_HOME_SECTION_B = 'section_B'
def bar_run_axis(size_in, length_axis, width_axis, thickness_axis):
"""Which panel axis a bar runs along: 'length', 'width', or None.
size_in is the bar's local-frame box extents, indexed by axis; the
three axis arguments are the indices of the panel's own length /
width / thickness (ticketchecks' `panel_axes`).
None means the tool could not say -- unusable input, a bar whose
longest extent is the THICKNESS (it stands through the piece and is
neither longitudinal nor transverse), or a bar the two in-plane
extents tie on (a square hoop or a bent bar's bounding box). None is
never a pass; the caller must say it could not classify.
"""
try:
axes = (int(length_axis), int(width_axis), int(thickness_axis))
except (TypeError, ValueError):
return None
if len(set(axes)) != 3 or not all(0 <= a <= 2 for a in axes):
return None
try:
vals = [float(size_in[a]) for a in axes]
except (TypeError, ValueError, IndexError, KeyError):
return None
along, across, through = vals
if along <= 0 or across <= 0:
return None
if through >= along and through >= across:
return None # stands through the piece: not placed here
if abs(along - across) < 1e-9:
return None # square in plan: no run direction to read
return 'length' if along > across else 'width'
def bar_drawing_home(run_axis, extent_in, panel_extent_in, full_fraction):
"""Checklist 5.2: where the standard says this one bar is drawn.
Pure logic, host-testable. Returns (home, why):
(BAR_HOME_SECTION_A, why) -- full-length longitudinal (note 25)
(BAR_HOME_SECTION_B, why) -- full-width transverse (note 40)
(BAR_HOME_PLAN, why) -- partial-length / trim / corner bar
(None, why) -- COULD NOT CLASSIFY. `why` says what
is missing. This is never a pass and
never a finding: the caller reports
it as an unclassified bar, or the
row reads NOT RUN.
run_axis 'length' | 'width' | None, from bar_run_axis.
extent_in how far the bar reaches along that axis, inches.
panel_extent_in the panel's own extent on that axis, inches.
full_fraction drawings.full_length_bar_fraction -- the fraction of
the panel a bar must reach to count as full-length
(or full-width). PLANT KNOWLEDGE, null by default:
the standard says "full length bar" and never says
how short still counts, so no number is invented
here. Unset, this returns None and 5.2's reinforcing
half stands down, exactly as rails.max_bed_width_in
stands BED-OFFSET down.
"""
if run_axis not in ('length', 'width'):
return (None, 'bar has no readable run direction in the plane of '
'the piece')
if full_fraction is None:
return (None, 'drawings.full_length_bar_fraction is unset -- how '
'much of the panel a bar must span to be "full '
'length" is plant knowledge and is not guessed here')
try:
frac = float(full_fraction)
reach = float(extent_in)
whole = float(panel_extent_in)
except (TypeError, ValueError):
return (None, 'bar or panel extent not readable')
if whole <= 0 or reach < 0:
return (None, 'bar or panel extent not readable')
if not 0.0 < frac <= 1.0:
return (None, 'drawings.full_length_bar_fraction must be a fraction '
'in (0, 1]; got {0}'.format(full_fraction))
spans = reach >= frac * whole - 1e-9
pct = 100.0 * reach / whole
if run_axis == 'length':
if spans:
return (BAR_HOME_SECTION_A,
'full-length bar ({0}% of the length) -- Section A-A '
'only, not the BED PLAN (note 25)'.format(_n(pct)))
return (BAR_HOME_PLAN,
'partial-length bar ({0}% of the length) -- plan view '
'(note 25)'.format(_n(pct)))
if spans:
return (BAR_HOME_SECTION_B,
'full-width transverse bar ({0}% of the width) -- Section '
'B-B only, not the BED PLAN (note 40)'.format(_n(pct)))
return (None,
'transverse bar spanning {0}% of the width -- note 26 only keeps '
'transverse bars OUT of Section A-A and note 40 covers only the '
'full-width one; the standard does not say where a part-width '
'transverse bar is drawn'.format(_n(pct)))
def bed_plan_bar_disposition(homes_by_family):
"""5.2: which bar families the BED PLAN difference may still judge.
homes_by_family maps family -> the set of homes its instances
classified to (bar_drawing_home's first value, None included).
Returns (plan_bound, section_bound, unclassified), each a sorted list
of family names.
A family is judged over ALL its instances, and the order of the three
tests is the whole safety property:
1. any instance unclassified -> UNCLASSIFIED. The family leaves the
difference entirely. Not judged is not a pass and not a flag.
2. any instance plan-bound -> PLAN. Being drawn on the plan is
what the difference tests, so one such instance keeps it in.
3. otherwise -> SECTION. Absence from the plan is
correct for it and must not be reported.
Putting (1) first is deliberate: a family with one full-length bar
and one bar nobody could measure must NOT be flagged on the strength
of the half that was readable.
"""
plan, section, unknown = [], [], []
for fam in sorted(homes_by_family or {}):
homes = homes_by_family.get(fam) or set()
if not homes or None in homes:
unknown.append(fam)
elif BAR_HOME_PLAN in homes:
plan.append(fam)
else:
section.append(fam)
return plan, section, unknown
# ------------------------------------------- 5.7 bar length vs stock
#
# Will, 2026-08-30: "rebars come in 40ft stock. unless overwritten by other
# knowledge, all rebars pieces must be less than 40ft." A bar longer than a
# stick cannot be made from one, and nothing else on the ticket notices.
#
# THE LENGTH IS READ, NEVER MEASURED. Will, same day, on a bar's length with
# BENT BARS INCLUDED: "钢筋的长度,即使是弯筋的长度,也都不要自己测量"
# -- never measure it yourself; read the parameter he named. The
# gathering side reads `param_names(know, 'member.length')`; a bounding box
# would understate every bent bar, because what a bent bar consumes off the
# stick is its DEVELOPED length and a box only sees the envelope it folds
# into. There is no geometry fallback here and there must never be one.
#
# GROUPED BY MARK, NOT BY INSTANCE. Measured on the live model 2026-08-30:
# 2,122 elements carry a length at or over 40 ft. As one finding per element
# that is a wall of noise that buries every other check on the ticket; as one
# finding per MARK it is a short list a person can rule on, which is also the
# grain the owner's ruling will arrive in ("SR1101 is a continuous run").
def bar_stock_length_status(bars, stock_length_ft, exempt_marks=None):
"""Checklist 5.7 core: bar lengths against the stock they are cut from.
`bars` is a list of dicts, one per BAR INSTANCE, from the gathering
side: {'mark': str-or-None, 'family': str, 'length_ft': float-or-None}.
`length_ft` is None when the role was unmapped or the parameter absent
on that bar, and 0 when the parameter is there and holds zero.
Returns (findings, unread, zero, exempted) where `findings` is a list
of (severity, message) pairs and the other three are sorted lists of
the labels set aside, so the caller can report them under their own
code instead of letting them decide the row.
FOUR OUTCOMES PER BAR, and they are four different things:
over stock -> WARN, grouped by mark, longest first
under stock -> counted toward the pass
length 0.0 -> COULD NOT JUDGE. Measured: 260 elements on the
live model carry the parameter holding zero.
Zero is not a length and it is not an absence,
so it is neither passed nor flagged.
no length at all -> COULD NOT JUDGE, named, never silently dropped.
An exempt mark is set aside BEFORE the comparison and listed, so a
reader can always see what was not asked -- same shape as `checks.muted`
and as 5.2's covered families.
PER-SIZE STOCK, added 2026-08-31. A bar may carry its own `stock_ft`,
resolved from `knowledge.rebar_stock` by the gathering side; when it does
it is compared against THAT and not against `stock_length_ft`. The
argument stays for the bars that carry none. A #4 at a plant stocking #4
in 20 ft lengths used to be compared against the generic 40 and pass at
30 ft, silently, on a real defect class.
The message keeps its old single-length wording when every bar was held
to the SAME length -- one number in a sentence is easier to read than the
same number repeated -- and names the length per mark as soon as they
differ. A sentence that said "the 40 ft stock length" while half the bars
were judged against 20 would be worse than no sentence.
A bar may also carry its `size`, and where it does the over-stock line
NAMES IT: "60 ft for a #4" is a claim a person can check against the
plant's stock list and "60 ft" is not. It is printed only where the
gather resolved one, never inferred from the stock length it matched.
"""
if not stock_length_ft and not any(
(bar or {}).get('stock_ft') for bar in (bars or [])):
return [], [], [], []
exempt = set()
for name in (exempt_marks or []):
text = _bar_label(name)
if text:
exempt.add(text.upper())
over = {} # mark -> [lengths]
over_limit = {} # mark -> the stock length it was held to
over_size = {} # mark -> the size(s) that length belongs to
limits = set() # every stock length actually used
under = 0
unread, zero, exempted = {}, {}, {}
for bar in (bars or []):
label = _bar_label(bar.get('mark')) or _bar_label(bar.get('family')) \
or '(unmarked bar)'
length = bar.get('length_ft')
limit = bar.get('stock_ft') or stock_length_ft
if label.upper() in exempt:
exempted.setdefault(label, 0)
exempted[label] += 1
continue
if length is None:
unread.setdefault(label, 0)
unread[label] += 1
continue
try:
length = float(length)
except (TypeError, ValueError):
unread.setdefault(label, 0)
unread[label] += 1
continue
if length <= 0:
zero.setdefault(label, 0)
zero[label] += 1
continue
if not limit:
# No stock length for this bar at all. Not a pass -- it is the
# comparison never happening, and it is named like every other
# could-not-judge here.
unread.setdefault(label, 0)
unread[label] += 1
continue
limits.add(limit)
if length >= limit:
over.setdefault(label, []).append(length)
over_limit[label] = limit
if bar.get('size'):
over_size.setdefault(label, set()).add(str(bar['size']))
else:
under += 1
one_limit = list(limits)[0] if len(limits) == 1 else None
findings = []
if over:
rows = sorted(over.items(), key=lambda kv: (-max(kv[1]), kv[0]))
parts = []
for mark, lengths in rows:
# THE SIZE IS PART OF THE CLAIM. "60 ft against a 40 ft stick"
# and "60 ft against the 40 ft stick a #4 comes in" are two
# different statements, and only the second can be checked by
# somebody holding the plant's stock list. Named only where the
# gather actually resolved a size -- a mark whose bars disagree
# about it says so rather than picking one.
size = ' for a {0}'.format(', '.join(sorted(over_size[mark]))) \
if over_size.get(mark) else ''
if one_limit is not None:
parts.append('{0} at {1} ft{2} ({3} piece(s))'
.format(mark, _n(max(lengths)), size,
len(lengths)))
else:
parts.append('{0} at {1} ft vs its {2} ft stock{3} ({4} '
'piece(s))'.format(mark, _n(max(lengths)),
_n(over_limit[mark]), size,
len(lengths)))
findings.append((
'WARN',
'{0} bar mark(s) are at or over the {1} stock length and '
'cannot be cut from one stick: {2}'
.format(len(rows),
'{0} ft'.format(_n(one_limit)) if one_limit is not None
else 'stock length for their size',
'; '.join(parts))))
elif under:
findings.append((
'OK',
'every one of the {0} bar(s) whose length could be read is '
'shorter than the {1}'
.format(under,
'{0} ft stock length'.format(_n(one_limit))
if one_limit is not None
else 'stock length for its own size')))
return (findings, _label_counts(unread), _label_counts(zero),
_label_counts(exempted))
def _bar_label(value):
if value is None:
return ''
return str(value).strip()
def _label_counts(counts):
"""{label: n} -> ['LABEL (n)'] sorted, for a message that names things."""
out = []
for label in sorted(counts or {}):
n = counts[label]
out.append('{0} ({1})'.format(label, n) if n > 1 else label)
return out
# ------------------------------- 1.5 call-outs must not overlap
#
# Two printed call-outs whose boxes intersect are two call-outs a reader has
# to disentangle, and nothing else on the ticket notices. TEXT AGAINST TEXT
# ONLY -- text against LINEWORK is deliberately out of scope, because a
# call-out sitting over a drawn edge is the normal condition on every
# elevation and flagging it would report the whole sheet.
#
# WHAT A BOX IS AND IS NOT. `get_BoundingBox(view)` returns the annotation's
# extent in view space, which APPROXIMATES the printed glyphs -- it carries
# the text's leading and side bearings, so it is a little larger than the ink
# and a small intersection can be two notes that print clear of each other.
# That is why this is grade C: an intersection is a place to look, never a
# defect claim, and the message says the size so a reader can judge.
def rect_overlap(a, b):
"""(w, h) of the intersection of two boxes, or None when they miss.
A box is (x0, y0, x1, y1). Touching edges are NOT an overlap: the
owner's spec is a POSITIVE-area intersection, and two boxes sharing a
boundary print clear of each other.
"""
if not a or not b:
return None
ax0, ay0, ax1, ay1 = min(a[0], a[2]), min(a[1], a[3]), \
max(a[0], a[2]), max(a[1], a[3])
bx0, by0, bx1, by1 = min(b[0], b[2]), min(b[1], b[3]), \
max(b[0], b[2]), max(b[1], b[3])
w = min(ax1, bx1) - max(ax0, bx0)
h = min(ay1, by1) - max(ay0, by0)
if w <= 0 or h <= 0:
return None
return w, h
OVERLAP_MIN_IN = 0.005 # 1/200 in each way: below this it is rounding
def callout_overlap_status(items, min_in=OVERLAP_MIN_IN):
"""Checklist 1.5 core: text-bearing annotations that print over each other.
`items` is a list of dicts from the gathering side, one per annotation:
{'view': view name, 'text': printed string, 'kind': 'text note' |
'tag' | 'keynote', 'box': (x0, y0, x1, y1) in PRINTED INCHES,
'rotated': True/False/None}
The caller converts view-space feet to printed inches through the view
scale, so the number in the message is the size on paper -- the only
unit a reader can act on. `box` is None when it could not be read and
`rotated` is None when rotation could not be determined.
Returns (findings, unread, rotated) -- findings a list of
(severity, message), the other two lists of labels set aside. Only
annotations on the SAME view are ever compared; two views' coordinates
have nothing to do with each other.
ROTATED TEXT IS SET ASIDE, NOT FLAGGED. A rotated string still reports
an AXIS-ALIGNED box, which circumscribes the glyphs and overstates the
ink by up to the length of the string. Comparing one of those against a
neighbour manufactures overlaps that do not print, so a pair with a
rotated member is reported as could-not-judge under its own code.
"""
by_view = {}
unread, rotated = [], []
for item in (items or []):
label = _callout_label(item)
if not item.get('box'):
unread.append(label)
continue
if item.get('rotated'):
rotated.append(label)
continue
by_view.setdefault(item.get('view') or '(unnamed view)',
[]).append(item)
hits = []
for view in sorted(by_view):
items_on = by_view[view]
for i in range(len(items_on)):
for j in range(i + 1, len(items_on)):
a, b = items_on[i], items_on[j]
size = rect_overlap(a['box'], b['box'])
if size is None or size[0] < min_in or size[1] < min_in:
continue
hits.append((size[0] * size[1], view, a, b, size))
findings = []
if hits:
hits.sort(key=lambda h: -h[0])
parts = []
for (_area, view, a, b, size) in hits:
parts.append('on "{0}", {1} overlaps {2} by {3}" x {4}"'
.format(view, _callout_label(a), _callout_label(b),
_n(size[0]), _n(size[1])))
findings.append((
'WARN',
'{0} pair(s) of printed call-outs overlap: {1}'
.format(len(hits), '; '.join(parts))))
elif by_view:
findings.append((
'OK',
'no two of the {0} printed call-out(s) on this ticket overlap'
.format(sum(len(v) for v in by_view.values()))))
return findings, sorted(set(unread)), sorted(set(rotated))
def _callout_label(item):
"""How a call-out is named in a finding: its words, not its element id."""
text = clean_text((item or {}).get('text') or '').strip()
text = ' '.join(text.split())
kind = (item or {}).get('kind') or 'annotation'
if not text:
return 'an untitled {0}'.format(kind)
if len(text) > 40:
text = text[:40] + '...'
return '{0} "{1}"'.format(kind, text)
# ------------------------- 5.6 bar marks unique, model against print
#
# THE COMPUTABLE HALF OF THE OLD 5.5. Legibility is a property of the
# picture and stays with a person (grade D, `ticketvision`); uniqueness is a
# comparison between what the model carries and what the sheet prints, and a
# comparison is code's job.
#
# WHAT A PRINTED CALL-OUT LOOKS LIKE. Read off the live model 2026-08-30 via
# the bridge, on a bar-carrying WP ticket: `SR324 (F.F.)`, `SR310 (E.L.)`,
# `SR310 (N.F.)`. The printed string CONTAINS the mark and is never equal to
# it -- a face designation follows in parentheses -- so the match is
# containment of a whole token, never string equality.
def bar_mark_uniqueness_status(printed, bars, attached=None, classify=None):
"""Checklist 5.6 core: printed bar marks against the modelled bars.
`printed` is a list of {'text', 'view'} -- one per printed call-out
string. `bars` is a list of {'mark', 'family', 'length_ft'} -- one per
modelled bar instance, `mark` None when the bar carries none.
Returns (findings, unread) -- findings a list of (severity, message),
`unread` the marks of bars that could not be read at all.
THREE QUESTIONS, and each is a different defect:
(a) one printed mark, two bar populations. Two bars sharing a mark
but differing in family or in length are two different parts
under one name, and the drawing cannot tell them apart. The mark
is what a fabricator cuts to, so this is the expensive one.
(b) a printed mark no bar carries. The sheet calls out a part the
model does not have.
(c) a modelled bar whose mark is never printed. The part exists and
the drawing never names it.
A bar with NO mark at all is not any of the three: it is could-not-judge,
returned separately, and it is never counted as (c) -- an absent mark
cannot be looked for in the printed text.
---- TWO NARROWINGS, BOTH DECLARED, BOTH INJECTED (2026-08-31) ---------
`attached` is `knowledge.attached_parents()`: {BAR MARK: [parent, ...]},
a bar that ships inside another item and is COVERED BY ITS PARENT'S
CALLOUT. The plant's own words, written down long before this read them:
"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."
It narrows (c) ONLY, and into two outcomes, never into silence:
* the parent IS printed -> the bar is accounted for. A JUDGED result,
named in the sentence, because a coverage nobody can see is a
coverage nobody can question.
* the parent 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, and a report naming the bar would send the
reader to fix the wrong thing.
`classify` is `knowledge.printed_token_population`: whether a printed
token is a bar mark, a piece of hardware, or something the grammar cannot
place. It narrows (b) only. `P78 W/ SR5502 (S.I.F.)` carries BOTH
populations, and no `W/` splitter is needed or wanted: the grammar
answers per token, so the part is set aside and the bar after it is
compared -- one mechanism instead of two that can disagree.
A token the grammar CANNOT place is its own finding: could-not-judge,
never a quiet drop. Both arguments default to None, which is exactly what
this function did before either existed -- every token a candidate, no
bar covered.
"""
marks = {} # MARK -> [bar, ...]
unread = {}
for bar in (bars or []):
mark = _bar_label(bar.get('mark'))
if not mark:
label = _bar_label(bar.get('family')) or '(unnamed family)'
unread.setdefault(label, 0)
unread[label] += 1
continue
marks.setdefault(mark.upper(), []).append(bar)
# EVERY token, whatever it turns out to be. "Is this mark printed
# anywhere on the ticket" is answered from this set and never from the
# narrowed one: a bar mark the grammar misfiled as hardware is still ink
# on the sheet, and (c) must not go on to report it as unprinted.
printed_tokens = {} # TOKEN -> set(view)
for row in (printed or []):
for token in mark_tokens(row.get('text')):
printed_tokens.setdefault(token, set()).add(
row.get('view') or '(unnamed view)')
candidates, hardware, unplaced = {}, {}, {}
for token in printed_tokens:
population, why = ('ungoverned', None)
if classify is not None:
population, why = classify(token)
if population == 'hardware':
hardware[token] = why
elif population == 'unknown':
unplaced[token] = why
else:
candidates[token] = why
covers = {}
for child in (attached or {}):
covers[_text(child).strip().upper()] = [
_text(parent).strip() for parent in (attached[child] or [])
if _text(parent).strip()]
findings = []
# (a) one mark, two populations
split = []
for mark in sorted(marks):
populations = set()
for bar in marks[mark]:
populations.add((_bar_label(bar.get('family')),
_round_ft(bar.get('length_ft'))))
if len(populations) > 1:
split.append((marks[mark][0].get('mark') or mark,
sorted('{0} @ {1} ft'.format(f or '(no family)',
_n(l) if l is not None
else '?')
for (f, l) in populations)))
if split:
findings.append((
'WARN',
'{0} bar mark(s) name more than one part, so the drawing cannot '
'tell them apart: {1}'
.format(len(split),
'; '.join('{0} = {1}'.format(m, ' / '.join(p))
for (m, p) in split))))
def _where(tok):
return '{0} (on "{1}")'.format(
tok, '", "'.join(sorted(printed_tokens[tok])))
# (b) printed, but not modelled -- over the BAR-MARK population only.
orphan = sorted(tok for tok in candidates if tok not in marks)
if orphan:
findings.append((
'WARN',
'{0} printed bar mark(s) match no bar in the model: {1}'
.format(len(orphan), ', '.join(_where(tok) for tok in orphan))))
# (b2) printed, and the grammar could not say WHAT it is. Not a bar
# finding, and not a drop either: an unclassifiable token is a fact about
# the sheet or about the grammar, and it is reported as one.
if unplaced:
findings.append((
'WARN',
'{0} printed token(s) could not be placed as either a bar mark '
'or a part, so nothing is claimed about them: {1}'
.format(len(unplaced),
' | '.join('{0} -- {1}'.format(
_where(tok), unplaced[tok] or 'no reason recorded')
for tok in sorted(unplaced)))))
# (c) modelled, but never printed -- split by whether a parent covers it.
silent, covered, orphaned_parent, unplaceable_parent = [], [], [], []
for m in sorted(marks):
if m in printed_tokens:
continue
label = marks[m][0].get('mark') or m
parents = covers.get(m)
if not parents:
silent.append(label)
continue
# A PARENT IS NAMED IN TWO VOCABULARIES AND BOTH ARE LEGITIMATE.
# Measured on the live layers 2026-08-31: the plant file names a
# parent by its FAMILY (`<vendor>_EMBED_P76`), the job file 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` does with a family name, for the
# same reason -- rather than demanding one spelling and turning the
# other into a finding nobody could act on.
shown, unreadable = [], []
for parent in parents:
numbers = part_numbers_in(parent)
if not numbers:
unreadable.append(parent)
continue
if any(number in printed_tokens for number in numbers):
shown.append(parent)
if shown:
covered.append((label, shown))
elif unreadable and len(unreadable) == len(parents):
# NO CLAIM EITHER WAY. Nothing in the parent's name is shaped
# like a part number, so whether the ticket prints it could not
# be established -- and "the parent is missing" is not something
# this may assert on the strength of a lookup it could not do.
unplaceable_parent.append((label, parents))
else:
orphaned_parent.append((label, parents))
if silent:
findings.append((
'WARN',
'{0} modelled bar mark(s) are never printed on this ticket: {1}'
.format(len(silent), ', '.join(silent))))
if orphaned_parent:
findings.append((
'WARN',
'{0} bar(s) are covered by a parent item callout and NEITHER the '
'bar nor its parent is printed on this ticket, so the bar is '
'unaccounted for -- what is missing is the PARENT callout, not '
'the bar: {1}'
.format(len(orphaned_parent),
' | '.join('{0} is attached to {1}'
.format(label, '/'.join(parents))
for (label, parents) in orphaned_parent))))
if unplaceable_parent:
findings.append((
'WARN',
'{0} bar(s) are declared covered by a parent item, and nothing in '
'that parent name is shaped like a part number, so whether the '
'ticket calls the parent out could not be established -- nothing '
'is claimed about the bar: {1}'
.format(len(unplaceable_parent),
' | '.join('{0} is attached to {1}'
.format(label, '/'.join(parents))
for (label, parents) in unplaceable_parent))))
covered_note = ''
if covered:
covered_note = ('; {0} bar(s) are never drawn on their own because a '
'parent item callout covers them, and that parent IS '
'printed: {1}'
.format(len(covered),
', '.join('{0} is attached to {1}'
.format(label, '/'.join(shown))
for (label, shown) in covered)))
hardware_note = ''
if hardware:
hardware_note = ('; {0} printed token(s) are hardware part numbers by '
'the plant part-number grammar, not bar marks, and '
'were not looked for among the bars: {1}'
.format(len(hardware), ', '.join(sorted(hardware))))
if not findings and marks:
findings.append((
'OK',
'all {0} modelled bar mark(s) are accounted for, each names one '
'part, and every printed bar mark exists in the model{1}{2}'
.format(len(marks), covered_note, hardware_note)))
elif covered_note or hardware_note:
# SAID EVEN WHEN SOMETHING ELSE FAILED. A narrowing visible only on a
# green row is a narrowing nobody audits on the runs that matter.
findings.append((
'OK', 'what 5.6 set aside on this piece{0}{1}'
.format(covered_note, hardware_note)))
return findings, _label_counts(unread)
# =========================================================================
# THE DESIGN-SUMMARY VOCABULARY -- rows 8.1 and 5.9
# =========================================================================
#
# WHAT THIS IS FOR. Row 8.1 enumerates the design-summary lines nothing
# could interpret. On a real insulated piece that was SEVEN lines out of
# seven, and one of them -- `HK ST100'S @1'-4" O.C.` -- is the requirement
# row 5.9 exists to compare against, so 5.9 stood down on every press with
# "the summary parser cannot read it yet". Seven uninterpreted lines and a
# permanently silent row, out of one missing grammar.
#
# WHAT IT IS NOT. This grammar CATEGORISES; it does not evaluate. A line it
# places is a line whose SHAPE is known -- "this is a spacing requirement
# naming this product at this pitch" -- and nothing here decides whether
# the piece honours it. Two consequences the next reader must keep:
#
# * A LINE THE GRAMMAR CANNOT PLACE STAYS AN 8.1 FINDING. 8.1's job is
# unparsed lines and it must never be taught to shrug: `classify` puts
# what it could not place into its second return value, verbatim, and
# 8.1 reports it exactly as it always did.
# * A LINE PLACED BUT NOT VALUED IS NOT A CLEAN LINE. `@ "S" O.C.` (a
# table variable) and `@ __ O.C.` (a literal blank on the sheet) are
# recognised as spacing requirements whose spacing IS NOT A NUMBER;
# they carry `spacing_in: None` and `spacing_unread_because`, and 8.1
# names them in their own clause rather than counting them as read.
# Anything consuming a requirement must check `spacing_in is None`.
#
# THE CORPUS THE PRODUCTIONS COME FROM. Two bodies, both quoted for GRAMMAR
# only -- never for the job or the client they belong to:
#
# (a) the design-summary lines a live run recorded as unresolved, read
# back out of the evidence files: 9 distinct lines, 7 of them on the
# one piece the owner read (counted 2026-08-31);
# (b) the plant standards manual extractions in QNA, which are the same
# sentences written many more ways: 54 distinct `... @ <spacing>
# O.C. ...` call-outs over 75 occurrences (counted 2026-08-31).
#
# Each production below names how many of those it was drawn from. Where
# (b) contradicts a shape (a) alone would have suggested, (b) wins -- that
# is why the spacing reader refuses `"S"` and `__` instead of parsing the
# first digits it can find.
SUMMARY_SPACING = 'spacing'
SUMMARY_PLACEMENT = 'placement'
SUMMARY_CONDITIONAL = 'conditional'
SUMMARY_LABEL = 'label'
# The vulgar fractions the manual prints. Written out because a fraction
# glyph silently dropped turns 1 1/2" into 1", and the engines this ships
# to do not all carry `unicodedata`.
_VULGAR = {
u'½': ' 1/2', u'¼': ' 1/4', u'¾': ' 3/4',
u'⅛': ' 1/8', u'⅜': ' 3/8', u'⅝': ' 5/8',
u'⅞': ' 7/8', u'⅓': ' 1/3', u'⅔': ' 2/3',
}
def _normalise_summary_text(text):
"""One design-summary line, ready for the productions to match.
Curly quotes to straight, vulgar fractions spelled out, whitespace
collapsed. Nothing is upper-cased here: the verbatim text is what 8.1
prints and what a person recognises on the sheet.
"""
import re as _re
if text is None:
return ''
out = _text(text)
out = out.replace(u'’', "'").replace(u'”', '"')
out = out.replace(u'‘', "'").replace(u'“', '"')
for glyph in _VULGAR:
out = out.replace(glyph, _VULGAR[glyph])
return _re.sub(r'\s+', ' ',
out.replace('\r', ' ').replace('\n', ' ')).strip()
def summary_spacing_inches(text):
"""A printed spacing as inches: (inches, None) or (None, why).
THE REASON IS THE POINT. The manual writes a spacing that is not a
number in two shapes, and both are in the corpus --
`@ "S" O.C.` a table variable; the sheet carries a table of
widths against spacings and "S" is its column
(7 occurrences over 5 distinct call-outs)
`@ __ O.C.` a literal blank left on the sheet (1 occurrence)
-- and a reader that scanned for the first digits would answer both
from whatever happened to sit to the left of the `@`. So the numeric
shapes are matched WHOLE and anything else is refused by name.
"""
import re as _re
raw = _normalise_summary_text(text)
if not raw:
return None, 'no spacing was printed after the @'
body = raw.strip()
# <n>'-<n>" and <n>' on its own -- 22 of the 54 distinct call-outs.
m = _re.match(
r"^(\d+)\s*'\s*-?\s*(?:(\d+)(?:\s+(\d+)\s*/\s*(\d+))?\s*\"?)?$", body)
if m:
inches = float(m.group(1)) * 12.0
if m.group(2):
inches += float(m.group(2))
if m.group(3):
inches += float(m.group(3)) / float(m.group(4))
return inches, None
# bare inches, whole / mixed / fractional -- 18 of the 54.
m = _re.match(
r"^(?:(\d+)(?:\s+(\d+)\s*/\s*(\d+))?|(\d+)\s*/\s*(\d+))\s*\"$", body)
if m:
if m.group(4):
return float(m.group(4)) / float(m.group(5)), None
inches = float(m.group(1))
if m.group(2):
inches += float(m.group(2)) / float(m.group(3))
return inches, None
if _re.match(r'^"?[A-Z]"?$', body, _re.I):
return None, (
'the spacing is printed as the symbol {0}, which is a column of '
'a table on the sheet and not a length -- the sheet resolves it '
'against that table and this tool does not read it'.format(body))
if _re.match(r'^[_\-\s]+$', body):
return None, (
'the spacing is a literal blank on the sheet ({0}), so the '
'design states the shape of the requirement and not its value'
.format(body))
# QUOTED, NOT `!r`. On the button engine a repr of a unicode string
# prints `u'...'`, and this sentence goes on the report.
return None, (
'the spacing "{0}" is not written in any form this reader knows '
'(feet-and-inches, whole inches, or a fraction of an inch)'
.format(body))
# `<product> @ <spacing> O.C. <qualifiers>`. THE FIRST `@` WINS AND THE REST
# IS A QUALIFIER, which is not a shortcut -- `#3S @ 18" O.C. +(2)@3" EA.
# END` states ONE field spacing and then an end densification, and the
# measured piece honours exactly that reading (16.0 in across the field
# with tighter pairs at the ends, 2026-08-31). A reader that took the last
# `@` would report the end pair as the requirement and fail every piece.
_SUMMARY_RES = {}
def _summary_res():
import re as _re
if not _SUMMARY_RES:
_SUMMARY_RES['spacing'] = _re.compile(
r"^(?P<product>[^@]*?)\s*@\s*(?P<spacing>[^@]*?)\s*"
r"O\.?\s*C\.?(?P<tail>.*)$", _re.I)
# `CL` -- centred. Confirmed as a PLACEMENT rather than a part
# number by QNA/proposed-checks-2026-08-31.md P12, which reads
# `CL #3 BARS@ TOP WYTHE` as "a bar size, a placement (centred) and
# a wythe". 5 of the 9 distinct live lines open with it.
_SUMMARY_RES['cl'] = _re.compile(r"^C\.?\s*L\.?\s+(?P<items>\S.*)$",
_re.I)
# A prose condition. Deliberately a SHORT list of connectives that
# actually appear, not a general grammar of English: a line whose
# condition this cannot see stays unplaced, which is the safe
# direction.
_SUMMARY_RES['cond'] = _re.compile(
r"^(?P<requirement>.*?)\s+(?:NEEDED\s+WHEN|ONLY\s+WHEN|WHEN|"
r"WHERE|IF)\s+(?P<condition>\S.*)$", _re.I)
_SUMMARY_RES['label'] = _re.compile(r'^[A-Za-z .]+$')
_SUMMARY_RES['split'] = _re.compile(r'\s*&\s*|\s+AND\s+', _re.I)
_SUMMARY_RES['tokens'] = _re.compile(r'[\s/,]+')
_SUMMARY_RES['possessive'] = _re.compile(r"'S$")
return _SUMMARY_RES
# Words that make an all-letters line a STATEMENT rather than a label, so
# `REINFORCING PER DESIGN` is never filed as a heading and quietly emptied
# of its content. A label production without this list is how a deferral
# ("per design", "as required") becomes a line the tool claims to have
# understood.
_NOT_A_LABEL = ('PER', 'DESIGN', 'REQUIRED', 'TYP', 'MIN', 'MAX', 'NOT',
'NO', 'ALL', 'EACH', 'ONLY', 'SEE', 'NOTE', 'BY', 'OR',
'AND', 'WITH', 'FROM', 'INTO', 'AT', 'ON', 'TO')
def summary_tokens(text):
"""A product phrase as comparable tokens.
Uppercased, punctuation stripped, and a trailing possessive/plural `'S`
removed -- the summary writes `HK ST100'S` where the plant's own alias
writes `ST100`, and those are the same product.
"""
res = _summary_res()
out = []
for raw in res['tokens'].split(_normalise_summary_text(text).upper()):
token = res['possessive'].sub('', raw.strip())
token = token.strip('.,;:()[]"\'')
if token:
out.append(token)
return out
def parse_summary_line(text):
"""One design-summary line as a typed requirement, or None.
None means UNPLACED, and an unplaced line goes back to 8.1 verbatim.
The productions, conditional first so a condition WRAPS whatever it
qualifies rather than being swallowed by it:
conditional `<requirement> NEEDED WHEN <condition>` 1 live line
spacing `<product> @<spacing> O.C. <qualifiers>` 2 live lines,
54 distinct call-outs in the manual corpus
placement `CL <items>[@ <zone>]` 3 live lines
label an all-letters heading naming the group 1 live line
"""
res = _summary_res()
line = _normalise_summary_text(text)
if not line:
return None
m = res['cond'].match(line)
if m and m.group('requirement').strip():
return {
'kind': SUMMARY_CONDITIONAL,
'text': line,
'condition': m.group('condition').strip(),
'requirement': parse_summary_line(m.group('requirement')),
'requirement_text': m.group('requirement').strip(),
}
m = res['spacing'].match(line)
if m and m.group('spacing').strip():
inches, why = summary_spacing_inches(m.group('spacing'))
product = m.group('product').strip().strip('-').strip()
# KEPT VERBATIM, trailing period and all: `E.W.` is each-way and
# `E.W` is a typo the reader would have to go and check.
tail = m.group('tail').strip()
return {
'kind': SUMMARY_SPACING,
'text': line,
'product': product or None,
'product_tokens': summary_tokens(product),
'spacing_in': inches,
'spacing_text': m.group('spacing').strip(),
'spacing_unread_because': why,
'qualifiers': [tail] if tail else [],
}
m = res['cl'].match(line)
if m:
body = m.group('items').strip()
zone = None
# `CL #3 BARS@ TOP WYTHE` -- the zone rides on an `@` with no O.C.
# after it. Split on the LAST `@`, because a product may carry one.
if '@' in body:
body, _sep, zone = body.rpartition('@')
body, zone = body.strip(), zone.strip()
items = [part.strip() for part in res['split'].split(body)
if part.strip()]
if items:
return {
'kind': SUMMARY_PLACEMENT,
'text': line,
'placement': 'centred',
'placement_token': 'CL',
'items': items,
'zone': zone or None,
}
words = line.replace('.', '').split()
if (words and len(words) <= 3 and res['label'].match(line)
and not [w for w in words if w.upper() in _NOT_A_LABEL]):
return {
'kind': SUMMARY_LABEL,
'text': line,
'states_no_requirement': True,
}
return None
def classify_summary_lines(lines):
"""([requirement, ...], [unplaced entry, ...]).
`lines` is whatever 8.1 was handed: `{text, reason}` entries as the
extractor writes them, or bare strings from an older spec. The unplaced
half comes back in the SAME shape it arrived in, because 8.1 prints it
and a reader has to be able to find the line on the sheet.
"""
placed, unplaced = [], []
for entry in (lines or []):
text = entry.get('text') if isinstance(entry, dict) else entry
parsed = parse_summary_line(text)
if parsed is None:
unplaced.append(entry)
else:
placed.append(parsed)
return placed, unplaced
def summary_requirement_inventory(placed):
"""What the grammar made of the lines it placed, as one sentence.
8.1 prints this on the clean answer. A row that went from seven red
lines to green with no sentence under it would have replaced one
silence with a quieter one -- the reader must be able to see WHAT the
tool decided each line was, the conditionals must be visible because
the tool states them and does not evaluate them, and a spacing it could
not value must not hide inside a count of lines it "read".
"""
if not placed:
return ''
counts, conditionals, unvalued = {}, [], []
for req in placed:
kind = req.get('kind')
counts[kind] = counts.get(kind, 0) + 1
if kind == SUMMARY_CONDITIONAL:
conditionals.append(req.get('text'))
if kind == SUMMARY_SPACING and req.get('spacing_in') is None:
unvalued.append('{0} ({1})'
.format(req.get('text'),
req.get('spacing_unread_because')))
order = (SUMMARY_SPACING, SUMMARY_PLACEMENT, SUMMARY_CONDITIONAL,
SUMMARY_LABEL)
words = {SUMMARY_SPACING: 'spacing requirement',
SUMMARY_PLACEMENT: 'placement statement',
SUMMARY_CONDITIONAL: 'conditional',
SUMMARY_LABEL: 'group label'}
parts = []
for kind in order:
if counts.get(kind):
parts.append('{0} {1}{2}'.format(counts[kind], words[kind],
's' if counts[kind] > 1 else ''))
said = ('{0} line(s) categorised by the summary vocabulary: {1}'
.format(len(placed), ', '.join(parts)))
if conditionals:
said += ('. {0} of them state a CONDITION this tool records and does '
'not evaluate: {1}'
.format(len(conditionals), '; '.join(conditionals)))
if unvalued:
said += ('. {0} spacing requirement(s) were recognised but carry no '
'numeric spacing, so nothing may be compared against them: '
'{1}'.format(len(unvalued), '; '.join(unvalued)))
return said
# ------------------------------------------- connectors (5.8, 5.9, 5.10)
#
# One recognition gate, three questions. The gate lives in `ticketchecks`
# (`_connector_gate`) because it reads knowledge and a ticket; what is here
# is the arithmetic, which is pure and host-testable.
def connectors_exist_status(count, gated_out=0):
"""5.8: an insulated panel has connectors. (severity, message).
ZERO IS A FAIL AND NOT A WARN. A sandwich panel whose wythes are not
tied together is a structural condition, not a drafting one -- the two
concrete layers are travelling in company. Most zero-shaped findings in
this tool are WARNs because a zero usually means the tool could not see
something; this one is reached ONLY after recognition has been confirmed
by the gate, so a zero here is a zero in the piece.
"""
if count:
note = ('{0} connector(s) cross the insulation on this piece'
.format(count))
if gated_out:
note += ('; {0} member(s) carried a connector mark and were '
'excluded by the plant component gate'.format(gated_out))
return 'OK', note
if gated_out:
return 'FAIL', (
'NO connector was counted on this insulated panel, and {0} '
'member(s) DID carry a connector mark but were excluded by the '
'plant component gate (connectors.component_gate). Either this '
'piece has untied wythes or that gate is wrong -- both need '
'somebody, and nothing here decides which'.format(gated_out))
return 'FAIL', (
'NO connector was found on this insulated panel. A connector is what '
'crosses the insulation and ties the two concrete wythes together; '
'without any, the wythes are two slabs travelling in company')
def connector_spacing(positions_in):
"""Gaps between consecutive connector stations, in inches."""
values = sorted(set([round(float(v), 3) for v in (positions_in or [])
if v is not None]))
return [round(values[i + 1] - values[i], 3)
for i in range(len(values) - 1)]
def connectors_sufficient_status(count, required, spacing_in=None,
required_spacing_in=None,
requirement_note=None,
tolerance_in=None, tolerance_why=None):
"""5.9: enough connectors for what the design calls for.
`required` / `required_spacing_in` come from the DESIGN side. Until
2026-08-31 nothing ever supplied either, and this returned (None,
reason) on every press; the summary vocabulary
(`classify_summary_lines`) and `knowledge.connector_spacing_requirement`
now resolve the second from the design summary's own line. It still
returns (None, reason) when they cannot -- 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.
`requirement_note` is WHERE THE REQUIREMENT CAME FROM, and it is in
every message this function returns, pass or fail. "16 in on centre" and
"16 in on centre, from the summary line `HK ST100'S @1'-4" O.C.`, joined
to this plant's R82 through ST100" are different claims, and only the
second can be argued with.
END DENSIFICATION IS NORMAL AND MUST NOT BE FLAGGED. Measured on one
insulated piece 2026-08-31: field spacing exactly 16.0 in -- which IS
the design summary's 1 ft 4 in on centre -- with tighter pairs (9.5 and
6.5 in) at the ends. So the comparison is against the WIDEST gap, and a
gap smaller than required is never the defect.
THE TOLERANCE IS AN ARGUMENT AND IT IS PASSED IN, not a literal here.
It was `+ 0.5` in this function; `knowledge.connector_spacing_tolerance`
now owns the number and its provenance, and a run that cannot resolve
one COMPARES NOTHING rather than falling back on a constant -- a
borrowed tolerance is the same class of mistake as a borrowed edge
distance (5.10) and this row is not going to make it a second time.
"""
tail = ' ({0})'.format(requirement_note) if requirement_note else ''
shortfall = 'this piece carries {0} connector(s) and the design calls '
if required is None and required_spacing_in is None:
return None, (
'no connector requirement could be resolved from the design, so '
"there is nothing to compare this piece's {0} connector(s) "
'against. Nothing is claimed{1}'.format(count, tail))
if required is not None and count < required:
return 'FAIL', (shortfall + 'for {1}{2}').format(count, required, tail)
if required_spacing_in:
gaps = [g for g in (spacing_in or []) if g]
if not gaps:
return None, (
'the design calls for connectors at {0} in on centre and '
"this piece's connector positions could not be measured, so "
'the spacing was not compared{1}'
.format(required_spacing_in, tail))
if tolerance_in is None:
return None, (
'the design calls for connectors at {0} in on centre and the '
'widest gap on this piece is {1} in, but {2}, so the two were '
'not compared -- a tolerance borrowed from somewhere else is '
'not the one this comparison needs{3}'
.format(required_spacing_in, max(gaps),
tolerance_why or 'no tolerance was resolved', tail))
widest = max(gaps)
allowance = ' (tolerance {0})'.format(tolerance_why or tolerance_in)
if widest > float(required_spacing_in) + float(tolerance_in):
return 'FAIL', (
'the widest gap between connectors is {0} in and the design '
'calls for {1} in on centre; tighter groups at the ends are '
'normal and are not counted against this{2}{3}'
.format(widest, required_spacing_in, tail, allowance))
return 'OK', (
'{0} connector(s), widest gap {1} in against the {2} in on '
'centre the design calls for{3}{4}'
.format(count, widest, required_spacing_in, tail, allowance))
return 'OK', ('{0} connector(s) against the {1} the design calls for{2}'
.format(count, required, tail))
def connector_edge_status(edges_in, minimum_in, source, mark=None):
"""5.10: no connector closer to an edge than its product allows.
`source` is 'product' or 'plant' and it is IN THE MESSAGE: "6 inches"
and "6 inches for this product" are different claims, and a reader
deciding whether to move a pin needs to know which one he was handed.
That is 6.2's lesson carried one part along.
A missing minimum is (None, reason) -- never a pass, and never another
product's number.
"""
measured = [e for e in (edges_in or []) if e is not None]
if not measured:
return None, ('no connector position could be measured on this '
'piece, so no edge distance was checked')
if minimum_in is None:
return None, ('no minimum edge distance is published for {0}, so how '
'close to an edge these {1} connector(s) may sit is not '
'written down anywhere and nothing is claimed. The '
'closest measured is {2} in'
.format(mark or 'this connector', len(measured),
min(measured)))
close = sorted([e for e in measured if e < float(minimum_in)])
if source == 'product':
basis = 'the published minimum for {0}'.format(mark)
else:
basis = ('a PLANT-WIDE minimum, which is NOT this product own '
'number').replace('product own', "product's own")
if not close:
return 'OK', ('every connector clears {0} in ({1}); closest is {2} in'
.format(minimum_in, basis, min(measured)))
return 'WARN', (
'{0} connector(s) sit closer to an edge than {1} in ({2}): {3}'
.format(len(close), minimum_in, basis,
', '.join(['{0} in'.format(e) for e in close])))
# =========================================================================
# SOLID ZONES AND INSULATION RECESSES -- master rows 5.11 to 5.15
# =========================================================================
#
# Owner, 2026-08-31, having asked for it before: "没有 solid zone 相关的检查。
# lifter 自带 solid zone,没检查这些 solid zone 是不是被加入了 assembly,是不是
# 被显示在 ticket 上面,hatch pattern 对不对。这些都没有啊" -- and then "不光是
# solid zone,insulation recess 也得加上。你看看 page 2 里面的那些 hatch
# pattern 就知道了。"
#
# THE MEASUREMENTS THESE ARE BUILT ON, all bridge-verified 2026-08-31 and
# quoted here because a number in a comment is checkable and a number in a
# commit message is not:
#
# * every recognised lifter on an insulated piece nests EXACTLY ONE solid
# zone, reachable by `GetSubComponentIds()` -- 192 of 192 across the 27
# insulated pieces. There are 82 standalone zones at openings besides.
# * only 21 of those 192 nested zones are assembly members; 17 of the 27
# pieces have ZERO members.
# * 0 of 192 render on any ticket view. The insulation layout view shows
# only the standalone ones.
# * the layout view is ASSEMBLY-SCOPED, so membership is the visibility
# mechanism. THAT LAST SENTENCE IS AN INFERENCE, not a measurement, and
# `solid_zone_ticket_status` says so in its own finding rather than
# letting it pass as a read.
# * the `SOLID ZONE` view filter on the insulation view template overrides
# the surface foreground pattern to TRIANG -- the RECESS pattern -- so
# a model solid zone renders with the wrong hatch today. Nested and
# standalone zones share ONE TYPE, so fixing visibility alone would add
# eight more wrongly-hatched zones per piece rather than none. That is
# why 5.12 and 5.13 are separate rows.
def solid_zone_membership_status(zones, lifters_without_zone=0,
lifters_unread=0):
"""5.11: every lifter's nested solid zone is a member of the assembly.
`zones` is one dict per nested zone: `member` True / False / None, where
None is "membership could not be read". Returns (severity, message), or
(None, reason) when nothing could be judged at all.
WHY NOT-A-MEMBER IS A FAIL. A zone outside the assembly is not on the
ticket: the layout view is assembly-scoped, so the shop is handed a
drawing of a piece whose solid zones are invisible, and the insert it
exists for looks as though it pulls against insulation. That is a defect
in what gets built, not a drafting untidiness.
A ZONE WHOSE MEMBERSHIP COULD NOT BE READ IS NAMED IN THE SAME FINDING
and never in a second one. `checklist_states` keeps the LAST finding per
code, so a second `SOLID-ZONE-ASM` would silently replace the first and
the row would print half the answer -- 6.7's lesson, and it is cheaper
to obey than to rediscover.
"""
rows = list(zones or [])
if not rows:
return None, (
'no solid zone was found nested in any lifter on this piece, so '
'there was no membership to judge -- which is a different '
'statement from "every zone is a member" and is never printed as '
'one')
outside = [z for z in rows if z.get('member') is False]
unread = [z for z in rows if z.get('member') is None]
inside = [z for z in rows if z.get('member') is True]
tail = ''
if unread:
tail += ('; {0} zone(s) whose assembly membership could not be read '
'were NOT judged either way'.format(len(unread)))
if lifters_unread:
tail += ('; {0} lifter(s) could not be read, so a zone nested in one '
'of them would not have been seen'.format(lifters_unread))
if lifters_without_zone:
tail += ('; {0} lifter(s) nest no solid zone at all -- see the '
'separate SOLID-ZONE-ABSENT finding, which is a different '
'defect'.format(lifters_without_zone))
if outside:
return 'FAIL', (
'{0} of {1} solid zone(s) nested in this piece\'s lifters are NOT '
'members of its assembly, so they are not on the ticket: the '
'layout view is assembly-scoped, and the shop is shown insulation '
'where the insert actually pulls against concrete{2}'
.format(len(outside), len(rows), tail))
if not inside:
return None, (
'none of the {0} nested solid zone(s) on this piece had a '
'readable assembly membership, so nothing was judged{1}'
.format(len(rows), tail))
return 'OK', (
'all {0} solid zone(s) nested in this piece\'s lifters are members of '
'its assembly{1}'.format(len(inside), tail))
def solid_zone_absent_status(lifters_without_zone, lifters_total):
"""5.11's second code: a lifter that nests NO solid zone at all.
ITS OWN FINDING, ON ITS OWN CODE, AND ZERO TODAY. Measured 2026-08-31 it
is 0 of 192 -- and it is built anyway, because the day it is not zero it
is a WORSE defect than any membership question: there is no zone to add
to the assembly, so the repair is in the model and not on the ticket.
Folding it into the membership sentence would have made the two look
like one problem with one fix.
Returns None when there is nothing to report -- the caller emits no
finding, and 5.11 is set by the membership code alone.
"""
if not lifters_without_zone:
return None
return 'FAIL', (
'{0} of {1} lifter(s) on this insulated piece nest NO solid zone at '
'all. This is not a ticket problem and adding the lifter to the '
'assembly will not fix it: there is no block of concrete for the '
'insert to pull against, so the model is what has to change'
.format(lifters_without_zone, lifters_total or lifters_without_zone))
def solid_zone_ticket_status(zones, view_name, members_are_visible=True):
"""5.12: the nested solid zones appear on the insulation layout view.
HOW THIS IS DECIDED, SAID OUT LOUD IN THE FINDING. The layout view is
assembly-scoped, so a zone that is an assembly member reaches it and one
that is not cannot. That relationship is an INFERENCE from how the view
is built, not a rendered-geometry read, and the message says so -- a
reader who is told "this zone is not on the ticket" is owed the basis,
because the repair (add it to the assembly) follows from the basis and
not from the verdict.
`members_are_visible` is the escape hatch for the day somebody measures
the rendered view directly: pass the real answer and this stops
inferring. It is not a default anybody should change casually.
"""
rows = list(zones or [])
if not view_name:
return None, (
'the insulation layout view is not named for this plant, so '
'there was nowhere to look for these solid zones')
if not rows:
return None, (
'no solid zone was found nested in any lifter on this piece, so '
'none could be looked for on {0}'.format(view_name))
if not members_are_visible:
return None, (
'whether an assembly member reaches {0} was not established on '
'this run, so nothing is claimed about what the ticket shows'
.format(view_name))
unread = [z for z in rows if z.get('member') is None]
shown = [z for z in rows if z.get('member') is True]
missing = [z for z in rows if z.get('member') is False]
tail = ''
if unread:
tail = ('; {0} zone(s) whose membership could not be read were not '
'judged'.format(len(unread)))
if missing:
return 'FAIL', (
'{0} of {1} solid zone(s) do not appear on {2}. INFERRED, and '
'the basis matters for the repair: that view is assembly-scoped, '
'so a zone that is not a member of the assembly cannot be drawn '
'on it -- adding it to the assembly is what puts it on the '
'ticket{3}'.format(len(missing), len(rows), view_name, tail))
if not shown:
return None, (
'no solid zone on this piece had a readable membership, so what '
'{0} shows could not be inferred{1}'.format(view_name, tail))
return 'OK', (
'all {0} nested solid zone(s) reach {1} -- INFERRED from assembly '
'membership, which is what that view is scoped on{2}'
.format(len(shown), view_name, tail))
def hatch_status(code_label, rendered, expected, expected_source, why,
count=None):
"""5.13 / 5.15: a rendered hatch against the legend's own swatch.
(severity, message), or (None, reason). BOTH PATTERNS AND BOTH
PROVENANCES ARE IN THE MESSAGE, always. "The hatch is wrong" is not
actionable; "it draws TRIANG and the legend's own swatch for a solid
zone is ANSI35" tells a reader which of the two to go and change, and
lets him disagree with the tool if the legend is what is wrong.
"""
if expected is None:
return None, why
if rendered is None:
return None, (
'the pattern {0} actually renders with could not be read on this '
'run, so it was not compared against the {1} ({2})'
.format(code_label, expected, why))
basis = ('this ticket\'s own legend' if expected_source == 'legend'
else 'the plant layer\'s copy of the legend')
where = ' on {0} element(s)'.format(count) if count else ''
if _text(rendered).strip().upper() == _text(expected).strip().upper():
return 'OK', (
'{0} draws {1}{2}, which is what {3} shows'
.format(code_label, rendered, where, basis))
return 'FAIL', (
'{0} draws {1}{2}, and {3} shows {4}. {5}'
.format(code_label, rendered, where, basis, expected, why))
def recess_presence_status(regions_by_type, design_requirement,
modelled_path_note=None):
"""5.14: recess regions present where the design calls for them.
SCOPED HONESTLY AND IT STANDS DOWN TODAY. `design_requirement` is what
the design says this piece should have, and NOTHING PARSES IT: the
recesses are drafted regions with no model counterpart, and the design
summary has no line anybody has taught a parser to read. So the row
reports what it found, names what it could not compare it against, and
returns (None, reason).
THAT IS STILL WORTH THE ROW, and the reason is the architecture's own:
the inventory is real and it is written down, the unblocker is named in
the sentence a reader actually sees, and the day either half arrives
this becomes a comparison with no new plumbing. A presence count printed
as agreement would be the alternative, and it would be a green row over
a question nobody asked.
"""
found = dict((k, v) for (k, v) in (regions_by_type or {}).items() if v)
inventory = ', '.join(['{0} x{1}'.format(k, found[k])
for k in sorted(found)]) or 'none'
if design_requirement:
wanted = dict(design_requirement)
missing = [k for k in sorted(wanted)
if wanted[k] and not found.get(k)]
if missing:
return 'FAIL', (
'the design calls for {0} and the ticket draws no region of '
'that type (it draws: {1})'
.format(', '.join(missing), inventory))
return 'OK', (
'every recess the design calls for is drawn on the ticket ({0})'
.format(inventory))
tail = ' {0}'.format(modelled_path_note) if modelled_path_note else ''
if not found:
# ZERO IS THE WHOLE JOB'S ANSWER TODAY, and it must not read as
# "judged and correct". Measured 2026-08-31 once the gather landed:
# NO recess region is placed on any ticket view in this job. The
# only four that exist are LEGEND SWATCHES, on two different legend
# views, and the gather excludes legend views for exactly that
# reason -- counting them would make every job look like it drew
# two recesses.
#
# AND THERE IS NO POSITIVE SPECIMEN. Nothing in this job draws a
# recess, so this reader has never been shown one it got right, and
# it cannot tell "this job draws no recesses" from "this job forgot
# them". The sentence says so rather than implying the first.
tail += (' NOTHING WAS FOUND, AND THAT IS NOT A CLEAN RESULT: no '
'recess region is drawn on any ticket view of this piece. '
'The legend views are excluded from this count on purpose '
'-- their swatches are pictures of a recess, not recesses '
'-- so a zero here means the ticket draws none. This '
'reader has never seen a job that DOES draw one, so it '
'cannot tell a piece that needs no recess from a piece '
'whose recesses were forgotten, and it claims neither.')
return None, (
'this ticket draws {0} recess region(s) [{1}], and NOTHING WAS '
'COMPARED: recesses are drafted regions with no model counterpart, '
'and no design-side source states which recesses this piece should '
'have -- so a presence count is an inventory, not a judgement, and '
'is not printed as one. Two unblockers, either of which turns this '
'into a real comparison: a design-summary reader that states the '
'recess requirement, or a ruling that the recesses are modelled '
'after all.{2}'
.format(sum(found.values()) or 0, inventory, tail))
def recess_size_caveat():
"""The sentence 5.15 carries about a question that is not its own.
The legend's TEXT says 1" and 2"; the region TYPE NAMES say 2in and 3in.
Both are on the same sheet, the owner has been asked, and this row does
not decide it. Kept as its own function so the wording is identical
everywhere it appears and so a reader grepping for it finds one place.
"""
return ('NOTE, and not this row\'s question: the legend\'s text says 1" '
'and 2" while these region type names say 2in and 3in. This row '
'compares PATTERNS, which have an exact answer; the size '
'discrepancy is a question for the owner and he has been asked')
_MARK_TOKEN_RE = None
def mark_tokens(text):
"""The bar-mark-shaped tokens inside a printed call-out, uppercased.
`SR310 (E.L.)` -> ['SR310']. The face designation in parentheses is
dropped because it is a FACE, not a part: measured on the live model,
every printed bar call-out on the specimen ticket is a bare mark plus
one of `(F.F.)` / `(N.F.)` / `(E.L.)` / `(S.I.F.)` / `(E.S.)`.
A token is one to three letters followed by digits -- the shape of
every mark this plant prints (`SR310`, `P106`, `SP126`) -- matched as
a WHOLE token so `SR1` inside `SR1101` is never mistaken for a mark of
its own. The comparison it feeds is symmetrical: the same function
reduces both sides, so a shape it does not recognise goes missing from
BOTH and produces silence, never a false finding.
"""
global _MARK_TOKEN_RE
if _MARK_TOKEN_RE is None:
import re
_MARK_TOKEN_RE = re.compile(r'\b([A-Z]{1,3}\d+[A-Z]?\d*)\b')
if not text:
return []
out = []
for token in _MARK_TOKEN_RE.findall(clean_text(text).upper()):
if token not in out:
out.append(token)
return out
_PART_NUMBER_RE = None
def part_numbers_in(text):
"""The part numbers inside a NAME, uppercased. `mark_tokens`' sibling.
WHY THIS IS NOT `mark_tokens`, AND WHY THE DIFFERENCE IS EXACTLY ONE
`\b`. `mark_tokens` reads a printed CALL-OUT, where the part number is a
whole word (`SR310 (E.L.)`) and the word boundary is what stops `SR1`
being read out of `SR1101`. This reads a family NAME, where the part
number is embedded after an underscore -- and in regex `_` is a WORD
character, so `\bP76\b` never matches `<vendor>_EMBED_P76`. That is the
same blindness that let the scrub gate miss `PREFIX_TERM` for months
(PROJECT.md 3.4), and it is why these are two functions with two regexes
rather than one shared by both.
`check_bed_plan_reverse` has done it this way since 08-28 for the same
reason; this is that technique named, so the third caller does not
reinvent it with a `\b` in it.
"""
global _PART_NUMBER_RE
if _PART_NUMBER_RE is None:
import re
_PART_NUMBER_RE = re.compile(r'([A-Z]{1,3}\d+[A-Z]?\d*)')
if not text:
return []
out = []
for token in _PART_NUMBER_RE.findall(_text(text).upper()):
if token not in out:
out.append(token)
return out
def _round_ft(value):
if value is None:
return None
try:
return round(float(value), 3)
except (TypeError, ValueError):
return None
def invisible_chars(texts):
"""Checklist 8.2: no invisible characters in ticket text.
clean_text strips U+200B..U+200F, U+FEFF, U+00AD, U+2028/U+2029 at
clean_text strips U+200B..U+200F, U+FEFF, U+00AD, U+2028/U+2029 at
the scrape boundary -- so if any survive to the checked values, the
boundary was bypassed (a parameter read directly, a view text
scraped without cleaning). This check makes that visible instead of
silent (measured once: a lone U+200E in a parameter reached a
report and crashed a tool that printed it). Returns (severity,
message).
"""
bad = []
for t in (texts or []):
if t is None:
continue
for ch in t:
o = ord(ch)
if 0x200B <= o <= 0x200F or o in (0xFEFF, 0xAD, 0x2028,
0x2029):
bad.append((ch, hex(o)))
break
if not bad:
return 'OK', 'no invisible characters in ticket text'
return 'WARN', ('invisible character(s) {0} present in ticket text '
'-- clean_text was bypassed somewhere'
.format(', '.join(sorted(set(b[1] for b in bad)))))
def sheet_name_matches(mark, sheet_names):
"""Checklist 1.1: every sheet named after this piece actually names it.
The piece's mark and the sheet's Name, compared case-insensitively
with whitespace collapsed -- 'WP133 ' and 'wp133' both count. This
is the simplest of the four 'not implemented' checks: it compares
two values the model already carries (Claude 08-28: pure code work,
no judgement). Returns (severity, message).
"""
if not sheet_names:
return 'FAIL', 'no sheet found for this piece'
def norm(s):
return ' '.join((s or '').upper().split())
m = norm(mark)
bad = [s for s in sheet_names if norm(s) != m]
if not bad:
return 'OK', '{0} sheet(s) name this piece'.format(len(sheet_names))
return 'WARN', ('sheet(s) named differently from the piece mark: '
'{0}'.format(', '.join(bad)))
# ------------------------------------------------- ticket-set uniqueness (1.3)
def ticket_set_status(sheet_names):
"""Checklist 1.3: exactly one ticket set per piece.
A ticket is one or more sheets all named after the piece mark. In the
two-sheet form the sheets carry '1 OF 2' / '2 OF 2' in their NAME; a
single-sheet ticket is '1 OF 1'. sheet_names is the names of every
sheet found for this piece's mark.
Returns (severity, message):
'OK' -- exactly one complete set.
'WARN' -- sheets exist but the set is not perfect: an unmatched
page number (a '2 OF 2' without its '1 OF 2'), or an
ambiguous multi-set (two '1 OF 2's).
'FAIL' -- no sheets at all (orphan piece: the model has this piece
but nobody drew its ticket). 'FAIL' is safe here: an
assembly with no ticket is a definite miss, not a
judgement call.
Raises nothing. A piece with no sheets is reported, never silent --
the checklist exists to make exactly that visible.
"""
if not sheet_names:
return 'FAIL', 'no sheet named after this piece -- nobody drew it'
import re as _re
pages = []
for name in sheet_names:
m = _re.match(r'.*\b(\d+)\s+OF\s+(\d+)\b', name, _re.IGNORECASE)
if m:
pages.append((int(m.group(1)), int(m.group(2))))
if not pages:
# NO page markers in the input. The criterion source (SheetNumber
# 'N OF M') is absent, so NOTHING was judged -- and 'OK' here
# would be the exact 'ran and said fine while seeing nothing'
# failure (Claude 08-28: the old bug returned OK on ['WP133',
# 'WP133']). Names alone cannot prove a single set; say so as
# WARN, never OK.
return 'WARN', ('{0} sheet(s) found but none carries a page '
'marker (expect "1 OF 2" style) -- the ticket set '
'could not be judged'.format(len(sheet_names)))
total = pages[0][1]
if any(p[1] != total for p in pages):
return 'WARN', ('sheet page markers disagree (mix of {0} and '
'{1})'.format(sorted(set(p[1] for p in pages))))
needed = set(range(1, total + 1))
have = set(p[0] for p in pages)
if have == needed and len(pages) == total:
return 'OK', '{0}-sheet ticket'.format(total)
if have != needed:
missing = sorted(needed - have)
return 'WARN', ('ticket set incomplete: page(s) {0} missing from '
'the {1} sheets found'.format(missing, len(pages)))
# have == needed but duplicates: two '1 OF 2's etc.
dupes = [p[0] for p in pages if pages.count(p) > 1]
return 'WARN', ('duplicate page(s) {0} -- more than one ticket set for '
'this piece?'.format(sorted(set(dupes))))
# --------------------------------------- placed view names vs design (1.4)
#
# WHAT THIS CHECK IS AND IS NOT (read before changing it).
#
# Recorded plant knowledge says a VIEW NAME DOES NOT IDENTIFY A DESIGN --
# the design number is the sheet number, and a view may be called anything.
# This check does not contradict that; it depends on it. It never uses a
# view name to decide which design a ticket is for (1.2 does that, from the
# sheet's design-number parameter). It only reports a view name that CARRIES
# A DESIGN LABEL OF ITS OWN and labels itself with the wrong design.
#
# The case that produced it: a view named
# 'TICKET NOTE - WP2- STRAND JACKING FORCE'
# is placed on 100 sheets, every one of them a WP1 piece. Reading the
# content settled it -- '0.70 fpu = 28.9 kips' is WP1's own number, so the
# NOTE IS RIGHT AND THE NAME IS A LEFTOVER. That is the whole finding, and
# it is why this is WARN and never FAIL: **never infer the content is wrong
# from the name**. What it protects against is the next person reading the
# stale label and believing it -- either trusting a WP2 note on a WP1 sheet,
# or deleting a correct note because its name looked foreign.
#
# Only a token whose LETTER PREFIX matches this design's is compared. A view
# called 'Elevation Top 2' would otherwise reduce to 'TOP2' and be reported
# as a foreign design. Same-prefix-only is a deliberate bias towards silence:
# it cannot manufacture a finding out of an ordinary view name, and it can
# miss a label from an entirely different piece family. For a hygiene warning
# that is the right way round.
def view_name_design_status(design_key, view_names, token_re):
"""Checklist 1.4: no placed view labels itself with another design.
design_key -- this ticket's design, already reduced (designspec.
design_key). Reduced means PNL1.0 and PNL1 are one key.
view_names -- names of every view placed on the ticket's sheets.
token_re -- UNANCHORED pattern for a design token embedded anywhere
in a name, two groups (letters, number), same shape as
nomenclature.design_number_re. Plant knowledge.
Returns (severity, message), or None when it could not judge -- no
design key, no pattern, or no view names. None is NOT a pass: the
caller must turn it into NOT RUN with the reason.
"""
import re as _re
if not design_key or not token_re or not view_names:
return None
try:
rx = _re.compile(token_re)
except Exception:
return None
key = design_key.strip().upper()
m = _re.match(r'^([A-Za-z]+)', key)
if not m:
return None
prefix = m.group(1).upper()
bad = []
labelled = 0
for name in view_names:
if not name:
continue
found = None
for hit in rx.finditer(name):
groups = [g for g in hit.groups() if g] or [hit.group(0)]
token = ''.join([g.strip() for g in groups]).upper()
tp = _re.match(r'^([A-Za-z]+)', token)
if not tp or tp.group(1).upper() != prefix:
continue # not a label from this design family
found = token
if token != key:
break # a wrong one beats a right one
if found is None:
continue # name carries no design label -- fine
labelled += 1
if found != key:
bad.append((name, found))
if not labelled:
return ('OK', 'no placed view labels itself with a design number '
'({0} views read)'.format(len(view_names)))
if not bad:
return ('OK', '{0} of {1} placed view name(s) carry design {2} and '
'all of them agree'.format(labelled, len(view_names), key))
listed = ', '.join(['{0} (says {1})'.format(n, t) for (n, t) in bad])
return ('WARN', 'placed view name(s) label another design -- this '
'ticket is {0}: {1}. The NAME is the finding; do not infer the '
'note content is wrong from it.'.format(key, listed))
# ------------------------------------ note views that say nothing (6.5)
#
# WHAT IS DETERMINISTIC HERE AND WHAT IS NOT.
#
# A ticket's DUNNAGE / ERECTION / PICKING-STRIPPING views print a heading
# and, underneath it, the instruction. On the audited pieces those views
# contained THEIR OWN TITLE AND NOTHING ELSE -- a heading printed over an
# empty region, which reads on the sheet exactly like an instruction that
# was given.
#
# 'Is there anything in this view besides its heading' is a counting
# question the API answers exactly, and that is all this function decides.
# Content that is present but WRONG, stale, or says nothing useful ('N/A',
# a leftover line) is a different question, it needs a reader, and this
# function deliberately does not pretend to answer it -- a check that
# passed such a view silently would be worse than no check.
#
# Graphics count as content. The checklist called this out and it is not a
# detail: if these views draw their instruction instead of writing it,
# counting text notes alone reports every one of them as empty. So the
# caller passes a count of the OTHER view-owned elements (detail lines,
# filled regions, detail components, symbols, dimensions) and a view with
# any of them is content-bearing, though the report says the content was
# graphics so a reader knows what was and was not read.
# WHAT A DECLARED NOTE VIEW ACTUALLY IS, measured over 1105 sheets
# ----------------------------------------------------------------
# The first version of this read a FLAT LIST of view names and reported
# "not placed" for every declared name it did not find. That model does
# not survive the real project, for two separate reasons:
#
# VARIANTS SUBSTITUTE FOR ONE ANOTHER. Eight panel note views exist;
# a ticket places THREE of them -- one dunnage, one erection, one
# picking. 15 sheets carry PICKING/STRIPPING (V3) rather than the
# plain one, and a flat list would have called all 15 defective.
#
# THE PRODUCT TYPE VARIES. A slab ticket has NO erection note at all
# and a beam ticket uses a different family of views entirely. There
# is no one list that is right for every piece.
#
# Both mistakes produce the same failure, and it is the failure this
# project keeps meeting: a steady drip of findings that are not defects.
# People learn to ignore the output, and then the real findings go
# unread with the rest. A check that cries wolf is worse than absent.
#
# So a declared entry is a GROUP OF ALTERNATIVES: the ticket must carry
# AT LEAST ONE of them, and whatever it carries must say something. Only
# a group with none of its alternatives placed can report "not placed",
# and it reports the GROUP's name -- listing four picking variants as
# four separate missing views would be four times the noise for one
# fact.
#
# Product type is handled by SETS. Each set is one product family, and
# only the set that matches this ticket is required of it -- a panel
# ticket is never asked for the slab family's views. The match is the
# ticket's own evidence: the set with the most groups actually placed
# wins. When no declared set matches at all, that is NOT a finding and
# not a pass -- it is an undeclared product type, and the check says so
# and reports NOT RUN.
def _norm_view_name(s):
return ' '.join((s or '').upper().split())
_GROUP_VIEW_KEYS = ('views', 'alternatives', 'names', 'any_of')
def _group_option_map(value):
"""A group written as an OPTION MAP -> (alternatives, optional).
Returns None when `value` is not one, so the caller can go on treating
it the way it always did. The marker is the presence of a views key --
a map without one is a map OF groups, not one group's options.
ERECTION: <- the group
optional: true
views:
- TICKET NOTE - SLAB - ERECTION
MERGE BEHAVIOUR of this new shape (this is the part that bites):
`optional` and `views` are keys of a MAP, so the map DEEP-MERGES
across knowledge layers -- a job may set `optional: false` on a group
without restating its views, and vice versa. `views` is itself a
LIST, so a job naming views REPLACES the layer below it, exactly as
the bare list form always did. Restate the whole list when overriding.
"""
if not isinstance(value, dict):
return None
alts = None
for key in _GROUP_VIEW_KEYS:
if key in value:
alts = value[key]
break
if alts is None:
return None
if not isinstance(alts, (list, tuple)):
alts = [alts]
return list(alts), bool(value.get('optional'))
def _normalise_groups(node):
"""One declaration -> [{'label', 'alternatives', 'optional'}].
Accepts, in order of how much it says:
'DUNNAGE' one group, one name
[A, B] two groups of one (TODAY'S SHAPE)
[A, [B1, B2]] a group of one, a group of two
[{ERECTION: [B1, B2]}] the same, with the group named
{DUNNAGE: A, ERECTION: [B1, B2]} groups keyed by name
{ERECTION: {optional: true, views: [B1]}} an OPTIONAL group
The bare-string list is the shape the existing tests and PLANT_STARTER
use and it means exactly what it meant before: one group per name, so
every name is still required. Every shape but the last yields
optional=False, so nothing that was required stops being required.
An OPTIONAL group is one the manufacturer's standard itself calls
conditional (ROTATE TO EDGE), or one a family carries only sometimes.
Placed, it is checked for content like any other group. Absent, it is
SILENCE -- not a finding. Declaring such a note as an ordinary group
would report it 'not placed' on every ticket that legitimately does
not need it, which is the standing false positive this whole
mechanism exists to prevent.
"""
if node is None or node == '' or node == [] or node == {}:
return []
if isinstance(node, dict):
out = []
for label in sorted(node.keys()):
out.extend(_normalise_groups([{label: node[label]}]))
return out
if not isinstance(node, (list, tuple)):
node = [node]
groups = []
for item in node:
label, alts, optional = None, None, False
if isinstance(item, dict):
opts = _group_option_map(item)
if opts is None and len(item) != 1:
# A multi-key map here is a map of groups, not one group.
groups.extend(_normalise_groups(item))
continue
if opts is not None:
# An option map standing on its own has no name of its own.
alts, optional = opts
else:
label = list(item.keys())[0]
alts = item[label]
opts = _group_option_map(alts)
if opts is not None:
alts, optional = opts
elif not isinstance(alts, (list, tuple)):
alts = [alts]
elif isinstance(item, (list, tuple)):
alts = list(item)
else:
alts = [item]
alts = [a for a in alts if a not in (None, '')]
if not alts:
continue
if label in (None, ''):
label = alts[0] # a bare group is named by its first name
groups.append({'label': label, 'alternatives': list(alts),
'optional': optional})
return groups
def instruction_view_groups(notes, placed_names):
"""Which note-view groups this ticket is answerable for.
notes -- the plant knowledge `notes` sub-tree
placed_names -- the view names actually on this ticket's sheets
Returns (groups, reason). `groups` is a list of
{'label', 'alternatives', 'optional', 'set',
'placed': [names actually there]}.
An OPTIONAL group with nothing placed is not returned at all -- see
_normalise_groups. It still counts as evidence when choosing which
product family this ticket belongs to, because carrying a family's
conditional note is as good a sign of the family as any other.
A truthy `reason` means NOTHING COULD BE JUDGED and says why; it is
never a pass. Both are never truthy at once.
"""
notes = notes if isinstance(notes, dict) else {}
placed = set([_norm_view_name(n) for n in (placed_names or []) if n])
def found(group):
return [a for a in group['alternatives']
if _norm_view_name(a) in placed]
always = _normalise_groups(notes.get('instruction_views'))
for g in always:
g['set'] = None
sets = notes.get('instruction_view_sets')
sets = sets if isinstance(sets, dict) else {}
chosen, best = None, 0
for name in sorted(sets.keys()):
groups = _normalise_groups(sets[name])
for g in groups:
g['set'] = name
score = len([g for g in groups if found(g)])
if score > best:
chosen, best = groups, score
if not always and not sets:
return [], ('no notes.instruction_views in plant knowledge -- the '
'DUNNAGE / ERECTION / PICKING note views are named by '
'the plant, so nothing was looked at')
if not always and chosen is None:
return [], ('none of the declared note-view families '
'({0}) has a single view on this ticket -- this piece\'s '
'product type is not declared yet, so its notes were '
'not judged'.format(', '.join(sorted(sets.keys()))))
out = always + (chosen or [])
kept = []
for g in out:
g['placed'] = found(g)
if g.get('optional') and not g['placed']:
# A conditional note nobody placed is not a missing note. It
# is dropped here rather than reported, so it can never become
# the steady false positive that teaches people to stop
# reading this check. Placed, it stays and is judged for
# content exactly like a required group.
continue
kept.append(g)
return kept, None
def _heading_like(text, view_name):
"""Whether a note's text is just the view's own heading.
'PICKING/STRIPPING', 'PICKING - STRIPPING' and 'PICKING STRIPPING' are
one heading; so is a heading with a trailing colon. Compared on letters
and digits only, which collapses all of that without a synonym list.
"""
import re as _re
def bare(s):
return _re.sub(r'[^A-Z0-9]', '', (s or '').upper())
t, v = bare(text), bare(view_name)
if not t:
return True # blank text is not content either
if not v:
return False
return t == v or t in v or v in t
def _row_label(row):
"""How a placed group is named in the message.
The group's name, plus the view actually found when that differs --
'PICKING/STRIPPING (TICKET NOTE - PANEL - PICKING/STRIPPING (V3))'.
Without it a reader is told a group is empty and has no way to know
which of its four variants to go open.
"""
name = row.get('name') or '?'
matched = row.get('matched')
if matched and _norm_view_name(matched) != _norm_view_name(name):
return '{0} ({1})'.format(name, matched)
return name
def note_view_content_status(views):
"""Checklist 6.5: the note views actually say something.
views -- one dict per note view GROUP the plant declared, in the order
declared:
{'name': the group's name,
'placed': bool -- one of the group's alternatives is on
the ticket's sheets,
'matched': the alternative actually found, when it is not
the group's own name (optional),
'texts': [text-note strings in the view],
'other_elements': int -- view-owned elements that are not
text notes (graphics count as content)}
A group whose ticket carries TWO of its alternatives gets one
row per placed view: a placed note that prints a heading over
nothing is the finding whether or not a sibling covers it.
Returns (severity, message), or None when there was nothing to judge
(no declared note views). None is NOT a pass.
"""
if not views:
return None
missing, empty, graphics_only, ok = [], [], [], []
for v in views:
name = v.get('name') or '?'
shown = _row_label(v)
if not v.get('placed'):
missing.append(name)
continue
body = [t for t in (v.get('texts') or [])
if not _heading_like(t, v.get('matched') or v.get('name'))]
others = int(v.get('other_elements') or 0)
if body:
# A passing view needs naming once, not twice; a failing one is
# a view somebody has to go open, so it gets the full address.
ok.append(v.get('matched') or name)
elif others > 0:
graphics_only.append(shown)
else:
empty.append(shown)
# One view can answer two groups (a combined PICKING/ERECTION note), so
# it lands in a list twice. Say it once.
def once(names):
seen, out = set(), []
for n in names:
if n not in seen:
seen.add(n)
out.append(n)
return out
missing, empty = once(missing), once(empty)
graphics_only, ok = once(graphics_only), once(ok)
parts = []
if empty:
parts.append('{0} print(s) a heading and nothing else'
.format(', '.join(empty)))
if missing:
parts.append('{0} not placed on this ticket'
.format(', '.join(missing)))
if graphics_only:
parts.append('{0} carr(y) graphics but no text -- content is '
'there, a reader must judge it'
.format(', '.join(graphics_only)))
if not empty and not missing:
if graphics_only:
return ('OK', 'every note view carries content; ' + parts[0])
return ('OK', 'every note view carries text ({0})'
.format(', '.join(ok)))
return 'WARN', '; '.join(parts)
# ==================================================================
# THE CHECKLIST AS A PAGE
# ==================================================================
#
# Owner, 2026-08-30, having read a real run:
#
# "大量的配置信息也都往后放,并且默认折叠。我最在乎的是那张检查的表。
# 那张检查的表放在最前面,并且字儿可以再大一点。OK用绿色,
# WARN,FOR_ENGINEER用黄色,NOTRUN,FAIL用红色。"
#
# The checklist table IS the product. Everything else -- the design intent
# being enforced, the scope, the knowledge path, the issue tally -- is
# appendix, and appendix goes after the table and starts folded
# (`vibe.foldreport`, which owns that rule for every report this tool
# prints).
#
# WHAT THE COLOUR GROUPING SAYS, AND WHY IT IS NOT SOFTENED
# ---------------------------------------------------------
# NOT RUN is RED, in the same bucket as FAIL. That is the owner's grouping
# and it is this codebase's founding principle expressed as colour: a check
# that did not run tells you nothing about the piece, so it must not look
# calmer than one that failed. Silence must never read as a pass. Anyone
# tempted to grey NOT RUN out "because it is not really an error" is about
# to rebuild the exact failure this tool exists to prevent -- a clean-looking
# page over an unasked question.
#
# OK green judged, and it is fine
# WARN, FOR ENGINEER yellow a person has to look
# NOT RUN, FAIL red the piece is wrong, or nobody knows
# MUTED grey it ran, it is not a failure, and this
# job asked for it quietly
#
# GREY IS NOT AN EXCEPTION TO THE PARAGRAPH ABOVE, it is the reason that
# paragraph can hold. The pressure to grey NOT RUN out is real -- a job
# where 3.3 can never apply prints a red row forever and the reader learns
# to skip red rows, which is the failure this table exists to prevent
# arriving by the back door. MUTED is the pressure valve: a DECLARED,
# per-job, reasoned state that is its own word, so NOT RUN stays red and a
# quiet row is quiet because somebody said so in the job file and signed
# for it. `state_color` still sends an unknown word to red.
#
# COLOUR IS PRESENTATION; THE STATE WORD IS THE DATA. Nothing below changes
# a status word except `apply_muting`, which changes it to MUTED and says
# in the note what it really was.
GREEN_STATES = ('OK',)
YELLOW_STATES = ('WARN', 'FOR ENGINEER')
RED_STATES = ('NOT RUN', 'FAIL')
# WHY THE WORD IS `MUTED` AND NOT `NOT RUN`, so nobody "simplifies" it.
#
# The owner asked for a grey NOT RUN. A row that ran and passed cannot print
# NOT RUN: it is a false statement, in a report whose entire premise is that
# it never makes one, and a real NOT RUN -- a check that COULD not be asked,
# which is a defect in the tooling -- would then be one styling rule away
# from invisible. Two different answers must not share a word.
#
# It is a distinct value precisely so the label is a one-line change if he
# overrules; the question is with him in writing. `configform.MUTED_STATE`
# is the other half of the seam and
# `test_configform.py::test_the_muted_state_seam_with_ticketpage` holds the
# two equal.
MUTED_STATE = 'MUTED'
GREY_STATES = (MUTED_STATE,)
# NOT APPLICABLE -- the fifth state, and why it is not any of the other four.
#
# Owner, 2026-08-31: "ticketqc 检查的内容需要进行分级。根据 plant 和 ticket 的
# 具体内容进行分级 ... 让 ticketqc 工具读取厂知识来得知啥需要检查,啥不用检查。"
#
# A DECLARED INAPPLICABILITY: this question does not apply to this kind of
# piece at this plant. It is VOLUNTARY and STATED (a dormant check is
# involuntary and is cured by supplying a fact), it is PLANT-level and
# conditional on the piece (a mute is per job and unconditional), and the
# check DOES NOT RUN (a mute runs it and turns the volume down). Grades A-D
# say who answers the question; this says the question was never asked
# because it is not a question about this piece.
#
# IT GETS ITS OWN COLOUR, NOT A REUSE. Grey already means "it ran and this
# job asked for it quietly", and a reader who has to work out which kind of
# quiet a grey row is has stopped trusting the table. Blue is calm on
# purpose -- nothing is wrong -- and calm is only ever safe here because the
# declaration is loud somewhere else: the section under the table names
# every row switched off, its reason, and the layer that declared it, and it
# opens itself. A mistaken applicability rule is a silently-disabled safety
# check, so it may never be the quietest thing on the page.
#
# `judged` is FALSE. Nothing produced a verdict on this piece. A consumer
# asking "is this row settled" must get no for exactly the same reason it
# gets no for NOT RUN -- the difference between them is WHY nobody asked,
# and that difference is the state word and the reason, not a truth value.
NOT_APPLICABLE_STATE = 'NOT APPLICABLE'
BLUE_STATES = (NOT_APPLICABLE_STATE,)
# OUT OF SCOPE -- the state `vibe.scope` produces. It is not printed on the
# checklist table today (scope is declared per PIECE, not per row: an
# out-of-scope piece is never run, so it has no rows), and no colour group
# below claims it, so `state_color` sends it to red like any other word this
# table does not paint. It is named here because the MACHINE-READABLE side
# has to be able to say it: `scope.py`'s founding rule is that out of scope
# is a third state and never an absence, and a run file that could only spell
# OK / WARN / FAIL / NOT RUN would have to collapse it into one of those.
OUT_OF_SCOPE_STATE = 'OUT OF SCOPE'
# ------------------------------------------- the state vocabulary, as DATA
#
# WHY THIS EXISTS AT ALL: colour is not the state. NOT RUN and FAIL are both
# red -- deliberately, see the block above -- and OK and MUTED differ by a
# grey nobody can read out of a JSON file. A consumer handed only a colour, or
# only a truthy/falsey "passed", cannot tell "judged, and it is fine" from
# "nobody asked", which is the exact failure this whole tool exists to
# prevent. So every state carries a STABLE MACHINE KEY of its own, distinct
# from every other, and says whether anything actually produced a verdict.
#
# `judged` is the answer to "did this row's question get asked and answered":
#
# OK / WARN / FAIL True -- a check ran and said something
# MUTED True -- the check RAN; only its volume was turned
# down, and `apply_muting` keeps what it
# really reported in the note
# NOT RUN False -- nobody could ask it
# FOR ENGINEER False -- nothing computes it, by design
# OUT OF SCOPE False -- it was declared somebody else's
#
# A word this table does not know is NOT judged and does not get a key that
# looks like one -- same reasoning as `state_color`'s red fallthrough.
# (status word, machine key, did anything produce a verdict)
STATE_VOCABULARY = (
('OK', 'ok', True),
('WARN', 'warn', True),
('FAIL', 'fail', True),
('NOT RUN', 'not_run', False),
('FOR ENGINEER', 'for_engineer', False),
(MUTED_STATE, 'muted', True),
(OUT_OF_SCOPE_STATE, 'out_of_scope', False),
# Declared not to apply to this kind of piece. NOT judged: the check did
# not run, and the reason it did not is a ruling, not an answer.
(NOT_APPLICABLE_STATE, 'not_applicable', False),
)
UNKNOWN_STATE_KEY = 'unrecognised'
def _state_entry(status):
word = (status or '').strip().upper()
for (state, key, judged) in STATE_VOCABULARY:
if state.upper() == word:
return (state, key, judged)
return (None, UNKNOWN_STATE_KEY, False)
def state_key(status):
"""A stable machine name for a status word, distinct for every state.
This is what a file writes instead of a colour. `state_key('NOT RUN')`
and `state_key('FAIL')` differ even though `state_color` sends both to
red, and an unrecognised word gets its own key rather than borrowing
one -- never `ok`, and never a key that reads like a verdict.
"""
return _state_entry(status)[1]
def state_is_judged(status):
"""True when this state means a verdict was actually produced.
False for NOT RUN, FOR ENGINEER, OUT OF SCOPE and anything unrecognised.
A consumer that wants "is this row settled" asks this rather than
inferring it from a missing finding, which is the inference this table
was built to make impossible.
"""
return _state_entry(status)[2]
# Picked to survive an IE-engine WebBrowser on a white panel: saturated
# enough to tell apart at a glance, dark enough to read text on.
STATE_COLORS = {
'green': ('#1c6b2e', '#e5f4e8'), # (ink, ground)
'yellow': ('#7a5600', '#fdf3d6'),
'red': ('#a41d1d', '#fbe6e6'),
'grey': ('#555555', '#f0f0f0'),
# Its own entry, never a reuse of grey. Dark enough to read, plainly not
# the muted grey and plainly not the yellow that means somebody must
# look. See NOT_APPLICABLE_STATE for why a calm colour is allowed here.
'blue': ('#1f4b73', '#e7eef6'),
}
def state_color(status):
"""'green' / 'yellow' / 'grey' / 'red' for a checklist status word.
An unknown status is 'red', never 'grey': a state this table does not
recognise is a state nobody has judged, and the safe reading of an
unrecognised state is the loud one. Grey is reached only by a word this
function knows -- a DECLARED mute -- which is what keeps the red
fallthrough meaning what it says.
"""
word = (status or '').strip().upper()
if word in tuple([s.upper() for s in GREEN_STATES]):
return 'green'
if word in tuple([s.upper() for s in YELLOW_STATES]):
return 'yellow'
if word in tuple([s.upper() for s in GREY_STATES]):
return 'grey'
if word in tuple([s.upper() for s in BLUE_STATES]):
return 'blue'
return 'red'
# ------------------------------------------------------- muting, per job
#
# Owner, 2026-08-30:
#
# "让我可以注释掉一些check。比如说这个项目里面3.3我就想注释掉。
# 因为所有的panel都不会有这个,一直看着NOT_RUN我也糟心。"
#
# A job where no piece can ever have the thing 3.3 asks about prints NOT RUN
# on 3.3 for every piece forever, and a permanent red row trains the reader
# to ignore red rows. That is a real cost, so muting is real -- but it is
# DECLARED, per job, WITH A REASON, and what it removes is still counted:
#
# * the key is `checks.muted`, a MAP (so the knowledge layers deep-merge
# it and a job can mute one item without restating the others);
# * the reason is MANDATORY and an empty one is refused by
# `configmodel.validate` -- a row that vanished for a reason nobody
# wrote down is indistinguishable from a bug;
# * a muted item STAYS ON THE TABLE, in grey, saying what it really
# reported, and is also LISTED by number and reason in a folded
# section. (It used to leave the table; muting is now a volume control
# and not a switch -- the check runs either way and only its
# presentation changes. See `apply_muting` and `MUTED_STATE`.)
# Folded is not gone, and quiet is not gone either;
# * MUTING CANNOT HIDE A FAIL. See `apply_muting`.
def muted_checks(know, getter=None, resolver=None, plant=None):
"""`checks.muted` off the merged knowledge, as {number: reason}.
`getter` is knowledge.get, injected so this stays Revit-free and
testable with a plain dict. Entries with no reason are kept and
reported (see `muting_lines`) rather than dropped: a mute the config UI
let through without a reason is a thing the reader must be told about,
not a thing this function quietly un-mutes.
"""
if getter is None:
from vibe import knowledge as K
getter = K.get
raw = getter(know or {}, 'checks.muted', {}) or {}
if not isinstance(raw, dict):
return {}
out = {}
for key, reason in raw.items():
# `_text`, not `str`: the reason is a sentence the owner typed into
# the job file, and it survives to the reports only if it is text
# from the moment it is read.
written = _text(key).strip()
stable = written
if resolver is not None:
got, _why, problem = resolver(know or {}, written, plant=plant,
ids=CHECKLIST_IDS)
if got:
stable = got
elif problem:
# KEPT UNDER WHAT WAS WRITTEN, NOT DROPPED. `apply_muting`
# already reports a number that mutes nothing, by name, and
# that is the right place for it: a mute the tool silently
# discarded is a row the owner believes is quiet and is not.
stable = written
out[stable] = ('' if reason is None else _text(reason).strip())
return out
def muted_reference_problems(know, resolver=None, plant=None, getter=None):
"""[(what was written, why it could not be placed)].
Separate from `muted_checks` so the resolution stays a pure lookup and
the reporting stays a list -- a resolver that both answered and reported
would have to be called twice or return a pair nobody wants.
"""
if resolver is None:
from vibe import knowledge as K
resolver = K.resolve_check_reference
if getter is None:
from vibe import knowledge as K
getter = K.get
raw = getter(know or {}, 'checks.muted', {}) or {}
if not isinstance(raw, dict):
return []
out = []
for key in sorted(raw):
written = _text(key).strip()
got, _why, problem = resolver(know or {}, written, plant=plant,
ids=CHECKLIST_IDS)
if not got and problem:
out.append((written, problem))
return out
def apply_muting(rows, muted):
"""(rows to print, [(number, name, reason, why_kept)]).
A MUTED ITEM STAYS ON THE TABLE. It is repainted, not removed: the
status word becomes `MUTED_STATE`, which `state_color` prints grey, and
the note says what the check really reported. Muting is a volume
control -- the check still runs, every time; only its presentation
changes.
That is a change from what this used to do (it dropped the row and left
only a folded line), and the reason is the same reason the state word
is MUTED rather than NOT RUN: a row that vanishes cannot say it ran, so
a reader cannot tell a muted-and-fine item from an item nobody built.
Grey on the table says both at once -- it was asked, and this job asked
for the answer quietly.
A FAIL IS NEVER SOFTENED. Muting exists to stop a permanent NOT RUN
from wearing the reader down; it is not a way to make a defect quiet,
and a job file that could grey out a failure is a job file that will
eventually be used to. So a FAIL keeps its own word and its own red,
and the muted section says the mute was declined and why.
(Judgement call, Claude 2026-08-30, flagged in the report: the brief
said "a muted check leaves the table entirely", and this narrows that
by one case. The owner can overrule it in one line -- delete the
`is_fail` branch.)
Findings are NOT touched here, by anything, ever. This function takes
rows and returns rows. The findings list the button hands to the JSON
writer and to the clean/failing/warning tally never passes through it,
so a muted check that failed still fails the ticket, still lands in
`qc-run.json`, and still counts in the header line.
"""
muted = muted or {}
if not muted:
return list(rows or []), []
known = set([item[0] for item in CHECKLIST_ITEMS])
kept, notes = [], []
seen = set()
for row in (rows or []):
num = row[0]
if num not in muted:
kept.append(row)
continue
seen.add(num)
# The RAW reason, empty string and all. `muting_lines` substitutes a
# placeholder for display; substituting it here would hide the empty
# reason from `muting_problem`, which is the one thing that has to
# see it.
reason = muted[num]
was = (row[3] or '').strip().upper()
# ALREADY SWITCHED OFF BY A DECLARED INAPPLICABILITY. Repainting it
# grey would replace "this question is not about this piece" with
# "this job asked for it quietly", which is a weaker and different
# claim, and it would hide that two mechanisms are doing one job. It
# is flagged rather than ignored: a job muting a row the plant has
# already declared inapplicable is a mute that should be deleted, or
# a plant rule that should not be there.
if was == NOT_APPLICABLE_STATE.upper():
kept.append(row)
notes.append((num, row[1], reason,
'THE MUTE DID NOTHING: this row is already '
'declared NOT APPLICABLE by a knowledge layer, so '
'the check does not run at all. One of the two '
'declarations is redundant -- see the '
'applicability section.'))
continue
is_fail = was == 'FAIL'
if is_fail:
kept.append(row)
notes.append((num, row[1], reason,
'REPORTED AT FULL VOLUME: this item FAILED on '
'this piece, and muting never softens a failure'))
else:
kept.append(_muted_row(row, reason))
# `why` stays None for a routine mute: `muting_problem` reads a
# non-empty `why` as "somebody has to look at the job file", and
# a mute that worked exactly as asked is not that.
notes.append((num, row[1], reason, None))
# A number in the job file that is not a checklist item at all. It
# mutes nothing, and a mute that silently does nothing is how somebody
# believes an item is off when it is on.
for num in sorted(muted):
if num in seen:
continue
if num not in known:
notes.append((num, '(not a checklist item)', muted[num] or '',
'THIS NUMBER MUTES NOTHING -- there is no '
'checklist item {0}'.format(num)))
return kept, notes
def _muted_row(row, reason):
"""One row repainted as MUTED, keeping what it really reported.
THE ORIGINAL STATUS GOES INTO THE NOTE, and that is the whole point of
repainting rather than removing. A grey row saying only "MUTED" has
thrown away the answer; a grey row saying "ran and reported OK, muted
for this job because ..." has merely stopped shouting it. Every other
field of the row -- and any extra field a caller carries past the
five this module names -- is passed through untouched.
"""
out = list(row)
was = (out[3] or '').strip() if len(out) > 3 else ''
note = out[4] if len(out) > 4 else None
while len(out) < 5:
out.append(None)
out[3] = MUTED_STATE
said = ('ran and reported {0}'.format(was) if was
else 'ran; it reported no status')
# THE MUTE SENTENCE IS THE OWNER'S OWN WORDS, in whichever language he
# wrote them in the job file, and it must reach every consumer whole --
# see `_text`, and the text report that dropped exactly this string's
# non-ASCII characters on 2026-08-31.
quiet = _text('{0} -- muted for this job{1}').format(
_text(said), (_text(': ') + _text(reason)) if reason else
_text(', and the job file gives no reason'))
if note:
quiet += _text('. ') + _text(note)
out[4] = quiet
return tuple(out)
def muting_lines(notes):
"""The muted section's body: one line per muted item. Never a count."""
out = []
for (num, name, reason, why) in (notes or []):
line = '**{0}** {1} &mdash; {2}'.format(
num, name, reason or '(no reason given)')
if why:
line += ' &mdash; _{0}_'.format(why)
out.append(line)
return out
def muting_problem(notes):
"""A sentence when the muted section must open itself, else None.
Routine muting folds. A mute that was declined, a mute with no reason,
or a number that matches no item is not routine -- somebody has to fix
the job file, or read a failure they thought was off.
"""
if not notes:
return None
bad = [n for n in notes if n[3] or not n[2]]
if not bad:
return None
return ('{0} of the {1} muted entr(ies) need attention -- a mute was '
'declined, or has no reason, or names nothing'
.format(len(bad), len(notes)))
# ============================================================================
# DECLARED INAPPLICABILITY -- per plant, per kind of piece
# ============================================================================
#
# The knowledge shape, the classifier and the refusals all live in
# `vibe.knowledge` (`checks.not_applicable`, `classify_panel`,
# `check_not_applicable`). What lives HERE is the part that owns the rows:
# which checklist numbers a run switched off, how such a row is printed, and
# the statement the report has to carry once per piece.
#
# WHERE THE BOUNDARY WITH THE OTHER FOUR SITS, stated once so the next reader
# does not merge two of them:
#
# muting the check RUNS, the row goes grey, per JOB, presentation
# only. Use it for "I know, stop telling me".
# declared scope a whole PIECE is somebody else's. Per piece, never a row.
# dormancy the check WOULD run and cannot, because a fact is unset.
# Involuntary; cured by supplying the fact.
# grade A-D who answers the question.
# INAPPLICABILITY the question is not about this kind of piece. The check
# does NOT run. Declared, dated, and conditional on the
# piece.
#
# AND THE ONE THING IT MAY NEVER DO. A row that already has a FAIL keeps its
# FAIL, and the declaration is recorded as DECLINED -- exactly as a mute over
# a failure is. The case is real rather than theoretical: two rows can name
# the same check function (4.4 and 4.6 both name `check_strand_layout`), so a
# check runs for the row that still applies and its findings reach the row
# that does not. Silencing a finding that already exists is the one thing no
# switch in this file is allowed to do.
def applicability_decisions(know, panel_type=None, verified=False,
resolver=None, profile=None):
"""{row number: decision} for EVERY checklist row, from the knowledge.
`decision` is `knowledge.check_not_applicable`'s dict. Every row is
present, including the ones nothing declared -- a caller reading this as
"the rows in here are the switched-off ones" would be reading an absence
as a value, and `not_applicable` on each entry is the answer instead.
`resolver` is injected so this stays Revit-free and testable with a
plain dict, the same seam `muted_checks` uses for `knowledge.get`.
"""
if resolver is None:
from vibe import knowledge as K
resolver = K.check_not_applicable
profile = profile or {}
items = profile.get('items')
on_board = None
if profile.get('profile') and items is not None:
on_board = dict([(check, number) for (number, check) in items])
closed = (profile.get('unlisted') == 'not_applicable')
out = {}
for item in CHECKLIST_ITEMS:
stable = item[0]
if on_board is not None and stable not in on_board and closed:
out[stable] = {
'item': stable, 'not_applicable': True,
'condition': 'profile', 'source': 'profile',
'reason': ('the {0!r} checklist does not ask this question of '
'this kind of member. {1}'
.format(profile.get('profile'),
profile.get('unlisted_because') or
'No reason is recorded.')),
'since': profile.get('unlisted_since'),
'declared_by': profile.get('declared_by'),
'declared': True, 'problem': None, 'unaskable': False,
'only_types': [], 'layer': None, 'off_board': True}
continue
decision = resolver(know or {}, stable, panel_type=panel_type,
verified=verified)
decision['source'] = 'declaration'
# ASKED ANYWAY, AND THE PROFILE IS TOLD ON ITSELF. A board that does
# not name a check has not caught up with the code, and that is a
# fact about the YAML rather than about the piece.
decision['off_board'] = bool(on_board is not None and
stable not in on_board)
out[stable] = decision
return out
def off_board(decisions):
"""Master ids a profile never named but which were asked anyway.
A LIST FOR THE REPORT, NOT A FAILURE. The questions were asked -- the
safe direction -- and saying so is how the board gets updated instead of
quietly diverging from the code.
"""
decisions = decisions or {}
return [item[0] for item in CHECKLIST_ITEMS
if (decisions.get(item[0]) or {}).get('off_board')]
def switched_off(decisions):
"""Just the rows a decision actually switched off, in table order."""
decisions = decisions or {}
return [item[0] for item in CHECKLIST_ITEMS
if (decisions.get(item[0]) or {}).get('not_applicable')]
def applicability_problems(decisions):
"""Every refusal, in table order: [(row, sentence)].
A declaration that did not take effect has to be as visible as one that
did. The owner who wrote it believes that row is off; while the refusal
is unreported he has no way to find out it is on.
"""
decisions = decisions or {}
out = []
for item in CHECKLIST_ITEMS:
problem = (decisions.get(item[0]) or {}).get('problem')
if problem:
out.append((item[0], problem))
return out
def apply_applicability(rows, decisions):
"""(rows to print, [(num, name, reason, condition, since, by, layer, why)]).
A row declared not applicable is REPAINTED, not removed -- the same
ruling as muting, for the same reason: a row that vanishes cannot say
anything, and a reader cannot tell "this does not apply to an insulated
panel" from "nobody built this". The note carries the declared reason
verbatim and what the row would have been.
A FAIL IS NEVER SWITCHED OFF. See the block above.
"""
decisions = decisions or {}
kept, notes = [], []
for row in (rows or []):
num = row[0]
decision = decisions.get(num) or {}
# SCOPED TO A PANEL TYPE, ON A PIECE NOBODY COULD PLACE. Not
# applicable would claim the piece is not that type; OK would answer
# an insulated question about a piece that may not be one. Nobody
# could ask it, so it says so, in red -- and a FAIL that somehow
# exists still keeps its own word.
if decision.get('unaskable'):
was = (row[3] or '').strip().upper() if len(row) > 3 else ''
if was == 'FAIL':
kept.append(row)
continue
out = list(row) + [None] * max(0, 5 - len(row))
out[3] = 'NOT RUN'
out[4] = decision.get('reason') or out[4]
kept.append(tuple(out))
continue
if not decision.get('not_applicable'):
kept.append(row)
continue
was = (row[3] or '').strip() if len(row) > 3 else ''
reason = decision.get('reason')
entry = (num, row[1] if len(row) > 1 else None, reason,
decision.get('condition'), decision.get('since'),
decision.get('declared_by'), decision.get('layer'), None)
if was.upper() == 'FAIL':
kept.append(row)
notes.append(entry[:7] + (
'REPORTED AT FULL VOLUME: this row FAILED on this piece, so '
'the declaration that it does not apply was DECLINED. A check '
'declared inapplicable is not run -- this one ran because '
'another row needs the same check, and it found something. '
'Either the declaration is wrong or the finding is; nothing '
'here decides which, and it is not hidden while somebody '
'does.',))
continue
kept.append(_not_applicable_row(row, decision))
notes.append(entry)
return kept, notes
def _not_applicable_row(row, decision):
"""One row repainted as NOT APPLICABLE, keeping what it really said."""
out = list(row)
was = (out[3] or '').strip() if len(out) > 3 else ''
note = out[4] if len(out) > 4 else None
while len(out) < 5:
out.append(None)
out[3] = NOT_APPLICABLE_STATE
condition = decision.get('condition')
where = (_text('every piece this plant makes')
if condition == 'any'
else _text('a {0} piece').format(_text(condition)))
said = _text('declared not to apply to {0}').format(where)
reason = decision.get('reason')
if reason:
said += _text(': ') + _text(reason)
since = decision.get('since')
if since:
said += _text(' (declared {0}').format(_text(since))
by = decision.get('declared_by')
said += (_text(' by {0})').format(_text(by)) if by else _text(')'))
layer = decision.get('layer')
if layer:
said += _text(' [{0} layer]').format(_text(layer))
# WHAT IT WOULD HAVE BEEN, when anything got that far. Normally the check
# never ran and there is nothing to say; where a sibling row shares the
# check, the answer exists and throwing it away would be the muting
# mistake in a new coat.
if was and was.upper() not in ('NOT RUN', ''):
said += _text('. Before the declaration this row read {0}').format(
_text(was))
if note:
said += _text('. ') + _text(note)
out[4] = said
return tuple(out)
def profile_view(rows, profile=None):
"""Rows in the profile's ORDER, carrying the profile's DISPLAY NUMBER.
THE STABLE ID STAYS AT `row[0]` AND NOTHING MOVES IT. Every consumer
upstream and downstream keys on it -- `checklist_details`, the note fold
ids, `apply_muting`, `apply_applicability`, `qc-run.json` -- and the one
thing this whole design exists to prevent is a number that means one
question here and a different one there. The display number is APPENDED
at index 5, which is a field this module already promises to carry
through untouched (`_muted_row`).
So this function only ever REORDERS and LABELS. It cannot change a
state, drop a row, or add one.
ROWS THE BOARD DOES NOT NAME COME LAST, under their own master ids, and
`off_board` on the decision is what put a sentence about them on the
report. They are not dropped: a board that has not caught up with the
code must not be able to make a question disappear.
"""
rows = list(rows or [])
profile = profile or {}
items = profile.get('items')
if not profile.get('profile') or not items:
return rows
display = dict([(check, number) for (number, check) in items])
order = dict([(check, index) for (index, (_n, check))
in enumerate(items)])
tail = len(order)
def _key(row):
return order.get(row[0], tail)
out = []
for row in sorted(rows, key=_key):
got = list(row)
while len(got) < 6:
got.append(None)
got[5] = display.get(row[0])
out.append(tuple(got))
return out
def row_display(row):
"""(what to print in the # column, the master id) for one row.
One place, because three renderers and the JSON all have to agree about
which of the two numbers is which, and a row with no profile has only
one number and must not print it twice.
"""
stable = row[0] if row else None
shown = row[5] if (row and len(row) > 5 and row[5]) else None
return (shown or stable, stable)
def applicability_lines(notes):
"""The section's body: one line per row switched off. Never a count."""
out = []
for entry in (notes or []):
num, name, reason, condition, since, by, layer, why = (
list(entry) + [None] * 8)[:8]
line = '**{0}** {1} &mdash; not asked on {2} &mdash; {3}'.format(
num, name,
'any piece at this plant' if condition == 'any'
else '{0} pieces'.format(condition),
reason or '(no reason given)')
if since:
line += ' _(declared {0}{1})_'.format(
since, ', ' + by if by else '')
# THE LAYER, ALWAYS SAID. A reader who wants to argue with a
# switched-off row has to know which file to open, and "somewhere in
# the five layers" is not an answer. Where it could not be computed
# the line says that rather than leaving a gap that reads as "the
# plant", which is the guess a reader makes when nothing is said.
line += ' &mdash; declared in the **{0}** layer'.format(
layer or 'UNKNOWN (the layer files could not be re-read)')
if why:
line += ' &mdash; _{0}_'.format(why)
out.append(line)
return out
def applicability_problem(notes, problems=None):
"""A sentence when the section must open itself, else None.
IT OPENS WHENEVER ANYTHING WAS SWITCHED OFF, and that is the difference
from muting, which folds when it is tidy. A mute leaves the check
running; this stops it. A mistaken rule here is a safety check disabled
in silence, so the page says so every single run, and the declaration is
as loud as the scope line.
"""
notes = notes or []
problems = problems or []
if not notes and not problems:
return None
parts = []
if notes:
parts.append('{0} checklist row(s) were NOT ASKED on this run '
'because a knowledge layer declares they do not apply '
'({1}) -- read them, because a wrong rule here is a '
'safety check switched off'
.format(len(notes), ', '.join([n[0] for n in notes])))
if problems:
parts.append('{0} declaration(s) were REFUSED and their rows RAN '
'({1}) -- somebody believes those rows are off'
.format(len(problems),
', '.join([p[0] for p in problems])))
return '; '.join(parts)
def profile_line(mark, plant, member, profile, off_board_ids=None):
"""WHICH BOARD RAN, in one line, for one piece.
Which plant, which kind of member, which checklist, and on what basis --
the header half that says what the numbers in the table even mean. A
reader handed a renumbered table with no profile line has been shown a
checklist and not told whose.
"""
member = member or {}
profile = profile or {}
kind = member.get('member_kind')
name = profile.get('profile')
if name:
head = ('**{0}** &mdash; {1} checklist **{2}**'
.format(mark, plant or 'this plant',
profile.get('title') or name))
head += ' _(profile `{0}`, chosen on {1})_'.format(
name, (profile.get('basis') or 'nothing')
.replace('_', ' ').replace('+', ' and '))
else:
head = ('**{0}** &mdash; no plant checklist applies, so the FULL '
'master checklist ran under its own numbering'.format(mark))
head += ' &middot; member kind: {0}'.format(
'**{0}**'.format(kind) if kind else '**not established**')
ids = list(off_board_ids or [])
if ids:
head += (' &middot; **{0} question(s) this board does not name were '
'asked anyway** ({1}) &mdash; the board has not caught up '
'with the code'.format(len(ids), ', '.join(ids)))
why = member.get('why')
if why:
head += ' \n_{0}_'.format(why)
if profile.get('why'):
head += ' \n_{0}_'.format(profile['why'])
if profile.get('problem'):
head += ' \n_**{0}**_'.format(profile['problem'])
return head
def classification_line(mark, classification, notes):
"""WHICH PROFILE RAN, in one line, for one piece.
Required once per run per piece: what the piece was classified as, on
what basis, and how many rows the plant then declared do not apply. A
reader who cannot see the profile cannot audit the rows it removed --
and a report that shows the removals without the profile has asked him
to trust a classification he was never shown.
"""
classification = classification or {}
kind = classification.get('panel_type')
if kind:
head = '**{0}** classified as **{1}**'.format(mark, kind)
if not classification.get('verified'):
head += (' _(panel_types.detect_verified is false, so this '
'classification was NOT used to switch anything off)_')
else:
head = ('**{0}** NOT CLASSIFIED into a panel type, so the FULL '
'checklist ran'.format(mark))
if notes:
head += ' &mdash; {0} row(s) declared not applicable: {1}'.format(
len(notes), ', '.join([n[0] for n in notes]))
else:
head += ' &mdash; no row was declared not applicable'
why = classification.get('why')
if why:
head += ' \n_{0}_'.format(why)
return head
# ------------------------------------------- the lines behind a count (8.1)
#
# Owner, reading WP133: "都哪些lines not interpreted?" -- 8.1 said "4 line(s)
# of the design summary were not interpreted" and named three. A count the
# reader cannot resolve into the things counted is a prompt to go looking,
# not a report. `ticketchecks.check_spec_unresolved` now enumerates every
# line; this splits the enumeration back out so the page can print one per
# row instead of one very long cell.
# THERE IS NO NOTE CAP ANY MORE, AND NOTHING MAY PUT ONE BACK.
#
# `NOTE_CAP = 180` and `capped_note()` lived here. `checklist_states` ran
# every non-OK message through them, so the row tuple itself carried a
# stump -- and the row tuple is what the HTML table, the plain-text file and
# every other consumer are built from. Owner, 2026-08-30, on 4.5: "不要有截断,
# item不要折叠,需要一行就一行,需要两行就两行,但是note做成默认折叠显示跟item
# 一样的行数。" A cut note is worse than a long one, because the reader
# cannot tell whether the missing half said "and 3 more" or "and one of them
# is a FAIL".
#
# What replaced it is a VISUAL fold and nothing else: `note_cell_html` puts
# the whole note in the cell and clips the cell's height, so every character
# is in the document -- selectable, copyable, findable -- and the plain-text
# file prints all of it, wrapped, with no fold at all.
#
# If a cell ever looks too tall again, the answer is the fold, never a cap.
#
# THE ONE THING THE FOLD MUST NEVER HIDE: a file path.
#
# A note whose evidence is too long for a cell can hand the reader a FILE
# instead -- the interference check writes every clash pair to a CSV and puts
# the path here (see `ticketchecks.check_member_interference`). Two rules
# follow from that, and both are enforced below rather than trusted to
# whoever writes the next message:
#
# * the path goes LAST, behind this marker, and it is rendered OUTSIDE the
# fold, so a collapsed note still shows its link. A path cut in half is
# a dead link, and a dead link is worse than a long cell. (Measured on
# WP296, 2026-08-30 -- back when a cap existed:
# the old cap landed inside the word `mark SR612` -- `... [id 24086619
# mar...` -- so the row showed six counts and half of one piece of
# evidence.)
# * the VISIBLE TEXT stays the real Windows path, exactly as
# `specsave.link` and PanelLiftCheck do it, because the path is what gets
# copied into Explorer. Only the href gets the `file:///` form, and only
# in HTML -- the text file and the JSON carry the plain path.
NOTE_FILE_SEP = ' -- every pair, with marks and ids: '
def note_parts(note):
"""(prose, separator, path) for a note that ends in a file path.
('the whole note', '', '') when it does not -- so a caller can always
unpack three values and only has to test the middle one.
"""
text = _text(note or '')
head, sep, path = text.partition(NOTE_FILE_SEP)
if not sep or not path.strip() or '\n' in path:
return (text, '', '')
return (head, sep, path)
def note_href(path):
"""`file:///` for a Windows path -- `specsave.href` plus two characters.
Spelled again rather than imported: `specsave` reaches for
`vibe.specformat` at module scope and this module is the one that must
stay importable with nothing but the standard library.
The two characters `specsave.href` does not escape and this does:
* `#` ENDS A URL. Everything after it is a fragment, so one `#` in a
folder name silently truncates the link -- and the marks this job
talks in are written `mark#`, so a folder named after one is not a
far-fetched path.
* `%` would otherwise make the next two characters an escape.
A space is left alone, exactly as `specsave` and PanelLiftCheck leave it:
that form is what the IE control here has been opening all along, and
this is not the place to change it for every link in the tool at once.
"""
url = str(path or '').replace('\\', '/')
return 'file:///' + url.replace('%', '%25').replace('#', '%23')
# ------------------------------------------------ how tall a folded note is
#
# THE OWNER'S RULE, EXACTLY: "item不要折叠,需要一行就一行,需要两行就两行,但是
# note做成默认折叠显示跟item一样的行数。如果note需要40行,但是item只有两行,那么
# 默认显示前两行,然后可以打开折叠。" The item cell is never managed; the note
# cell collapses to the height its OWN row's item cell occupies.
#
# WHAT IS EXACT AND WHAT IS A GUESS, SAID PLAINLY.
#
# Exact: the note is never shortened, the fold is height-only, and a clipped
# note keeps every character in the document.
#
# A guess: HOW MANY LINES the item cell occupies. That is a rendered
# measurement -- it depends on the column width the browser chose and on the
# font it chose to do it in -- and this code runs before either exists.
# `ITEM_CHARS_PER_LINE` is an estimate for the item column at the panel's
# 11pt in a window nobody has measured. Being wrong costs a line of PREVIEW,
# never a character of content: too small and the reader opens the fold one
# row earlier, too large and the collapsed cell is a line taller than its
# item. Both are cosmetic, which is the only reason a guess is allowed here
# at all.
#
# NOTE_LINE_HEIGHT MUST MATCH the `line-height` in CHECKLIST_STYLE below --
# the fold's height is computed as lines x this, in `em`, so a style edit
# that changes one and not the other silently changes what "two lines" means.
ITEM_CHARS_PER_LINE = 46
NOTE_LINE_HEIGHT = 1.35
# When the note is not obviously longer than the item, it is not folded at
# all: a fold on a cell that fits is a control that does nothing. Deliberately
# generous -- an unfolded long-ish note is a tall row, and a tall row loses
# nothing.
NOTE_CHARS_PER_LINE = 62
def item_line_estimate(name, per_line=ITEM_CHARS_PER_LINE):
"""Roughly how many lines the item cell takes. Never fewer than one."""
text = str(name or '')
if not text:
return 1
return max(1, (len(text) + per_line - 1) // per_line)
# Owner, verbatim (2026-08-31): "notes太长的话默认折叠"
def note_folds(prose, item_lines, status):
"""Whether this note's cell collapses -- ON LENGTH ALONE, every status.
Owner, 2026-08-31, overruling the red exemption below: "if notes are too
long, they fold by default" -- long notes fold by default, full stop. No
status word is named, and none is exempted.
Until this ruling, FAIL and NOT RUN never folded here -- the reasoning
was that a red row is the one the reader must not have to click to read.
That reasoning did not survive contact with what red rows actually carry
now: row probes write full multi-clause reasons, shared-silence
cross-references ("the same missing input also left rows 3.1, 3.2..."),
and dated mute reasons -- walls of text, overwhelmingly on NOT RUN, which
is exactly the status that used to be exempt. The owner is looking at
those walls and the instruction is blanket: long notes fold, no
exception for colour.
What this does NOT change: `status` still colours the row (`state_color`
is untouched), a folded note is still WHOLLY IN THE DOM (`note_cell_html`
never shortens a character, only clips height), and the collapsed cell
still shows exactly `item_lines` lines before the reader clicks -- a red
row folds to the SAME number of visible lines its own item takes, never
to zero. Folding is not the truncation `NOTE_CAP` used to do and is not
hiding either; it is `foldreport.wtClip`'s max-height clip, unconditional
on status now.
"""
return len(str(prose or '')) > item_lines * NOTE_CHARS_PER_LINE
def note_fold_id(table_id, num):
"""A DOM id unique on a page that prints one table per ticket.
Every ticket's table repeats the same row numbers, so the number alone
would give twenty elements called `5.3` and one toggle would open
whichever the browser found first.
"""
raw = '{0}_{1}'.format(table_id, num)
keep = [c if (c.isalnum() or c == '_') else '_' for c in str(raw)]
return 'wt_ckn_' + ''.join(keep)
_TABLE_SEQ = [0]
def _next_table_id():
_TABLE_SEQ[0] += 1
return 't{0}'.format(_TABLE_SEQ[0])
# Item number -> the finding code whose message enumerates. Kept as a table
# rather than a guess about message shape, so a message reworded tomorrow
# cannot silently stop producing details.
ENUMERATING_CODES = {
'SPEC-UNRESOLVED': '8.1',
}
def checklist_details(findings, sep=' | '):
"""{item number: [line, ...]} -- what a counted row actually counted.
Only for findings that enumerate, and only when there is more than one
thing to enumerate; a single line already fits the cell.
"""
out = {}
for f in (findings or []):
num = ENUMERATING_CODES.get(getattr(f, 'code', None))
if num is None:
continue
message = getattr(f, 'message', '') or ''
head, _found, tail = message.partition(': ')
if not tail:
continue
lines = [part.strip() for part in tail.split(sep)]
lines = [line for line in lines if line]
if len(lines) < 2:
continue
out[num] = lines
return out
# Owner, verbatim (2026-08-30), reading the WP296 run: "8.1 跟 8.2 之间那是
# 啥?我总感觉它在的表格的位置不太对。"
def note_lead(note, lines):
"""The note as it prints when its own lines are printed under it.
SAID ONCE, NOT TWICE. Owner, reading the WP296 run, 2026-08-30: "what's
that between 8.1 and 8.2? I keep feeling like its position in the table
isn't quite right." What sat between the two
numbered rows was 8.1's own enumeration -- printed in full inside 8.1's
note cell, and then printed AGAIN one-per-bullet in the block underneath:
8.1 WARN C 4 line(s) ... were not interpreted: STRAND PATTERN |
1/2" DIA. STR. | #3S @ 18" O.C. ... | STRIPPING ...
- STRAND PATTERN
- 1/2" DIA. STR.
...
A block that repeats the sentence above it verbatim does not read as
that sentence's detail; it reads as a second, unlabelled thing. So when
the lines are printed on their own, the note stops at the colon and lets
them carry themselves. The finding's `message` is untouched -- this is a
rendering decision, and `qc-run.json` still holds the whole string.
"""
text = _text(note or '')
if not lines:
return text
head, found, _tail = text.partition(': ')
if not found:
return text
return head + ':'
# ------------------------------------------------------------ the styling
#
# Everything below writes HTML for an IE-engine WebBrowser (see the module
# docstring of `vibe.foldreport` for what that engine is and is not). No
# HTML5 elements, no modern DOM, no CSS newer than IE7 understands: no
# flexbox, no `border-radius` that matters, no `rgba`.
#
# "字儿可以再大一点": the panel's own body text is 8pt. The checklist table is
# set at 11pt with a 12pt bold status column, and a note that will not fit
# wraps onto more lines rather than being cut ("一行放不下的就多来几行").
CHECKLIST_STYLE = """
.wt-ck { border-collapse: collapse; width: 100%; margin: 6px 0 14px 0;
font-size: 11pt; }
.wt-ck th { background: #33404f; color: #ffffff; font-size: 10pt;
text-align: left; padding: 5px 7px; }
.wt-ck td { padding: 5px 7px; border-bottom: 1px solid #d8dde3;
vertical-align: top; font-size: 11pt; line-height: 1.35;
white-space: normal; word-wrap: break-word; }
/* EVERY PER-COLUMN RULE IS WRITTEN `.wt-ck td.x`, NOT `.wt-ck-x`, AND THAT
IS THE WHOLE REASON THE DETAIL BLOCK LOOKED LIKE A ROW OF ITS OWN.
`.wt-ck td` is one class plus one element; a bare `.wt-ck-detail` is one
class. The bare form LOSES every property the td rule also sets, and it
loses silently -- the declaration is there in the file, and the browser
throws it away. Found 2026-08-30 while chasing "what's that between 8.1
and 8.2?": the detail block asked for `padding-left: 51px` and got the td rule's 7px,
so it began at the table's left edge, full width, indented by nothing. The
same bug was eating `white-space: nowrap` on the # and status columns and
the status column's 12pt -- all three now bind. */
.wt-ck td.wt-ck-num { font-weight: bold; width: 52px; white-space: nowrap; }
/* The master checklist id, under a profile's own number and only when the
two differ. Small and grey: it is what a RULE is written against, not
what the reader navigates by. */
.wt-ck .wt-ck-master { font-size: 8pt; font-weight: normal; color: #7a8290; }
.wt-ck td.wt-ck-grade { width: 40px; text-align: center; color: #667; }
.wt-ck td.wt-ck-state { font-size: 12pt; font-weight: bold;
white-space: nowrap; width: 118px;
text-align: center; }
/* The note keeps the table's own 11pt: "the text could stand to be a bit
bigger" was about this table, and the point of fixing the specificity is not to shrink it. */
.wt-ck-note a { color: #1d4f8c; }
/* THE NOTE FOLD. Height only -- `max-height` plus `overflow: hidden`, both
of which this engine has had since IE7. The text inside is the WHOLE
note; nothing is cut, so a reader can select the cell and copy every
character of it whether or not the toggle works. The inline
`max-height` written per row is what the toggle flips; this class exists
for the clipping and for the cursor.
The item column is deliberately NOT styled here. It is never folded --
one line if it needs one, two if it needs two ("the item must not fold"). */
.wt-ck-note-clip { overflow: hidden; cursor: pointer; }
.wt-ck-note-more { font-size: 9pt; color: #1d4f8c; cursor: pointer;
padding: 2px 0 0 0; }
/* Outside the fold, always: a collapsed note still shows its link. */
.wt-ck-note-path { padding: 2px 0 0 0; }
/* A DETAIL ROW IS ITS PARENT'S SECOND HALF, NOT A ROW OF ITS OWN.
The rule under the parent is removed, the # column is left blank so the
block starts under the item and not at the table edge, the ground is
neutral rather than the parent's status colour -- a pale yellow band the
full width of the table under a white row is exactly the free-floating
band the owner could not place -- and the parent's status INK comes back
as the left rule that ties the two together. */
.wt-ck tr.wt-ck-open td { border-bottom: none; }
.wt-ck td.wt-ck-detail-pad { width: 44px; }
.wt-ck td.wt-ck-detail { font-size: 10pt; padding: 0 7px 7px 0;
background: #f4f6f8; }
.wt-ck-detail-body { border-left: 3px solid #99a3b0; padding: 1px 0 1px 10px;
color: #333333; }
.wt-ck-detail div { padding: 1px 0; }
.wt-ck-key { font-size: 9pt; color: #556; margin: 0 0 6px 0; }
.wt-ck-key b { padding: 2px 6px; }
.wt-ck-head { font-size: 12pt; font-weight: bold; margin: 12px 0 2px 0; }
"""
def _swatch(color, label):
ink, ground = STATE_COLORS[color]
return ('<b style="color:{0};background:{1}">{2}</b>'
.format(ink, ground, label))
def checklist_key_html():
"""The one line that tells the reader what the colours mean.
It says what NOT RUN means in words, on the page, next to the red it is
printed in -- because a red row a reader decodes as "broken tool" gets
ignored, and a red row he decodes as "nobody checked this" does not.
And it says what the grey is, in the same breath, so the two can never
be confused: grey is a check that RAN and was asked for quietly by this
job. A reader who thinks grey might mean "skipped" is back to not
trusting the table.
"""
return ('<p class="wt-ck-key">{0} judged, fine &nbsp; {1} a person must '
'look &nbsp; {2} the piece is wrong, or <i>nobody checked</i> '
'&mdash; an unrun check tells you nothing about the piece, so it '
'is not quieter than a failure. &nbsp; {3} it ran and this job '
'asked for it quietly &mdash; the row says what it really '
'reported, and a FAIL is never muted. &nbsp; {4} a knowledge '
'layer declares this question is not about this kind of piece, '
'so the check did <i>not run</i> &mdash; every one is listed '
'below with its reason and the layer that declared it.</p>'
.format(_swatch('green', 'OK'),
_swatch('yellow', 'WARN / FOR ENGINEER'),
_swatch('red', 'FAIL / NOT RUN'),
_swatch('grey', MUTED_STATE),
_swatch('blue', NOT_APPLICABLE_STATE)))
def checklist_table_html(rows, details=None, escape=None, table_id=None):
"""The checklist, as one coloured table. The product; printed first.
`details` is `checklist_details(findings)`: item number -> the lines
behind a counted note, printed as their own rows under the item so a
long enumeration wraps into lines instead of one unreadable cell.
`table_id` only has to be unique on the page -- it namespaces the note
folds' DOM ids, because a run prints one of these tables per ticket and
every one of them has a row 5.3. It counts up on its own when the caller
does not care, which is what the button does.
"""
escape = escape or _html_escape
details = details or {}
table_id = table_id or _next_table_id()
out = ['<table class="wt-ck">',
'<tr><th>#</th><th>item</th><th>grade</th><th>status</th>'
'<th>note</th></tr>']
for row in (rows or []):
num, name, grade, status, note = row[0], row[1], row[2], row[3], row[4]
color = state_color(status)
ink, ground = STATE_COLORS[color]
# `details` and the note-fold ids are keyed by the MASTER ID, which
# is `row[0]` and stays there. Only the # column changes.
lines = details.get(num)
shown, stable = row_display(row)
out.append('<tr class="wt-ck-open">' if lines else '<tr>')
# THE MASTER ID IS RECOVERABLE ON THE PAGE, and only where it says
# something: printed under a profile number it differs from, and not
# at all when the two are the same word. A reader looking up a rule
# the owner wrote needs the id; a reader of a plant that has not
# renumbered needs one number, not the same number twice.
cell = escape(str(shown))
if str(stable) != str(shown):
cell += ('<div class="wt-ck-master" title="master checklist id">'
'{0}</div>'.format(escape(str(stable))))
out.append('<td class="wt-ck-num">{0}</td>'.format(cell))
out.append('<td>{0}</td>'.format(escape(str(name))))
out.append('<td class="wt-ck-grade">{0}</td>'
.format(escape(str(grade or ''))))
out.append('<td class="wt-ck-state" style="color:{0};background:{1}">'
'{2}</td>'.format(ink, ground, escape(str(status))))
out.append('<td class="wt-ck-note">{0}</td>'
.format(note_cell_html(note_lead(note, lines), name,
status, table_id, num, escape)))
out.append('</tr>')
if lines:
body = ''.join(['<div>&bull; {0}</div>'.format(escape(str(line)))
for line in lines])
out.append('<tr><td class="wt-ck-detail-pad">&nbsp;</td>'
'<td colspan="4" class="wt-ck-detail">'
'<div class="wt-ck-detail-body" '
'style="border-left-color:{0}">{1}</div>'
'</td></tr>'.format(ink, body))
out.append('</table>')
return ''.join(out)
def note_html(note, escape=None):
"""A note cell, flat: escaped, with a trailing file path made clickable.
The path is a REAL Windows path in the visible text and a `file:///` URL
only in the href -- see NOTE_FILE_SEP. An empty note is an em dash, so a
row that says nothing looks deliberate rather than broken.
This is the UNFOLDED shape and it is what every consumer outside the
checklist table gets. `note_cell_html` wraps it when a row's note is
long enough to fold.
"""
escape = escape or _html_escape
head, sep, path = note_parts(note)
if not sep:
return escape(head) or '&mdash;'
return '{0}{1}<a href="{2}">{3}</a>'.format(
escape(head), escape(sep), escape(note_href(path)), escape(path))
def note_cell_html(note, name, status, table_id, num, escape=None):
"""The note cell: whole, and folded to its row's height when it is long.
NOTHING IS REMOVED. The full prose is in the div; the div's HEIGHT is
capped at the item cell's estimated line count and `overflow: hidden`
does the rest, so the text is still in the document -- select it, copy
it, or open the fold.
THE PATH LIVES OUTSIDE THE FOLD. 7.1's note ends in a link to the pair
list, and a fold that hid the link would take the evidence with it.
THE TOGGLE, AND WHAT HAPPENS IF IT IS DEAD. The panel is an IE-engine
WebBrowser (see `foldreport`'s module docstring): no `<details>`, no
modern DOM. The control is the same inline `onclick` + `id`/`id + '_h'`
convention every other fold in this tool uses, calling `wtClip` out of
the script the button already injects -- one mechanism, extended, not a
second one.
If the script does not run, a folded note stays visually clipped. That
is a real cost, and two guards remain against it -- a red row folds like
any other now (owner, 2026-08-31: "if notes are too long, they fold by
default", overruling the earlier red exemption; see `note_folds`) but the path is never inside the
fold, and the whole note is in `last-ticketqc.txt` unfolded, which is the
fallback the footer already points at. The control also SAYS how much is
behind it, so a reader is never left guessing whether anything is -- and
the item-height lines that stay visible above the fold are never
clipped to nothing, on any row: `item_line_estimate` is always >= 1.
"""
escape = escape or _html_escape
head, sep, path = note_parts(note)
if not (head or path):
return '&mdash;'
lines = item_line_estimate(name)
tail = ''
if sep:
tail = ('<div class="wt-ck-note-path">{0} <a href="{1}">{2}</a></div>'
.format(escape(sep.strip().strip('-').strip()),
escape(note_href(path)), escape(path)))
if not note_folds(head, lines, status):
return '<div class="wt-ck-note-body">{0}</div>{1}'.format(
escape(head) or '&mdash;', tail)
ident = note_fold_id(table_id, num)
height = '{0:.2f}em'.format(lines * NOTE_LINE_HEIGHT)
return ('<div id="{0}" class="wt-ck-note-body wt-ck-note-clip" '
'style="max-height:{1}" wtcap="{1}">{2}</div>'
'<div id="{0}_h" class="wt-ck-note-more" '
'onclick="wtClip(\'{0}\')">[+] the whole note ({3} '
'characters)</div>{4}'
.format(ident, height, escape(head), len(head), tail))
# `_text` MOVED TO THE TOP OF THIS FILE (2026-08-31). It is called during
# module import now -- the master checklist is read off disk there and every
# refusal it can raise is a sentence -- so it can no longer live down here
# among the renderers. Its reasoning went with it; read it there before
# putting a `str()` back anywhere in this file.
def _wrap_text(value, width, indent, hanging=None):
"""`textwrap`, with its whitespace TRANSLATE step taken out of the path.
THE SECOND HALF OF THE SAME INCIDENT, and the half that is invisible on
a host interpreter. `textwrap` munges whitespace by calling
`text.translate(<a 256-entry table>)` on anything it considers a `str`.
On CPython that table is applied to a byte string, where every byte has
an entry; the extension's engine is IronPython 2.7, whose `str` holds
.NET UTF-16 text, so a character above U+00FF has no entry in the table
at all -- and the report came back with exactly those characters missing
and nothing else disturbed.
So the whitespace is flattened HERE, by `split()`/`join()`, and
`textwrap` is told not to do it again. Wrapping is unchanged; the only
behavioural difference is that a run of spaces inside a note collapses to
one, which is what a wrapped line wants anyway. `foldreport._flat` does
the same flattening for the same reason.
This cannot be reproduced on the host interpreter that runs the suite --
see `test_the_text_report_never_hands_a_note_to_textwraps_translate`,
which pins the shape rather than the symptom for that reason.
"""
import textwrap as _tw
flat = ' '.join(_text(value).split())
return _tw.wrap(flat, width=width,
initial_indent=indent,
subsequent_indent=indent if hanging is None else hanging,
expand_tabs=False, replace_whitespace=False)
def checklist_table_text(rows, details=None, width=100):
"""The same table as plain text -- the log, the file, the CLI.
Long notes WRAP onto continuation lines rather than being cut: the note
is what a reader uses to find the thing, and a truncated note sends him
looking for something the tool already saw. Colour does not survive
here and does not need to -- the STATE WORD is the data, and it is
printed in full.
NOR DOES AN ENCODING SURVIVE BY ITSELF: every string that reaches this
table goes through `_text`, and every wrap through `_wrap_text`. Read
both docstrings before putting a `str()` or a bare `textwrap` call back
-- between them they are one measured defect, not two style preferences.
"""
details = details or {}
lines = []
for row in (rows or []):
num, name, grade, status, note = row[0], row[1], row[2], row[3], row[4]
shown, stable = row_display(row)
# The file is the fallback a reader is told to trust, so the master
# id is ALWAYS recoverable here -- there is no hover text and no
# second column to put it in.
# `_text` on both sides, never the builtin text coercion. The
# tombstone test forbids that call anywhere in this function -- by
# searching the SOURCE, comments included -- and it is right to:
# this is the function that lost the owner's own words through
# exactly that boundary. See `_text` at the top of the file.
label = (_text(shown) if _text(shown) == _text(stable)
else _text('{0} [{1}]').format(_text(shown), _text(stable)))
lines.append(_text('{0:<5} {1:<13} {2:<1} {3}')
.format(label, _text(status),
_text(grade or '-'), _text(name)))
note = note_lead(note, details.get(num))
head, sep, path = note_parts(note)
if sep:
# A path gets its own line, unwrapped: `textwrap` would break it
# at a space in a folder name and half a path is not a path.
for chunk in _wrap_text(head + sep.rstrip(), width, ' ' * 8):
lines.append(chunk)
lines.append(_text(' ' * 8) + _text(path))
elif note:
for chunk in _wrap_text(note, width, ' ' * 8):
lines.append(chunk)
for detail in details.get(num, []):
for chunk in _wrap_text(_text('- ') + _text(detail), width,
' ' * 10, hanging=' ' * 12):
lines.append(chunk)
return _text('\n').join(lines)
def checklist_tally(rows):
"""{status word: count} for the rows the reader is actually looking at.
Counted off the ROWS, so the sentence over the table and the table
agree -- which is why a muted row, which is still a row, gets counted.
IT GETS ITS OWN BUCKET, `MUTED`, rather than folding into the green
count. A muted row is not a pass: it is a row somebody chose not to
read, and a header that said "31 OK" when four of them were nobody's
judgement would be the quiet lie this whole table is built against.
(OPEN QUESTION, with the owner: he may want them folded into OK to keep
the header short. Changing it is a change here and in
`checklist_headline`'s order, nowhere else.)
"""
counts = {}
for row in (rows or []):
status = row[3]
counts[status] = counts.get(status, 0) + 1
return counts
def checklist_headline(counts):
"""One sentence over the table, naming the loud states first.
MUTED comes last, after OK -- it is the quietest thing on the table and
the order of this sentence is what a reader skims. It is still SAID,
because a mute nobody can see is a mute nobody can question.
"""
order = ('FAIL', 'NOT RUN', 'WARN', 'FOR ENGINEER', 'OK',
NOT_APPLICABLE_STATE, MUTED_STATE)
parts = []
for word in order:
if counts.get(word):
parts.append('{0} {1}'.format(counts[word], word))
for word in sorted(counts):
if word not in order:
parts.append('{0} {1}'.format(counts[word], word))
if not parts:
return 'no checklist items'
return ' &middot; '.join(parts)
def _html_escape(body):
return (body.replace('&', '&amp;').replace('<', '&lt;')
.replace('>', '&gt;'))