WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,805 @@
|
||||
# -*- 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 ('<Proposal {0} exact={1}/{2} master={3} elevation={4} '
|
||||
'total={5:.4f}>').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 '<Suggestion {0} donors={1} features={2} proposals={3}>'.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))
|
||||
Reference in New Issue
Block a user