Files
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

1585 lines
74 KiB
Python

# -*- coding: utf-8 -*-
"""Joining the reinforcing index to the matcher: features, the real `covers`,
the donor filter, and the end-to-end path from a piece name to proposals.
WHAT A FEATURE IS HERE, AND WHY IT IS NOT A VOID
------------------------------------------------
Owner's reframe, 2026-08-30: what gets reinforced is the CONCRETE THAT IS
LEFT, not the hole. Two openings 3 ft apart leave a 3 ft strip that has to
carry load and take bars; the same two openings 8 in apart leave a little
column section that may not develop a strand at all. The two holes are
identical in both cases. The strip between them is the thing an engineer
details, and it belongs to neither hole.
So the features this module hands the matcher are LIGAMENTS -- strips of
remaining concrete -- built from the index's voids rather than being the
voids. A ligament is the strip between two voids, or between a void and a
panel edge.
LOCALITY NEEDS NO RADIUS. There is no neighbourhood size to tune here and
none is invented: a ligament's extent is fully determined by the two things
that bound it. The strip between two voids spans the overlap of their ranges
on one axis and the gap between them on the other; both numbers fall out of
the geometry. That is the whole definition.
NO THRESHOLD. Every ligament is emitted, however narrow. The width below
which a strip is a "little column section" that cannot develop a strand is a
plant number, it lives in the local knowledge YAML, and it is not this
module's to guess -- consistent with the matcher's standing rule that
exactness RANKS and does not CLASSIFY.
A LIGAMENT MUST BE CONCRETE YOU CAN POINT AT. Every candidate strip is
checked clear of every other void before it is emitted. Without that check a
"strip" between two voids at opposite ends of a panel would be reported as
concrete while a third void sits in the middle of it. Zero-width strips are
not emitted either, because a void flush with the panel edge leaves no
concrete there; that condition shows up as the ABSENCE of an edge ligament,
which the set cover reads correctly -- a target that has the strip and a
donor that does not means the strip goes uncovered.
THE RECTANGLE ASSUMPTION, AND THE FLAG THAT KEEPS IT HONEST
------------------------------------------------------------
Ligaments are derived from `rect(length_ft, width_ft) minus the voids`. That
was measured against the model rather than assumed: comparing each panel's
actual solid volume with its extents box minus its recorded voids, 242 of the
255 WP donors agree to a median 0.00% and a p90 of 0.01%. The derivation is
sound for those.
THIRTEEN OF 255 DO NOT. Their solid is 3% to 33% smaller than their extents
box with no voids to explain it -- WP108 is 32.7% short against 0.2 cubic
feet of recorded voids. Those panels have a non-rectangular outline (a slope
or a corner cut) that the index, which stores only a bounding length and
width, cannot see. For them the derived shape claims concrete that is not
there, and it claims it exactly at the panel edge, which is where the edge
ligaments live.
Those pieces are marked `outline_approximate` and their EDGE ligaments can
never score exact (see `covers`). They still take part -- excluding them
would silently shrink the pool -- but a caller can never mistake an
approximated edge condition for a measured one. `judged` and `judged on an
approximation` stay different answers, which is the same rule the index
already applies to `features == []` against `features is None`.
KNOWN FOLLOW-UP, for whoever picks this up
-------------------------------------------
The fix is one new key on the index's piece record: the boundary of
`concrete_solid` projected into the piece frame, as a list of absolute-feet
`(x, y)` vertices, alongside the existing `length_ft` / `width_ft`. It needs
a `reinforcecore.SCHEMA` bump and a change in `reinforceindex.extract`.
Deferred deliberately on 2026-08-30 for a deadline, with the owner's
agreement that cleanup happens at integration; the evidence for doing it is
the 13-of-255 measurement above, reproducible with `outline_audit()` below.
Until it lands, `outline_approximate` is what stands in for it.
UNITS: FEET IN, INCHES OUT
--------------------------
Every record this module reads is in decimal FEET -- Revit's internal unit,
which is what `reinforcecore` caches and what the geometry extraction
produces. Nothing here converts a coordinate.
`covers` is the one exception and it is the seam: the distance it RETURNS is
INCHES, on a 1/8 in grain, by the owner's ruling of 2026-08-30
("all actual measurements use inches, to two decimal places"). It converts once, through
`reinforcecore.inches()`, and everything above it -- the matcher, the
proposals, the report's distance column -- is inches from there on. The
report converts stored feet coordinates for display through the same helper,
at the moment of printing, and never writes an inch value back onto a record.
So a double conversion has nowhere to live. See `GRAIN_IN` for why the grain
exists and `reinforcecore`'s seam note for the whole rule.
WHAT IS REVIT-FREE
------------------
The MODULE is: nothing at module scope imports Revit, so
`from vibe import reinforcebind` works under host CPython (that is how the
tests reach it, and how `tools/reinforce_offline.py` does).
TWO FUNCTIONS IN IT ARE NOT, and the line is drawn at the function, not at
the file, so an offline caller can be told exactly what it may call:
* `outline_audit(doc, ...)` -- reads each panel's real solid through
`liftexport.concrete_solid`. There is no way to compute a volume from a
snapshot that stores only a bounding rectangle; that is the whole reason
the audit exists. Offline, `load_audit_at(folder)` reads the RESULT it
left on disk, which is all a suggestion needs.
* `suggest_for_piece(doc, ...)` -- measures the target through the document,
and resolves the index path through `reinforceindex.index_path` ->
`designspec`, which imports `Autodesk.Revit.DB` at module scope.
`suggest_from_index` is the whole of `suggest_for_piece` except those two
steps, and it takes a cache instead of a document. Both of them only READ --
no Transaction, no parameter write, nothing in this feature writes to the
model.
"""
# pylint: disable=invalid-name,broad-except
from vibe import reinforcecore as core
from vibe import reinforcematch as match
# Revit-free, like the two above -- it reaches only `yamlkit` and `compat`.
# It is here for `design_key`, which is this codebase's ONE answer to "are
# these two design numbers the same design?" and which lived in `designspec`
# (Revit at module scope) until 2026-09-01. See `design_family` below.
from vibe import specformat
# Two lengths this close are the same length as far as reinforcing goes:
# 1/32 in in feet, under the 3-decimal rounding the index already applies.
EXACT_TOL_FT = 0.003
# The same tolerance on the other side of the unit seam. `covers` returns
# inches, so the "close but not exact" floor has to be quoted in inches too.
EXACT_TOL_IN = core.inches(EXACT_TOL_FT) # 0.036 in
# THE GRAIN, and the whole answer to "two decimals does not mean 0.01 in".
#
# The owner ruled the DISPLAY: "实际测量全部用英寸,带两位小数". Two decimal
# inches is 0.01", which is an order of magnitude finer than any tolerance
# this tool uses anywhere -- dimensions are checked at 3/8" (0.375") and
# interference at 1/8" (0.125"). Printing a ranking driven by 0.01" would be
# claiming a precision the measurement does not have, and this project has
# been burned before by a criterion that measured confidently and wrongly.
#
# So the DISPLAY does what he asked and the MEASUREMENT does not pretend:
# every non-exact distance `covers` returns is rounded to the nearest 1/8 in,
# the finest tolerance the tool actually works to. Two consequences, both
# wanted:
#
# * the ranking cannot be steered by a difference smaller than 1/16 in.
# Two donors that differ by less than that rank EQUAL and fall through to
# the next tie-break (MASTER, then input order) instead of being separated
# by noise.
# * a printed distance can only ever be a multiple of 1/8 in -- 0.13, 0.25,
# 0.38, 0.50 ... -- so the two decimals the owner asked for are visibly
# granular on the page. The report says the grain out loud as well
# (`reinforcereport.GRAIN_NOTE`), but a reader who never reads a footnote
# still cannot mistake this for 0.01 in resolution.
#
# EXACTNESS IS NOT QUANTIZED. Rounding to 1/8 in would make anything under
# 1/16 in read as 0.0, i.e. as EXACT, and "exact" is the one word in this
# report that has to keep meaning measured-identical. So the exact test stays
# at EXACT_TOL (1/32 in) and quantization applies only above it, with a floor
# so a non-exact match can never quantize down to zero.
GRAIN_IN = 0.125
# A strip narrower than this is not concrete, it is two voids touching.
MIN_STRIP_FT = 0.005
# Feature kinds. `covers` never compares across kinds -- a strip and an
# opening are not the same thing however well their rectangles line up.
OPENING = 'opening'
STRIP_X = 'strip_x' # width measured along the piece's length
STRIP_Y = 'strip_y' # width measured across the piece's width
VOID_VOID = 'void-void'
VOID_EDGE = 'void-edge'
# ------------------------------------------------------------ the features
def features_for(piece, outline_approximate=False, include_voids=True):
"""The feature list for one index piece record: ligaments, and the voids.
Returns None when the piece was never measured (`features is None`) --
could-not-judge propagates rather than becoming an empty list. Returns a
list, possibly empty, when it was measured.
JUDGEMENT CALL, flagged for review: the voids themselves are kept as
features alongside the ligaments (`include_voids`, default True). The
reframe says the strip is what gets detailed, and it does; but an opening
still needs trim bars sized to the opening, so dropping the hole entirely
would lose a real requirement. Keeping both means a donor has to account
for the target's holes AND the concrete between them. Pass
`include_voids=False` to get ligaments only.
"""
voids = (piece or {}).get('features')
if voids is None:
return None
L = _opt(piece.get('length_ft'))
W = _opt(piece.get('width_ft'))
out = []
if include_voids:
for v in voids:
out.append(_record(piece, OPENING, v['x0'], v['x1'],
v['y0'], v['y1'], None, None,
through=v.get('through'),
concrete_in=v.get('concrete_in'),
outline_approximate=outline_approximate))
if L is not None and W is not None:
out.extend(_ligaments(piece, voids, L, W, outline_approximate))
for i, f in enumerate(out):
f['index'] = i
return out
def _ligaments(piece, voids, L, W, outline_approximate):
"""Every strip of concrete bounded by two voids, or by a void and an edge.
Both axes, because a strip between two voids stacked vertically is as real
as one between two side by side, and the index records both axes precisely
so this cannot be got wrong in one of them.
"""
out = []
n = len(voids)
# void to void: an x-gap needs the two to overlap in y, and the reverse.
for i in range(n):
for j in range(i + 1, n):
a, b = voids[i], voids[j]
for lo, hi in ((a, b), (b, a)):
if lo['x1'] <= hi['x0']:
span0 = max(lo['y0'], hi['y0'])
span1 = min(lo['y1'], hi['y1'])
if span1 - span0 > MIN_STRIP_FT:
_add(out, piece, STRIP_X, lo['x1'], hi['x0'],
span0, span1, VOID_VOID, None, voids, (i, j),
outline_approximate)
if lo['y1'] <= hi['y0']:
span0 = max(lo['x0'], hi['x0'])
span1 = min(lo['x1'], hi['x1'])
if span1 - span0 > MIN_STRIP_FT:
_add(out, piece, STRIP_Y, span0, span1,
lo['y1'], hi['y0'], VOID_VOID, None, voids,
(i, j), outline_approximate)
# void to edge, all four edges of the piece.
for i in range(n):
v = voids[i]
_add(out, piece, STRIP_X, 0.0, v['x0'], v['y0'], v['y1'],
VOID_EDGE, 'x0', voids, (i,), outline_approximate)
_add(out, piece, STRIP_X, v['x1'], L, v['y0'], v['y1'],
VOID_EDGE, 'x1', voids, (i,), outline_approximate)
_add(out, piece, STRIP_Y, v['x0'], v['x1'], 0.0, v['y0'],
VOID_EDGE, 'y0', voids, (i,), outline_approximate)
_add(out, piece, STRIP_Y, v['x0'], v['x1'], v['y1'], W,
VOID_EDGE, 'y1', voids, (i,), outline_approximate)
out.sort(key=lambda f: (f['kind'], f['x0'], f['y0'], f['x1'], f['y1']))
return out
def _add(out, piece, kind, x0, x1, y0, y1, between, edge, voids, owners,
outline_approximate):
"""Emit one strip, if it is really concrete: wide enough, and clear."""
if x1 - x0 <= MIN_STRIP_FT or y1 - y0 <= MIN_STRIP_FT:
return # no concrete here to detail
if not _clear(x0, x1, y0, y1, voids, owners):
return # a third void sits in the strip
out.append(_record(piece, kind, x0, x1, y0, y1, between, edge,
outline_approximate=outline_approximate))
def _clear(x0, x1, y0, y1, voids, owners):
"""No void may overlap the strip's interior. This is what keeps a
ligament a thing you can point at on the piece."""
for k in range(len(voids)):
if k in owners:
continue
v = voids[k]
if (v['x0'] < x1 - MIN_STRIP_FT and v['x1'] > x0 + MIN_STRIP_FT and
v['y0'] < y1 - MIN_STRIP_FT and v['y1'] > y0 + MIN_STRIP_FT):
return False
return True
def _record(piece, kind, x0, x1, y0, y1, between, edge, through=None,
concrete_in=None, outline_approximate=False):
x0, x1 = _r(x0), _r(x1)
y0, y1 = _r(y0), _r(y1)
return {
'piece': piece.get('name'),
'kind': kind,
'index': None,
'x0': x0, 'x1': x1, 'y0': y0, 'y1': y1,
'w': _r(x1 - x0), 'h': _r(y1 - y0),
'cx': _r((x0 + x1) / 2.0), 'cy': _r((y0 + y1) / 2.0),
# For a strip, the width that matters is the one across it. For an
# opening it is not meaningful and stays None.
'strip_ft': (None if kind == OPENING else
_r(x1 - x0 if kind == STRIP_X else y1 - y0)),
'span_ft': (None if kind == OPENING else
_r(y1 - y0 if kind == STRIP_X else x1 - x0)),
'between': between,
'edge': edge,
'through': through,
'concrete_in': concrete_in,
'piece_length_ft': piece.get('length_ft'),
'piece_width_ft': piece.get('width_ft'),
'piece_thickness_in': piece.get('thickness_in'),
'outline_approximate': bool(outline_approximate),
}
def _r(v, places=3):
return None if v is None else round(float(v), places)
def _opt(value):
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
# --------------------------------------------------------------- the compare
def _quantize(distance_in):
"""A non-exact distance, on the 1/8 in grain, never rounded down to exact.
Below `EXACT_TOL_IN` this is never called -- that case returned 0.0
already. Above it, anything that would land on zero is floored at
`EXACT_TOL_IN` instead, so "off by a hair" and "identical" stay two
different answers however coarse the grain gets.
"""
steps = int(distance_in / GRAIN_IN + 0.5)
got = steps * GRAIN_IN
if got < EXACT_TOL_IN:
return EXACT_TOL_IN
return got
def covers(donor_feature, target_feature):
"""The production `covers`. None / 0.0 / a positive distance in INCHES.
Meets the matcher's seven requirements as follows.
TOTAL (1): any pair that cannot be read returns None. It never raises.
PURE (2): arithmetic on the two records and nothing else.
DIRECTIONAL (3): the strip-width rule below is one-way on purpose.
COMMENSURABLE (4): one unit, INCHES, for every kind. The distance is a
sum of absolute lengths, so 0.5 on a strip means what 0.5 means on an
opening. No per-kind weighting exists and none is invented: the owner
ruled the unit, not a weighting.
ABSOLUTE (5): every coordinate on the RECORD is feet from the start of the
piece -- the index stores Revit's internal unit and nothing converts
it. Nothing here divides by a panel dimension.
TWO-DIMENSIONAL (6): both the centre test and the distance use x and y.
CHEAP (7): a few floats.
COVERS AT ALL, in four conditions:
a. Same kind. An opening is not a strip, and a strip whose width runs
along the panel is not one whose width runs across it, however well
the rectangles line up.
b. Each rectangle contains the other's centre. Two-dimensional on
purpose: measured on this model, two voids share an x-range while
sitting in different height bands, and an x-only test calls those the
same feature. It also does the size sanity for free.
c. For strips, the donor's strip may be NARROWER than the target's or
equal, never wider (beyond tolerance). This is the directional part
and it is the one piece of reinforcing judgement in the test: bars
detailed for a narrow strip fit in a wider one with room to spare,
while a cage laid out for a wide strip does not fit into concrete
that is not there. NOTE, because I said the opposite in an earlier
reassessment and it was wrong: the permissive direction is the narrow
donor, not the wide one. The owner's own concern -- a little column
section that cannot develop a strand -- is about narrow strips, and a
donor that solved a narrow one is a good template for a wider one.
d. Penetration must not CONTRADICT, for openings. A through opening and
a blind pocket are different reinforcing: the pocket leaves concrete
behind it that carries load and takes its own trim. `through=None` is
COULD NOT JUDGE, never False, and is never compared as if it were --
a pair with an unjudged side passes here, on the grounds that nothing
was measured to contradict it, and `explain()` reports it as unjudged.
DISTANCE is the sum of the four absolute corner offsets. Zero for
identical rectangles. The edge-condition information that an earlier
version tried to bolt on as extra distance terms is not needed: the edge
ligaments ARE that information, as their own features.
THE UNIT SEAM IS THIS FUNCTION AND ONLY THIS FUNCTION. The two records
come in as feet -- that is what `reinforcecore` stores, because feet is
Revit's internal unit and converting on the way in is one more chance at a
12x error. The sum is computed in feet, converted ONCE by
`reinforcecore.inches()`, and everything above this line -- the matcher's
distance vectors, `Proposal.total_distance`, the report -- is inches and
is never converted again. See the seam note in `reinforcecore`.
THE GRAIN. The returned distance is quantized to 1/8 in, the finest
tolerance this tool uses. See `GRAIN_IN` above for why, and for why
exactness is deliberately left off the grain.
THE APPROXIMATION FLOOR. A feature on a piece whose outline could only be
approximated (see the module docstring) and which touches a panel edge can
never return 0.0. It returns EXACT_TOL_IN instead, so it ranks immediately
behind every genuinely exact match and can never be reported as one. That
is not a fudge factor on the geometry; it is the refusal to claim an exact
edge match on an edge nobody measured.
"""
d = _read(donor_feature)
t = _read(target_feature)
if d is None or t is None:
return None
if d['kind'] != t['kind']:
return None
if not _holds_centre(d, t['cx'], t['cy']):
return None
if not _holds_centre(t, d['cx'], d['cy']):
return None
if d['kind'] != OPENING:
if d['strip'] is None or t['strip'] is None:
return None
if d['strip'] > t['strip'] + EXACT_TOL_FT:
return None # donor cage will not fit
elif _through_contradicts(donor_feature, target_feature):
return None
# Computed in feet, because that is what the records are in.
dist_ft = (abs(d['x0'] - t['x0']) + abs(d['x1'] - t['x1']) +
abs(d['y0'] - t['y0']) + abs(d['y1'] - t['y1']))
if dist_ft < EXACT_TOL_FT:
if _edge_approximate(donor_feature, target_feature):
return EXACT_TOL_IN # close, but not claimed as exact
return 0.0
# The one conversion. Feet below, inches above, and nothing converts twice.
return _quantize(core.inches(dist_ft))
def _read(feature):
try:
x0 = float(feature['x0'])
x1 = float(feature['x1'])
y0 = float(feature['y0'])
y1 = float(feature['y1'])
kind = feature['kind']
except (TypeError, ValueError, KeyError, IndexError):
return None
strip = _opt(_get(feature, 'strip_ft'))
return {'kind': kind, 'x0': x0, 'x1': x1, 'y0': y0, 'y1': y1,
'cx': (x0 + x1) / 2.0, 'cy': (y0 + y1) / 2.0, 'strip': strip}
def _holds_centre(rect, cx, cy):
return (min(rect['x0'], rect['x1']) <= cx <= max(rect['x0'], rect['x1']) and
min(rect['y0'], rect['y1']) <= cy <= max(rect['y0'], rect['y1']))
def _through_contradicts(donor_feature, target_feature):
"""True only when both sides were judged AND they disagree.
`None` is could-not-judge. It is not False and is never read as one.
"""
dt = _get(donor_feature, 'through')
tt = _get(target_feature, 'through')
if dt is None or tt is None:
return False
return bool(dt) != bool(tt)
def _edge_approximate(donor_feature, target_feature):
"""Either side an approximated outline, and the comparison touches an edge."""
for f in (donor_feature, target_feature):
if not _get(f, 'outline_approximate'):
continue
if _get(f, 'between') == VOID_EDGE or _get(f, 'kind') == OPENING:
return True
return False
def _get(record, key):
try:
return record.get(key)
except AttributeError:
return None
# -------------------------------------------------------- the design number
#
# THE HARD RULE. Owner, 2026-09-01: "选择母版的目标,必须得是跟自己的design
# number一样的panel。我刚在一个WP2.0上面跑了reinforce,给我俩WP1.0的母版,压根
# 用不了。" 必须 -- so this is a FILTER, not a term in the ranking. A WP1.0
# donor offered for a WP2.0 target is not a weaker suggestion that a penalty
# could push down the list; it is an answer the engineer cannot use at all,
# and a ranking that can be overruled by geometry would put one back on top
# the moment its openings happened to line up.
#
# HOW TWO DESIGN NUMBERS ARE COMPARED: through `specformat.design_key`, which
# is this codebase's one answer to that question and was already the rule
# Ticket QC checks a piece against its ticket with (`_check_design_number`).
# It reduces a packet's page suffixes away -- PNL1, PNL1.0, PNL1.A and PNL1.B1
# are all the key PNL1 -- which is the owner's established nomenclature
# ruling: a packet suffix and a `.A` note are not separate designs. It does
# NOT merge PNL1 with PNL2, which is exactly the distinction he was handed
# wrongly.
#
# WHY THE PATTERN IS A PARAMETER. The reduction is plant knowledge
# (`nomenclature.design_number_re`), and this module may not import
# `knowledge` -- it has to run in `tools/reinforce_offline.py` with no model
# and therefore no knowledge layer. So `suggest_for_piece` resolves the
# pattern from the job's knowledge and hands it in; offline it is None and
# `design_key` falls back to its built-in pattern, which is what an unmapped
# nomenclature has always produced everywhere else in this codebase.
#
# NONE IS NOT A DESIGN. `design_family` returns None both for a piece with no
# design number and for one whose number the pattern cannot read. Two Nones
# are NOT a match here -- see `_design_reject` -- because "neither of these was
# read" is not "these are the same".
def design_family(value, pattern=None):
"""The design family key for one design number, or None if unreadable.
Thin, and deliberately so: it exists to name the rule at the call site and
to keep `specformat` the only implementation of it.
"""
return specformat.design_key(value, None, pattern)
def design_pattern(know):
"""The plant's design-number pattern out of a knowledge tree, or None.
The literal key is written out here rather than reached through a
constant so `tools/knowledge_drift.py` credits it with a reader; see the
same note in `reinforceindex.master_token`. None -- no knowledge layer, or
a plant that has not set it -- means `design_key`'s built-in fallback,
which is what every other reader of this key already does.
"""
if not know:
return None
from vibe import knowledge
return knowledge.get(know, 'nomenclature.design_number_re', None)
# ------------------------------------------------------ the elevation affinity
#
# THE LAST TIE-BREAK, RULED 2026-09-02. WP425 ranked five donors dead level
# on every other criterion -- 1 donor, 5/5 covered, 4 exact, 0 reviewed,
# 0.04 in total distance, all five identical -- and the tie fell through to
# mark order, so WP133 printed first though WP234 shares WP425's own
# building elevation: owner, "WP234明明在同一个elevation,为什么排名没有
# 提前?" Approved as briefed the same day ("同意,就这么做"): elevation is
# an AFFINITY, never a filter -- a cross-elevation donor with better
# coverage still wins, exactly the standing correction on donor order this
# module already carries for the design-number rule. See
# `reinforcematch`'s ranking notes for where it sits in the comparator.
#
# WHAT "SAME ELEVATION" MEANS, settled in three passes the same day. First:
# a hand-typed parameter (`panel.elevation`) -- wrong, reversed. Second: the
# owner ruled it PURELY GEOMETRIC ("elevation是纯几何问题"). Third, the
# scope correction once MC's real model turned up a usable grouping
# parameter (`Tagged_Sheet`) that not every job has: geometry is the FLOOR,
# always computed and always correct; a plant-declared grouping key is an
# OPTIONAL ACCELERATOR checked first where it exists. See
# `reinforcecore.elevation_match`'s module note for the whole story --
# this function is just the caller, via `core.elevation_match`, which
# returns which of the two sources answered so the report can say.
#
# UNKNOWN IS NO AFFINITY, NEVER A MATCH, from EITHER source.
# `core.elevation_match` never treats two could-not-judge values as
# agreeing -- a target with no signal at all prefers nobody, and a donor
# with no signal is never preferred even when the target's is known. Same
# discipline `_design_reject` already applies to design numbers, one level
# down in consequence: this is a ranking term, not a filter, so getting it
# wrong here means a worse ORDER, never a lost donor.
def _elevation_affinity(target, kept):
"""(target's own elevation signal, {donor name, ...} sharing it,
{donor name: source, ...}).
`target`'s signal is `{'group': ..., 'band': ...}` -- whatever the
target itself carries of each, for the report to describe even when
nothing in the pool matches. `source` is `core.ELEVATION_BY_GROUP` or
`core.ELEVATION_BY_BAND`, naming which one actually decided that donor.
`kept` is `select_donors`'s first return -- the pool AFTER the filter,
so a donor already rejected for design or thickness is never considered
for the affinity either; there is nothing to prefer among candidates
that were never offered.
"""
target = target or {}
target_group = core.elevation_group_key(target.get('elevation_group'))
target_band = target.get('elevation_band')
same = set()
sources = {}
if target_group is not None or target_band:
for rec in kept or ():
source = core.elevation_match(target, rec or {})
if source is not None:
donor_name = (rec or {}).get('name')
same.add(donor_name)
sources[donor_name] = source
return {'group': target_group, 'band': target_band}, same, sources
# ---------------------------------------------------------------- the filter
def _target_measured_empty(target):
"""True only when the TARGET was positively measured to have no voids.
`features == []` and not `features is None` -- the project's cardinal
distinction, re-asserted here because this is the one place a FALSE
positive on it (reading a never-measured target as a featureless one)
would silently widen a filter instead of narrowing it. See
`DonorFilter.require_features`.
"""
feats = (target or {}).get('features')
return feats is not None and not feats
# Owner, verbatim (2026-09-01), hit on a real piece: "如果扫描到目标没有
# opening,那么就找另一块没有opening的板子给target当doner。" -- if the scan
# finds the target has no opening, find another panel with no opening to be
# the target's donor.
# Owner, verbatim (2026-09-01), the very next sentence: "在这个过程当中,很
# 多其他东西依然是有用的,比如说design number。" -- in this process a lot of
# other things are still useful, such as design number.
class DonorFilter(object):
"""Which indexed pieces may stand in for this target. A named predicate.
Every rule is a judgement call, stated so it can be argued with and
changed in one place rather than buried in a comprehension.
prefixes = None (the DECLARED SCOPE decides)
Which marks may donate. Owner's ruling, 2026-08-30: a splitter for
wall panels filters to wall panels -- a beam's or a slab's reinforcing
has nothing to do with a panel's, and on the job this was measured on
the majority of eligible pieces were exactly that.
The ruling has not changed; where it is WRITTEN DOWN has. It used to
be this default. That made one job's division of labour a constant in
a shareable repository, which is the same unauthorised exclusion the
rest of this codebase exists to prevent, one layer further back.
`suggest_for_piece` now fills this in from `vibe.scope` -- the scope
the JOB file declares -- and `None` here means no prefix rule at all,
which is what an undeclared scope correctly produces. Every rejection
is still counted and named in the report either way.
match_thickness = True, thickness_tol_in = 0.01
The donor panel must be the same thickness as the target. The
strongest of the geometric rules: thickness sets the number of bar
layers, the cover top and bottom, and where the strand sits, so an
8 in cage cannot be transplanted into a 12 in panel however well the
voids line up. In this model the WP pool is 244 at 8 in and 11 at
12 in -- two populations that should never mix. High confidence.
max_width_diff_ft = None (off)
Width -- a wall panel's height -- is the ARGUABLE one and it is off by
default. For: a taller panel carries more longitudinal bars and more
strand rows, so its sheet is not a template for a short one. Against,
and this is why it is off: the comparison is now per-ligament, and a
strip 2 ft off the bottom of a 12 ft panel is detailed like the same
strip 2 ft off the bottom of a 9 ft panel. Turning it on can only
remove donors the matcher would have ranked on their merits, against
the owner's standing correction that a filter must never exclude a
better-shaped donor. THIS IS THE ONE TO REVIEW. Set feet to enable.
require_features = True
A donor measured to have no voids (`features == []`) has no ligaments
either and can cover nothing, so it is dropped before the compare
rather than after: arithmetic, not judgement. A donor that was NEVER
measured (`features is None`) is dropped for a different reason and
under a different label -- letting one in would silently remove a
candidate rather than flag it.
THE RULE INVERTS WHEN THE TARGET ITSELF HAS NO VOIDS. Owner, hit on a
real piece 2026-09-01: "if the scan finds the target has no opening,
find another panel with no opening to be the target's donor." The reasoning above only holds when the
TARGET has openings to cover -- a featureless donor genuinely has
nothing to offer a panel that has something to cover. A target
measured to have NO openings is the opposite case: a panel with no
openings IS the right donor for a panel with no openings, and this
rule was discarding exactly that pool. So `reject` now reads the
TARGET's own `features` (already on the record it is handed) and
skips this rejection when the target was MEASURED and came back
empty (`features == []`, never `features is None` -- see point 4
below). Every other rule in this filter stays in force: design,
thickness, prefix and self-exclusion are unaffected, because the
owner's very next sentence ruled that out explicitly: "in this
process a lot of other things are still useful, such as design
number." The ranking among the
survivors -- and what to do with a donor that DOES have voids when
the target has none -- is `reinforcebind.shape_search_for`, not this
filter; a filter decides who is IN the pool, not their order.
A target that was never measured (`features is None`) must NOT take
this path -- that is could-not-judge, not measured-empty. `reject` is
called with the raw target record regardless of its `target_status`
(`select_donors` always runs, even though `propose` goes on to refuse
an unjudged target before using the result), so the check here is
conservative on purpose: it inverts only on a positive empty-list
read of the target, never on an absent one.
exclude_self = True
A piece is not its own donor; it would win every comparison exactly
and tell the engineer nothing.
match_design = True, design_re = None
THE HARD ONE, and the only rule here the owner has stated as
mandatory (2026-09-01). A donor must carry the SAME DESIGN NUMBER as the target,
reduced through `design_family` so a packet suffix or a `.A` note does
not split one design into several. It is not a ranking penalty and it
may not be turned into one: a WP1.0 donor for a WP2.0 target is
unusable however well its openings line up.
It is on by default and there is no knob on the button to turn it off.
`match_design=False` exists for one purpose -- letting a test state
what the ranking does INSIDE one design without having to give every
fixture a design number -- and switching it off in production would
reintroduce the exact defect.
`design_re` is the plant's `nomenclature.design_number_re`, handed in
rather than read, because this module may not import `knowledge` (see
the note above `design_family`). None means the built-in fallback.
THREE OUTCOMES, not two. `design` is a judged mismatch. `design_unknown`
is a donor whose own design number could not be read, and it is a
REJECTION, never a pass: an unread field that defaulted to matching is
how this defect got shipped. `design_target_unknown` is the target's
own number missing, which rejects everybody -- and `propose` refuses
before it ever gets that far, so the report says it once instead of
six hundred times.
"""
def __init__(self, prefixes=None, match_thickness=True,
thickness_tol_in=0.01, max_width_diff_ft=None,
require_features=True, exclude_self=True,
match_design=True, design_re=None):
self.prefixes = tuple(prefixes) if prefixes else None
self.match_thickness = match_thickness
self.thickness_tol_in = thickness_tol_in
self.max_width_diff_ft = max_width_diff_ft
self.require_features = require_features
self.exclude_self = exclude_self
self.match_design = match_design
self.design_re = design_re
def reject(self, donor, target):
"""None to accept, else a short reason string, so every rejection can
be counted and named."""
donor = donor or {}
target = target or {}
name = donor.get('name')
if self.exclude_self and name == target.get('name'):
return 'self'
feats = donor.get('features')
if feats is None:
return 'unjudged'
if (self.require_features and not feats and
not _target_measured_empty(target)):
return 'no_features'
if not is_eligible(donor):
return 'not_eligible'
if self.prefixes is not None and not _has_prefix(name, self.prefixes):
return 'prefix'
# AFTER the scope, before the geometry. After, so another engineer's
# beams are still reported as out of scope rather than as a design
# mismatch -- they were never candidates and the pool table should
# keep saying so. Before, because design is 必须 and thickness is a
# judgement: when a piece fails both, the harder rule is the one worth
# printing.
if self.match_design:
got = self._design_reject(donor, target)
if got is not None:
return got
if self.match_thickness:
got = _diff(donor.get('thickness_in'), target.get('thickness_in'))
if got is None:
return 'thickness_unknown'
if got > self.thickness_tol_in:
return 'thickness'
if self.max_width_diff_ft is not None:
got = _diff(donor.get('width_ft'), target.get('width_ft'))
if got is None:
return 'width_unknown'
if got > self.max_width_diff_ft:
return 'width'
return None
def _design_reject(self, donor, target):
"""None to accept on design grounds, else which of the three it is.
Two unreadable design numbers are NOT a match. `None == None` is true
in Python and false in precast: it would mean two pieces nobody read
were declared the same design, which is the whole defect.
"""
want = design_family(target.get('design_number'), self.design_re)
if want is None:
return 'design_target_unknown'
got = design_family(donor.get('design_number'), self.design_re)
if got is None:
return 'design_unknown'
if got != want:
return 'design'
return None
def accepts(self, donor, target):
return self.reject(donor, target) is None
def is_eligible(record):
"""The index's own donor rule, re-asserted on a record from any source."""
from vibe import reinforcecore as core
return core.is_eligible((record or {}).get('reinforced'),
(record or {}).get('on_hold'))
def _has_prefix(name, prefixes):
if not name:
return False
for p in prefixes:
if name.startswith(p):
return True
return False
def _diff(a, b):
if a is None or b is None:
return None
try:
return abs(float(a) - float(b))
except (TypeError, ValueError):
return None
# ------------------------------------------------ the design-number verdict
#
# Five answers, and only one of them lets a suggestion be printed. The four
# refusals are all "could not judge" or "judged, and the answer is no" -- the
# project's standing distinction -- and none of them may degrade into offering
# a donor of another design, even labelled as one. There is no fallback here
# on purpose: a labelled cross-design suggestion is still the thing the owner
# could not use, and a label is exactly what a hurried reader skips.
# The vocabulary is `reinforcecore`'s, so that `reinforcereport` can name
# these statuses without importing this module. Re-exported here under the
# same names because this is where the rule that produces them lives, and a
# reader of `design_info` should not have to go two files to see them.
#
# DESIGN_OK at least one candidate is the same design.
# DESIGN_TARGET_UNKNOWN the target's own design number is missing or
# unreadable. Nothing can be filtered against a
# design nobody knows.
# DESIGN_INDEX_NOT_RECORDED the index does not carry the field at all.
# DESIGN_INDEX_UNREAD it carries the field and not one candidate has
# a value in it: the role is unmapped, or the
# build ran without a knowledge layer.
# DESIGN_NONE_MATCH everything was read; there is no donor of this
# design.
DESIGN_OK = core.DESIGN_OK
DESIGN_TARGET_UNKNOWN = core.DESIGN_TARGET_UNKNOWN
DESIGN_INDEX_NOT_RECORDED = core.DESIGN_INDEX_NOT_RECORDED
DESIGN_INDEX_UNREAD = core.DESIGN_INDEX_UNREAD
DESIGN_NONE_MATCH = core.DESIGN_NONE_MATCH
DESIGN_REFUSALS = core.DESIGN_REFUSALS
DESIGN_UNREADABLE = core.DESIGN_UNREADABLE
# Reasons `DonorFilter.reject` can give BEFORE it ever looks at the design.
# A piece dropped for one of these was never a candidate for this target, so
# its design number says nothing about what the pool held -- another
# engineer's beam is not evidence that this job has no WP2 donor.
PRE_DESIGN_REASONS = ('self', 'unjudged', 'no_features', 'not_eligible',
'prefix')
def design_info(target, records, rejected, donor_filter=None):
"""What the design rule did, as data the report turns into a sentence.
`rejected` is `select_donors`' second return. The candidate pool is
reconstructed from it rather than re-filtered, so this can never disagree
with the filter that actually ran.
"""
donor_filter = donor_filter or DonorFilter()
pattern = donor_filter.design_re
raw = (target or {}).get('design_number')
want = design_family(raw, pattern)
dropped_early = set()
for reason in PRE_DESIGN_REASONS:
for nm in (rejected or {}).get(reason) or ():
dropped_early.add(nm)
# EVERY NUMBER BELOW IS ABOUT CANDIDATES, not about the whole index, and
# that is what keeps this rule from claiming findings that belong to
# somebody else. A pool emptied by the scope, by eligibility or by a piece
# nobody measured was emptied BEFORE the design test ran; saying "no donor
# of design WP2 exists" about a pool that never contained a single
# candidate would blame the design rule for another rule's work, and would
# bury the real reason under a confident sentence.
candidates = [rec or {} for rec in (records or [])
if (rec or {}).get('name') not in dropped_early]
seen = {}
same = []
unreadable = []
for rec in candidates:
key = design_family(rec.get('design_number'), pattern)
if key is None:
unreadable.append(rec.get('name'))
seen[DESIGN_UNREADABLE] = seen.get(DESIGN_UNREADABLE, 0) + 1
continue
seen[key] = seen.get(key, 0) + 1
if want is not None and key == want:
same.append(rec.get('name'))
pool = core.design_status(candidates)
if not donor_filter.match_design or not candidates:
# No candidates: the design rule never ran, and NO_DONORS is the
# finding. `ran` says so, so a reader of `info` cannot mistake this
# DESIGN_OK for "the design was checked and is fine".
status = DESIGN_OK
elif pool == core.DESIGN_NOT_RECORDED:
status = DESIGN_INDEX_NOT_RECORDED
elif pool == core.DESIGN_UNREAD:
status = DESIGN_INDEX_UNREAD
elif want is None:
status = DESIGN_TARGET_UNKNOWN
elif not same:
status = DESIGN_NONE_MATCH
else:
status = DESIGN_OK
return {
'status': status,
'refused': status in DESIGN_REFUSALS,
'enforced': bool(donor_filter.match_design),
'ran': bool(donor_filter.match_design and candidates),
'target': raw,
'target_key': want,
'index_status': pool,
# What survived everything the filter asks BEFORE the design, which is
# the honest denominator for "designs seen".
'candidates': len(candidates),
'considered': len(records or []),
'same_design': sorted(n for n in same if n),
'unreadable': sorted(n for n in unreadable if n),
'designs_seen': seen,
}
def select_donors(records, target, donor_filter=None):
"""(kept, rejected). `rejected` is {reason: [name, ...]} -- a pool that
shrank from 690 to 200 without saying why is not a filter."""
donor_filter = donor_filter or DonorFilter()
kept, rejected = [], {}
for rec in records or []:
why = donor_filter.reject(rec, target)
if why is None:
kept.append(rec)
else:
rejected.setdefault(why, []).append(rec.get('name'))
kept.sort(key=lambda r: r.get('name') or '')
for names in rejected.values():
names.sort()
return kept, rejected
# --------------------------------------------------- the featureless target
#
# WHAT HAPPENS WHEN THE TARGET HAS NO OPENINGS AT ALL.
#
# `DonorFilter.reject` stops excluding a featureless donor once the target is
# itself featureless (see the note on `require_features`), so the pool
# `select_donors` returns for that target is no longer empty by construction
# -- but there is nothing left for `reinforcematch.suggest`'s set-cover search
# to do with it: `target_features` is `[]`, there is nothing to cover, and
# zero donors are needed to cover nothing. Set cover is the wrong question
# here. The right one -- "which of these is the closest TEMPLATE, having
# nothing to cover?" -- has no ligaments to compare, so it is answered with
# whatever the filter left standing to compare: design (already forced equal
# by the hard filter), thickness, length and width.
#
# TWO TIERS, NOT A FLAT RANKING. A donor that is ALSO featureless is an exact
# shape twin as far as this tool can tell -- nothing about its cage was driven
# by a void the target does not have. A donor that DOES carry openings is a
# real but weaker candidate: the reinforcing it built around its own voids is
# irrelevant to this target, but the rest of its cage (the field steel that
# has nothing to do with an opening) may still be a fine template. Owner's
# brief left this one to be decided rather than ruling on it; the position
# taken here is INCLUDE, RANKED BELOW -- dropping them would silently narrow
# the pool again, in the same shape as the defect this module exists to fix,
# and "irrelevant to this target" is not the same claim as "useless to this
# target". They are never allowed to outrank a featureless donor, which is
# the one thing that would make this worse than saying nothing.
#
# THE RANKING ITSELF. Within a tier, ordered by absolute difference from the
# target on thickness, then length, then width -- thickness first because
# `DonorFilter`'s own docstring already ranks it "the strongest of the
# geometric rules"; design is left out because the filter has already forced
# it equal, so ranking on it again would be decoration. DEGENERACY IS
# EXPECTED, not a bug to route around: this codebase's own nomenclature
# convention is that a design number identifies a specific sheet, so two
# panels of the same design very often share the same length, width and
# thickness exactly, and the "ranking" is then a coin flip on a difference
# smaller than anything this tool measures to. `_shape_degenerate` catches
# that case and the report is told to say "indistinguishable", not to print
# an order it cannot defend -- the same discipline `covers`' GRAIN applies to
# distances that are close but not exact, one level up.
SHAPE_FEATURELESS = 'featureless'
SHAPE_HAS_FEATURES = 'has_features'
_SHAPE_TIER_RANK = {SHAPE_FEATURELESS: 0, SHAPE_HAS_FEATURES: 1}
# The tie-break precision: differences at or under this (in the same units
# the comparison already uses -- inches for thickness, feet for length and
# width) are not a finding, they are noise the rest of this tool already
# refuses to rank on. Reusing `EXACT_TOL_FT`/`EXACT_TOL_IN` rather than a new
# number keeps this consistent with `covers`' own exactness floor instead of
# inventing a second opinion about how fine a measurement can be trusted.
def _shape_diff(donor_value, target_value):
"""Absolute difference, or None when either side is unreadable.
None is COULD NOT COMPARE, not "no difference" -- it must sort as the
worst case, never as a tie for first, or an unmeasured donor would win
the ranking by default.
"""
if donor_value is None or target_value is None:
return None
try:
return abs(float(donor_value) - float(target_value))
except (TypeError, ValueError):
return None
def _shape_row(target, rec):
feats = rec.get('features')
return {
'name': rec.get('name'),
'tier': SHAPE_HAS_FEATURES if feats else SHAPE_FEATURELESS,
'design_number': rec.get('design_number'),
'thickness_diff_in': _shape_diff(rec.get('thickness_in'),
target.get('thickness_in')),
'length_diff_ft': _shape_diff(rec.get('length_ft'),
target.get('length_ft')),
'width_diff_ft': _shape_diff(rec.get('width_ft'),
target.get('width_ft')),
'master': rec.get('master'),
}
def _shape_sort_key(row):
def key(v):
return v if v is not None else float('inf')
return (_SHAPE_TIER_RANK[row['tier']], key(row['thickness_diff_in']),
key(row['length_diff_ft']), key(row['width_diff_ft']),
0 if row.get('master') else 1, row['name'] or '')
def _shape_degenerate(rows):
"""True when the FEATURELESS tier cannot be told apart on shape.
Fewer than two rows is not degenerate -- there is nothing to rank between,
which is a plain single answer, not a tie. Two or more whose thickness,
length and width all land within this tool's own exactness floor are a
tie the ranking cannot honestly break, and the report has to say so
rather than present sort order as if it meant something.
"""
if len(rows) < 2:
return False
def rounded(row):
return tuple(
(round(row[k], 3) if row[k] is not None else None)
for k in ('thickness_diff_in', 'length_diff_ft', 'width_diff_ft'))
return len(set(rounded(r) for r in rows)) == 1
def shape_search_for(target, kept):
"""The ranked pool for a target measured to have NO openings.
`kept` is `select_donors`'s first return -- the pool AFTER the filter,
which by now already includes featureless donors (the inversion above)
alongside any donor that has openings of its own. This just tiers and
ranks what the filter already decided was eligible; it rejects nobody.
"""
rows = [_shape_row(target, rec) for rec in (kept or [])]
rows.sort(key=_shape_sort_key)
eligible = [r for r in rows if r['tier'] == SHAPE_FEATURELESS]
secondary = [r for r in rows if r['tier'] == SHAPE_HAS_FEATURES]
return {
'ran': True,
'considered': len(kept or ()),
'eligible': eligible,
'secondary': secondary,
'degenerate': _shape_degenerate(eligible),
}
# ------------------------------------------------------------- the whole run
def propose(target, records, donor_filter=None, approximate=None,
include_voids=True,
max_proposals=match.DEFAULT_MAX_PROPOSALS,
max_donors=match.DEFAULT_MAX_DONORS):
"""Filter, build features, match. Returns (suggestion, info). Revit-free.
`max_donors` is how many donors ONE proposal may contain -- four, by the
owner's ruling. `max_proposals` is how many proposals come back. They are
different axes and are passed through untouched.
`target` is an index piece record. `records` is every candidate -- pass
`reinforcecore.donors(cache)`. `approximate` is the set of piece names
whose outline could only be approximated (see `outline_audit`).
`suggestion` is None when there was nothing to judge, and
`info['target_status']` says which nothing it was: `unjudged` (never
measured) or `no_features` (measured, genuinely nothing to reinforce).
For `no_features`, `info['shape_search']` carries a real search anyway --
see `shape_search_for` -- because "nothing to cover" is not "nothing to
say"; the target's own emptiness makes a featureless donor the right
template, and this is where that answer comes from.
IT IS ALSO None WHEN THE DESIGN RULE REFUSES, and `info['design']['status']`
says which of the four refusals it was. That is not a fifth way of saying
"no suggestions": a target whose design has no donor in the index gets
NOTHING rather than the nearest other design, by ruling. See
`design_info` and `DonorFilter.match_design`.
"""
donor_filter = donor_filter or DonorFilter()
approximate = set(approximate or ())
kept, rejected = select_donors(records, target, donor_filter)
design = design_info(target, records, rejected, donor_filter)
target_elevation, same_elevation, elevation_sources = _elevation_affinity(
target, kept)
target_features = features_for(
target, (target or {}).get('name') in approximate, include_voids)
info = {
'target': (target or {}).get('name'),
'target_status': _status(target, target_features),
'target_approximate': (target or {}).get('name') in approximate,
'target_features': target_features,
'design': design,
'considered': len(records or []),
'donors': len(kept),
'donor_names': [r.get('name') for r in kept],
'rejected': dict((k, len(v)) for k, v in rejected.items()),
'rejected_names': rejected,
'approximate_in_pool': sorted(
r.get('name') for r in kept if r.get('name') in approximate),
# The reviewed templates IN THIS POOL, after the filter. Reported
# separately from the index-wide count because a MASTER that the
# thickness filter dropped is not one this answer could have used.
'master_donors': sorted(r.get('name') for r in kept
if r.get('master') is True),
# NOT RUN, not "none found": the plant has not mapped
# `parameters.member.identity_description`, so no piece in this pool
# was ever read for the marker. An empty `master_donors` means two
# completely different things depending on this flag and the report
# must never collapse them.
'master_unjudged': bool(kept) and all(r.get('master') is None
for r in kept),
# The target's own elevation signal -- `{'group': ..., 'band': ...}`,
# geometry always present, the plant-declared group only where one
# is mapped and set (see `reinforcecore.elevation_match`) -- and
# which of THIS POOL shares it: the LAST ranking tie-break
# (`reinforcematch`'s `same_elevation` term). Both being falsy means
# "could not be determined", never "nothing matches"; see
# `_elevation_affinity`.
'target_elevation': target_elevation,
'same_elevation_donors': sorted(same_elevation),
# Which SOURCE decided each matching donor --
# `core.ELEVATION_BY_GROUP` ("same sheet") or `core.ELEVATION_BY_BAND`
# ("same geometry") -- so the report can say why rather than just
# that. Keyed by donor name; a name absent from `same_elevation_donors`
# is absent here too.
'same_elevation_sources': elevation_sources,
'features_by_donor': {},
}
if not target_features:
# A MEASURED-EMPTY TARGET STILL GETS A SEARCH. `suggest` (the set-cover
# matcher) has nothing to do with zero target features -- there is
# nothing to cover -- but that is not the same as nothing to say: the
# donor pool above already inverted `require_features` for exactly
# this target, and `shape_search_for` is what turns that pool into an
# answer. An UNJUDGED target (`features is None`) must not take this
# path -- `_status` already told them apart, and only the genuinely
# measured-empty one gets searched.
if info['target_status'] == 'no_features':
info['shape_search'] = shape_search_for(target, kept)
return None, info
# THE HARD FILTER'S REFUSAL, and it is deliberately here -- after the
# target has been measured, so the report can still say what the piece is
# and how current the index was, and before any donor's features are built,
# so not one cross-design comparison is ever made.
if design['refused']:
return None, info
donors = []
for rec in kept:
feats = features_for(rec, rec.get('name') in approximate,
include_voids)
info['features_by_donor'][rec.get('name')] = feats
donors.append((rec.get('name'), feats or []))
info['donor_cap'] = max_donors
suggestion = match.suggest(target_features, donors, covers,
max_proposals=max_proposals,
masters=info['master_donors'],
same_elevation=same_elevation,
max_donors=max_donors)
return suggestion, info
def _status(target, feats):
if not target:
return 'no_target'
if target.get('features') is None:
return 'unjudged' # never measured -- NOT "has no features"
if not feats:
return 'no_features' # measured, genuinely nothing to cover
return 'ok'
def explain(suggestion, info, proposal=None):
"""One row per target feature: what covered it, how far off, and whether
the answer rests on an approximation or on an unjudged penetration."""
proposal = proposal or (suggestion.best if suggestion else None)
if proposal is None:
return []
feats = info.get('target_features') or []
by_donor = info.get('features_by_donor') or {}
# THE FEATURE VOCABULARY, read-only. Deserialised once here off whatever
# `suggest_from_index` stamped on `info` (the cache's plain registry dict).
# A target NEVER grows the vocabulary -- `Registry.lookup` is read-only, so
# a target shape no donor ever produced projects to None, which is the
# informative "no donor carries this shape" signal (surfaced as
# uncovered/PARTIAL), not an error. Donor feature-IDs come off
# `core.feature_ids_of`, the read-only projection over a record's features.
registry = core.Registry.deserialize(info.get('registry'))
out = []
for i in range(len(feats)):
tf = feats[i]
row = {
'target_index': i,
'kind': tf.get('kind'),
'between': tf.get('between'),
'edge': tf.get('edge'),
'rect': (tf.get('x0'), tf.get('x1'), tf.get('y0'), tf.get('y1')),
'strip_ft': tf.get('strip_ft'),
'target_feature_id': registry.lookup(core.canonical_descriptor(tf)),
'donor': None, 'donor_index': None, 'distance': None,
'exact': False, 'donor_rect': None, 'donor_master': False,
'donor_feature_id': None,
'approximate': bool(tf.get('outline_approximate')),
'through_unjudged': (tf.get('kind') == OPENING and
tf.get('through') is None),
}
got = proposal.donor_for(i)
if got is not None:
key, d_index, dist = got
donor_feats = by_donor.get(key) or []
df = donor_feats[d_index]
donor_ids = core.feature_ids_of({'features': donor_feats}, registry)
row.update({
'donor': key, 'donor_index': d_index, 'distance': dist,
'exact': dist == 0.0,
'donor_master': key in (proposal.masters or ()),
'donor_rect': (df.get('x0'), df.get('x1'),
df.get('y0'), df.get('y1')),
'donor_feature_id': (donor_ids[d_index]
if donor_ids is not None and
d_index < len(donor_ids) else None),
'approximate': (row['approximate'] or
bool(df.get('outline_approximate'))),
'through_unjudged': (row['through_unjudged'] or
(tf.get('kind') == OPENING and
df.get('through') is None)),
})
out.append(row)
return out
# ------------------------------------------------------------- the Revit end
AUDIT_FILE = 'outline.json'
# A panel whose solid is this much smaller than its extents box, after its
# recorded voids are taken out, has an outline the index cannot see. Measured
# on this model: 242 of 255 WP land under 0.1%, the other 13 sit at 3.8% and
# up, so anything in the low single digits separates the two populations
# cleanly. Three percent, not tuned finer than the evidence supports.
APPROX_PCT = 3.0
def outline_audit(doc, cache=None):
"""Which pieces have an outline the index's rectangle cannot represent.
READ ONLY. Compares each piece's real solid volume against its extents box
minus its recorded voids. Cached beside the index, because it costs one
geometry read per piece and changes only when the model does.
This is the stand-in for the deferred outline-polygon key on the index
record; see the module docstring for what the real fix is.
"""
import json
import os
from vibe import reinforceindex as index
from vibe import liftexport as L
if cache is None:
cache = index.read(doc)
by = dict(index.assemblies(doc))
names = set()
rows = {}
for name, rec in (cache.get('pieces') or {}).items():
feats = rec.get('features')
if feats is None:
continue
Lf, Wf, Tin = (rec.get('length_ft'), rec.get('width_ft'),
rec.get('thickness_in'))
asm = by.get(name)
if asm is None or not Lf or not Wf or not Tin:
continue
panel = _panel_of(doc, asm)
if panel is None:
continue
try:
volume = L.concrete_solid(panel).Volume # cubic FEET
except Exception:
continue
# NOT THE UNIT SEAM. These divisions turn the recorded THICKNESS,
# which is stored in inches, back into feet so it multiplies with the
# feet length and width into cubic feet -- the unit Revit reports a
# solid's volume in. Nothing here is displayed or ranked; the seam
# that the owner's inch ruling lands on is `covers` alone.
box = Lf * Wf * (Tin / 12.0)
voided = 0.0
for f in feats:
depth = (Tin / 12.0) if f.get('through') else (
(Tin - (f.get('concrete_in') or 0.0)) / 12.0)
voided += (f['w'] or 0.0) * (f['h'] or 0.0) * depth
pct = 100.0 * (box - voided - volume) / box if box else 0.0
rows[name] = round(pct, 2)
if pct > APPROX_PCT:
names.add(name)
path = os.path.join(os.path.dirname(index.index_path(doc)), AUDIT_FILE)
handle = open(path, 'wb')
try:
handle.write(json.dumps(
{'approximate': sorted(names), 'pct': rows},
separators=(',', ':'), sort_keys=True).encode('utf-8'))
finally:
handle.close()
return names
def load_audit(doc):
"""The cached outline audit, or None if it has never been run.
None is not an empty set: never audited and audited-and-found-none are
different answers, and a caller that reads the first as the second is
claiming every outline is exact when nobody checked.
"""
import os
from vibe import reinforceindex as index
return load_audit_at(os.path.dirname(index.index_path(doc)))
def load_audit_at(folder):
"""The same file, addressed by FOLDER instead of by document.
This is the offline half of `load_audit`: the audit is a plain JSON file
that sits beside `index.json`, so reading it needs a path and nothing
else. `load_audit(doc)` exists only to work out that path, and working it
out is the one step that needs Revit (`index_path` -> `designspec` ->
`Autodesk.Revit.DB` at module scope).
"""
import json
import os
path = os.path.join(folder or '', AUDIT_FILE)
if not os.path.isfile(path):
return None
try:
handle = open(path, 'rb')
try:
data = json.loads(handle.read().decode('utf-8'))
finally:
handle.close()
return set(data.get('approximate') or ())
except Exception:
return None
def _panel_of(doc, asm):
try:
member_ids = list(asm.GetMemberIds())
except Exception:
return None
for mid in member_ids:
member = doc.GetElement(mid)
if member is None:
continue
try:
cat = member.Category.Name if member.Category else ''
except Exception:
cat = ''
if cat == 'Structural Framing':
return member
return None
def suggest_for_piece(doc, name, refresh=False, audit=False,
donor_filter=None, include_voids=True,
max_proposals=match.DEFAULT_MAX_PROPOSALS, know=None,
max_donors=match.DEFAULT_MAX_DONORS):
"""Everything, for one assembly by name, against the live model. READ ONLY.
The target is always measured live rather than taken from the cache: the
piece being reinforced is usually not a donor, so it is usually not in it.
`refresh` brings the index up to date first; `audit` re-runs the outline
audit. Both default off so the common press is a read.
"""
from vibe import reinforceindex as index
from vibe import reinforcecore as core
from vibe import scope as scopelib
# The donor pool is bounded by the DECLARED SCOPE, read from the job
# knowledge rather than written into the filter. An undeclared scope
# leaves `prefixes` None -- every indexed piece may donate -- and the
# report says which of the two happened, because a pool of one kind and
# a pool of everything look identical once the answer is printed.
the_scope = scopelib.of(know) if know else scopelib.Scope()
if donor_filter is None:
donor_filter = DonorFilter(prefixes=the_scope.prefixes or None,
design_re=design_pattern(know))
if refresh:
cache, report = index.refresh(doc, know=know)
else:
cache, report = index.read(doc), None
approximate = outline_audit(doc, cache) if audit else load_audit(doc)
# THE TARGET IS MEASURED LIVE HERE AND THAT IS THE ONLY REASON THIS
# FUNCTION NEEDS A DOCUMENT. Since schema 5 the index also holds
# in-scope non-donors, so the cache usually has a record for it too --
# but a live measurement is never stale and the model is right there, so
# in Revit the live one wins. `suggest_from_index` is the same tail with
# the cached record instead, and is what runs with no Revit at all.
target = (cache.get('pieces') or {}).get(name)
for asm_name, asm in index.assemblies(doc):
if asm_name == name:
sigs = index.live_signatures(doc, [(name, asm)])
target = index.extract(doc, name, asm, sigs[name][0], know=know)
break
if target is None:
return None, {'target': name, 'target_status': 'not_in_model'}
return suggest_from_index(
cache, name, approximate=approximate, the_scope=the_scope,
donor_filter=donor_filter, include_voids=include_voids,
max_proposals=max_proposals, max_donors=max_donors,
target=target, refresh_report=report, offline=False)
# ------------------------------------------------- the whole run, NO DOCUMENT
def suggest_from_index(cache, name, approximate=None, the_scope=None,
donor_filter=None, include_voids=True,
max_proposals=match.DEFAULT_MAX_PROPOSALS,
max_donors=match.DEFAULT_MAX_DONORS,
target=None, refresh_report=None, offline=True):
"""Everything, for one piece, FROM THE SNAPSHOT. No Revit on this path.
This is the tail `suggest_for_piece` used to hold inline, lifted out
whole. It is the same filter -> features -> covers -> set cover -> info
that the button runs; the only difference is where the target record came
from. Live, `suggest_for_piece` measures the target through the document
and passes it in as `target`. Offline, `target` is left None and the
record is taken out of the index like every donor's.
`offline=True` (the default here, and False from the live path) is carried
on `info` and is what makes a report say, in as many words, that it was
answered from a snapshot rather than from the model. It changes no
arithmetic anywhere -- see `reinforcereport.offline_banner`.
`the_scope` is a `vibe.scope.Scope`. Offline it comes out of the index
itself (`reinforceindex.scope_in`), because there is no knowledge layer to
read without a model.
Returns `(suggestion, info)` -- a TUPLE, always, exactly like
`suggest_for_piece`. A caller that treats the return as a Suggestion gets
a tuple and no error until much later.
"""
from vibe import reinforcecore as core
from vibe import scope as scopelib
the_scope = the_scope if the_scope is not None else scopelib.Scope()
if donor_filter is None:
# The design pattern comes OUT OF THE INDEX here, not out of a
# knowledge layer -- offline there is no model and therefore no
# knowledge layer, and `reinforceindex.refresh` stamped the pattern
# into the file for exactly this reader. An index written before that
# stamp returns None and `design_key` falls back, which is what an
# unmapped nomenclature has always produced.
donor_filter = DonorFilter(prefixes=the_scope.prefixes or None,
design_re=core.design_re_in(cache))
if target is None:
target = (cache.get('pieces') or {}).get(name)
if target is None:
# OFFLINE THIS IS NOT "NOT IN THE MODEL". The snapshot simply does not
# hold this piece -- because it is out of the declared scope, or
# because the index predates it, or because the index was built by an
# older donors-only version. The model may well contain it and nobody
# here can tell. Two different words for two different findings.
return None, {'target': name, 'offline': offline,
'target_status': 'not_in_index' if offline
else 'not_in_model'}
suggestion, info = propose(target, core.donors(cache),
donor_filter=donor_filter,
approximate=approximate,
include_voids=include_voids,
max_proposals=max_proposals,
max_donors=max_donors)
info['report'] = refresh_report
info['audited'] = approximate is not None
info['offline'] = offline
info['target_source'] = 'index' if offline else 'model'
info['built_at'] = (cache or {}).get('built_at')
# THE FEATURE VOCABULARY, carried as its plain JSON dict (never a live
# Registry object -- `info` may be handed to layers that serialise it), so
# `explain()` can project read-only feature-IDs. See `core.Registry`.
info['registry'] = (cache or {}).get('registry')
# A COLD DONOR DB IS NOT "JUDGED AND FOUND NOTHING". An index with no pieces
# is either brand new (no Snapshot has run yet) or was just discarded by a
# schema bump (`core.load` returns an empty cache with a `_load_error`).
# Either way NOTHING has been indexed to compare against, which reads as
# NO_DONORS -- but the fix is "run a Snapshot", not "no donor matches". The
# report reuses the NO_DONORS headline and swaps in that wording so a cold
# DB can never render as a silent all-clear.
info['cold_db'] = not ((cache or {}).get('pieces'))
info['cold_reason'] = (cache or {}).get('_load_error')
# What the scope kept out of the donor pool, so the report can say it in
# the same breath as the rest of the pool arithmetic. `rejected['prefix']`
# already counts them; this names the rule they were held to.
rejected_names = (info.get('rejected_names') or {}).get('prefix') or []
in_scope = the_scope.includes(name)
info['scope'] = {
'declared': the_scope.is_declared,
'label': the_scope.label(),
'declaration': the_scope.declaration_lines(),
'line': the_scope.line(rejected_names, unit='indexed donor'),
'target_in_scope': in_scope,
# ONLY when the target really is outside it. Passing the name
# unconditionally made every report carry "JUDGED OUTSIDE THE DECLARED
# SCOPE, AT YOUR REQUEST" beside `target_in_scope: True` -- verified
# against a live in-scope run, 2026-08-30. A report that contradicts
# itself in one dict teaches the reader to skip both lines.
'target_annotation': the_scope.annotation(
[] if in_scope else [name], unit='piece'),
}
# DONORS ONLY, and that is not a narrowing -- it is what this number has
# always meant: "indexed pieces that took no part in this search". Since
# schema 5 the file also holds pieces measured only so they can be
# targets, and one of those that would not measure never was a candidate.
# Counting it here would put a warning on every press of a job whose scope
# is undeclared, where the index widens to every assembly in the model and
# a beam with no structural-framing member to measure is an ordinary,
# expected miss rather than a lost donor.
info['coverage_unjudged'] = sorted(
r['name'] for r in core.donors(cache, include_unjudged=True)
if (r or {}).get('features') is None)
return suggestion, info