# -*- coding: utf-8 -*- # Owner, verbatim (2026-08-30): "我想 reinforce 一个 piece,然后 AI 帮我找最 # 合适的几个母版 ... 这几个母版拼吧拼吧能把这个 panel 给拼出来 ... 每一个细节 # 都要照顾到,但是尽量选少的母版,覆盖多的细节。" # Owner, verbatim (2026-08-30), on the measurement unit: "实际测量全部用英寸, # 带两位小数。" # Owner, verbatim (2026-08-30), on exactness ranking, not classifying: # "如果有完全一样的形状,那么这个会在推荐的前侧。如果只是相似,那么就排在后 # 面。" # Owner, verbatim (2026-08-30), on the donor cap: "一个方案,只要找到足够覆盖 # 全部特征的母版,就够了。我期待是 1-4 个,最多不超过 4 个。" """The reinforcing-suggestion matcher: a set-cover solver over opaque features. WHAT THIS ANSWERS ----------------- Will, 2026-08-30: "I want to reinforce a piece, and have AI find me the best-fitting master(s) ... these masters, pieced together, can reconstruct this panel ... every detail has to be accounted for, but pick as few masters as possible, covering the most detail." That is not a similarity ranking (the 08-26 attempt built one of those, and it answers a different question). It is SET COVER: every feature of the target panel must be accounted for by some donor, and the number of donors must be as small as possible. Donors may overlap; overlap is expected and is not a conflict (ruling, 2026-08-30). Coverage is counted BY FEATURE, not by area -- the target's small end features appear identically in both candidate donors of the worked example, so an area weighting would let two big openings drown them out, while counting features keeps each one a thing that must be covered. THE SEAM, AND WHY IT IS HERE ON PURPOSE --------------------------------------- This module never reads a field off a feature record. Not one. Every comparison of two features happens inside a single injected callable, `covers`, whose contract is stated below. Everything else in this file -- coverage, set selection, ranking, tie-breaking -- works on opaque records and on the numbers that callable returns. That boundary is not a scheduling convenience, and it should survive after the index lands. Comparing two precast features is the one part of this problem that needs engineering judgement about geometry: what makes a blockout "the same" blockout, whether a notch at the same station but a different Z band is the same feature, how a through opening compares to a blind one. That judgement is exactly the thing this project has been burned by before -- the shim check that measured the wrong axis, the clash check that reported nothing because a cylinder's edge curves close on themselves. Isolating it in one small callable means it can be argued about, measured, and replaced without touching a line of the selection logic, and means the selection logic can be tested to death on synthetic data with no model, no licence and no Revit. So: do not add a fallback here that peeks at a field "just in case". If the matcher needs something `covers` cannot give it, that is a finding to report, not a thing to work around. THE `covers` CONTRACT --------------------- covers(donor_feature, target_feature) -> None or float None this donor feature does not cover that target feature at all. 0.0 exact match. > 0.0 covers it; the number is how far from exact, smaller being closer. Requirements on any implementation: 1. TOTAL. It must accept any pair of records the caller passes and must not raise. A pair it cannot judge is `None`, not an exception. 2. PURE AND DETERMINISTIC. Same two records, same answer, every time, with no dependence on call order or on any global. The matcher's determinism rests entirely on this. 3. DIRECTIONAL. `covers(a, b)` need not equal `covers(b, a)`. A donor feature may stand in for a target feature without the reverse being true. 4. COMMENSURABLE, IN INCHES. The returned distances are compared and sorted ACROSS DIFFERENT FEATURE PAIRS and across different feature kinds. A distance of 0.4 on a notch must mean the same amount of "off" as 0.4 on a blockout, or the ranking is comparing apples to oranges. ONE UNIT FOR ALL KINDS, and that unit is INCHES (owner's ruling, 2026-08-30: "all actual measurements use inches, to two decimal places"). The owner answered a UNIT question, not a WEIGHTING one -- there is no per-kind weight here and none is invented; an inch off on a blockout is an inch off on a notch. THE STORED GEOMETRY IS STILL FEET and must stay that way: feet is Revit's internal unit and `reinforcecore` caches feet. The production `covers` (`reinforcebind.covers`) is the single place the crossing happens, via `reinforcecore.inches()`. Nothing in THIS module converts anything; it sorts numbers and does not know what they measure. PRECISION IS NOT THE SAME AS DISPLAY. Two-decimal inches is 0.01", an order of magnitude finer than any tolerance this tool uses (dimensions 3/8", interference 1/8"). The production `covers` therefore returns distances QUANTIZED TO 1/8 in so the ranking cannot be steered by differences the measurement cannot support. This module imposes nothing: it will rank whatever grain it is handed. See `reinforcebind.GRAIN_IN`. 5. ABSOLUTE, NOT PROPORTIONAL. Positions compare as absolute distance from the start of the panel, never as a fraction of panel length. Measured: the two big openings of the worked example land at the identical feet-from-start rectangle on panels of two different lengths, and a proportional comparison would have missed the match entirely. Nothing in this module normalises by length, and nothing in `covers` should either. 6. TWO-DIMENSIONAL. The same measurement showed two donor features sharing an x-range while sitting in different Z bands. A single-axis comparison calls those the same feature. They are not. 7. CHEAP. It is called exactly once per (donor, donor feature, target feature) triple -- the matcher builds the whole comparison table up front and never calls it again -- but that is still on the order of a few thousand calls per run. Note what the contract does NOT contain: a similarity threshold. There is none, anywhere in this module, by ruling -- "if there is a completely identical shape, that ranks at the front of the recommendation; if it is only similar, it goes further back." Exactness RANKS, it does not CLASSIFY. The matcher never asks "are these two the same?", only "which of these is closer?", which is arithmetic rather than an unfalsifiable judgement call. The one classification that does exist -- covers-at-all vs. does-not -- lives inside `covers`, where the geometry knowledge is, and it is the only place it may live. WHAT THE CALLER PASSES ---------------------- suggest(target_features, donors, covers, ...) `target_features` is a sequence of opaque feature records. `donors` is a sequence of `(key, feature_list)` pairs -- the key is any hashable value that identifies the donor to the caller (an element id, a mark, a tuple); this module treats it as a label and nothing else. Feature records are whatever the index produces; this module only ever hands them to `covers`. SAME ELEVATION, RULED 2026-09-02 -- superseding the note this module used to carry about donor order being the lever for this. WP425 ranked five donors in a dead tie on every criterion below, and WP234 -- which shares WP425's own building elevation -- printed last, by mark order, because nothing here knew elevation existed: owner, "WP234明明在同一个elevation,为什么排名没有提前?" Approved as briefed the same day ("同意,就这么做"): elevation is an AFFINITY, the LAST tie-break -- after fewest donors, most exact, most reviewed and total distance, before mark order -- never a filter, exactly the shape Will's standing correction on donor order already described (a cross-design or cross-fit exclusion is not what this is; a better-shaped cross-elevation donor still wins). It is now an explicit ranking term, `same_elevation`, rather than a reordering of the input list: see `Proposal.same_elevation` and the ranking notes below. Donor input order is still the true last resort -- it decides a tie elevation cannot, same as it always has. WHY A TERM AND NOT THE ORDERING TRICK THIS MODULE USED TO SUGGEST. Sorting same-elevation donors to the front of the input list would have worked for the reported case (a single donor covers everything, so the tie-break really is just "which donor is listed first"), but it hides the reason inside sort position -- a report that wants to say WHY WP234 won would have to recompute the same ordering itself. An explicit term, exactly like `masters` below, is a fact the caller can read straight off the winning `Proposal` and print. AT MOST FOUR DONORS IN A PROPOSAL --------------------------------- Owner's ruling, 2026-08-30: "for one proposal, it is enough to find masters that cover all the features between them. I'd expect 1-4, never more than 4." `DEFAULT_MAX_DONORS = 4`, and it is a HARD cap on the donors WITHIN one proposal. Do not confuse it with `max_proposals`, which is a different axis entirely: `max_proposals` is how many alternative answers get printed, `max_donors` is how big any one answer may be. WHAT HAPPENS WHEN FOUR CANNOT COVER EVERYTHING -- INFERRED, NOT STATED. The owner ruled the cap; he did not rule the overflow. The reading implemented here is that the cap is hard: the search returns the best partial cover of four donors and NAMES what it could not reach, rather than relaxing to a fifth. That is already this module's established behaviour for the other reason a feature goes uncovered (nobody has it at all), it is the answer the first live run gave, and a suggestion that quietly grew to six donors would not be the thing that was asked for. The two causes are reported separately and never merged -- see `CAPPED` below. Everything here is IronPython 2.7 compatible: no f-strings, no pathlib, no modern typing, no Python 3 `functools`. """ # -------------------------------------------------------------------------- # Outcome vocabulary. "Could not judge" and "judged and found nothing" are # different answers and must read differently -- the standing convention on # this project, learned from a check that reported a clean result when it had # in fact measured nothing. # -------------------------------------------------------------------------- NO_DONORS = 'no_donors' # nothing eligible was offered; nothing was judged NO_COVERAGE = 'no_coverage' # donors were judged; not one covers any feature PARTIAL = 'partial' # some feature no donor covers, in any combination CAPPED = 'capped' # every feature IS coverable; four donors cannot reach them all COMPLETE = 'complete' # every target feature is covered # PARTIAL and CAPPED are different findings and must never be collapsed. # PARTIAL means nobody in the pool has that feature -- the engineer details it # from scratch. CAPPED means somebody does have it, but not within the four # donors a proposal is allowed; the engineer can go and find that donor # himself. When both are true the status is PARTIAL (the harder fact) and # `Suggestion.capped_out` still names the second set. # `suggest` returns None -- not an empty result -- when the input cannot be # judged at all (no target features). An empty `proposals` with a status is # always the result of an actual judgement. DEFAULT_MAX_PROPOSALS = 5 # How many donors ONE proposal may contain. The owner's number. This is not # `max_proposals` -- see the module docstring; conflating the two axes gives # you either one giant answer or four tiny ones. DEFAULT_MAX_DONORS = 4 class Proposal(object): """One set of donors, and what each target feature got from it.""" def __init__(self, donor_indexes, donor_keys, assignment, uncovered, master_keys=(), same_elevation_keys=()): self.donor_indexes = tuple(donor_indexes) self.donors = tuple(donor_keys) # Which of THIS proposal's donors are reviewed templates, in the same # order as `donors`. A label and a tie-break, never a filter. self.masters = tuple(k for k in self.donors if k in set(master_keys)) self.master_count = len(self.masters) # Which of THIS proposal's donors share the target's building # elevation, same shape as `masters` and ranked directly below it -- # see the module docstring's 2026-09-02 note and the ranking # comment above `_proposal_sort_key`. A label and a tie-break, # never a filter: a donor whose elevation is unknown, or differs, is # simply absent from `same_elevation_keys` and this stays empty. self.same_elevation = tuple( k for k in self.donors if k in set(same_elevation_keys)) self.elevation_count = len(self.same_elevation) # assignment[i] is None (feature i uncovered by this proposal) or a # 3-tuple (donor_index, donor_feature_index, distance). self.assignment = tuple(assignment) self.uncovered = tuple(uncovered) dists = [a[2] for a in self.assignment if a is not None] # Ascending: the ranking key. See `_proposal_sort_key`. self.distances = tuple(sorted(dists)) self.exact_count = len([d for d in self.distances if d == 0.0]) self.covered_count = len(self.distances) self.total_distance = sum(self.distances) if self.distances else 0.0 self.is_exact = (self.covered_count > 0 and self.exact_count == self.covered_count) def donor_for(self, feature_index): """(donor_key, donor_feature_index, distance) for one target feature. None when this proposal does not cover that feature. """ got = self.assignment[feature_index] if got is None: return None return (self.donors[self.donor_indexes.index(got[0])], got[1], got[2]) def is_master(self, donor_key): return donor_key in self.masters def __repr__(self): return ('').format( list(self.donors), self.exact_count, self.covered_count, self.master_count, self.elevation_count, self.total_distance) class Suggestion(object): """The whole answer: ranked proposals plus what could not be covered.""" def __init__(self, status, proposals, uncovered, donor_count, target_feature_count, donors_needed, cover_count, donor_cap=None, capped_out=()): self.status = status self.proposals = tuple(proposals) # Target feature indexes that NO donor covers, in any combination. self.uncovered = tuple(uncovered) # The donor cap this answer was found under, and the features that # SOME donor covers but the best proposal could not reach inside it. # Disjoint from `uncovered` by construction: a feature nobody has is # not a feature the cap kept out. self.donor_cap = donor_cap self.capped_out = tuple(capped_out) self.donor_count = donor_count self.target_feature_count = target_feature_count # Size of the smallest cover FOUND. Not proven minimal -- the search # is greedy; see the note above `_greedy_covers`. There is no # `minimal` flag because the answer is always the same: no. self.donors_needed = donors_needed self.cover_count = cover_count # how many covers of that size exist @property def best(self): return self.proposals[0] if self.proposals else None def summary(self): """One sentence a button can print without knowing the vocabulary.""" n = self.target_feature_count if self.status == NO_DONORS: return ('No eligible donors were offered, so nothing was judged. ' '{0} target feature(s) unexamined.'.format(n)) if self.status == NO_COVERAGE: return ('Judged {0} donor(s): not one of them covers any of the ' '{1} target feature(s).'.format(self.donor_count, n)) k = self.donors_needed if self.status == CAPPED: return ('At most {0} donor(s) per proposal; {1} of them cover {2} ' 'of {3} target feature(s). {4} feature(s) are covered by ' 'some donor but not by any {0}-donor set: {5}.'.format( self.donor_cap, k, n - len(self.capped_out), n, len(self.capped_out), list(self.capped_out))) if self.status == COMPLETE: return ('{0} donor(s) cover all {1} target feature(s); {2} such ' 'cover(s) found, {3} shown.'.format( k, n, self.cover_count, len(self.proposals))) tail = '' if self.capped_out: tail = (' A further {0} feature(s) are covered by some donor but ' 'not within the {1}-donor cap: {2}.'.format( len(self.capped_out), self.donor_cap, list(self.capped_out))) return ('{0} donor(s) cover {1} of {2} target feature(s); {3} ' 'feature(s) no donor covers at all: {4}.{5}'.format( k, n - len(self.uncovered) - len(self.capped_out), n, len(self.uncovered), list(self.uncovered), tail)) def __repr__(self): return ''.format( self.status, self.donor_count, self.target_feature_count, len(self.proposals)) # -------------------------------------------------------------------------- # The coverage table. This is the only place `covers` is called. # -------------------------------------------------------------------------- def _coverage(target_features, donors, covers): """Build, for every donor, the best it can do on every target feature. Returns (best, masks) where best[d][i] = None, or (donor_feature_index, distance) masks[d] = bitmask over target feature indexes donor d covers A donor may carry several features that cover one target feature (two notches near one blockout, say). The best one wins: lowest distance, and among equal distances the lowest donor-feature index, so the choice does not depend on iteration luck. An exception raised by `covers` is NOT caught. A comparison that blows up is a bug in the comparison, and swallowing it here would turn it into a quiet "this donor covers nothing" -- a confident wrong answer, which is the exact failure mode this project exists to prevent. A contract-breaking return value (negative, NaN, not a number) is raised on, naming the pair, for the same reason. """ best = [] masks = [] for d_index in range(len(donors)): donor_features = donors[d_index][1] or [] row = [None] * len(target_features) mask = 0 for f_index in range(len(donor_features)): df = donor_features[f_index] for t_index in range(len(target_features)): got = covers(df, target_features[t_index]) if got is None: continue dist = _checked_distance(got, d_index, f_index, t_index) current = row[t_index] if current is None or dist < current[1]: row[t_index] = (f_index, dist) mask |= (1 << t_index) best.append(row) masks.append(mask) return best, masks def _checked_distance(value, d_index, f_index, t_index): try: dist = float(value) except (TypeError, ValueError): raise ValueError( 'covers() returned a non-number {0!r} for donor {1} feature {2} ' 'vs target feature {3}; the contract is None or a float >= 0' .format(value, d_index, f_index, t_index)) if dist != dist: # NaN, which compares false with itself raise ValueError( 'covers() returned NaN for donor {0} feature {1} vs target ' 'feature {2}'.format(d_index, f_index, t_index)) if dist < 0.0: raise ValueError( 'covers() returned a negative distance {0!r} for donor {1} ' 'feature {2} vs target feature {3}; 0.0 is exact and there is ' 'nothing closer than exact'.format(value, d_index, f_index, t_index)) return dist # -------------------------------------------------------------------------- # Set cover, by greedy with restarts. # # WHY GREEDY, AND WHERE IT IS WRONG. # # Set cover is NP-hard, so this does not solve it; it approximates it, and the # approximation is chosen deliberately over an exhaustive search. A panel # carries on the order of five to ten features, an exhaustive search over # subsets of that universe would also be affordable, and the honest reason to # take greedy anyway is that it is twenty lines instead of a hundred, has no # recursion, no node budget and no depth cap to get wrong, and can be read in # one sitting by whoever inherits it. If evidence ever turns up that it is # picking badly on real panels, replacing it is a local change: everything # else in this module works off the donor sets it returns. # # WHERE IT CAN RETURN A WORSE ANSWER THAN OPTIMAL. Greedy takes the donor that # covers the most still-uncovered features, and that first bite can be the # wrong one. Nine features 1..9, and donors # D = {3, 6, 9} A = {1, 2, 3} B = {4, 5, 6} C = {7, 8, 9} # Plain greedy takes D first (it is tied for the biggest bite and happens to # be listed first), and then still needs all three of A, B and C to finish: # four donors where {A, B, C} covers everything with three. In general greedy # can return a factor of ln(n) more donors than the minimum, and a suggestion # that says "you need four donors" when three would do is exactly the kind of # wrong that gets a tool stopped being used. # # Restarting from every possible first donor (below) removes that whole family # of failures -- the walk seeded from A finds {A, B, C} -- and at five-to-ten # features it is hard to construct a case that beats it. It is still not a # proof, there is no guarantee here, and nothing in the result claims one. If # a real panel ever shows this picking badly, swap `_greedy_covers` for an # exhaustive search over subsets: the universe is the target's features, which # is small enough that a complete search is affordable, and everything else in # this module works off the donor sets that function returns. # # THE RESTARTS. Plain greedy gives one answer, and one answer is not a # suggestion -- the engineer needs to see the second and third options and be # able to disagree with the ranking. So greedy is run once per donor, each run # forced to start from that donor and then filling greedily; the resulting # covers are deduplicated, the smallest size wins (fewest donors is the # objective), and exactness ranks what is left. It costs D passes over D # donors, which at these sizes is nothing. # -------------------------------------------------------------------------- def _popcount(mask): n = 0 while mask: mask &= mask - 1 n += 1 return n def _greedy_fill(masks, full, chosen, covered, max_donors=None): """Extend `chosen` greedily until nothing more can be added. Takes the donor adding the most still-uncovered features; ties go to the lowest donor index, so the walk is fixed and the whole module stays reproducible run to run. `max_donors` stops the walk once the proposal is that big, even if features remain. That is the owner's cap, and it is enforced HERE rather than by trimming a finished cover -- trimming would drop whichever donor happened to be last, while stopping early means every donor in the four was chosen for being the biggest remaining bite. """ chosen = list(chosen) while covered != full: if max_donors is not None and len(chosen) >= max_donors: break pick = None gain = 0 for d in range(len(masks)): if d in chosen: continue added = _popcount(masks[d] & ~covered) if added > gain: pick, gain = d, added if pick is None: break chosen.append(pick) covered |= masks[pick] return tuple(sorted(chosen)), covered def _greedy_covers(masks, full, max_donors=None): """Candidate covers: greedy restarted from every donor that covers anything. Returns (k, covers, reached) -- the smallest cover size found among the covers that get FURTHEST, every distinct cover of that size, and the union of what they reach. `k` is None and `covers` empty when no donor covers anything. REACH BEFORE SIZE, and only because of the cap. Without a cap every greedy walk ends at `full` (it stops only when no donor adds anything, and every feature in `full` has some donor), so all candidates reach equally far and this ordering is the old "fewest donors wins" unchanged. With a cap the walks can end short, and then a two-donor set covering four features must not beat a four-donor set covering eighteen: covering the target is the objective, fewest donors is the tie-break under it. """ if full == 0: return None, [], 0 seen = {} for d in range(len(masks)): if not masks[d]: continue cover, covered = _greedy_fill(masks, full, (d,), masks[d], max_donors) seen[cover] = covered if not seen: return None, [], 0 reach = max([_popcount(m) for m in seen.values()]) best = [c for c in seen if _popcount(seen[c]) == reach] k = min([len(c) for c in best]) keep = sorted([c for c in best if len(c) == k]) reached = 0 for c in keep: reached |= seen[c] return k, keep, reached # -------------------------------------------------------------------------- # Ranking. # # Fewest donors is the objective and it is not negotiable; exactness only # breaks ties between covers of equal size. Within one size, the question is # how per-feature distances combine into something comparable. # # NOT BY SUM. Summing loses precisely the information the owner's ruling is # about. A cover that is exact on four features and 2.0 off on the fifth, and # a cover that is 0.4 off on all five, have the same sum of 2.0 -- so a sum # calls them equal, when the owner said plainly that the exact shapes go in # front: "如果有完全一样的形状,那么这个会在推荐的前侧。如果只是相似,那么就排 # 在后面。" # # THE KEY IS THE SORTED DISTANCE VECTOR, ASCENDING, COMPARED LEXICOGRAPHICALLY. # Sort each cover's per-feature distances smallest-first and compare position # by position. Since exact matches are 0.0 and sort to the front, this makes # "has more exact features" the first thing that decides, then "its next-best # feature is closer", and so on down. It is the owner's rule written as # arithmetic, and it needs no weights, no normalisation and no threshold. # # WHAT THAT COSTS, STATED PLAINLY. It is a strict preference for exactness # over evenness, and it will sometimes look odd. Cover A = [0.0, 5.0] beats # cover B = [0.01, 0.02], because A is exact on one feature and B is exact on # none, even though B is obviously the better fit overall. That is the chosen # behaviour, not an oversight: it is what "完全一样的形状排在前面" means, and # the alternative (rank by the worst feature, i.e. compare the vectors # descending) is the "mediocre everywhere wins" rule the brief warns against. # `Proposal.total_distance` carries the sum anyway, so a UI can show the # overall figure next to the ranking without it steering the ranking. # # THE MASTER TIE-BREAK, AND WHERE IT SITS. # # Some donors are marked MASTER by hand in the model, and the owner's ruling # (2026-08-30) is that those have been REVIEWED: "某一些模板带有 MASTER,给这些 # MASTER 当母版的优先级提升到最高。他们是被审核过的。" It is the only provenance # signal this feature has -- `TICKET_IS_REINFORCED` says somebody drew bars, # not that anybody checked them. # # Asked directly how it interacts with exactness -- a loosely-fitting MASTER # against a perfectly exact non-MASTER -- the owner ruled: 拟合更好的排在前面. # FIT WINS. So MASTER sits BELOW the distance vector and ABOVE input order. # It cannot reorder anything the distance vector has already separated; it # decides only between covers the geometry has declared indistinguishable, # and that is exactly where "who checked this?" is the right question. # # Counted, not flagged: a cover with two masters ranks above a cover with one, # because more of it has been reviewed. Negated in the key so that MORE sorts # FIRST while everything else still sorts ascending. # # THE SAME-ELEVATION TIE-BREAK, AND WHERE IT SITS. # # Owner, 2026-09-02, on WP425: five donors tied on every criterion above -- # 1 donor, 5/5 covered, 4 exact, 0 reviewed, 0.04 in total distance, all # five identical -- and the tie fell through to mark order, so WP133 # printed first though WP234 shares WP425's own building elevation: # "WP234明明在同一个elevation,为什么排名没有提前?" Ruled the same day, # approved as briefed ("同意,就这么做"): elevation is an AFFINITY, and it # sits LAST, below MASTER -- after fewest donors, most exact, most reviewed # and total distance, and above only the donors' input order. It is ranked # below MASTER on the same reasoning MASTER is ranked below the distance # vector: a fact about WHERE a donor sits does not say whether it is a good # template, and a piece somebody has actually reviewed is worth more than # one that merely happens to be on the right facade. Nothing here reorders # what MASTER or the distance vector have already separated -- it only # decides among covers every criterion above it left dead level, exactly # the case WP425 hit. # # Counted the same way `master_count` is, for the same reason: a cover with # two same-elevation donors ranks above a cover with one. Negated in the key # so MORE sorts FIRST. # # UNKNOWN IS NEVER A MATCH. `same_elevation_keys` (built by # `reinforcebind.propose`) never contains a donor whose own elevation could # not be read, and it is empty outright when the TARGET's elevation could # not be read -- an unread elevation is could-not-judge, not a wildcard that # matches everything. See `reinforcecore.elevation_key`. # # LAST TIE-BREAK: the donors' input order. Two covers with identical distance # vectors, master counts and elevation counts are, on the evidence available # here, equally good, and the matcher has nothing further to say. Falling # back to input order makes the answer reproducible -- an engineer who runs # it twice and gets two different answers will not trust it a third time -- # and it is now genuinely the LAST word: mark order, nothing more, no # remaining lever a caller is expected to pull by reordering donors. # -------------------------------------------------------------------------- def _proposal_sort_key(proposal): # `-covered_count` first, and it is a NO-OP everywhere except under the # donor cap: uncapped, every candidate cover reaches the same feature set, # so this term is constant and the ranking is exactly what it always was # (fewest donors, then the distance vector, then MASTER, then same # elevation, then input order). Capped, it is the difference between # "eighteen of nineteen" and "four of nineteen, but with only two # donors". return (-proposal.covered_count, len(proposal.donor_indexes), proposal.distances, -proposal.master_count, -proposal.elevation_count, proposal.donor_indexes) def _build_proposal(donor_indexes, donors, best, target_count, master_keys=(), same_elevation_keys=()): assignment = [] uncovered = [] for t_index in range(target_count): pick = None for d_index in donor_indexes: got = best[d_index][t_index] if got is None: continue if pick is None or got[1] < pick[2]: pick = (d_index, got[0], got[1]) if pick is None: uncovered.append(t_index) assignment.append(pick) keys = [donors[d][0] for d in donor_indexes] return Proposal(donor_indexes, keys, assignment, uncovered, master_keys, same_elevation_keys) # -------------------------------------------------------------------------- # The entry point. # -------------------------------------------------------------------------- def suggest(target_features, donors, covers, max_proposals=DEFAULT_MAX_PROPOSALS, masters=None, max_donors=DEFAULT_MAX_DONORS, same_elevation=None): """Rank the smallest sets of donors that between them cover the target. target_features -- sequence of opaque feature records for the panel being reinforced. Order matters only as identity: every index in the result refers back into this sequence. donors -- sequence of (key, feature_list) pairs. `key` is opaque and is only ever used as a label and, at the very end, as the source of the input order that breaks a dead-even tie. See the module docstring on why that is a lever. covers -- the comparison callable. Contract in the module docstring. This is the only thing in this module that may read a feature's fields. masters -- the donor keys that are REVIEWED templates, as any iterable of keys. Purely a tie-break, ranked below the distance vector: see the ranking notes above. Keys not present among `donors` are ignored, and the default of none is a matcher that ranks exactly as it did before. same_elevation -- the donor keys that share the TARGET's building elevation, as any iterable of keys. Purely a tie-break, ranked below `masters` and above input order -- see the ranking notes above and `Proposal.same_elevation`. Keys not present among `donors` are ignored; the default of none ranks exactly as before this term existed. max_proposals -- how many ranked proposals to return. Five by default: enough that the engineer can see the second and third options and disagree with the ranking, few enough to read. `Suggestion.cover_count` reports how many there actually were. max_donors -- how many donors ONE proposal may contain. FOUR by the owner's ruling, and a different axis from `max_proposals`: this bounds the size of an answer, that bounds the number of answers. `None` removes the cap. When four donors cannot reach every coverable feature, the search returns the best four and names what it could not reach in `Suggestion.capped_out`, with status `CAPPED`. It does NOT relax to a fifth. That overflow rule is inferred, not ruled -- see the module docstring. Returns a `Suggestion`, or None when the input cannot be judged at all -- that is, when there are no target features. No features means there is nothing to cover and no basis for any suggestion; that is "could not judge", and it must not be confused with the empty-but-real results this returns when donors were examined and found wanting. `Suggestion.status` separates those: NO_DONORS (nothing eligible was offered, so nothing was judged) from NO_COVERAGE (donors were examined; none covers anything). PARTIAL COVERS ARE RETURNED, deliberately. When some feature is covered by no donor at all, "nothing found" and "here is a cover of nine of your ten features, and this is the one nobody has" are very different answers, and only the second is any use: it tells the engineer which detail they have to reinforce by hand, which is a much shorter job than the whole panel. So the search runs over the coverable features, the proposals cover all of those, and `Suggestion.uncovered` names the rest by index. `status` is PARTIAL so no caller can mistake it for a complete answer. """ if not callable(covers): raise ValueError('suggest() needs a covers(donor_feature, ' 'target_feature) callable; got {0!r}'.format(covers)) if not target_features: return None # could not judge -- see docstring target_count = len(target_features) all_features = tuple(range(target_count)) donors = list(donors or []) master_keys = frozenset(masters or ()) same_elevation_keys = frozenset(same_elevation or ()) if not donors: return Suggestion(NO_DONORS, [], all_features, 0, target_count, None, 0) best, masks = _coverage(target_features, donors, covers) full = 0 for m in masks: full |= m if full == 0: return Suggestion(NO_COVERAGE, [], all_features, len(donors), target_count, None, 0) uncovered = tuple([i for i in all_features if not (full & (1 << i))]) k, cover_sets, _reached = _greedy_covers(masks, full, max_donors) proposals = [_build_proposal(cs, donors, best, target_count, master_keys, same_elevation_keys) for cs in cover_sets] proposals.sort(key=_proposal_sort_key) # What the CAP kept out, read off the proposal that will be shown first # rather than off the union: the engineer acts on the answer he is given. # A feature nobody has is never counted here -- that is `uncovered`, a # different finding with a different remedy. nobody = set(uncovered) capped_out = tuple([i for i in (proposals[0].uncovered if proposals else ()) if i not in nobody]) if uncovered: status = PARTIAL elif capped_out: status = CAPPED else: status = COMPLETE return Suggestion(status, proposals[:max_proposals], uncovered, len(donors), target_count, k, len(cover_sets), donor_cap=max_donors, capped_out=capped_out) # -------------------------------------------------------------------------- # A reference `covers`, for tests and for reading. # # THIS IS NOT THE PRODUCTION COMPARISON and must not be mistaken for one. It # makes no claim about the index's feature schema -- it operates on a bare # 4-tuple chosen because it is the smallest thing that can express the worked # example, and it encodes no precast engineering judgement whatsoever. Its job # is to be a fixed, legible stand-in so the selection logic above can be tested # exhaustively before the real comparison exists, and to be an executable # statement of the contract. # -------------------------------------------------------------------------- def rect_distance(donor_rect, target_rect): """Reference `covers` over rectangles `(x0, x1, y0, y1)`, unit-agnostic. Coordinates are absolute, measured from the start of the piece -- never a fraction of its length. Covers-at-all: each rectangle must contain the other's centre. That is a two-dimensional test on purpose, and it is why it is written as a mutual condition rather than a range overlap: in the worked example two donor features share an x-range with a target feature while sitting in a different Z band, and a single-axis check calls those the same feature. They are not. Distance: the sum of the four absolute corner offsets, IN WHATEVER UNIT THE COORDINATES ARE IN. Zero for identical rectangles, and one unit for every kind of feature, as the contract requires. DELIBERATELY UNIT-AGNOSTIC, unlike the production comparison. This is a stand-in over bare tuples for testing the selection logic; it declares no unit because its inputs declare none, and it applies no quantization. The production `covers` -- `reinforcebind.covers` -- reads feet off index records and returns INCHES on a 1/8 in grain, which is where the owner's unit ruling actually lands. Do not read this function as the unit contract; read requirement 4 above. """ try: dx0, dx1, dy0, dy1 = [float(v) for v in donor_rect] tx0, tx1, ty0, ty1 = [float(v) for v in target_rect] except (TypeError, ValueError): return None # not judgeable -> not covered def contains(x0, x1, y0, y1, cx, cy): return x0 <= cx <= x1 and y0 <= cy <= y1 if not contains(dx0, dx1, dy0, dy1, (tx0 + tx1) / 2.0, (ty0 + ty1) / 2.0): return None if not contains(tx0, tx1, ty0, ty1, (dx0 + dx1) / 2.0, (dy0 + dy1) / 2.0): return None return (abs(dx0 - tx0) + abs(dx1 - tx1) + abs(dy0 - ty0) + abs(dy1 - ty1))