# -*- 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'(? 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