sorter: cross-system name adoption needs token containment; sys-retry keeps the higher score

This commit is contained in:
2026-10-06 23:44:00 +02:00
parent 7ccaf75a41
commit a995937c4b
2 changed files with 74 additions and 20 deletions
+42 -12
View File
@@ -273,6 +273,26 @@ def _is_junk_dir(name: str) -> bool:
return " " not in clean and clean in GENERIC_TOKENS
def _token_contained(a_clean: str, b_clean: str) -> bool:
"""True when one name's (stemmed) tokens all appear in the other's.
Guarded like _containment_boost: the smaller side must not consist of
generic army words alone. Used to gate cross-system adoption, where
plain char-ratio lies ("aeldari rangers" vs "dwarf rangers").
"""
ta = set(_stem_tokens(a_clean))
tb = set(_stem_tokens(b_clean))
if not ta or not tb:
return False
small = ta if ta <= tb else (tb if tb <= ta else None)
if small is None:
return False
if len(small) == 1:
# a lone generic word ("guard", "orc") must not carry a match
return next(iter(small)) not in GENERIC_TOKENS
return True
def _apply_entry(
item: SortItem, entry: dict, score: float, kind: str, confidence: str
) -> None:
@@ -324,7 +344,10 @@ def _match_name(
result = None
if system == "Unknown":
full = _best_in_pool(shim, catalog, use_inner=False)
if full and full[0] >= CROSS_SYSTEM_MIN:
if full and full[0] >= CROSS_SYSTEM_MIN and (
_token_contained(clean, full[1]["_clean"])
or any(_token_contained(clean, a)
for a in full[1]["_clean_aliases"])):
result = (full[0], full[1], False)
else:
pool = [e for e in catalog if e.get("system") == system]
@@ -389,6 +412,7 @@ def _rehome_from_dirs(
catalog: list[dict],
threshold: float,
cache: dict,
prefer_dirs_on_tie: bool = False,
):
"""Let matching directory names re-home an item ("deepest match wins").
@@ -408,11 +432,12 @@ def _rehome_from_dirs(
score, entry, weak, real_dir = m
if own and score < own_score:
return None
if own and score == own_score and not weak \
and item.match_kind == "catalog" and item.confidence == "normal":
# exact tie with a confident file match - the file name is the
# deeper side in non-provenance mode, keep it
return None
if own and score == own_score:
# exact tie: the deeper side wins, which is the dir only in
# provenance mode (pack name sits above the inner dirs); in manual
# trees the file name itself is the deepest side - keep it
if not (prefer_dirs_on_tie and not weak):
return None
kind = "weak" if weak else "catalog"
_apply_entry(item, entry, score, kind, "low" if weak else "normal")
return real_dir
@@ -464,15 +489,20 @@ def match_catalog(item: SortItem, catalog: list[dict], threshold: float) -> None
if best is None or best[0] < threshold:
# The keyword tables can lag behind GW's current categorization
# (e.g. units moved between factions); retry within the same
# system before giving up - never across systems.
# system before giving up - never across systems. Keep the
# higher of the two scores (a retry must not downgrade a
# weak-eligible same-faction hit).
sys_pool = [e for e in catalog if e.get("system") == item.system]
best = _best_in_pool(item, sys_pool, use_inner=False)
sys_best = _best_in_pool(item, sys_pool, use_inner=False)
if sys_best is not None and (best is None or sys_best[0] > best[0]):
best = sys_best
if best is None or best[0] < threshold:
best = _best_in_pool(item, sys_pool, use_inner=True)
sys_best = _best_in_pool(item, sys_pool, use_inner=True)
if sys_best is not None and (best is None or sys_best[0] > best[0]):
best = sys_best
# a retry hit from a *different* faction is only trusted when
# it is strong; weak inner-name votes must not re-home the file
if best is not None and best[0] >= threshold \
and best[1].get("faction") != item.faction \
if best is not None and best[1].get("faction") != item.faction \
and best[0] < CROSS_SYSTEM_MIN:
best = None
elif system_known:
@@ -670,7 +700,7 @@ def sort_plan(
d = (root / dest_key).joinpath(*inner_rel.parts[:i + 1])
dirs.append((d, inner_rel.parts[i]))
winner = _rehome_from_dirs(item, dirs, catalog, threshold,
dir_cache)
dir_cache, prefer_dirs_on_tie=True)
if winner is not None:
inner_rel = f.parent.relative_to(winner)
item.target = build_target(item, library, inner_rel=inner_rel)