25592fa6c2
ci / test (push) Successful in 6m14s
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.
1500 lines
68 KiB
Python
1500 lines
68 KiB
Python
# -*- 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
|