# -*- coding: utf-8 -*- """Reading the model for the reinforcing index. The Revit half only. The split is the one `ticketpage.py` / `ticketchecks.py` already models: everything that can be decided without a document lives in `vibe.reinforcecore` and is unit-tested on host CPython; this file does the gathering and nothing else. It is READ ONLY -- no Transaction, no parameter write, no delete, anywhere in it, and there is no reason for that to change: the feature writes nothing to the model. WHERE THE CACHE LIVES --------------------- Beside the model, under the same `_ticketqc` root the ticket evidence and specs already use (`designspec.root_dir`), in `reinforce/index.json`. It holds job geometry, so it may not enter the code repository; that root is gitignored and, when `WILLTOOLS_JOBS` is set, is not inside the repository at all. Nothing about this index is portable between models, so the model's own folder is also where it is actually useful. WHAT THE COST ACTUALLY IS -- measured, this model, 2026-08-30 ------------------------------------------------------------- 755 assemblies collected + both flags read 0.29 s every assembly's cheap signature 0.71 s full geometry build, all 690 eligible donors 6.9 s (10 ms each) slowest single piece 0.061 s serialise + write the finished index (983 kB) 2.0 s ---- what a button press actually costs ---- full rebuild, cold cache 10.1 s full rebuild, first press after a Revit restart 16.2 s refresh with nothing changed 2.3 s read the index without refreshing 1.4 s ---- schema 5, same model, measured 2026-08-30 ---- assemblies in the model 755 eligible donors 690 in-scope pieces added as TARGETS 28 (WP, not donors) pieces in the file 718 index size 1.01 MB Quote a rebuild as "~10-16 s", not as ten: the lower number was measured in a session that had been open for hours, and the first press after a restart is the one the owner actually experiences. That is not the 30-60 s per piece this feature was warned about, and the difference is worth stating because it moves the design: the expensive read in this project is GENERATING A VIEW -- what `read_ticket` pays when it walks a ticket sheet -- and nothing here touches a view. `concrete_solid` asks the panel element for its own geometry and `_openings` reads faces off that solid. So the index is not expensive to build; it is still cached, because the owner's constraint is that a button press must not re-analyse the model, and 6 s of frozen UI per click is 6 s too many. Honesty about that number: this session was already open when it was measured. Only a Revit restart restores a truly cold cache and a restart is the owner's to make. The structural argument -- no view is generated -- is what the number rests on, not a claim that the session was cold. """ # pylint: disable=import-error,broad-except,invalid-name import os import time from vibe import reinforcecore as core FOLDER = 'reinforce' FILENAME = 'index.json' # The two instance parameters on the assembly that decide donor eligibility. # Named here rather than in the pure module because they are this job's # ticketing-system parameters, read by name off the element. P_REINFORCED = 'TICKET_IS_REINFORCED' P_ON_HOLD = 'ON_HOLD' # ------------------------------------------------------- the review signal # # WHERE 'MASTER' ACTUALLY LIVES -- measured on this model, 2026-08-30, before # anything was written to match on it, because a guess about a hand-typed # field is the kind of thing that quietly matches nothing. # # The owner said "identity description". Four candidate free-text fields were # scanned across all 755 assemblies -- the assembly's identity comment, the # panel instance's identity comment, and two identity fields on the panel # type and instance. Only the first two ever contain the word: # # assembly identity comment 18 pieces <- what this reads # panel-instance identity comment 16 pieces (a strict subset of it) # panel-type identity field 0 -- 5 distinct values, all of the # form 'PRECAST CONCRETE WALL # PANEL'; a product class, not a # review state # instance short identity field 0 -- a category stub per piece # # The assembly field is a SUPERSET of the instance field on this model: every # one of the 18 carries it on the assembly, two carry it ONLY there, and none # carries it only on the instance. So this reads the assembly and nothing # else -- one parameter read, no member walk, and the same source in the cheap # signature pass as in the full extract, which is what keeps the staleness # stamp honest about this field. # # THE PARAMETER IS NAMED BY ROLE, NEVER BY LITERAL. This extension is the # shareable half of the project and a shared-parameter name is plant # knowledge: another office's template calls the same field something else, # and a name written into code is a plant that cannot be served without # editing the code. `parameters.member.identity_description` is the role; # `knowledge.param_names` resolves it to the office's real name(s), tried in # order. # # UNMAPPED MEANS NOT RUN, NEVER A FALLBACK. With no name behind the role the # signal STANDS DOWN: every piece gets `master = None`, which is the project's # could-not-judge value and is not False. It must not quietly guess a name -- # that would work in this office and silently do nothing in every other one, # while the report went on printing "no donor here is reviewed" as though # somebody had looked. A plant that genuinely has no such field says so the # way every other role does, under `parameters.not_applicable`. R_IDENTITY = 'member.identity_description' def master_token(know=None): """The plant's own word for a reviewed template. The default lives in `reinforcecore.DEFAULT_MASTER_TOKEN`, so a plant that maps the field but uses another word only has to set the key. The path is written out as a LITERAL rather than through a module constant on purpose: `tools/knowledge_drift.py` matches literal paths and a key reached through a variable lands in its blind-spot list instead of being credited with a reader -- which is how a key ends up reported as declared-and-never-read while a check is quietly using it. """ from vibe import knowledge return knowledge.get(know, 'reinforce.master_token', core.DEFAULT_MASTER_TOKEN) def identity_params(know=None): """The office's name(s) for the identity field, or [] when unmapped.""" from vibe import knowledge return knowledge.param_names(know, R_IDENTITY) def master_of(el, names, token): """(master, note) off one element. `master` is None when NOT RUN. None is could-not-judge -- the role is unmapped and nothing was looked at. False is judged: the field was read and does not carry the token. """ if not names: return None, None for name in names: note = _text(el, name) if note: return core.is_master_text(note, token), note return False, None # ------------------------------------------------- the elevation accelerator # # THE OPTIONAL FAST PATH, NEVER THE SOURCE OF TRUTH. The source of truth for # same-elevation is geometric (`_elevation_band_of`, below, in the expensive # pass) and is computed for every piece regardless of this section. This is # only a shortcut: a plant-declared GROUPING KEY a wall may already carry # for an unrelated reason -- MC's is `Tagged_Sheet`, the drafting sheet a # wall's elevation is drawn on. Owner, 2026-09-02: "对MC而言,所有的墙应该都 # 有一个参数叫Tagged_Sheet。如果俩panel这个参数一样,那么他们就是同一个 # elevation,不用跑哪个几何引擎。" Two panels sharing a non-empty value here # are the same elevation with no geometry involved. # # ABSENCE IS THE NORMAL STATE, not a stand-down worth reporting. Owner, the # same day, correcting the scope: 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. So an unmapped role, or a blank value on a piece, is # expected far more often than not, and `reinforcecore.elevation_match` # simply falls through to the geometric floor without comment. # # Read off the PANEL, exactly as `design_number` is (by role, never a # literal name) -- `parameters.panel.elevation_group`. R_ELEVATION_GROUP = 'panel.elevation_group' def _panel_member(doc, asm): """The Structural Framing member of one assembly, cheaply -- no geometry. The same walk `extract()` does to find the panel it measures, pulled out so the CHEAP pass can find it too: reading `elevation_group` needs the panel, and the signature has to see the same source `extract()` records or the two could disagree about what changed. """ try: member_ids = list(asm.GetMemberIds()) except Exception: return None for mid in member_ids: member = doc.GetElement(mid) if member is None: continue try: cat = member.Category.Name if member.Category else '' except Exception: cat = '' if cat == 'Structural Framing': return member return None def elevation_group_of(doc, asm, know=None): """The plant's elevation-grouping text off one assembly's panel, or None. None means could-not-judge, for any of: no Structural-Framing member to read it off, the role is unmapped (the common case -- see the section note above), or the field is blank on this piece. `Q.rstr` already returns None for an unmapped role, the same stand-down `design_number` uses. Never raises: a piece this cannot be read for still has the geometric floor to fall back on. """ panel = _panel_member(doc, asm) if panel is None: return None from vibe import ticketqc as Q try: return Q.rstr(panel, know, R_ELEVATION_GROUP) except Exception: return None def index_path(doc): from vibe import designspec return os.path.join(designspec.sub_dir(doc, FOLDER), FILENAME) def index_path_in(job_folder): """The index inside one model's job folder. THE OFFLINE RESOLVER. It takes a folder rather than a document, so nothing on this path imports Revit -- `index_path` reaches `designspec`, which imports the Revit API at module scope, and that single line is what would otherwise drag the whole API into an offline run. It also does not CREATE anything: reading an index must never leave a folder behind on a machine that has no model. The layout is `designspec`'s and is repeated here rather than shared, because the doc-side function is the one that knows about the model-folder and APPDATA fallbacks. When WILLTOOLS_JOBS is set -- the case that matters offline -- the whole path is `//reinforce/index.json` and `jobs_folder()` builds the first half of it. """ return os.path.join(job_folder, FOLDER, FILENAME) def jobs_folder(jobs_root, model): """`/` -- what WILLTOOLS_JOBS means, offline.""" return os.path.join(jobs_root, model or 'untitled') def doc_key(doc): """Which model a cache belongs to, without writing a path into the repo.""" try: return doc.Title or '' except Exception: return '' # ------------------------------------------------------------- the cheap pass def _name_of(el): from Autodesk.Revit.DB import Element try: return Element.Name.GetValue(el) except Exception: return None def _text(el, pname): """A string parameter's value, or None. Never raises. READ ONLY.""" try: p = el.LookupParameter(pname) except Exception: return None if p is None: return None try: value = p.AsString() except Exception: value = None if not value: return None return value def _flag(el, pname): """1 / 0 / None. None means the parameter is not on this element.""" try: p = el.LookupParameter(pname) except Exception: return None if p is None: return None try: return int(p.AsInteger()) except Exception: return None def assemblies(doc): """Every assembly instance, as (name, element). Scoped collector only.""" from Autodesk.Revit.DB import FilteredElementCollector, AssemblyInstance out = [] for a in (FilteredElementCollector(doc) .OfClass(AssemblyInstance) .WhereElementIsNotElementType() .ToElements()): name = _name_of(a) if name: out.append((name, a)) return out def live_signatures(doc, elements=None, know=None): """{name: (signature, reinforced, on_hold)} for every assembly. No geometry is read here. This is the pass that decides what has to be rebuilt, so it has to stay cheap enough to run on every button press -- measured at 0.23 s over 755 assemblies. The MASTER flag joined it in 2026-08-30 at the cost of one string parameter read per assembly: measured over the same 755 assemblies, reading the identity field and five other string fields together took 1.07 s including the member walk, so one field on its own is well inside the same budget. It has to be here -- see `reinforcecore.signature` for why a hand-typed field that is not in the stamp is a field that never takes effect. THE GEOMETRIC ELEVATION FLOOR IS NOT HERE, and it briefly stood in this spot under a different name for one day (2026-09-02): the owner's ruling made it a purely GEOMETRIC fact, and geometry needs the panel's measured solid -- unaffordable in a pass that has to stay cheap enough to run on every button press. See `reinforcecore.elevation_band`'s module note for the full reasoning; the band is captured only in `extract()`, alongside `thickness_in` and `features`, and never feeds this cheap stamp. THE ELEVATION ACCELERATOR IS HERE, and costs about what MASTER does: reading `elevation_group` needs the PANEL, not the assembly, so `elevation_group_of` pays its own member walk per assembly (`_panel_member`) rather than one parameter read off `a` directly. That walk is the same one `extract()` already pays to find the panel it measures -- it is not new work, only moved earlier. On most jobs the role is unmapped (see the section note above `R_ELEVATION_GROUP`), so this is one more `LookupParameter` call that returns nothing -- cheap even when it never answers. """ token = master_token(know) names = identity_params(know) out = {} for name, a in (elements if elements is not None else assemblies(doc)): reinforced = _flag(a, P_REINFORCED) on_hold = _flag(a, P_ON_HOLD) master = master_of(a, names, token)[0] elevation_group = elevation_group_of(doc, a, know) try: member_ids = [_int_id(i) for i in a.GetMemberIds()] except Exception: member_ids = [] out[name] = (core.signature(reinforced, on_hold, member_ids, master, elevation_group), reinforced, on_hold) return out def _int_id(element_id): try: return element_id.IntegerValue except Exception: return element_id.Value # ------------------------------------------------------- the expensive pass def _global_box_of_solid(solid): """The solid's own bounding box, in RAW GLOBAL (model) coordinates. `Solid.GetBoundingBox()` over the solid's 8 box corners, mirroring `ticketqc._one_solid_box`'s reasoning exactly: a curved edge's two "endpoints" coincide at the seam, so walking `solid.Edges` directly collapses a round feature to a sliver -- `Solid.GetBoundingBox()` does not have that failure. Unlike `_one_solid_box` this stops at the solid's own `box.Transform` and never applies the panel's inverse afterward: a building elevation is measured in the MODEL's frame, not the panel's own local one. Returns `(lo, hi)`, each an (x, y, z) list, or `(None, None)`. """ if solid is None: return None, None try: from Autodesk.Revit.DB import XYZ box = solid.GetBoundingBox() if box is None: return None, None local = box.Transform lo = [None, None, None] hi = [None, None, None] for i in (0, 1): for j in (0, 1): for k in (0, 1): corner = XYZ(box.Min.X if i else box.Max.X, box.Min.Y if j else box.Max.Y, box.Min.Z if k else box.Max.Z) q = local.OfPoint(corner) for axis, value in enumerate((q.X, q.Y, q.Z)): if lo[axis] is None or value < lo[axis]: lo[axis] = value if hi[axis] is None or value > hi[axis]: hi[axis] = value if lo[0] is None: return None, None return lo, hi except Exception: return None, None def _elevation_band_of(panel, extents, solid): """`core.elevation_band(...)` off the panel already being measured. `extents` is `Q.concrete_extents(panel, frame)`'s (lo, hi) LOCAL-frame feet triples -- the same measurement `thickness_in` already comes from, read again here rather than reconverted from `lo_in` / `hi_in` to avoid a needless feet -> inches -> feet round trip. `solid` is the same one `extract()` already paid for to build `features` -- no second geometry pass; only `panel.GetTransform()` is read again, and that is a placement property, not a solve. Never raises: any failure reading the transform or the box is could-not-judge, exactly what `core.elevation_band` itself returns for an ambiguous or off-axis panel -- a ranking signal going quiet, never a reason to fail the whole piece. """ from vibe import knowledge try: spans_ft = [extents[1][i] - extents[0][i] for i in (0, 1, 2)] transform = panel.GetTransform() basis = (transform.BasisX, transform.BasisY, transform.BasisZ) basis_xyz = [(b.X, b.Y, b.Z) for b in basis] except Exception: return None box_lo, box_hi = _global_box_of_solid(solid) try: return core.elevation_band( spans_ft, basis_xyz[0], basis_xyz[1], basis_xyz[2], box_lo, box_hi, near_square_ratio=knowledge.UP_END_NEAR_SQUARE_RATIO) except Exception: return None def extract(doc, name, asm, sig, know=None): """One piece's index record. Never raises: a failure becomes `error`. Every early return is a could-not-judge with a reason attached, never an empty feature list. A piece whose solid would not read and a piece with no openings are different answers. """ from vibe import ticketqc as Q, liftexport as L reinforced = _flag(asm, P_REINFORCED) on_hold = _flag(asm, P_ON_HOLD) # The review signal. Read here as well as in the cheap pass, and from the # same parameter, so a record and its own signature can never disagree # about it. A piece that could not be measured still carries it -- being # unreadable does not un-review a piece. master, note = master_of(asm, identity_params(know), master_token(know)) def fail(why, **extra): return core.piece_record(name, _int_id(asm.Id), sig, reinforced, on_hold, error=why, master=master, master_note=note, **extra) panel = None families = {} try: member_ids = list(asm.GetMemberIds()) except Exception as exc: return fail('members unreadable: {0}'.format(exc)) for mid in member_ids: member = doc.GetElement(mid) if member is None: continue try: fam = member.Symbol.Family.Name except Exception: continue families[fam] = families.get(fam, 0) + 1 # NOT filtered on SuperComponent -- but the reason written here # first was wrong in both halves, and it was corrected 2026-08-30 # against the live model: # # * SuperComponent set means the element IS A CHILD; it holds the # PARENT. Measured: SP03 members return # CONNX_HPKM39_COLUMN_SHOE(10 ANCHOR BOLTS) as their super # component. The old comment had it backwards, claiming the # property marks an element that HAS children. # * It is not "nearly every member". Over 25 WP assemblies, # 553 of 2755 members carry one -- 20.1%. # # So filtering would not empty the count. It would remove a fifth of # it, and that fifth is nested children that a naive count counts # twice: once as themselves and once inside their parent. # # The inventory is still recorded RAW and deliberately so -- this is # an index, and a consumer that needs a de-duplicated count should # decide its own rule and say which it used. But a consumer that # counts `families` without de-duplicating is over-counting by # roughly that 20%, and it should know that rather than inherit a # comment saying the property is useless. try: cat = member.Category.Name if member.Category else '' except Exception: cat = '' if cat == 'Structural Framing' and panel is None: panel = member if panel is None: return fail('no structural-framing member to measure', families=families) try: design_number = Q.rstr(panel, know, 'panel.design_number') except Exception: design_number = None # THE ELEVATION ACCELERATOR -- read the same way and at the same point # as design_number, since both are hand-set fields off the same panel. # None the vast majority of the time (unmapped role, or blank on this # piece) is expected, not a fault -- the geometric floor below is what # actually answers on most jobs. See `reinforcecore.elevation_match`'s # module note. try: elevation_group = Q.rstr(panel, know, R_ELEVATION_GROUP) except Exception: elevation_group = None try: frame = Q._panel_frame(panel) except Exception as exc: return fail('frame failed: {0}'.format(exc), families=families, design_number=design_number, panel_id=_int_id(panel.Id), elevation_group=elevation_group) if frame is None: return fail('no panel frame', families=families, design_number=design_number, panel_id=_int_id(panel.Id), elevation_group=elevation_group) try: extents = Q.concrete_extents(panel, frame) except Exception as exc: return fail('extents failed: {0}'.format(exc), families=families, design_number=design_number, panel_id=_int_id(panel.Id), elevation_group=elevation_group) if extents is None: return fail('no concrete extents', families=families, design_number=design_number, panel_id=_int_id(panel.Id), elevation_group=elevation_group) # concrete_extents is in FEET; _openings and opening_penetration both want # INCHES. Getting this backwards is a 12x error, which is the single most # expensive mistake this project has made. lo_in = [v * 12.0 for v in extents[0]] hi_in = [v * 12.0 for v in extents[1]] axes = L._axes(lo_in, hi_in) length_axis, width_axis, thick_axis = axes length_ft = (hi_in[length_axis] - lo_in[length_axis]) / 12.0 width_ft = (hi_in[width_axis] - lo_in[width_axis]) / 12.0 thickness_in = hi_in[thick_axis] - lo_in[thick_axis] try: solid = L.concrete_solid(panel) except Exception as exc: return fail('solid failed: {0}'.format(exc), families=families, design_number=design_number, panel_id=_int_id(panel.Id), elevation_group=elevation_group, length_ft=length_ft, width_ft=width_ft, thickness_in=thickness_in) if solid is None: return fail('no concrete solid', families=families, design_number=design_number, panel_id=_int_id(panel.Id), elevation_group=elevation_group, length_ft=length_ft, width_ft=width_ft, thickness_in=thickness_in) # THE ELEVATION FLOOR -- purely geometric, owner's ruling 2026-09-02; # see `reinforcecore.elevation_band`'s module note for the definition. # Computed for EVERY piece regardless of `elevation_group` above -- it # is the answer a job with no grouping parameter has, and the one that # keeps working if the parameter is added or dropped later. Off the # SAME solid `features` is about to be measured from -- no second # geometry pass -- plus the panel's own placement transform, which is a # cheap property read, not a solve. A failure here is a ranking signal # that could not be determined, never a reason to fail the whole piece: # `thickness_in` and `features` are still worth having even when the # panel turns out to be near-square or skewed off every global axis. elevation_band = _elevation_band_of(panel, extents, solid) try: rects = L._openings(solid, frame, lo_in, hi_in, axes) except Exception as exc: return fail('openings failed: {0}'.format(exc), families=families, design_number=design_number, panel_id=_int_id(panel.Id), elevation_group=elevation_group, elevation_band=elevation_band, length_ft=length_ft, width_ft=width_ft, thickness_in=thickness_in) # Penetration is a separate solve and can fail on its own. When it does, # the rectangles are still real -- so the features are kept and `through` # goes to None on each, which is the could-not-judge value, not False. try: pen = L.opening_penetration(panel, frame, solid, rects, axes, lo_in, hi_in) except Exception: pen = [] features = [] for i, rect in enumerate(rects): p = pen[i] if i < len(pen) else {} features.append(core.feature( name, i, rect[0], rect[1], rect[2], rect[3], p.get('through'), p.get('concrete_in'), length_ft, width_ft, thickness_in)) return core.piece_record( name, _int_id(asm.Id), sig, reinforced, on_hold, design_number=design_number, panel_id=_int_id(panel.Id), length_ft=length_ft, width_ft=width_ft, thickness_in=thickness_in, families=families, features=features, master=master, master_note=note, elevation_band=elevation_band, elevation_group=elevation_group) # ------------------------------------------------------------------ refresh def scope_of(know): """The declared scope, or an undeclared one. Never raises on a bad file.""" from vibe import scope as scopelib if not know: return scopelib.Scope() try: return scopelib.of(know) except Exception: return scopelib.Scope() def scope_in(cache): """(Scope, stamped) out of a cache. NO DOCUMENT, NO KNOWLEDGE FILE. `stamped` is False when the index carries no scope at all -- built before the stamp existed, or by a caller that had no knowledge layer. That is not the same as "no scope was declared", and an offline reader has to say which: the first means nobody knows what the pool was bounded by, the second means it was deliberately unbounded. Both come back as an undeclared `Scope()`; only this flag tells them apart. """ from vibe import scope as scopelib stamp = (cache or {}).get('scope') if not isinstance(stamp, dict): return scopelib.Scope(), False return scopelib.Scope(prefixes=stamp.get('prefixes') or (), note=stamp.get('note'), declared_by=stamp.get('declared_by'), source='the index, built ' + str((cache or {}).get('built_at'))), True def refresh(doc, budget_s=None, limit=None, rebuild_all=False, know=None, on_piece=None, targets=True): """Bring the index up to date and return (cache, report). READ ONLY. `budget_s` stops the build partway and leaves the rest for the next call. That is what makes a cold index usable immediately: the first press indexes what it can afford, answers with what it has, and says what it could not see. `report['coverage']['missing']` is that list and a caller that drops it is claiming a completeness the index does not have. `rebuild_all` ignores the cached signatures. It is the escape hatch for the thing signatures cannot see -- an opening moved or resized without the member set changing -- and it is cheap: a full build of this model's 690 eligible donors measured 6.07 s (16.2 s on the first build after a Revit restart, measured 2026-08-30). `targets` (schema 5, default ON) also measures every piece INSIDE THE DECLARED SCOPE that is not an eligible donor. Those are the pieces somebody actually wants suggestions for -- not yet reinforced, so donor eligibility left them out of the file altogether and the target had to be measured through a live document on every press. With them in the file the whole chain runs off the snapshot and `tools/reinforce_offline.py` can answer with no Revit at all. IT IS THE SCOPE THAT BOUNDS THIS, NOT A PREFIX IN THE CODE. The scope is read from the job knowledge (`scope.mark_prefixes`), which is where this project already keeps the answer to "whose pieces are these". An UNDECLARED scope includes everything, by `vibe.scope`'s standing rule that no declaration means judge everything -- which here costs measuring the whole model rather than the donors, roughly one extra second per hundred assemblies, and produces a `features=None` record for anything with no structural-framing member to measure. Pass `targets=False` for the donors-only index schema 4 built. """ started = time.time() path = index_path(doc) cache = core.load(path) if cache.get('doc_key') not in (None, doc_key(doc)): cache = core.new_cache(doc_key(doc)) cache['doc_key'] = doc_key(doc) the_scope = scope_of(know) # Stamped into the file so the offline reader, which cannot load a # knowledge layer without a model, gets the same scope the button had. cache['scope'] = {'prefixes': list(the_scope.prefixes), 'note': the_scope.note, 'declared_by': the_scope.declared_by} # And the nomenclature, for the same reason and to the same reader. A # donor must be the SAME DESIGN as its target (owner ruling, 2026-09-01), # and reducing two design numbers to one family is done with the plant's # `nomenclature.design_number_re`. The offline reader has no knowledge # layer to get that from, so it is stamped here alongside the scope. # Additive: a file written before this key falls back exactly as before. from vibe import knowledge as K cache['nomenclature'] = { 'design_number_re': K.get(know, 'nomenclature.design_number_re', None) if know else None} elements = assemblies(doc) by_name = dict(elements) t_sig = time.time() live = live_signatures(doc, elements, know=know) seconds_signatures = time.time() - t_sig if rebuild_all: cache['pieces'] = {} the_plan = core.plan(cache, live, include=the_scope.includes if targets else None) todo = the_plan['build'] if limit is not None: todo = todo[:limit] records = [] t_build = time.time() ran_out = False for name in todo: if budget_s is not None and (time.time() - t_build) > budget_s: ran_out = True break asm = by_name.get(name) if asm is None: continue sig = live[name][0] records.append(extract(doc, name, asm, sig, know=know)) if on_piece is not None: on_piece(name) seconds_build = time.time() - t_build # Rewriting an unchanged cache costs a full serialise for nothing, and the # commonest press of all is the one where nothing moved. Measured: writing # the 690-piece file back is the single largest slice of a no-change run. changed = bool(records) or bool(the_plan['drop']) cache = core.apply_plan(cache, the_plan, records, built_at=_stamp() if changed else None) # MAINTAIN THE FEATURE VOCABULARY on every refresh (schema 6). The registry # lives on the cache and is NEVER reset by `rebuild_all` -- only # `cache['pieces']` is cleared above -- so `get_or_add` keeps every existing # id stable and only assigns ids for shapes genuinely seen for the first # time. Registering the freshly-built records is sufficient: an unchanged # (fresh) piece's descriptors were registered when it was first built and # its ids never move. `_pack`/`_unpack` carry the registry to disk, so the # offline reader gets the same vocabulary this build produced. if records: core.register_features(cache, records) if changed: core.save(path, cache) report = { 'path': path, 'assemblies': len(elements), 'scope': the_scope.label(), 'targets_wanted': len(the_plan.get('targets') or ()), 'seconds_signatures': round(seconds_signatures, 3), 'seconds_build': round(seconds_build, 3), 'seconds_total': round(time.time() - started, 3), 'built': len(records), 'reused': len(the_plan['fresh']), 'dropped': len(the_plan['drop']), 'stopped_early': ran_out or (limit is not None and len(the_plan['build']) > len(todo)), 'coverage': core.coverage(cache, the_plan), } return cache, report def _stamp(): return time.strftime('%Y-%m-%d %H:%M:%S') def read(doc): """The index as it stands, without rebuilding anything. READ ONLY. For a caller that only wants to search: `refresh` first if you mean to be current, `read` if you mean to be fast. Neither touches the model. """ return core.load(index_path(doc))