Files
willtools-demo/WillTools.extension/lib/vibe/reinforcecore.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

1500 lines
68 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 -*-
"""The reinforcing-suggestion index: schema, staleness, cache. No Revit here.
WHAT THIS IS FOR
----------------
Reinforcing suggestion is a set-cover search: given a piece that needs
reinforcing, propose the smallest set of already-reinforced pieces that
between them account for every feature of the target. This module is the half
that has to exist before that search can run at all -- the candidate set, and
each candidate's features, held on disk so the search does not re-measure the
model on every button press.
It deliberately does NOT compare anything. No score, no ranking, no
similarity. A matcher is injected a `covers(donor_feature, target_feature)`
callable elsewhere; that callable is the only place feature fields get read,
which is why every feature record here is FLAT -- plain numbers and strings,
nothing the caller has to walk into.
WHAT A FEATURE IS, AND WHAT IT IS NOT
-------------------------------------
A feature here is one void measured in the concrete: a rectangle in the
piece's own frame plus whether the thickness is pierced. It carries no
category. There is no 'blockout' / 'pocket' / 'notch' field and that is
deliberate -- the job's own detail catalogue records seventy standard details
that differ in a pocket depth of 3 1/2" vs 2 1/2" and are otherwise the same
construction, so any category this code invented would be a guess dressed as
data. `kind` is fixed at 'opening' as a provenance label (this came from the
solid's void faces), not as a classification.
Positions are ABSOLUTE FEET from the start of the piece, never a fraction of
its length. Measured on this job: two panels of different lengths (27.625 ft
and 24.917 ft) carry openings at the identical absolute rectangle, and a
proportional comparison would have missed the match entirely.
Both axes are recorded. Two donors overlapped in x near a target's right end
while sitting in different y bands; a single-axis record cannot be repaired
after the fact.
COULD-NOT-JUDGE IS NOT NOTHING
------------------------------
A piece whose geometry could not be read gets `features = None` and an
`error` string. A piece that was read and genuinely has no voids gets
`features = []`. These are different answers and the cache keeps them
different. Anything downstream that treats `None` as "no features" is
reporting a full-coverage match on a piece nobody measured.
STALENESS
---------
Each piece carries a cheap signature -- flags (including the MASTER review
flag) plus its member-id set -- that can be recomputed model-wide in well
under a second, with no geometry. A
piece whose signature moved is re-extracted; the rest are reused. What this
catches and what it does not is written out at `signature()`.
"""
import json
import os
import re
# SCHEMA 5 (2026-08-30) -- WHAT THE CACHE CONTAINS CHANGED, NOT THE RECORD.
#
# Up to schema 4 `pieces` held ELIGIBLE DONORS AND NOTHING ELSE: `plan()`
# skipped every ineligible name and `drop` evicted anything cached that had
# stopped qualifying. Measured on the real index, 2026-08-30: all 690 records
# carried `(reinforced=1, on_hold=0)`, so the piece an engineer actually wants
# suggestions FOR -- the one not yet reinforced -- was never in the file. That
# is why `reinforcebind.suggest_for_piece` had to measure the target through a
# live document, and it is the one thing that kept this feature tied to Revit.
#
# Eligibility filters DONORS. It must not decide who can be a TARGET. So
# `plan(include=...)` now also measures pieces that are not donors, the donor
# flags on the record are untouched, and `donors()` re-asserts eligibility so
# a target record can never be offered as a template.
#
# The bump is not decoration. A schema-4 file is shape-identical and would be
# adopted silently -- and would then answer "that piece is not in the index"
# for every ordinary target, which reads exactly like "that piece is not in
# the model". Discarded whole, as `load()` has always done, and rebuilt.
#
# SCHEMA 6 (2026-09-02) -- THE FEATURE REGISTRY JOINS THE CACHE.
#
# The cache now carries a `registry`: a descriptor <-> positive-integer-id map
# (see `Registry` and `canonical_descriptor` below). It is an INDEX/ACCELERATOR
# ONLY -- the id never enters `covers()`, never ranks, never becomes a tolerance
# bin; `covers()` stays the sole ranker. Its equivalence grain is the index's
# EXISTING rounding (`_f`, 3 decimal feet ~= 1/32 in), no new tolerance.
#
# The bump is not decoration, for the same reason schema 5 was not. A schema-5
# file is shape-identical bar the missing key, and adopting it silently would
# serve an EMPTY registry that reads exactly like "no feature has ever been seen
# before" -- a false NO_DONORS for the accelerator. An empty registry is a
# could-not-judge condition, so the older file is discarded WHOLE, as `load()`
# has always done, and `new_cache` seeds a fresh empty one.
SCHEMA = 6
# ------------------------------------------------------------- THE UNIT SEAM
#
# EVERYTHING STORED HERE IS DECIMAL FEET, because that is Revit's internal
# unit and converting on the way in is one more chance to be wrong. This
# project has already lost real time to a 12x error, so the rule is written
# down rather than assumed:
#
# BELOW THIS LINE feet. Every coordinate on every feature record, every
# piece dimension, every cached number on disk.
# ABOVE IT inches, two decimals, by the owner's ruling
# ("实际测量全部用英寸,带两位小数", 2026-08-30).
#
# The crossing happens in exactly two places, and both of them call the ONE
# function below rather than writing a 12 of their own:
#
# 1. `reinforcebind.covers()` -- the distance it RETURNS is inches. That is
# the boundary for the matcher: everything the matcher ranks, sums and
# hands to the report is already inches, and no layer above ever
# converts again.
# 2. `reinforcereport` -- formatting a stored coordinate or strip width for
# display. Those are still feet on the record; the report converts at the
# moment of printing and never writes the inch value back anywhere.
#
# Nothing mutates a feature record into inches, so a double conversion has
# nowhere to happen: a record that has been converted does not exist.
IN_PER_FT = 12.0
def inches(feet):
"""Feet -> inches. The only place `12` appears in this whole feature.
None in, None out -- could-not-measure survives the conversion rather
than becoming a zero. An unreadable value is also None: a unit conversion
is not the place to raise.
"""
if feet is None:
return None
try:
return float(feet) * IN_PER_FT
except (TypeError, ValueError):
return None
# A feature's provenance label. Not a taxonomy: see the module docstring.
KIND_OPENING = 'opening'
# ---------------------------------------------------------------- the master
#
# PROVENANCE, THE ONE THING THE DONOR POOL COULD NOT SAY.
#
# `TICKET_IS_REINFORCED` means somebody drew reinforcing on the piece. It does
# not mean anybody checked it, and until now the index had no way to tell a
# reviewed piece from a merely-flagged one -- recorded as an open risk with no
# solution. The owner's answer (2026-08-30): "某一些模板带有 MASTER,给这些
# MASTER 当母版的优先级提升到最高。他们是被审核过的。" The signal is already in
# the model, hand-typed into the piece's identity comment.
#
# THE DEFAULT TOKEN IS PLANT KNOWLEDGE, not a literal in the code: another
# plant will use another word. `knowledge.reinforce.master_token` overrides it
# and this is only the fallback.
DEFAULT_MASTER_TOKEN = 'MASTER'
def master_token_re(token=DEFAULT_MASTER_TOKEN):
"""The compiled test for one plant's master token.
Word-bounded and case-insensitive, because the field is TYPED BY HAND.
The boundaries are written as explicit lookarounds rather than `\b` so
that a plant whose token starts or ends with punctuation still gets a
sane test instead of a regex that silently never matches.
"""
return re.compile(r'(?<![0-9A-Za-z_])' + re.escape(token or '') +
r'(?![0-9A-Za-z_])', re.IGNORECASE)
def is_master_text(text, token=DEFAULT_MASTER_TOKEN):
"""Does this hand-typed identity comment mark a reviewed template?
WHAT THIS ACCEPTS, stated so it can be argued with. The token anywhere in
the field, in any case, with anything around it, as long as it stands as
its own word:
'MASTER' yes
'master' / 'Master' yes
'MASTER;reverse strands' yes
'MASTER, bend bars on SP22 ...' yes
'reverse strands; master' yes
'MASTER-PANEL' yes (a hyphen is a boundary)
WHAT IT REJECTS:
'' / None no (an empty field is not a master)
'MASTERED' / 'REMASTER' no (not its own word)
'MASTERS' no (same rule; see below)
Two honest limitations. It cannot read a negation -- 'NOT A MASTER' would
be accepted -- and it will not accept a plural or a typo. Both are the
price of a rule simple enough that a reader can predict it, and neither
is silent: the raw string is kept on the piece record as `master_note`, so
anyone can see exactly what the verdict was made from.
"""
if not text:
return False
if not token:
return False
return bool(master_token_re(token).search(text))
# --------------------------------------------------------------- one feature
def feature(piece, index, x0, x1, y0, y1, through, concrete_in,
piece_length_ft, piece_width_ft, piece_thickness_in):
"""One void, flat, in absolute feet from the start of the piece.
Every value the comparison could want is on this dict directly, including
the piece's own dimensions -- a `covers()` callable should never have to
reach back up into a piece record to ask how long the panel was.
`through` is True / False / None, and None means the penetration test
could not be run, not that the void is blind.
"""
return {
'piece': piece,
'kind': KIND_OPENING,
'index': int(index),
'x0': _f(x0), 'x1': _f(x1),
'y0': _f(y0), 'y1': _f(y1),
'w': _f(x1 - x0), 'h': _f(y1 - y0),
'cx': _f((x0 + x1) / 2.0), 'cy': _f((y0 + y1) / 2.0),
'through': through,
'concrete_in': None if concrete_in is None else _f(concrete_in, 2),
'piece_length_ft': _f(piece_length_ft),
'piece_width_ft': _f(piece_width_ft),
'piece_thickness_in': _f(piece_thickness_in, 3),
}
def _f(value, places=3):
if value is None:
return None
return round(float(value), places)
def sort_features(features):
"""A stable order for the features of one piece: left to right, then up.
Only so that `index` means something reproducible and two builds of the
same piece compare equal. It is not a ranking.
"""
return sorted(features, key=lambda f: (f['x0'], f['y0'], f['x1'], f['y1']))
# ------------------------------------------------------------- one piece
def piece_record(name, asm_id, signature, reinforced, on_hold,
design_number=None, panel_id=None,
length_ft=None, width_ft=None, thickness_in=None,
families=None, features=None, error=None,
master=False, master_note=None, elevation_band=None,
elevation_group=None):
"""One candidate donor. `features=None` + `error` means could not judge.
`master` is a plain boolean -- this piece is a reviewed template -- and
`master_note` is the RAW identity string the verdict was read from, kept
so a later reader can see why rather than having to trust the boolean.
`master_note` is None when the field was empty, which is not the same as
a field that was read and did not say MASTER.
`master` may also be None, and that is a third answer: the plant has not
mapped `parameters.member.identity_description`, so NOTHING WAS READ. It
is the same could-not-judge value `features=None` carries, for the same
reason -- a piece nobody looked at must not be reported as a piece that
was checked and found ordinary.
ELEVATION HAS TWO SOURCES (owner's ruling, 2026-09-02, refined twice the
same day) -- see the section above `elevation_match()` for the full
definition of both:
`elevation_band` -- THE FLOOR. A purely GEOMETRIC fact (the panel's
thickness-axis coordinate band; see `elevation_band()` below), computed
and cached for every piece regardless of what `elevation_group` says,
because it is the only answer a job with no grouping parameter has, and
the only one that stays correct if the parameter is added or dropped
later. It is a dict `{'axis': 'X'|'Y'|'Z', 'min': float, 'max': float}`
or None; None is could-not-judge for any of the reasons
`elevation_band()` declines rather than guesses.
`elevation_group` -- THE ACCELERATOR, tried first because it is cheap
and exact where it exists. A plant-declared GROUPING KEY, an opaque
hand-typed string (MC's is the drafting sheet a wall elevation is drawn
on, `Tagged_Sheet`), read off the panel by ROLE
(`parameters.panel.elevation_group`) exactly as `design_number` is.
None means the role is unmapped or the field was blank on this piece
-- the NORMAL state on most jobs, plenty of which map no such
parameter at all -- and that is could-not-judge, not "no group": the
geometric floor answers instead.
Neither source is ever a filter, only a ranking AFFINITY
(`reinforcebind`'s same-elevation tie-break): a piece whose elevation
could not be determined by either one is still a perfectly usable
donor, it just cannot be preferred on this one signal -- and it is
never read as matching another undetermined value either (see
`elevation_match`).
"""
if features is not None:
features = sort_features(features)
for i, f in enumerate(features):
f['index'] = i
f['piece'] = name
return {
'name': name,
'asm_id': asm_id,
'sig': signature,
'reinforced': reinforced,
'on_hold': on_hold,
'design_number': design_number,
'panel_id': panel_id,
'length_ft': _f(length_ft),
'width_ft': _f(width_ft),
'thickness_in': _f(thickness_in, 3),
'families': families or {},
'features': features,
'error': error,
'master': None if master is None else bool(master),
'master_note': master_note,
'elevation_band': elevation_band,
'elevation_group': elevation_group,
}
def is_eligible(reinforced, on_hold):
"""Donor eligibility, the whole rule.
Reinforced is not enough: a piece on hold may not be a template, because
its reinforcing is not settled. Both are integer instance parameters on
the assembly; a missing parameter reads as None and is not a 1.
"""
return reinforced == 1 and on_hold != 1
# ------------------------------------------------------- the elevation band
#
# WHICH BUILDING ELEVATION A PIECE BELONGS TO. GEOMETRY IS THE FLOOR --
# always computed, plant-independent, no parameter required -- and an
# optional parameter shortcut sits on top of it for the (not universal)
# jobs that happen to carry one. Owner's ruling, 2026-09-02, in three
# passes the same day:
#
# PASS 1. Elevation is PURELY GEOMETRIC, read from no parameter at all.
# "同立面跟ERECT_SEQUENCE相关参数无关,只跟位置有关。一个panel的elevation
# 由它三个长度当中最小的哪个长度决定(其实就是thickness)。另一个panel,
# 如果thickness的global coordinate跟前一个panel有重合,那么就是同一个
# elevation。" Confirmed: "elevation是纯几何问题".
# PASS 2. MC's real model turned up a usable GROUPING parameter --
# `Tagged_Sheet`, the drafting sheet a wall's elevation is drawn on:
# "对MC而言,所有的墙应该都有一个参数叫Tagged_Sheet。如果俩panel这个参数
# 一样,那么他们就是同一个elevation,不用跑哪个几何引擎。"
# PASS 3, the scope correction. NOT every job has it and it must never be
# ASSUMED present: other plants carry no such parameter at all, and
# even on MC it exists only on COMPLEX jobs -- a simple job with a
# handful of elevations carries none, by a deliberate human-vs-computer
# workload balance, not an oversight. So GEOMETRY STAYS THE PRIMARY, always-on
# answer; the parameter is an OPTIONAL fast path that answers instantly
# when both the role is mapped and the value is set, and its absence
# -- the common case -- is normal, not a stand-down worth reporting.
#
# `elevation_band` -- THE FLOOR, computed and cached for EVERY piece
# regardless of whether a grouping parameter also answers, so a job that
# adds or drops the parameter later still works unchanged, and two pieces
# where only one carries a sheet tag can still be compared. THE DEFINITION,
# IMPLEMENTED EXACTLY:
# 1. A wall panel is a thin slab. Of its three bounding dimensions the
# SMALLEST is the thickness, and the thickness runs along one of the
# model's three GLOBAL axes -- the wall's face-normal direction.
# 2. The panel occupies a coordinate BAND along that axis: its min..max
# GLOBAL coordinate there.
# 3. Two panels are the SAME ELEVATION iff (a) their thickness axes are
# the same global direction AND (b) their bands overlap -- the same
# wall plane, exactly a building "North / South / East elevation."
# 4. A DIFFERENT thickness axis (one panel thin in global X, another in
# global Y -- perpendicular walls) is NEVER the same elevation, even
# when the coordinates happen to be numerically close. The owner
# confirmed this explicitly.
#
# `elevation_group` -- THE ACCELERATOR (role `parameters.panel.
# elevation_group`, MC maps it to `Tagged_Sheet`; other plants map nothing
# at all). An opaque, plant-declared STRING read off the panel by role,
# exactly as `design_number` is. Two panels with the SAME non-empty value
# are the same elevation, and `elevation_match()` below checks it FIRST
# because it is cheap and exact where it exists -- but unmapped or blank
# (most jobs, including simple MC jobs) it simply answers nothing, and
# the geometric floor decides instead, exactly as it would have on its own.
# Neither source is privileged in CORRECTNESS -- only in the ORDER they are
# tried -- because geometry is the one that is always there.
#
# THE BAND RECORD: `{'axis': 'X'|'Y'|'Z', 'min': float, 'max': float}`, or
# None for could-not-judge. Feet -- Revit's internal unit; see the unit-seam
# note at the top of this file. This is geometry that feeds a comparison,
# never a display value, so it is never converted to inches here --
# `reinforcereport` converts at the moment of printing, as it does for every
# other stored coordinate.
#
# TWO WAYS THE GEOMETRIC FLOOR DECLINES RATHER THAN GUESSES:
#
# * NEAR-SQUARE. When the smallest and second-smallest of the three
# dimensions are too close to call, which one is genuinely "the
# thickness" is ambiguous. This reuses the precedent already set for
# exactly this shape of ambiguity -- `knowledge.up_end_from_origin`'s
# near-square refusal, on the same ratio
# (`knowledge.UP_END_NEAR_SQUARE_RATIO`, 1.25, PASSED THROUGH by the
# caller rather than imported here -- see `elevation_band`'s docstring
# for why this module takes no dependency on `knowledge`). Below it,
# this returns None rather than picking a side.
# * NOT CLEANLY AXIS-ALIGNED. A skewed wall's thickness direction may not
# point cleanly along global X, Y or Z at all. `AXIS_ALIGN_TOLERANCE`
# (0.99 -- the cosine of about 8 degrees off an axis, the same
# strictness `knowledge.UP_END_UP_TOLERANCE` already uses for the
# analogous "is this vector really pointing where it looks like it
# does" judgement) is how "cleanly" is defined. Short of it, the
# panel's own thickness axis is not one the comparison can even name,
# and the band is None.
#
# UNKNOWN IS NEVER A MATCH, from EITHER source -- `same_elevation` and
# `elevation_match` below never treat two could-not-judge values as
# agreeing with each other.
#
# WHY THE GEOMETRIC FLOOR IS NOT IN `signature()`, EVEN THOUGH THE
# ACCELERATOR IS. `master`, `elevation_group` and the old `elevation_key`
# are all CHEAP: a single string parameter read, affordable in
# `reinforceindex.live_signatures` on every button press, before anything
# is rebuilt. A geometric band needs
# the panel's measured solid -- the same read `reinforceindex.extract`
# already pays for `thickness_in` and `features` -- and `signature()` is
# computed and FROZEN before `extract()` ever runs (`live_signatures`
# builds it, `refresh` hands it to `extract` as an already-decided value).
# There is no point in that pipeline where a freshly measured band could
# feed the cheap stamp even in principle, so the band is captured only
# where `thickness_in` and `features` already are: inside `extract()`, off
# the same geometry read, never a second pass. A panel that physically
# moves without its member set changing will not retrigger a rebuild on its
# own -- neither does an opening moved in place, or a panel stretched, and
# `reinforceindex.refresh`'s docstring already names that limitation;
# `rebuild_all` is the escape hatch for all three alike.
GLOBAL_AXES = ('X', 'Y', 'Z')
# Cosine of ~8.1 degrees off a global axis. Mirrors the strictness of
# `knowledge.UP_END_UP_TOLERANCE` (0.99) for the analogous question there --
# is this vector really pointing where it looks like it does -- rather than
# inventing a fresh number for what is the same kind of judgement call.
AXIS_ALIGN_TOLERANCE = 0.99
# Fallback when a caller passes no ratio. The shipped value is
# `knowledge.UP_END_NEAR_SQUARE_RATIO`; this module takes no dependency on
# `knowledge` (see the "no Revit here" module docstring -- `knowledge.py`
# pulls in a great deal this module has no reason to import), so the number
# is repeated here as the standalone default and `reinforceindex` passes the
# real one explicitly.
DEFAULT_NEAR_SQUARE_RATIO = 1.25
def _axis_of_vector(vector):
"""Which global axis (0=X, 1=Y, 2=Z) a direction points along, or None.
None means NOT CLEANLY ALONG ONE, within `AXIS_ALIGN_TOLERANCE` -- a
skewed wall -- never a forced pick of the nearest axis. `vector` is any
(x, y, z) triple; it need not be unit length, the tolerance test
normalises it.
"""
try:
x, y, z = float(vector[0]), float(vector[1]), float(vector[2])
except (TypeError, ValueError, IndexError, KeyError):
return None
size = (x * x + y * y + z * z) ** 0.5
if size <= 0:
return None
shares = (abs(x) / size, abs(y) / size, abs(z) / size)
axis = max(range(3), key=lambda i: shares[i])
if shares[axis] < AXIS_ALIGN_TOLERANCE:
return None
return axis
def elevation_band(spans, basis_x, basis_y, basis_z, box_lo, box_hi,
near_square_ratio=None):
"""The panel's elevation band, or None -- see the section note above.
`spans` -- the panel's three LOCAL bounding dimensions (length,
width, thickness, in ANY order), feet.
`basis_x` / `basis_y` / `basis_z`
-- each LOCAL axis's GLOBAL direction, as an (x, y, z)
triple (the panel's own transform basis vectors, in the
same order as `spans`). Only the one for whichever axis
turns out to be thinnest is ever used.
`box_lo` / `box_hi`
-- the panel's own solid, expressed as (x, y, z) triples in
RAW GLOBAL (model) coordinates -- the min/max corners of
its true bounding box, not a world-axis-aligned proxy.
`reinforceindex` builds these off the same solid
`extract()` already reads for `features`; see its own
comment for why this is not a second geometry pass.
`near_square_ratio`
-- `knowledge.UP_END_NEAR_SQUARE_RATIO`, passed through
rather than imported (this module takes no dependency on
`knowledge`). None or anything below 1.0 falls back to
`DEFAULT_NEAR_SQUARE_RATIO`.
Never raises: bad input is could-not-judge, the same as ambiguous or
off-axis geometry is.
"""
try:
s = [float(v) for v in spans]
except (TypeError, ValueError):
return None
if len(s) != 3:
return None
order = sorted(range(3), key=lambda i: s[i])
thick_axis, second_axis = order[0], order[1]
thick_span, second_span = s[thick_axis], s[second_axis]
if thick_span is None or thick_span <= 0:
return None
ratio_max = near_square_ratio
if ratio_max is None or ratio_max < 1.0:
ratio_max = DEFAULT_NEAR_SQUARE_RATIO
if second_span <= 0 or (second_span / thick_span) < ratio_max:
return None # near square: which dim is thickness is ambiguous
basis = (basis_x, basis_y, basis_z)[thick_axis]
axis = _axis_of_vector(basis)
if axis is None:
return None # not cleanly along a global axis: skewed wall
try:
lo = float(box_lo[axis])
hi = float(box_hi[axis])
except (TypeError, ValueError, IndexError, KeyError):
return None
if hi < lo:
lo, hi = hi, lo
return {'axis': GLOBAL_AXES[axis], 'min': lo, 'max': hi}
def same_elevation(a, b):
"""True iff two elevation bands are the SAME building elevation.
Owner's rule, exactly: same global axis AND overlapping coordinate
ranges. A different axis is never a match, however close the numbers --
that is a wall returning a corner, not the same wall plane. Either band
being None (could-not-judge) is never a match either, the same
discipline the parameter-era `elevation_key` already applied to two
unreadable strings.
OVERLAP IS STRICT (exclusive at the boundary): two bands that only
touch -- `a['max'] == b['min']`, no interior in common -- are NOT the
same elevation. A real same-plane pair of panels overlaps substantially
(both read the same thickness at the same position); a pair that merely
abuts is the signature of a STEP in the facade -- a different wall plane
that happens to start exactly where the other ends -- and treating that
as a match would blur the exact distinction the owner drew.
"""
if not a or not b:
return False
try:
if a.get('axis') != b.get('axis'):
return False
a_lo, a_hi = float(a['min']), float(a['max'])
b_lo, b_hi = float(b['min']), float(b['max'])
except (AttributeError, KeyError, TypeError, ValueError):
return False
return a_lo < b_hi and b_lo < a_hi
def elevation_group_key(value):
"""The ACCELERATOR's grouping label reduced to what it is compared on,
or None.
None in, None out; an empty or whitespace-only string is also None --
"never typed" and "unmapped role" (the common case -- see the section
note above `elevation_band`) are the same could-not-judge answer here,
exactly as an empty `master_note` is not a master. Case and surrounding
whitespace do not distinguish two groups (`'E3.16'` and `' e3.16 '`
name the same sheet).
"""
if value is None:
return None
try:
text = value.strip()
except AttributeError:
return None
return text.upper() if text else None
# What `elevation_match()` returns when a match is found, naming which
# source answered -- so a report can say "same sheet" rather than just
# "same elevation", which is the whole reason this is a named constant and
# not a bare `True`.
ELEVATION_BY_GROUP = 'group'
ELEVATION_BY_BAND = 'band'
def elevation_match(target, donor):
"""Which source says `target` and `donor` share a building elevation,
or None for neither. `ELEVATION_BY_GROUP` / `ELEVATION_BY_BAND` / None.
THE ACCELERATOR IS CHECKED FIRST, NEVER BOTH. If both pieces carry a
non-empty, equal `elevation_group`, that decides it and no geometry is
even looked at -- exactly the shortcut the owner described for MC's
`Tagged_Sheet`. But it answers only where a job has mapped the role AND
the piece has a value, which is the exception rather than the rule
(other plants have no such parameter at all; even on MC it is only on
complex jobs) -- so `same_elevation` on `elevation_band`, THE GEOMETRIC FLOOR,
is what actually answers most of the time: it is consulted whenever the
accelerator could not, and it is no less correct for being second in
line -- only cheaper to skip when the shortcut already agrees.
`target` / `donor` are piece-record-shaped (anything with `.get`);
a missing key reads as could-not-judge, the same as an explicit None,
so a pre-migration cache record -- built before either field existed --
never raises here.
"""
try:
t_group = elevation_group_key((target or {}).get('elevation_group'))
d_group = elevation_group_key((donor or {}).get('elevation_group'))
except AttributeError:
t_group = d_group = None
if t_group is not None and d_group is not None and t_group == d_group:
return ELEVATION_BY_GROUP
try:
t_band = (target or {}).get('elevation_band')
d_band = (donor or {}).get('elevation_band')
except AttributeError:
return None
if same_elevation(t_band, d_band):
return ELEVATION_BY_BAND
return None
def signature(reinforced, on_hold, member_ids, master=False,
elevation_group=None):
"""A cheap change stamp for one piece -- no geometry, no view.
It is flags + the member-id set. Recomputing it for every assembly in
this model measured 0.23 s, against 6.1 s for a full geometry rebuild,
so this is what makes 'rebuild only what changed' worth doing at all.
WHAT IT CATCHES: a member added, removed or swapped; a piece newly
marked reinforced; a piece put on or taken off hold; an assembly
deleted or created; a piece newly typed up as a MASTER, or a MASTER
un-typed.
The master flag is IN the signature on purpose. It is a hand-typed field
the owner edits between button presses, and it changes nothing else about
the piece -- so left out of the stamp, typing MASTER on a piece would have
no effect at all until something unrelated moved. That is precisely the
"quietly matches nothing" failure this signal exists to avoid, and it
costs one extra string read per assembly in the cheap pass.
ELEVATION_GROUP -- THE ACCELERATOR, see the section note above
`elevation_band` -- JOINS IT THE SAME WAY AND FOR THE SAME REASON. It
is a hand-set grouping field like the design number, not a flag that
changes on its own; left out of the stamp, mapping the role or typing a
new value would do nothing until something unrelated about the piece
moved, exactly the failure the master flag was folded in to avoid. It
costs one more string read per assembly, the same class of cost
`master` already pays -- and it is unmapped on most jobs, in which case
this segment is always empty and costs nothing beyond the lookup.
ELEVATION_BAND -- THE GEOMETRIC FLOOR -- IS DELIBERATELY NOT HERE, even
though it briefly stood in this field's place under the same name for
one day (2026-09-02). It needs the panel's measured solid, which is not
available at the point this function runs; see `elevation_band` and
`elevation_match`'s own notes above for the full reasoning. It is
captured only in `reinforceindex.extract`, the same place
`thickness_in` and `features` already are.
WHAT IT MISSES, and this is not a small list: an edit that changes the
geometry without changing the member set -- an opening moved or resized
in place, the panel stretched, a family type swapped underneath the same
instance id, or the panel's ELEVATION BAND (the geometric floor)
changing because the piece itself was moved. Those pieces will serve a
stale record until something else about them moves.
That is a deliberate trade and it is only defensible because of the
measurement above: a full rebuild of all 690 eligible pieces in this
model costs about six seconds, so `rebuild_all` is a cheap escape hatch
rather than a last resort, and the honest advice is to use it whenever
anyone has been editing geometry.
"""
ids = sorted(int(i) for i in (member_ids or []))
return '{0}|{1}|{2}|{3}|{4}|{5}'.format(
1 if reinforced == 1 else 0,
1 if on_hold == 1 else 0,
1 if master else 0,
elevation_group_key(elevation_group) or '',
len(ids),
# A checksum, not a hash with promises. Collisions here mean a stale
# record, not a wrong answer about geometry that was read.
sum(ids) % 1000000007)
# ----------------------------------------------------------------- the cache
def new_cache(doc_key):
# `scope` is the DECLARED SCOPE as it stood when the index was built --
# prefixes, note, who declared it. It is stamped by `reinforceindex.refresh`
# from the job knowledge, and it is in the file for the offline reader,
# which has no model and therefore no way to load a knowledge layer. None
# means the index predates the stamp or nothing was declared; an offline
# caller must say which rather than assuming the pool was unbounded.
#
# `nomenclature` is the same idea one key further on, and it is why it
# needed no schema bump: it is ADDITIVE, and a file that predates it reads
# exactly as it always did. It records the plant's
# `nomenclature.design_number_re` as it stood when the index was built, so
# that `tools/reinforce_offline.py` reduces two design numbers to a family
# with the SAME pattern the button used. Without it the offline run falls
# back to `specformat.FALLBACK_DESIGN_RE`, which is right for a plant that
# never set the key and quietly wrong for one that did -- and the wrong
# answer would be a donor of another design, which is the one thing this
# rule exists to prevent. None means "not stamped", never "no pattern".
#
# `registry` is the FEATURE VOCABULARY (schema 6): descriptor <-> positive
# integer id, seeded empty here. It is carried verbatim by `_pack`/`_unpack`
# and persisted so ids stay stable across rebuilds and so the offline reader
# gets the same vocabulary the button built. See `Registry` below. An empty
# registry is a real, valid state -- a fresh index that has judged nothing.
return {'schema': SCHEMA, 'doc_key': doc_key, 'built_at': None,
'scope': None, 'nomenclature': None,
'registry': _empty_registry(), 'pieces': {}}
def design_re_in(cache):
"""The design-number pattern the index was built with, or None.
None is "this file does not say", and the caller then falls back the way
an unmapped nomenclature has always fallen back. It is not "no pattern".
"""
stamp = (cache or {}).get('nomenclature')
if not isinstance(stamp, dict):
return None
return stamp.get('design_number_re') or None
def plan(cache, live, include=None):
"""What to build, given the cache and the live signatures.
`live` is {name: (signature, reinforced, on_hold)} for EVERY assembly --
ineligible ones included, so that a piece that has just been taken off
hold is picked up and one that has just been put on hold is dropped.
`include` is an optional `name -> bool` predicate naming pieces that must
be MEASURED even though they are not donors. It is how a target gets into
the index: the piece being reinforced is normally neither reinforced nor
on hold, so donor eligibility would leave it out of the file entirely and
the only way to know its features would be to measure it live. Eligibility
filters donors; it does not decide who may be a target. Nothing about the
donor rule moves -- `donors()` still re-asserts `is_eligible` on every
record, so a piece let in here can never be offered as a template.
LEFT OUT, IT FALLS BACK TO THE SCOPE THE CACHE ITSELF RECORDS, and that
is not a convenience -- it is what stops two callers disagreeing about
one file. The button computes a plan of its own, to size a progress bar
and to decide whether a refresh is needed at all, and it calls this with
two arguments. Were that plan donors-only while `refresh`'s plan was not,
every target record in the file would land in the button's `drop` list on
every press: `refreshed` would be true forever, "nothing needed
rebuilding" would never print again, and any caller that fed that plan to
`apply_plan` would evict every target. A cache with no `scope` stamp gets
the donors-only behaviour schema 4 had, because a file that never recorded
a scope has no targets in it to protect.
Returns a dict of name lists: `build` (new or stale, in a stable order),
`fresh` (reusable as-is), `drop` (in the cache, no longer wanted or no
longer in the model), `donors` (of build+fresh, the ones that are
genuinely eligible donors) and `targets` (the rest -- measured, but not
donors). `coverage()` reads `donors`, so widening what gets measured never
inflates the count of donors the index claims to hold.
"""
if cache.get('schema') != SCHEMA:
cache = new_cache(cache.get('doc_key'))
if include is None:
include = _stamped_include(cache)
have = cache.get('pieces') or {}
build, fresh, donor_names, target_names = [], [], [], []
for name in sorted(live):
sig, reinforced, on_hold = live[name]
eligible = is_eligible(reinforced, on_hold)
if not eligible and not _wanted(include, name):
continue
rec = have.get(name)
if rec is not None and rec.get('sig') == sig:
fresh.append(name)
else:
build.append(name)
(donor_names if eligible else target_names).append(name)
keep = set(fresh) | set(build)
drop = sorted(n for n in have if n not in keep)
return {'build': build, 'fresh': fresh, 'drop': drop,
'donors': donor_names, 'targets': target_names}
def _stamped_include(cache):
"""The predicate a cache implies about itself, or None.
`vibe.scope` is imported lazily and is as Revit-free as this module is --
`re` and `vibe.compat`, nothing else. It is imported rather than
reimplemented so that "does this mark belong to this engineer" has exactly
one answer in this codebase, and lazily so that this module keeps no
import-time dependency on anything at all.
"""
stamp = (cache or {}).get('scope')
if not isinstance(stamp, dict):
return None
try:
from vibe import scope as scopelib
return scopelib.Scope(prefixes=stamp.get('prefixes') or ()).includes
except Exception: # pylint: disable=W0703
return None
def _wanted(include, name):
"""A caller's predicate, made total. A predicate that raises must not take
the index down: it means 'could not tell', and could-not-tell measures the
piece rather than dropping it silently."""
if include is None:
return False
try:
return bool(include(name))
except Exception: # pylint: disable=W0703
return True
def apply_plan(cache, the_plan, records, built_at=None):
"""Fold freshly built records in and drop what no longer qualifies.
Records for only part of `plan['build']` is the normal case, not an
error: the build runs under a time budget and stops when it runs out.
"""
if cache.get('schema') != SCHEMA:
cache = new_cache(cache.get('doc_key'))
pieces = cache.setdefault('pieces', {})
for name in the_plan.get('drop', []):
pieces.pop(name, None)
for rec in records or []:
pieces[rec['name']] = rec
if built_at is not None:
cache['built_at'] = built_at
return cache
def coverage(cache, the_plan):
"""How complete the index is, in the terms a caller has to report.
`missing` is what a search run right now would not see. A partial index
is usable -- that is the point of building in batches -- but a caller
that does not say what it could not see is claiming a completeness it
does not have.
EVERY NUMBER HERE IS ABOUT DONORS. Since schema 5 the file may also hold
pieces measured only so they can be TARGETS, and counting those as donors
would inflate the pool the report claims to have searched. They are
counted separately, as `targets`. A plan from before that split -- one
with no `donors` key, which a test may well build by hand -- falls back to
build+fresh and reads exactly as it did.
"""
pieces = cache.get('pieces') or {}
wanted = the_plan.get('donors')
if wanted is None:
wanted = set(the_plan.get('build', [])) | set(the_plan.get('fresh', []))
wanted = set(wanted)
have = set(n for n in wanted if n in pieces)
unjudged = sorted(n for n in have if (pieces[n] or {}).get('features') is None)
targets = set(the_plan.get('targets') or ())
return {
'eligible': len(wanted),
'indexed': len(have),
'missing': sorted(wanted - have),
'unjudged': unjudged,
'complete': len(have) == len(wanted),
'targets': len(targets & set(pieces)),
'targets_missing': sorted(targets - set(pieces)),
}
def donors(cache, include_unjudged=False):
"""Every indexed donor record, name-sorted.
Pieces that could not be judged are held back by default: a record with
`features=None` looks like a piece with nothing to offer, and letting one
into a set-cover search silently removes a candidate rather than flagging
it. `coverage()['unjudged']` is where they are meant to be reported.
ELIGIBILITY IS RE-ASSERTED HERE, and that is what makes schema 5's wider
index safe. The file now also holds pieces measured only so they can be
targets; `is_eligible` is the same rule `plan` applies, so a target record
never reaches a set-cover search as a template and never counts as a
reviewed MASTER. `reinforcebind.DonorFilter` checks it a third time and
names the rejection -- belt, braces, and a line in the report.
"""
pieces = cache.get('pieces') or {}
out = []
for name in sorted(pieces):
rec = pieces[name] or {}
if not is_eligible(rec.get('reinforced'), rec.get('on_hold')):
continue
if rec.get('features') is None and not include_unjudged:
continue
out.append(rec)
return out
def targets(cache):
"""Every indexed piece that is NOT a donor, name-sorted. The other half.
These are in the file for one reason: so that a suggestion can be made for
them with no model open. Nothing ranks them and nothing may donate from
them.
"""
pieces = cache.get('pieces') or {}
out = []
for name in sorted(pieces):
rec = pieces[name] or {}
if not is_eligible(rec.get('reinforced'), rec.get('on_hold')):
out.append(rec)
return out
def master_signal_read(cache):
"""False when the review signal was never read at all.
True as soon as one indexed piece carries a real True/False verdict. An
empty index answers False -- nothing was read, because nothing was
indexed -- which is the honest answer either way.
"""
for rec in (cache.get('pieces') or {}).values():
if (rec or {}).get('master') is not None:
return True
return False
# ------------------------------------------------------- the design number
#
# WHICH DESIGN A PIECE IS, AND WHY THE INDEX HAS TO SAY SO.
#
# Owner's ruling, 2026-09-01: "选择母版的目标,必须得是跟自己的design number一样
# 的panel。我刚在一个WP2.0上面跑了reinforce,给我俩WP1.0的母版,压根用不了。"
# A donor of another design is not a worse donor, it is not a donor. The rule
# is a FILTER and it lives in `reinforcebind.DonorFilter`; what lives here is
# the question that has to be answered before that filter may be trusted at
# all -- does this index record design numbers?
#
# `piece_record` has carried `design_number` since schema 4, so a file this
# module will even load has the KEY. Carrying the key is not carrying a value:
# `reinforceindex.extract` reads it by ROLE through `panel.design_number`, and
# a build that ran without a knowledge layer (or at a plant that has not
# mapped the role) stores None on every piece while looking completely
# healthy. Measured on the real index, 2026-09-01: 773 pieces, every one of
# them `design_number: null`, on a job whose panels are plainly WP1.0 / WP2.0
# / WP3.0 -- the takeoff written off the same index two days earlier printed
# those numbers in its own CSV.
#
# So there are THREE answers and a caller may not collapse them:
#
# NOT_RECORDED no record even carries the field. The index predates the
# field. Nobody looked.
# UNREAD every record carries the field and not one carries a value.
# The role is unmapped, or the build had no knowledge layer.
# Nobody looked either -- but the fix is a different one.
# RECORDED at least one piece names its design. The filter can run.
#
# Neither of the first two may be read as "these designs match". That is the
# whole point: an unread field that defaults to matching would hand a WP2.0
# panel a pool of WP1.0 donors and call it an answer, which is the defect
# this was written for.
DESIGN_NOT_RECORDED = 'not_recorded'
DESIGN_UNREAD = 'unread'
DESIGN_RECORDED = 'recorded'
# ---- and the VERDICT the design rule reaches on one run.
#
# The rule itself is `reinforcebind.DonorFilter` / `design_info` and the
# wording is `reinforcereport.design_refusal`; the WORDS BOTH OF THEM USE live
# here, because this is the module they already both import at module scope
# and a status compared as a copied string literal is a status that drifts.
# (`reinforcereport` deliberately keeps no import of `reinforcebind` -- see
# its note on feature kinds -- so a shared vocabulary needs a third home, and
# this is the one that already owns the design-number question.)
#
# FOUR REFUSALS, AND THEY HAVE FOUR DIFFERENT FIXES: rebuild the index, map
# the role, fix the piece, detail a first one by hand. Collapsing any two of
# them into "no suggestions" throws away the only sentence that tells the
# engineer what to do next.
DESIGN_OK = 'ok'
DESIGN_TARGET_UNKNOWN = 'target_unknown'
DESIGN_INDEX_NOT_RECORDED = 'index_not_recorded'
DESIGN_INDEX_UNREAD = 'index_unread'
DESIGN_NONE_MATCH = 'none_match'
DESIGN_REFUSALS = (DESIGN_TARGET_UNKNOWN, DESIGN_INDEX_NOT_RECORDED,
DESIGN_INDEX_UNREAD, DESIGN_NONE_MATCH)
# What a candidate whose own design number could not be read is called in a
# tally of the designs a pool holds. Not a design, and never counted as one.
DESIGN_UNREADABLE = '?'
def design_status(records):
"""NOT_RECORDED / UNREAD / RECORDED over a sequence of piece records.
An EMPTY sequence is NOT_RECORDED: nothing was looked at, so nothing is
known, and that is the could-not-judge answer rather than a clean bill.
"""
saw_key = False
for rec in records or []:
rec = rec or {}
if 'design_number' not in rec:
continue
saw_key = True
if rec.get('design_number'):
return DESIGN_RECORDED
return DESIGN_UNREAD if saw_key else DESIGN_NOT_RECORDED
def masters(cache, include_unjudged=False):
"""The names of every indexed donor that is a reviewed template.
Only a True counts. A None -- the role unmapped, nothing read -- is not a
master and is not a not-master either; `master_signal_read` is how a
caller tells those apart before reporting a count of zero.
A record with no `master` key at all is NOT counted -- but it also cannot
reach here: the key arrived with SCHEMA 4, and `load()` throws away a file
written under any other schema rather than serving records that predate a
field. That is the whole answer to "what does an old index do": it is not
read, so no piece written before today can be reported as "checked and
found not to be a master". It is rebuilt, and rebuilding is cheap.
"""
return sorted(rec['name'] for rec in donors(cache, include_unjudged)
if rec.get('master'))
def all_features(cache):
"""Every feature of every judged donor, flat, ready for a comparison."""
out = []
for rec in donors(cache):
out.extend(rec.get('features') or [])
return out
# ------------------------------------------------------- the feature registry
#
# THE FEATURE-ID IS AN INDEX, NOT A RANKER. Owner-ruled 2026-09-02, three
# locked decisions:
#
# 1. DISCRETIZATION GRAIN = THE INDEX'S EXISTING ROUNDING, no new tolerance.
# A descriptor's dimensions are rounded with `_f` -- the SAME 3-decimal-
# feet grain (~= 1/32 in) every stored coordinate and width already
# carries -- and equivalence is bit-for-bit equality at that grain. So
# 3.00 ft and 3.02 ft are DIFFERENT ids (and `covers()` ranks them);
# 3.000 and 3.0004 are the SAME (the grain already collapsed them). No
# new bin is introduced, and the id never becomes a tolerance in
# `covers()`.
# 2. EDGE CONDITION IS A BINARY: concrete-edge vs block-out-edge, and nothing
# finer. It is exactly the existing `between` distinction -- a `VOID_EDGE`
# strip touches a concrete panel edge (`concrete_edge`); a `VOID_VOID`
# strip sits between two block-outs, and an OPENING is all block-out edge
# (`block_out_edge`). The specific panel-edge sub-label (x0/x1/y0/y1) is
# DELIBERATELY NOT in the descriptor -- the owner ruled only concrete-vs-
# blockout is needed, and strip ORIENTATION is already carried by `kind`
# (`strip_x`/`strip_y`). ("边界条件只需要区分concrete edge跟block-out-edge.")
# 3. THE ID FORM IS A POSITIVE INTEGER, sequential, assigned by the registry.
# ("编号形态是正整数.")
#
# `covers()` (in `reinforcebind`) stays the SOLE ranker. The id never enters
# it, never ranks, never bins. The vocabulary here only lets a caller ask "have
# I seen a feature shaped exactly like this before?" cheaply -- an accelerator
# over the geometry `covers()` still measures.
#
# The `between` string values are `reinforcebind`'s (`VOID_EDGE = 'void-edge'`,
# `VOID_VOID = 'void-void'`), repeated here as literals because this module may
# NOT import `reinforcebind` -- that module imports THIS one, and the dependency
# may run one way only. An opening carries `between = None` (or no key at all),
# which maps to block-out edge, the same as a void-void strip.
BETWEEN_VOID_EDGE = 'void-edge'
BETWEEN_VOID_VOID = 'void-void'
# The binary of decision 2. Two answers, and only two.
EDGE_CONCRETE = 'concrete_edge'
EDGE_BLOCK_OUT = 'block_out_edge'
def _edge_approximate_feature(feature):
"""Whether ONE feature is an APPROXIMATED EDGE condition.
Mirrors `reinforcebind._edge_approximate` for a single feature: an
approximated outline (`outline_approximate`) only matters where the
comparison actually touches a panel edge -- a `VOID_EDGE` strip or an
opening. A `VOID_VOID` strip on an approximate-outline piece still measures
exactly (its bounds are two voids, not the uncertain edge), so its flag is
NOT carried into the descriptor -- exactly as `covers()` still scores it
exact. Carried here so an approximate edge feature can never share a
descriptor, hence never an id, with a measured one: they must land in
different buckets, the same discipline `covers()`' approximation floor
applies one level up.
"""
feature = feature or {}
if not feature.get('outline_approximate'):
return False
return (feature.get('between') == BETWEEN_VOID_EDGE or
feature.get('kind') == KIND_OPENING)
def canonical_descriptor(feature):
"""A hashable, JSON-serialisable canonical key for ONE feature record.
Returns the tuple `(kind, dims, edge_condition, approx)`:
kind -- the existing feature kind: `opening` / `strip_x` /
`strip_y`. Orientation lives here, so the descriptor
does not need it twice.
dims -- the 几×几 at the EXISTING grain (decision 1). For a
strip, `(strip_ft, span_ft)` -- width across, span
along; for an opening, `(w, h)`. Rounded with `_f`, the
same 3-decimal-feet grain the records already carry, so
no new tolerance is introduced and 3.000 vs 3.0004
collapse while 3.00 vs 3.02 do not.
edge_condition -- the BINARY of decision 2, derived from `between`:
`VOID_EDGE` -> `concrete_edge`; `VOID_VOID` and an
OPENING (between None) -> `block_out_edge`. The specific
panel edge (x0/x1/y0/y1) is deliberately dropped.
approx -- `_edge_approximate_feature`: an approximate EDGE feature
never shares a descriptor with a measured one.
Pure and total: a missing key reads as None, and nothing here raises.
"""
feature = feature or {}
kind = feature.get('kind')
if kind == KIND_OPENING:
dims = (_f(feature.get('w')), _f(feature.get('h')))
else:
dims = (_f(feature.get('strip_ft')), _f(feature.get('span_ft')))
edge_condition = (EDGE_CONCRETE
if feature.get('between') == BETWEEN_VOID_EDGE
else EDGE_BLOCK_OUT)
return (kind, dims, edge_condition, _edge_approximate_feature(feature))
def _descriptor_key(descriptor):
"""A stable string key for a descriptor, for use as a dict/JSON map key.
`json.dumps` renders a tuple and its JSON-round-tripped list form
identically, so a descriptor built fresh and one read back off disk key to
the same string -- which is what makes `lookup` round-trip-safe.
"""
return json.dumps(descriptor, sort_keys=True, separators=(',', ':'))
def _empty_registry():
"""A valid, empty serialised registry -- ids start at the first positive
integer, 1."""
return {'next_id': 1, 'by_id': {}, 'by_key': {}}
class Registry(object):
"""The feature vocabulary: descriptor <-> positive-integer id.
An INDEX/ACCELERATOR ONLY (see the section note above). Identical
descriptors across panels share one id -- feature-level dedup -- and ids are
positive, sequential, assigned on first sight and NEVER renumbered on a
rebuild. `get_or_add` is the DONOR path that grows the vocabulary; `lookup`
is the read-only TARGET path that must never grow it.
Serialises to a plain JSON block `{next_id, by_id, by_key}` of integers and
strings only: `by_key` maps a descriptor-key string to its id, and `by_id`
maps `str(id)` back to that same key string (so a descriptor is recoverable
without storing it twice).
"""
def __init__(self, next_id=1, by_key=None, by_id=None):
try:
self._next = int(next_id)
except (TypeError, ValueError):
self._next = 1
if self._next < 1:
self._next = 1
self._by_key = dict(by_key or {}) # key-string -> int id
self._by_id = dict(by_id or {}) # str(id) -> key-string
def get_or_add(self, descriptor):
"""The id for `descriptor`, assigning the next positive int if new.
MUTATING: the donor path. An identical descriptor already seen -- on
this panel or any other -- returns its existing id.
"""
key = _descriptor_key(descriptor)
got = self._by_key.get(key)
if got is not None:
return got
new_id = self._next
self._by_key[key] = new_id
self._by_id[str(new_id)] = key
self._next = new_id + 1
return new_id
def lookup(self, descriptor):
"""The id for `descriptor`, or None. READ ONLY -- never mutates.
The target path: a target must never grow the shared vocabulary, so a
descriptor no donor ever produced comes back None (an accelerator miss,
which `covers()` then resolves the ordinary way) rather than being
assigned a fresh id.
"""
return self._by_key.get(_descriptor_key(descriptor))
def descriptor_of(self, feature_id):
"""The descriptor an id names, as its JSON-native list form, or None."""
key = self._by_id.get(str(feature_id))
return None if key is None else json.loads(key)
def serialize(self):
return {'next_id': self._next,
'by_id': dict(self._by_id),
'by_key': dict(self._by_key)}
@classmethod
def deserialize(cls, data):
data = data or {}
return cls(next_id=data.get('next_id', 1),
by_key=data.get('by_key'),
by_id=data.get('by_id'))
def register_features(cache, records):
"""Assign a feature-id to every donor feature descriptor, in the cache.
Pure over the given records: for each feature of each record, `get_or_add`
its `canonical_descriptor` in the cache's registry (feature-level dedup --
identical descriptors across panels collapse to one id). Updates
`cache['registry']` in place and returns the `Registry`.
A record that was never measured (`features is None`) contributes no
descriptors -- could-not-judge is not a feature.
"""
registry = Registry.deserialize((cache or {}).get('registry'))
for rec in records or []:
feats = (rec or {}).get('features')
if feats is None:
continue
for f in feats:
registry.get_or_add(canonical_descriptor(f))
if cache is not None:
cache['registry'] = registry.serialize()
return registry
def feature_ids_of(record, registry):
"""The DERIVED array of feature-ids for one piece, over `record['features']`.
A PROJECTION, not a stored field: the array is `registry.lookup` of each
feature's `canonical_descriptor`, so the only sources of truth are the
registry (the vocabulary) and the geometry (the feature records) -- the same
anti-drift discipline `_pack` applies to the derived feature fields. It is
never stored back on the record.
READ ONLY on the registry: a projection must never grow the vocabulary, so a
feature with no registered equivalent projects to None (an accelerator miss)
rather than being assigned a fresh id.
Returns None when the piece was never measured (`features is None`) --
could-not-judge propagates rather than becoming an empty list.
"""
feats = (record or {}).get('features')
if feats is None:
return None
return [registry.lookup(canonical_descriptor(f)) for f in feats]
def newly_reinforced(old_cache, live):
"""Piece names newly ELIGIBLE-AS-DONOR this snapshot -- the 'what got marked'
diff. Pure.
A name is newly reinforced when it is an eligible donor NOW but was not one
before: either absent from `old_cache` altogether, or present but not
eligible (unreinforced, or on hold). `live` is
`{name: (signature, reinforced, on_hold)}`, exactly what
`reinforceindex.live_signatures` produces.
Returns a set of names. A piece that was already a donor, or that is still
not one, is not in it.
"""
old_pieces = (old_cache or {}).get('pieces') or {}
def was_donor(name):
rec = old_pieces.get(name)
if not rec:
return False
return is_eligible(rec.get('reinforced'), rec.get('on_hold'))
out = set()
for name, sig_tuple in (live or {}).items():
try:
reinforced, on_hold = sig_tuple[1], sig_tuple[2]
except (TypeError, IndexError, KeyError):
continue
if is_eligible(reinforced, on_hold) and not was_donor(name):
out.add(name)
return out
def snapshot_stats(old_cache, new_cache, live):
"""The 'what got marked this snapshot' update statistics. Pure, host-testable.
This is the owner's ask -- "每次按 snapshot 先做更新统计:这次 mark 了什么
panel、有什么 feature、写入库". It diffs the pre-snapshot cache against the
freshly-refreshed one and reports, for every piece newly eligible-as-donor
this press: how many features it carries, its feature-IDs, and which of
those IDs are NEW to the shared vocabulary this press versus already known.
`old_cache` is the index as it stood BEFORE this refresh (read-only load);
`new_cache` is the index AFTER (its registry already grown by
`register_features`); `live` is `{name: (signature, reinforced, on_hold)}`
from `reinforceindex.live_signatures`.
An id is ALREADY-KNOWN when the OLD registry already carried it, and NEW
when it was assigned during this build -- ids are sequential and never
renumbered (`Registry.get_or_add`), so the new registry is a superset of
the old with identical ids, and old-registry membership is the whole test.
A feature whose descriptor no donor ever produced projects to None
(`feature_ids_of` is read-only) and is counted as `unregistered` rather
than silently dropped.
Returns a dict:
first_snapshot -- True when `old_cache` held no pieces (nothing to diff
against): every currently-reinforced piece is new, and
a caller should say so rather than imply a delta.
newly -- sorted names newly eligible-as-donor this press.
panels -- one dict per newly-marked piece, name-sorted:
{name, feature_count, feature_ids, new_ids, known_ids,
unregistered}. `feature_count`/`feature_ids` are None
for a piece that could not be measured (could-not-judge
propagates, it does not become an empty list).
new_ids / known_ids -- the DISTINCT ids, pool-wide over the newly-marked
pieces, new-to-the-registry this press vs already-known.
"""
newly = sorted(newly_reinforced(old_cache, live))
old_reg = Registry.deserialize((old_cache or {}).get('registry'))
new_reg = Registry.deserialize((new_cache or {}).get('registry'))
old_pieces = (old_cache or {}).get('pieces') or {}
new_pieces = (new_cache or {}).get('pieces') or {}
def known(fid):
# Already in the vocabulary before this press. descriptor_of is the
# read-only membership test on the OLD registry.
return old_reg.descriptor_of(fid) is not None
panels = []
pool_new, pool_known = set(), set()
for name in newly:
rec = new_pieces.get(name)
ids = feature_ids_of(rec, new_reg) if rec is not None else None
if ids is None:
panels.append({'name': name, 'feature_count': None,
'feature_ids': None, 'new_ids': [],
'known_ids': [], 'unregistered': 0})
continue
new_ids, known_ids, unregistered = [], [], 0
for fid in ids:
if fid is None:
unregistered += 1
elif known(fid):
known_ids.append(fid)
pool_known.add(fid)
else:
new_ids.append(fid)
pool_new.add(fid)
panels.append({'name': name, 'feature_count': len(ids),
'feature_ids': ids,
'new_ids': sorted(set(new_ids)),
'known_ids': sorted(set(known_ids)),
'unregistered': unregistered})
return {'first_snapshot': not old_pieces,
'newly': newly,
'panels': panels,
'new_ids': sorted(pool_new),
'known_ids': sorted(pool_known)}
# --------------------------------------------------------------------- io
#
# On disk a feature keeps only what was measured: the rectangle and the
# penetration. Everything else on the in-memory record is derived from those
# plus the piece's own dimensions, which are stored once per piece rather
# than once per feature.
#
# This is not tidiness. Measured on this model: the fully expanded form ran
# about 8 kB per piece, so a 690-piece index was ~5.5 MB, and IronPython's
# pure-Python json parser took seconds to read it back -- which would have
# put a no-change button press at eight seconds, the exact cost the index
# exists to avoid. What a caller sees is unchanged: `load()` rebuilds the
# full flat feature, so nothing downstream knows this happened.
_PACKED = ('x0', 'x1', 'y0', 'y1', 'through', 'concrete_in')
def _pack(cache):
# AN UNDERSCORE KEY IS ABOUT THE LOAD, NOT ABOUT THE CACHE, AND IS NEVER
# WRITTEN. `load()` stamps `_load_error` on the empty cache it returns
# after a schema mismatch; `reinforceindex.refresh` fills that same dict
# and saves it, and `dict(cache)` copied the key straight into the new
# file. Measured on the real index, 2026-08-30: a healthy, freshly built
# schema-4 file carried `"_load_error": "schema 3, expected 4"` for good,
# so every later press reported a load failure that had not happened and
# forced a full rebuild it did not need. Stripped here rather than at the
# one call site, so no future writer can reintroduce it.
out = dict((k, v) for k, v in cache.items() if not str(k).startswith('_'))
pieces = {}
for name, rec in (cache.get('pieces') or {}).items():
rec = dict(rec)
feats = rec.get('features')
if feats is not None:
rec['features'] = [dict((k, f.get(k)) for k in _PACKED)
for f in feats]
pieces[name] = rec
out['pieces'] = pieces
return out
def _unpack(cache):
for name, rec in (cache.get('pieces') or {}).items():
feats = rec.get('features')
if not feats:
continue
rec['features'] = [
feature(name, i, f['x0'], f['x1'], f['y0'], f['y1'],
f.get('through'), f.get('concrete_in'),
rec.get('length_ft'), rec.get('width_ft'),
rec.get('thickness_in'))
for i, f in enumerate(feats)]
return cache
def load(path):
"""The cache at `path`, or an empty one. A damaged file is not fatal.
A cache is a derived artefact: the worst a bad one can do is cost a
rebuild, so it is never worth raising over. It IS worth saying so, hence
the `_load_error` key rather than a silent empty return.
A file written under an older SCHEMA is discarded WHOLE, never partially
adopted, and that is what makes adding a field to the piece record safe:
an index built before `master` existed cannot be read as "every piece was
checked and none is a master". It reports `_load_error` and rebuilds.
"""
if not path or not os.path.isfile(path):
return new_cache(None)
try:
handle = open(path, 'rb')
try:
raw = handle.read().decode('utf-8')
finally:
handle.close()
cache = json.loads(raw)
except Exception as exc:
cache = new_cache(None)
cache['_load_error'] = str(exc)
return cache
if not isinstance(cache, dict) or cache.get('schema') != SCHEMA:
fresh = new_cache((cache or {}).get('doc_key')
if isinstance(cache, dict) else None)
fresh['_load_error'] = 'schema {0}, expected {1}'.format(
(cache or {}).get('schema') if isinstance(cache, dict) else '?',
SCHEMA)
return fresh
# A STRAY `_load_error` IN A HEALTHY FILE IS NOT A LOAD ERROR. Files
# written before `_pack` learned to strip transient keys carry one -- the
# message from the schema bump that CAUSED the rebuild, copied into the
# rebuilt file. Reading it back as a live failure is what turned a
# cosmetic defect into a full re-scan on every button press, twice over,
# measured 2026-08-30. This file parsed and its schema matches; whatever
# some earlier load thought is not a fact about it, so the key is dropped
# rather than reported. The one below, set on the way out of a genuine
# failure, is the only `_load_error` a caller may see.
for key in [k for k in cache if str(k).startswith('_')]:
del cache[key]
cache.setdefault('pieces', {})
# A same-schema file always carries a registry (`new_cache` seeds it), but a
# hand-built cache may not -- default it to a valid empty vocabulary rather
# than leave a caller to guess. Carried verbatim otherwise; `_unpack` does
# not touch it.
cache.setdefault('registry', _empty_registry())
return _unpack(cache)
def save(path, cache):
"""Write the cache. Never through the file the reader would half-see."""
folder = os.path.dirname(path)
if folder and not os.path.isdir(folder):
os.makedirs(folder)
tmp = path + '.tmp'
text = json.dumps(_pack(cache), separators=(',', ':'), sort_keys=True)
handle = open(tmp, 'wb')
try:
handle.write(text.encode('utf-8'))
finally:
handle.close()
if os.path.isfile(path):
os.remove(path)
os.rename(tmp, path)
return path