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

1093 lines
48 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 -*-
"""Job-level reconciliation: does the issued set of tickets cover the job?
WHY THIS MODULE EXISTS.
Everything else in this tool judges ONE ticket at a time. A per-ticket check
cannot see a missing ticket, because the missing ticket is not there to be
checked -- and cannot see a ticket issued for a piece that no longer exists,
because it never looks outside the ticket it was handed. The consultancy's
real exposure is that the SET is wrong. This module is the only thing here
that asks a question about the set.
THE UNIT OF THE RECONCILIATION IS THE CONTROL NUMBER.
A control number is one physical piece: one crew, one bed, one day. A mark is
a drawing, and several identical pieces share it. So:
* the model side of the reconciliation is every control number carried by a
model element -- NOT every assembly. An assembly is a piece somebody has
already started drawing; a piece that nobody has assembled yet is still a
piece on the floor, and it is exactly the piece a ticket is most likely to
be missing for. Reconciling against assemblies alone reports a job clean
while most of it has never been looked at (measured on a real job: 755
assemblies, 2480 control-numbered elements).
* the ticket side is every control number PRINTED on a ticket, expanded --
a title-block slot may hold one number, a comma list, or a RANGE
('50120-50123' is four pieces). Reading a range as one opaque token
under-counts the ticket by three pieces and invents three orphans.
WHAT THIS MODULE MUST NEVER DO.
* Invent an exclusion. Nothing is filtered out for looking out of scope.
A family literally named '... (DO NOT USE)' is still reported, because
"this looked wrong to the tool so the tool dropped it" is the single
failure this project has already been bitten by -- one unauthorised
exclusion hid forty tickets and every check that ran came back clean.
A DECLARED scope is the one exception, and it is not an exception to the
rule above -- it is the rule enforced. `reconcile(scope=...)` takes the
scope the JOB file declares (`vibe.scope`), sets aside the pieces and
ticket sheets outside it, and reports that in `out_of_scope`, which
`format_report` prints as its own section with the counts named by kind.
The tool never decides this for itself: no scope declared means every
piece is reconciled, and the same section says so. What is set aside is
stated in the report; what is dropped silently is the failure.
* Report a clean set for a part of the job it could not see. Every result
carries `blind_spots`: the questions the inputs could not answer. A
reconciliation that cannot see part of the job says WHICH part.
* Count without naming. '3 pieces unaccounted for' sends somebody hunting.
Every finding here carries the control numbers and marks individually.
DATA CONTRACT (plain dicts -- no Revit anywhere in this file).
piece {'control', 'mark', 'design', 'id', 'category', 'family',
'in_assembly', 'ticket_created', 'ticket_detailed'}
`control` may be None or '' -- that is a finding, not a reason to
drop the piece.
sheet {'id', 'number', 'name', 'design', 'controls' (list of raw
title-block slot strings, empties included), 'total_required',
'sheet_count'}
The Revit side that fills these in is `vibe.jobreconscan`, the same split
`ticketpage.py` / `ticketchecks.py` uses.
THE REPORT FOLDS. `render()` returns `vibe.foldreport.Section`s: the verdict
in numbers as a headline that is never folded, every finding that has
anything in it as a section that opens ITSELF, and everything routine
collapsed to a header that still says what it holds. `format_report()` is the
same sections as plain text with nothing folded and nothing capped -- the
copy the button writes to disk, so a fold that will not open costs a file
open and never a sentence. The reasoning, section by section, is the comment
block above `render`.
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
"""
import re
from vibe.compat import string_types
from vibe import foldreport as fold
from vibe import scope as scopelib
# A sheet number that reads as a page of a ticket set: '1 OF 2', '2OF 2',
# '1 of 1'. Tolerant of the missing space, which real sheets have.
PAGE_RE = re.compile(r'^\s*(\d+)\s*OF\s*(\d+)\s*$', re.IGNORECASE)
# One control number as printed. Kept deliberately loose -- a plant that
# letters its control numbers is not this plant's problem to reject.
_PLAIN_RE = re.compile(r'^[A-Za-z0-9_.]+$')
_RANGE_RE = re.compile(r'^([A-Za-z]*)(\d+)\s*-\s*([A-Za-z]*)(\d+)$')
# Dashes a detailer's keyboard, autocorrect or a pasted PDF can produce, all
# meaning 'to'. They are normalised to a plain hyphen before the range is
# matched: an en dash in one slot must not turn four pieces into one
# unreadable token.
_DASHES = (u'‐', u'‑', u'‒', u'–', u'—', u'―',
u'−')
# How far a printed range may run before it is treated as unreadable rather
# than expanded. A title-block slot holding '50120-50123' is four pieces; a
# slot holding '1-99999' is a typo, and materialising 99999 fake pieces would
# bury the real finding. 500 is far above any real casting run and far below
# anything that could be a mis-keyed year or dimension.
MAX_RANGE = 500
SEPARATORS = re.compile(r'[,;/]| and ', re.IGNORECASE)
def expand_control_field(value):
"""One title-block control slot -> (controls, unreadable tokens).
Handles the three forms real tickets use, and says so when it meets a
fourth rather than guessing:
'20037' -> ['20037']
'60011, 60018' -> ['60011', '60018']
'50120-50123' -> ['50120', '50121', '50122', '50123']
'PL 1/4" X 28" X 4'-0"' -> [], ["PL 1/4\\" X 28\\" X 4'-0\\""]
The last case is real: on this plant's non-ticket sheets the same
parameter slots carry a material list. A token that is not a control
number is RETURNED, never dropped -- a slot the tool cannot read is a
slot whose pieces it cannot vouch for.
"""
if value is None:
return [], []
text = value.strip() if isinstance(value, string_types) else str(value)
if not text:
return [], []
for dash in _DASHES:
if dash in text:
text = text.replace(dash, u'-')
controls, unreadable = [], []
for part in SEPARATORS.split(text):
part = part.strip()
if not part:
continue
m = _RANGE_RE.match(part)
if m:
prefix_a, lo, prefix_b, hi = m.group(1), m.group(2), m.group(3), m.group(4)
width = len(lo)
lo_n, hi_n = int(lo), int(hi)
if prefix_a.upper() != prefix_b.upper() or hi_n < lo_n:
unreadable.append(part)
continue
if hi_n - lo_n > MAX_RANGE:
unreadable.append(part)
continue
for n in range(lo_n, hi_n + 1):
controls.append(prefix_a + str(n).zfill(width))
continue
if _PLAIN_RE.match(part):
controls.append(part)
else:
unreadable.append(part)
return controls, unreadable
def sheet_controls(sheet):
"""Every control number a sheet prints, plus what it could not read."""
controls, unreadable = [], []
for slot in (sheet.get('controls') or []):
got, bad = expand_control_field(slot)
controls.extend(got)
unreadable.extend(bad)
return controls, unreadable
def page_of(sheet):
"""(page, of) from a sheet number, or (None, None)."""
m = PAGE_RE.match(sheet.get('number') or '')
if not m:
return None, None
return int(m.group(1)), int(m.group(2))
def classify_sheet(sheet, known_marks):
"""Is this sheet a page of a shop ticket? ('ticket'|'other', why)
TWO SIGNALS, AND THE UNION OF THEM -- read this before narrowing it.
A ticket page is recognised by (a) its NAME being a piece mark the model
carries, or (b) its sheet number reading as a page ('1 OF 2') AND its
title block carrying a design number.
Requiring BOTH is the trap. A multi-page ticket's later sheets are
stripped of the title block -- no length, no weight, no f'c, no design
number -- so an and-gate silently drops them, and every finding that
depends on a page-2 disappears with them. Measured here: 28 real ticket
pages carry the mark but no design number.
Requiring only (b) is the other half of the same trap: it drops the
stripped pages too. Only the union sees a whole ticket.
A sheet reaching (a) alone or (b) alone is still a ticket, and the reason
string records which signal fired so a reader can see how sure the tool is.
"""
name = (sheet.get('name') or '').strip()
by_name = bool(name) and name in (known_marks or ())
page, _total = page_of(sheet)
by_block = page is not None and bool(sheet.get('design'))
if by_name and by_block:
return 'ticket', 'named after a piece mark, with a page number and a design number'
if by_name:
return 'ticket', 'named after a piece mark (title block stripped or absent)'
if by_block:
return 'ticket', 'page number and design number, but its name is not a piece mark'
return 'other', 'neither a piece mark for a name nor a page-numbered title block'
def page_set_status(pages):
"""Job-level version of the per-ticket page-set check.
`pages` -- the (page, of) pairs of every sheet found for one mark.
Returns (status, message) where status is 'ok', 'unjudged' or 'broken'.
'unjudged' is not 'ok'. A set with no page markers at all has not been
checked; saying OK there is the exact bug this whole module exists to
catch.
"""
marked = [p for p in pages if p[0] is not None]
if not marked:
return 'unjudged', ('no sheet carries a page marker -- the ticket set '
'could not be judged')
totals = sorted(set(p[1] for p in marked))
if len(totals) > 1:
return 'broken', ('pages disagree on how many there are: {0}'
.format(', '.join(str(t) for t in totals)))
total = totals[0]
have = sorted(p[0] for p in marked)
missing = [n for n in range(1, total + 1) if n not in have]
dupes = sorted(set(n for n in have if have.count(n) > 1))
if missing and dupes:
return 'broken', ('page(s) {0} missing and page(s) {1} duplicated out '
'of {2}'.format(_join(missing), _join(dupes), total))
if missing:
return 'broken', ('page(s) {0} missing out of {1}'
.format(_join(missing), total))
if dupes:
return 'broken', ('page(s) {0} appear more than once'.format(_join(dupes)))
if len(marked) < len(pages):
return 'broken', ('{0} of {1} sheets carry no page marker'
.format(len(pages) - len(marked), len(pages)))
return 'ok', '{0}-page ticket'.format(total)
def _join(values):
return ', '.join(str(v) for v in values)
def _as_int(value):
if value is None:
return None
try:
return int(str(value).replace(',', '').strip())
except (TypeError, ValueError):
return None
# --------------------------------------------------------------- the recon
def reconcile(pieces, sheets, sort_key=None, scope=None):
"""Reconcile the model's pieces against the issued ticket set.
pieces -- every control-numbered model element (see the data contract).
sheets -- every sheet in the document, ticket or not. Handing this
function only the sheets a caller already believes are tickets
would move the classification -- and its blind spots -- out of
the reconciliation, where nobody can see it.
scope -- a `vibe.scope.Scope`. Pieces and sheets outside the DECLARED
scope are set aside before anything is judged and reported
under `out_of_scope`. None, or an undeclared scope, reconciles
everything -- and still fills `out_of_scope` with the line
saying nobody declared one.
Returns a dict; every list in it names its members individually.
"""
sort_key = sort_key or (lambda s: s)
if scope is None:
scope = scopelib.Scope()
# ---- the declared scope, before anything is judged ------------------
#
# A sheet is named for its mark, so the same predicate reaches both. A
# sheet with no readable name falls back to its design number, and one
# with neither stays IN -- 'could not tell' never removes work.
all_pieces, all_sheets = pieces, sheets
pieces, out_pieces = scope.split(pieces or [],
key=lambda p: (p.get('mark') or ''))
sheets, out_sheets = scope.split(
sheets or [],
key=lambda s: scopelib.as_mark(s.get('name') or s.get('design') or ''))
out_of_scope = {
'declared': scope.is_declared,
'label': scope.label(),
'declaration': scope.declaration_lines(),
'pieces': scope.tally([p.get('mark') for p in out_pieces]),
'sheets': scope.tally([(s.get('name') or s.get('design'))
for s in out_sheets]),
'piece_count': len(out_pieces),
'sheet_count': len(out_sheets),
'piece_line': scope.line(
[p.get('mark') for p in out_pieces],
total=len(all_pieces or []), unit='model element',
unreadable_names=[p.get('mark') for p in (all_pieces or [])
if scope.is_unreadable(p.get('mark') or '')]),
'sheet_line': scope.line(
[(s.get('name') or s.get('design')) for s in out_sheets],
total=len(all_sheets or []), unit='sheet'),
}
known_marks = set()
for p in pieces:
mark = (p.get('mark') or '').strip()
if mark:
known_marks.add(mark)
# ---- model side ----------------------------------------------------
by_control = {}
no_control = []
for p in pieces:
control = (p.get('control') or '').strip()
if not control:
no_control.append(p)
continue
by_control.setdefault(control, []).append(p)
marks_of_control = {}
for control, group in by_control.items():
marks_of_control[control] = sorted(set(
(g.get('mark') or '?').strip() for g in group))
model_marks = {}
for control, group in by_control.items():
for mark in marks_of_control[control]:
model_marks.setdefault(mark, set()).add(control)
# ---- ticket side ---------------------------------------------------
ticket_pages, other_sheets = [], []
for s in sheets:
kind, why = classify_sheet(s, known_marks)
(ticket_pages if kind == 'ticket' else other_sheets).append((s, why))
issued = {} # control -> set of ticket names printing it
unreadable_slots = []
pages_by_mark = {}
for s, _why in ticket_pages:
name = (s.get('name') or '').strip() or '(unnamed sheet)'
pages_by_mark.setdefault(name, []).append(s)
controls, bad = sheet_controls(s)
for token in bad:
unreadable_slots.append({
'sheet': name, 'page': s.get('number'), 'id': s.get('id'),
'token': token,
})
for c in controls:
issued.setdefault(c, set()).add(name)
# A ticket page carrying no control number at all cannot vouch for
# anything -- and on a stripped page 2 that is CORRECT and expected, so
# it is reported as a fact about the set, not as a failure.
silent_pages = []
for s, _why in ticket_pages:
controls, _bad = sheet_controls(s)
if not controls:
silent_pages.append({'sheet': (s.get('name') or '').strip(),
'page': s.get('number'), 'id': s.get('id')})
model_controls = set(by_control)
issued_controls = set(issued)
issued_by_mark = {}
for control, names in issued.items():
for name in names:
issued_by_mark.setdefault(name, set()).add(control)
# ---- 1. every piece accounted for, every ticket for a real piece ----
#
# Two very different failures live in this one set, and merging them
# buries the worse one. A piece whose mark has NO ticket is work that
# visibly has not been drawn. A piece whose mark HAS a ticket that does
# not print its control number is work everyone believes is done: the
# ticket exists, the per-ticket checks pass on it, and the piece is still
# unaccounted for. `mark_has_ticket` separates them; the report leads
# with the second.
pieces_without_ticket = []
for control in sorted(model_controls - issued_controls, key=sort_key):
group = by_control[control]
first = group[0]
pieces_without_ticket.append({
'control': control,
'marks': marks_of_control[control],
'design': first.get('design'),
'ids': [g.get('id') for g in group],
'ticket_created': first.get('ticket_created'),
'ticket_detailed': first.get('ticket_detailed'),
'in_assembly': any(g.get('in_assembly') for g in group),
'mark_has_ticket': any(m in issued_by_mark
for m in marks_of_control[control]),
})
controls_without_piece = []
for control in sorted(issued_controls - model_controls, key=sort_key):
controls_without_piece.append({
'control': control,
'sheets': sorted(issued[control]),
})
# ---- 2. counts reconcile, per mark ---------------------------------
mark_count_mismatch = []
for mark in sorted(set(issued_by_mark) | set(model_marks)):
printed = issued_by_mark.get(mark, set())
modelled = model_marks.get(mark, set())
if not printed:
continue # no ticket at all -- reported below
# Compared as SETS, not as counts. Two numbers that happen to be
# equal while naming different pieces is the quietest way for a
# count check to pass on a wrong ticket -- a ticket that prints one
# piece of this mark and one piece of another reconciles perfectly
# on arithmetic and is wrong on both pieces.
if printed == modelled:
continue
mark_count_mismatch.append({
'mark': mark,
'issued': len(printed),
'modelled': len(modelled),
'issued_only': sorted(printed - modelled, key=sort_key),
'modelled_only': sorted(modelled - printed, key=sort_key),
})
marks_without_ticket = []
for mark in sorted(set(model_marks) - set(issued_by_mark)):
controls = sorted(model_marks[mark], key=sort_key)
marks_without_ticket.append({
'mark': mark,
'pieces': len(controls),
'controls': controls,
'design': _first_design(by_control, controls),
})
# ---- 3. what the ticket itself claims ------------------------------
quantity_disagreements = []
for s, _why in ticket_pages:
stated = _as_int(s.get('total_required'))
if stated is None:
continue
controls, _bad = sheet_controls(s)
listed = len(set(controls))
if listed and stated != listed:
quantity_disagreements.append({
'sheet': (s.get('name') or '').strip(),
'page': s.get('number'), 'id': s.get('id'),
'stated': stated, 'listed': listed,
})
design_disagreements = []
for s, _why in ticket_pages:
printed = (s.get('design') or '').strip()
if not printed:
continue
controls, _bad = sheet_controls(s)
designs = set()
for c in controls:
for g in by_control.get(c, []):
d = (g.get('design') or '').strip()
if d:
designs.add(d)
if designs and printed not in designs:
design_disagreements.append({
'sheet': (s.get('name') or '').strip(),
'page': s.get('number'), 'id': s.get('id'),
'printed': printed, 'pieces_say': sorted(designs),
})
page_set_problems = []
for mark in sorted(pages_by_mark):
pages = [page_of(s) for s in pages_by_mark[mark]]
status, message = page_set_status(pages)
if status != 'ok':
page_set_problems.append({
'mark': mark, 'status': status, 'message': message,
'numbers': [s.get('number') for s in pages_by_mark[mark]],
})
stated_page_counts = []
for mark in sorted(pages_by_mark):
group = pages_by_mark[mark]
stated = None
for s in group:
stated = _as_int(s.get('sheet_count'))
if stated is not None:
break
if stated is not None and stated != len(group):
stated_page_counts.append({
'mark': mark, 'stated': stated, 'found': len(group),
})
# ---- 4. the model's own claim about its tickets ---------------------
state_contradictions = []
ticketed_marks = set(issued_by_mark)
for control in sorted(model_controls, key=sort_key):
group = by_control[control]
first = group[0]
created = _yes(first.get('ticket_created'))
covered = control in issued_controls
has_named_sheet = any(m in ticketed_marks
for m in marks_of_control[control])
if created and not covered and has_named_sheet:
# The worst shape in the whole reconciliation: the ticket exists,
# the model says it was created, every per-ticket check passes on
# it -- and this piece is not printed on it. Nothing that reads
# one ticket at a time can see this.
state_contradictions.append({
'control': control, 'marks': marks_of_control[control],
'design': first.get('design'),
'problem': ('the model says its ticket was created, and the '
'ticket for its mark does not print this control '
'number'),
})
elif created and not covered:
state_contradictions.append({
'control': control, 'marks': marks_of_control[control],
'design': first.get('design'),
'problem': 'the model says its ticket was created, and there is no ticket',
})
elif covered and created is False:
state_contradictions.append({
'control': control, 'marks': marks_of_control[control],
'design': first.get('design'),
'problem': 'a ticket prints this piece, and the model says no ticket was created',
})
# ---- 5. one control number, several elements ------------------------
shared_controls = []
for control in sorted(model_controls, key=sort_key):
group = by_control[control]
if len(group) < 2:
continue
shared_controls.append({
'control': control,
'marks': marks_of_control[control],
'count': len(group),
'ids': [g.get('id') for g in group],
'families': sorted(set(g.get('family') or '?' for g in group)),
})
# ---- coverage, by design -------------------------------------------
coverage = {}
for control, group in by_control.items():
design = (group[0].get('design') or '(no design number)').strip() \
or '(no design number)'
row = coverage.setdefault(design, {'pieces': 0, 'covered': 0,
'uncovered': []})
row['pieces'] += 1
if control in issued_controls:
row['covered'] += 1
else:
row['uncovered'].append(control)
for row in coverage.values():
row['uncovered'].sort(key=sort_key)
# ---- what could not be judged ---------------------------------------
blind = []
if no_control:
blind.append('{0} model element(s) carry no control number and could '
'not be reconciled at all -- ids {1}'
.format(len(no_control),
_join([p.get('id') for p in no_control][:20])))
if unreadable_slots:
blind.append('{0} title-block control slot(s) hold text this tool '
'cannot read as a control number -- the pieces behind '
'them are unaccounted for either way'
.format(len(unreadable_slots)))
weak = [s for s, why in ticket_pages if 'stripped' in why or 'not a piece mark' in why]
if weak:
blind.append('{0} ticket page(s) were recognised on one signal only '
'(name, or title block, not both)'.format(len(weak)))
unjudged_sets = [p for p in page_set_problems if p['status'] == 'unjudged']
if unjudged_sets:
blind.append('{0} ticket set(s) carry no page markers, so whether the '
'set is complete was not judged'.format(len(unjudged_sets)))
return {
'totals': {
'elements': len(pieces),
'pieces': len(model_controls),
'pieces_covered': len(model_controls & issued_controls),
'pieces_uncovered': len(model_controls - issued_controls),
'controls_printed': len(issued_controls),
'controls_without_piece': len(issued_controls - model_controls),
'marks': len(model_marks),
'marks_with_ticket': len(set(model_marks) & set(issued_by_mark)),
'sheets': len(sheets),
'ticket_pages': len(ticket_pages),
'other_sheets': len(other_sheets),
'ticket_sets': len(pages_by_mark),
},
'pieces_without_ticket': pieces_without_ticket,
'controls_without_piece': controls_without_piece,
'marks_without_ticket': marks_without_ticket,
'mark_count_mismatch': mark_count_mismatch,
'quantity_disagreements': quantity_disagreements,
'design_disagreements': design_disagreements,
'page_set_problems': page_set_problems,
'stated_page_counts': stated_page_counts,
'state_contradictions': state_contradictions,
'shared_controls': shared_controls,
'pieces_without_control': [
{'id': p.get('id'), 'mark': p.get('mark'),
'design': p.get('design'), 'family': p.get('family')}
for p in no_control],
'unreadable_control_slots': unreadable_slots,
'silent_ticket_pages': silent_pages,
'coverage_by_design': coverage,
# What was LEFT ALONE, kept apart from what could not be judged.
# Both are printed; neither is a pass.
'out_of_scope': out_of_scope,
'blind_spots': blind,
}
def _yes(value):
"""Tri-state read of a Yes/No parameter: True, False, or None for unset.
None is not False. 'the model never said' and 'the model said no' are
different facts and a contradiction may only be raised on the second.
"""
if value is None:
return None
text = str(value).strip().upper()
if text in ('YES', 'TRUE', '1'):
return True
if text in ('NO', 'FALSE', '0'):
return False
return None
def _first_design(by_control, controls):
for c in controls:
for g in by_control.get(c, []):
if g.get('design'):
return g.get('design')
return None
# ------------------------------------------------------------------ report
#
# THE TAB-WIDE FOLD RULE, APPLIED HERE. Owner, 2026-08-30: the answer is the
# headline and is never folded; everything else follows it, collapsed; a
# section that carries a problem opens itself; a folded header still says
# what it holds. The reader's contract is that **nothing open below the
# headline means nothing needs him**. The rule itself, and why the HTML that
# implements it looks like 2003, live in `vibe.foldreport` -- not here,
# because it is not about reconciliation.
#
# WHAT THE ANSWER IS ON THIS PAGE, AND WHAT IT IS NOT.
# The verdict of a reconciliation is a set of NUMBERS: how many pieces are
# accounted for, how many printed control numbers have a piece behind them,
# and how many findings there are of each kind. It is NOT the findings
# themselves. On a job where four hundred pieces are uncovered the list runs
# to thousands of lines and the verdict is invisible inside it -- which is
# exactly the page this replaces. So the headline is the four totals plus one
# row per finding that has anything in it, and every finding's own list is a
# section underneath.
#
# WHAT COUNTS AS A PROBLEM HERE -- a non-empty finding, every one of them.
# Each names work somebody has to do or a contradiction somebody has to
# resolve; there is no finding in this module that is merely interesting.
# `findings()` is the single list they are all built from, so the counts in
# the headline and the sections below it cannot drift apart.
#
# A CHECK THAT FOUND NOTHING IS NOT AN EMPTY BLOCK. The old report printed
# thirteen headings and twelve 'none's, which is precisely the noise the
# owner asked to be rid of. It is not deleted either -- 'ran and found none'
# and 'never ran' must stay distinguishable -- so every empty finding is
# named, by name, in one folded line under CHECKS THAT RAN AND FOUND NOTHING.
#
# THE TWO SECTIONS THAT SAY WHAT THIS RUN COULD NOT SEE STAY SEPARATE, AND
# CARRY THEIR COUNTS ON THEIR HEADERS EVEN WHEN FOLDED. 'Not judged, out of
# scope' and 'could not be judged' are different states and neither is a
# pass; a fold that reduced either to the word 'details' would do exactly
# what this project has already been bitten by. So their summaries are the
# numbers themselves -- `OUT OF SCOPE: 1897 model element(s) -- BM 1056, ...`
# -- readable with the section shut. And a blind spot that is a REFUSAL
# (nothing was reconciled at all) is a problem: it opens itself, and it is
# said again in the headline, because a refusal reported quietly reads as a
# clean job.
#
# ASCII '--' IN A TITLE, A SUMMARY AND A PROBLEM. `foldreport` restores the
# reports' entities in those three as of 2026-08-30, so `&mdash;` would work
# now; this page keeps the plain dashes because `foldreport.md_text` leaves
# them alone and the panel renders them as themselves -- one string that is
# right in both outputs. The test below holds the page to it.
def _piece_line(r):
return '{0} {1} {2}{3}'.format(
r['control'], '/'.join(r['marks']), r['design'] or '?',
' [model says its ticket was created]'
if _yes(r['ticket_created']) else '')
def _control_line(r):
return '{0} printed on {1}'.format(r['control'], ', '.join(r['sheets']))
def _mark_line(r):
return '{0} {1} piece(s) {2} ({3})'.format(
r['mark'], r['pieces'], r['design'] or '?',
', '.join(r['controls'][:8]))
def _count_line(r):
return ('{0} ticket issues {1}, model has {2} '
'(on the ticket only: {3}; in the model only: {4})'.format(
r['mark'], r['issued'], r['modelled'],
', '.join(r['issued_only'][:6]) or '-',
', '.join(r['modelled_only'][:6]) or '-'))
def _quantity_line(r):
return '{0} {1} says {2} required, prints {3}'.format(
r['sheet'], r['page'], r['stated'], r['listed'])
def _design_line(r):
return '{0} {1} prints {2}, its pieces are {3}'.format(
r['sheet'], r['page'], r['printed'], ', '.join(r['pieces_say']))
def _page_set_line(r):
return '{0} [{1}] {2} ({3})'.format(
r['mark'], r['status'], r['message'],
', '.join(str(n) for n in r['numbers']))
def _page_count_line(r):
return '{0} states {1}, found {2}'.format(r['mark'], r['stated'],
r['found'])
def _contradiction_line(r):
return '{0} {1} {2}'.format(r['control'], '/'.join(r['marks']),
r['problem'])
def _shared_line(r):
return '{0} {1} {2} elements {3}'.format(
r['control'], '/'.join(r['marks']), r['count'],
', '.join(r['families']))
def _no_control_line(r):
return '{0} {1} {2}'.format(r['id'], r['mark'] or '?',
r['family'] or '?')
def _slot_line(r):
return '{0} {1} {2}'.format(r['sheet'], r['page'], r['token'])
def findings(recon):
"""Every check that can find something, in the order it is reported.
One list, six fields per row: key, title, the rows themselves, the phrase
saying what those rows ARE (used as the problem sentence when there are
any, and as the 'no ...' line when there are none), a renderer for one
row, and an optional lead paragraph.
The headline's counts and the sections below it are both built from this,
which is the only arrangement in which they cannot disagree.
"""
without = recon.get('pieces_without_ticket') or []
missing = [r for r in without if r.get('mark_has_ticket')]
never = [r for r in without if not r.get('mark_has_ticket')]
return [
('missing_from_ticket', 'PIECES MISSING FROM A TICKET THAT EXISTS',
missing,
'piece(s) whose mark HAS a ticket that does not print them',
_piece_line,
'The ticket exists and passes every per-ticket check. These pieces '
'are still not printed on it, so everybody believes they are '
'covered. Nothing that reads one ticket at a time can see this.'),
('never_drawn', 'PIECES WITH NO TICKET FOR THEIR MARK AT ALL',
never, 'piece(s) whose mark was never drawn at all', _piece_line,
None),
('orphan_controls', 'TICKET CONTROL NUMBERS WITH NO PIECE',
recon.get('controls_without_piece') or [],
'control number(s) printed on a ticket with no piece behind them',
_control_line, None),
('marks_without_ticket', 'MARKS WITH NO TICKET AT ALL',
recon.get('marks_without_ticket') or [],
'mark(s) with no ticket anywhere in the set', _mark_line, None),
('count_mismatch', 'COUNT DOES NOT RECONCILE',
recon.get('mark_count_mismatch') or [],
'mark(s) where the pieces issued and the pieces modelled are not '
'the same pieces', _count_line, None),
('quantity', 'TICKET QUANTITY vs CONTROL NUMBERS PRINTED',
recon.get('quantity_disagreements') or [],
'ticket page(s) stating a quantity they do not print',
_quantity_line, None),
('design', 'TICKET DESIGN NUMBER vs ITS PIECES',
recon.get('design_disagreements') or [],
'ticket page(s) whose own pieces disagree with the design number '
'printed on them', _design_line, None),
('page_sets', 'TICKET SET INCOMPLETE OR UNJUDGED',
recon.get('page_set_problems') or [],
'ticket set(s) with a page missing, or with no page markers to be '
'judged by', _page_set_line, None),
('stated_pages', 'SHEET COUNT THE TICKET STATES vs SHEETS FOUND',
recon.get('stated_page_counts') or [],
'ticket set(s) whose stated page count is not how many pages are '
'there', _page_count_line, None),
('contradictions', 'MODEL CONTRADICTS THE TICKET SET',
recon.get('state_contradictions') or [],
'piece(s) where the model and the ticket set say opposite things',
_contradiction_line, None),
('shared_controls', 'ONE CONTROL NUMBER, SEVERAL ELEMENTS',
recon.get('shared_controls') or [],
'control number(s) carried by more than one element', _shared_line,
None),
('no_control', 'ELEMENTS WITH NO CONTROL NUMBER',
recon.get('pieces_without_control') or [],
'model element(s) that could not be reconciled at all',
_no_control_line, None),
('unreadable_slots', 'CONTROL SLOTS THAT COULD NOT BE READ',
recon.get('unreadable_control_slots') or [],
'title-block slot(s) holding text this tool cannot read as a '
'control number', _slot_line, None),
]
def blind_refusal(recon):
"""A sentence when this run reconciled NOTHING, or None when it ran.
A refusal is not one blind spot among others and it is not a clean job:
it is the state in which every number on the page is meaningless. So it
opens its section and it is repeated in the headline. It is decided from
the totals as well as from the gatherer's own note, because a run can
reconcile nothing without anybody having written a note about it.
"""
for note in (recon.get('blind_spots') or []):
low = str(note).lower()
if 'nothing was reconciled' in low or 'is a refusal' in low:
# SHORT on purpose. The note itself is printed in full inside the
# blind-spot section, which this sentence forces open, and a
# header carrying two hundred characters is a header nobody
# reads.
return ('NOTHING WAS RECONCILED -- a refusal, not a clean job. '
'The blind spot below says which role is unmapped.')
totals = recon.get('totals') or {}
if not totals.get('pieces'):
return ('NOTHING WAS RECONCILED -- not one control-numbered piece '
'was found, so nothing on this page means the job is clean.')
if totals.get('sheets') and not totals.get('ticket_pages'):
return ('NO SHEET WAS RECOGNISED AS A TICKET -- {0} sheet(s) were '
'read and none of them classified as one, so every piece '
'below reads as unticketed for that reason alone.'
.format(totals.get('sheets')))
return None
def _rows_blocks(rows, render_row, limit):
"""One md block per row, and a line admitting the cap when it bites.
A truncated list always says how many it did not print, so 'the rest'
never reads as 'there is no rest'.
"""
out = [(fold.MD, render_row(row)) for row in rows[:limit]]
if len(rows) > limit:
out.append((fold.MD, '... and {0} more (not printed)'
.format(len(rows) - limit)))
return out
def render(recon, limit=25, full_path=None):
"""The reconciliation as `vibe.foldreport.Section`s, headline first.
Pure: no Revit, no output panel, no file written. `limit` caps each named
list -- the cap belongs to the PAGE, which is read, never to the file,
which is searched.
`full_path` is where the unfolded copy was written, if anywhere. It is
named on the page, so a folded section is never the only copy of a line.
"""
t = recon['totals']
checks = findings(recon)
hit = [c for c in checks if c[2]]
clear = [c for c in checks if not c[2]]
refusal = blind_refusal(recon)
# ---- the headline: the verdict, in numbers. Never folded.
head = fold.Section('headline', 'Answer', headline=True)
head.md('# JOB RECONCILIATION')
head.md('**model** &mdash; {0} control-numbered elements, {1} pieces'
.format(t['elements'], t['pieces']))
head.md('**tickets** &mdash; {0} sheets, {1} ticket pages in {2} sets'
.format(t['sheets'], t['ticket_pages'], t['ticket_sets']))
head.md('**coverage** &mdash; {0} of {1} pieces are printed on a ticket; '
'{2} are not'.format(t['pieces_covered'], t['pieces'],
t['pieces_uncovered']))
head.md('**marks** &mdash; {0} of {1} marks have a ticket'
.format(t['marks_with_ticket'], t['marks']))
if refusal:
head.md('## ' + refusal)
if hit:
head.md('## {0} finding(s) need you'.format(len(hit)))
head.table(['What', 'How many'],
[[title, len(rows)] for _k, title, rows, _p, _r, _l in hit])
head.md('Each is a section below, open. The other {0} check(s) ran '
'and found nothing.'.format(len(clear)))
elif not refusal:
head.md('## Nothing needs you')
head.md('All {0} checks ran and every one of them found nothing. '
'What this run did not judge, and what it could not judge, '
'are still below &mdash; folded, with their counts on the '
'header.'.format(len(checks)))
out = [head]
# ---- the findings. Non-empty is a problem, and a problem opens itself.
for key, title, rows, phrase, line, lead in hit:
section = fold.Section(
key, title, summary='{0} row(s)'.format(len(rows)),
problem='{0} {1}'.format(len(rows), phrase))
if lead:
section.md(lead)
for block in _rows_blocks(rows, line, limit):
section.add(block)
out.append(section)
# ---- coverage by design. A TABLE, not aligned text: `foldreport` folds
# runs of spaces inside a markdown block, so a column built out of them
# would collapse on the way to the panel.
coverage = recon.get('coverage_by_design') or {}
if coverage:
rows = []
unticketed = 0
for design in sorted(coverage):
row = coverage[design]
short = row['pieces'] - row['covered']
unticketed += short
rows.append([design, row['pieces'], row['covered'], short or ''])
out.append(fold.Section(
'coverage', 'COVERAGE BY DESIGN',
summary='{0} design(s), {1} piece(s) not on a ticket'
.format(len(coverage), unticketed),
blocks=[(fold.TABLE,
['Design', 'Pieces', 'Ticketed', 'Not ticketed'],
rows)]))
# ---- the ticket set, as inventory. A page printing no control number is
# a FACT about the set -- on a stripped continuation page it is correct
# -- so it is counted here and never reported as a failure.
silent = recon.get('silent_ticket_pages') or []
inventory = fold.Section(
'pageset', 'PAGE-SET INVENTORY',
summary='{0} ticket page(s) in {1} set(s); {2} print no control '
'number'.format(t['ticket_pages'], t['ticket_sets'],
len(silent)))
inventory.md('{0} of the {1} sheet(s) read classified as a ticket page; '
'{2} did not.'.format(t['ticket_pages'], t['sheets'],
t['other_sheets']))
if silent:
inventory.md('**Ticket pages printing no control number at all.** '
'Expected on a stripped continuation page; listed so '
'the set can be seen, not as a fault.')
for block in _rows_blocks(
silent, lambda r: '{0} {1}'.format(r['sheet'], r['page']),
limit):
inventory.add(block)
out.append(inventory)
# ---- what was swept. The gatherer's inventory of categories, kept apart
# from its blind spots: 'I looked here' is not 'I could not look'.
searched = recon.get('searched') or []
if searched:
out.append(fold.Section(
'searched', 'CATEGORIES SEARCHED',
summary='{0} line(s) of what the sweep opened'
.format(len(searched)),
blocks=[(fold.MD, note) for note in searched]))
# ---- the checks that found nothing. Folded is not gone, and a check
# that passed is not gone either: each is named, so 'found none' can
# never be mistaken for 'never ran'.
if clear:
section = fold.Section(
'clear', 'CHECKS THAT RAN AND FOUND NOTHING',
summary='{0} of {1} checks{2}'.format(
len(clear), len(checks),
' -- but nothing was reconciled, so this is not a pass'
if refusal else ''))
if refusal:
# A check finds nothing when there is nothing to check, too. On a
# refusal that is the only reason it found nothing, and a header
# counting twelve clean checks over an empty run is the exact
# shape of the bug this whole module exists to prevent.
section.md('**Nothing was reconciled on this run.** These checks '
'found nothing because there was nothing to check. It '
'is not a pass.')
for _k, title, _rows, _phrase, _r, _l in clear:
# 'ran, found none' rather than 'no <what the check looks for>':
# half these titles are already negative and 'no marks with no
# ticket' is a sentence nobody parses on the first read.
section.md('**{0}** &mdash; ran, found none'.format(title))
out.append(section)
# ---- how long it took, and where the whole thing was written.
timing = recon.get('timing') or {}
if timing or full_path:
about = fold.Section(
'run', 'ABOUT THIS RUN',
summary=('read the model in {0} s'.format(timing.get('total_s',
'?'))
if timing else 'where the full report was written'))
if timing:
about.md('Read the model in {0} s ({1} s pieces, {2} s sheets). '
'No geometry was built.'
.format(timing.get('total_s', '?'),
timing.get('pieces_s', '?'),
timing.get('sheets_s', '?')))
if full_path:
about.md('The whole report, nothing folded and nothing capped, '
'was written to `{0}`.'.format(full_path))
out.append(about)
# ---- what was left alone, and then what could not be seen. Two states,
# two sections, never merged -- and both carry their counts on the header
# so a shut fold still says how much is behind it.
oos = recon.get('out_of_scope') or {}
if oos:
section = fold.Section(
'out_of_scope',
'WHAT THIS RUN DID NOT JUDGE -- OUTSIDE THE DECLARED SCOPE',
summary=_scope_summary(oos))
section.md(oos.get('piece_line', ''))
if oos.get('declared'):
section.md(oos.get('sheet_line', ''))
for note in (oos.get('declaration') or []):
section.md(note)
out.append(section)
blind = recon.get('blind_spots') or []
spots = fold.Section(
'blind_spots', 'WHAT THIS RUN COULD NOT JUDGE',
summary=('{0} blind spot(s)'.format(len(blind)) if blind
else 'none -- every piece and every sheet was read'),
problem=refusal)
if not blind:
spots.md('nothing -- every piece and every sheet was read')
for note in blind:
spots.md(note)
out.append(spots)
return out
def _scope_summary(oos):
"""The out-of-scope header, with its counts on it.
This one is read with the fold SHUT, so it carries the number and the
breakdown rather than the word 'details'. Not judged is not clean.
"""
if not oos.get('declared'):
return ('no scope declared -- every piece was judged, including work '
'this tool has never been validated against')
count = oos.get('piece_count') or 0
if not count:
return 'none set aside -- nothing was removed from this run'
kinds = ', '.join('{0} {1}'.format(k, n)
for k, n in (oos.get('pieces') or []))
sheets = oos.get('sheet_count') or 0
return 'OUT OF SCOPE: {0} model element(s){1}{2}'.format(
count, ' -- ' + kinds if kinds else '',
'; {0} sheet(s)'.format(sheets) if sheets else '')
def format_report(recon, limit=None, full_path=None, show_all=True):
"""The reconciliation as text lines. What a file and a terminal want.
`show_all` prints every section, folded or not, which is what the copy
written for the record is FOR -- it is the reason nothing on the panel is
the only copy of anything. Pass False to read what the panel shows.
`limit` caps each named list and defaults to no cap at all: the page has
a cap because a page is read; the file has none because it is searched.
"""
if limit is None:
limit = 10 ** 9
sections = render(recon, limit=limit, full_path=full_path)
return fold.text(sections, show_all=show_all).split('\n')