sorter: probe votes on full inner paths (dir names count); no single-token containment boost for inner votes; catalog class wins on unit adoption; confidence copied from probe
This commit is contained in:
+54
-18
@@ -154,14 +154,15 @@ def classify_item(item: SortItem) -> None:
|
|||||||
if hit:
|
if hit:
|
||||||
item.system, item.faction = hit
|
item.system, item.faction = hit
|
||||||
item.klass = _match_class(item.clean) or (
|
item.klass = _match_class(item.clean) or (
|
||||||
_match_class(_clean_name(" ".join(item.inner_names))) if item.inner_names else None
|
_match_class(_clean_inner(" ".join(item.inner_names)))
|
||||||
|
if item.inner_names else None
|
||||||
)
|
)
|
||||||
|
|
||||||
if item.faction == UNSORTED and item.inner_names:
|
if item.faction == UNSORTED and item.inner_names:
|
||||||
# hash-named archives: vote on the inner names' classification
|
# hash-named archives: vote on the inner names' classification
|
||||||
votes: dict[tuple[str, str], int] = {}
|
votes: dict[tuple[str, str], int] = {}
|
||||||
for inner in item.inner_names:
|
for inner in item.inner_names:
|
||||||
ihit = _match_faction(_clean_name(inner))
|
ihit = _match_faction(_clean_inner(inner))
|
||||||
if ihit:
|
if ihit:
|
||||||
votes[ihit] = votes.get(ihit, 0) + 1
|
votes[ihit] = votes.get(ihit, 0) + 1
|
||||||
if votes:
|
if votes:
|
||||||
@@ -198,20 +199,24 @@ def load_catalog(path: Path,
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def _best_ratio(clean: str, candidates: list[str]) -> float:
|
def _best_ratio(
|
||||||
|
clean: str, candidates: list[str], allow_single: bool = True
|
||||||
|
) -> float:
|
||||||
"""Best sequence ratio, pruned with cheap upper-bound checks.
|
"""Best sequence ratio, pruned with cheap upper-bound checks.
|
||||||
|
|
||||||
lru_cached because the same (name, entry) pair is scored over and
|
lru_cached because the same (name, entry) pair is scored over and
|
||||||
over across thousands of files.
|
over across thousands of files.
|
||||||
"""
|
"""
|
||||||
return _best_ratio_cached(clean, tuple(candidates))
|
return _best_ratio_cached(clean, tuple(candidates), allow_single)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
@lru_cache(maxsize=None)
|
||||||
def _best_ratio_cached(clean: str, candidates: tuple[str, ...]) -> float:
|
def _best_ratio_cached(
|
||||||
|
clean: str, candidates: tuple[str, ...], allow_single: bool = True
|
||||||
|
) -> float:
|
||||||
best = 0.0
|
best = 0.0
|
||||||
toks = set(_stem_tokens(clean))
|
toks = set(_stem_tokens(clean))
|
||||||
best = _containment_boost(best, toks, candidates)
|
best = _containment_boost(best, toks, candidates, allow_single)
|
||||||
for c in candidates:
|
for c in candidates:
|
||||||
sm = difflib.SequenceMatcher(None, clean, c)
|
sm = difflib.SequenceMatcher(None, clean, c)
|
||||||
if sm.real_quick_ratio() < best or sm.quick_ratio() < best:
|
if sm.real_quick_ratio() < best or sm.quick_ratio() < best:
|
||||||
@@ -230,13 +235,18 @@ def _stem_tokens(clean: str) -> list[str]:
|
|||||||
for t in clean.split()]
|
for t in clean.split()]
|
||||||
|
|
||||||
|
|
||||||
def _containment_boost(best: float, toks: set[str], candidates: tuple[str, ...]) -> float:
|
def _containment_boost(
|
||||||
|
best: float, toks: set[str], candidates: tuple[str, ...],
|
||||||
|
allow_single: bool = True,
|
||||||
|
) -> float:
|
||||||
"""Token-containment bonus: a name whose tokens all appear in the
|
"""Token-containment bonus: a name whose tokens all appear in the
|
||||||
other (or vice versa) matches strongly even when string ratio is low
|
other (or vice versa) matches strongly even when string ratio is low
|
||||||
("telemon" vs "Telemon Heavy Dreadnought"). Guarded: needs >= 2
|
("telemon" vs "Telemon Heavy Dreadnought"). Guarded: needs >= 2
|
||||||
tokens, or a single rare one (>= 7 chars), and the smaller side must
|
tokens, or a single rare one (>= 7 chars), and the smaller side must
|
||||||
not consist of generic army words ("chaos lord" is contained in half
|
not consist of generic army words ("chaos lord" is contained in half
|
||||||
the catalog) so "head" or "guard" never trigger it."""
|
the catalog) so "head" or "guard" never trigger it. Inner-name votes
|
||||||
|
pass allow_single=False: one short inner file name ("lieutenant")
|
||||||
|
must not claim a whole pack."""
|
||||||
if not toks:
|
if not toks:
|
||||||
return best
|
return best
|
||||||
for c in candidates:
|
for c in candidates:
|
||||||
@@ -246,7 +256,7 @@ def _containment_boost(best: float, toks: set[str], candidates: tuple[str, ...])
|
|||||||
small = toks if toks <= ctoks else (ctoks if ctoks <= toks else None)
|
small = toks if toks <= ctoks else (ctoks if ctoks <= toks else None)
|
||||||
if small is None or any(t in GENERIC_TOKENS for t in small):
|
if small is None or any(t in GENERIC_TOKENS for t in small):
|
||||||
continue
|
continue
|
||||||
if len(small) >= 2 or len(next(iter(small))) >= 7:
|
if len(small) >= 2 or (allow_single and len(next(iter(small))) >= 7):
|
||||||
if best < 0.9:
|
if best < 0.9:
|
||||||
best = 0.9
|
best = 0.9
|
||||||
break
|
break
|
||||||
@@ -278,9 +288,18 @@ _JUNK_DIRS = frozenset({
|
|||||||
"model", "output", "exports", "release", "releases", "license",
|
"model", "output", "exports", "release", "releases", "license",
|
||||||
"readme", "sources", "docs", "textures", "new", "old", "final",
|
"readme", "sources", "docs", "textures", "new", "old", "final",
|
||||||
"fixed", "print", "prints", "plates", "plate", "assembly", "test",
|
"fixed", "print", "prints", "plates", "plate", "assembly", "test",
|
||||||
|
"characters", "character",
|
||||||
}) # noqa: C405
|
}) # noqa: C405
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_inner(name: str) -> str:
|
||||||
|
"""Clean an archive-internal (relative) path: dir names join the vote
|
||||||
|
("2E Catachany fellaz/Catachan/Jungle Fighters/x.stl" -> "2e catachany
|
||||||
|
fellaz catachan jungle fighters x") - the file stem alone loses the
|
||||||
|
archive's own folder structure."""
|
||||||
|
return _clean_name(name.replace("/", " ").replace("\\", " "))
|
||||||
|
|
||||||
|
|
||||||
def _is_junk_dir(name: str) -> bool:
|
def _is_junk_dir(name: str) -> bool:
|
||||||
"""True for directory names that must never be match candidates."""
|
"""True for directory names that must never be match candidates."""
|
||||||
clean = _clean_name(name)
|
clean = _clean_name(name)
|
||||||
@@ -309,6 +328,14 @@ def _token_contained(a_clean: str, b_clean: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _contained_somewhere(item: SortItem, entry: dict) -> bool:
|
||||||
|
"""Token containment between the entry (or an alias) and the outer
|
||||||
|
name or any inner (relative-path) name."""
|
||||||
|
cands = [entry["_clean"], *entry["_clean_aliases"]]
|
||||||
|
probes = [item.clean, *[_clean_inner(n) for n in item.inner_names[:10]]]
|
||||||
|
return any(_token_contained(p, c) for p in probes for c in cands)
|
||||||
|
|
||||||
|
|
||||||
def _shares_rare_token(a_clean: str, b_clean: str) -> bool:
|
def _shares_rare_token(a_clean: str, b_clean: str) -> bool:
|
||||||
"""True when two cleaned names share at least one non-generic token
|
"""True when two cleaned names share at least one non-generic token
|
||||||
(>= 3 chars). Gates the weak tier: a sub-threshold hit with zero
|
(>= 3 chars). Gates the weak tier: a sub-threshold hit with zero
|
||||||
@@ -520,9 +547,10 @@ def _best_in_pool(
|
|||||||
cands = [entry["_clean"], *entry["_clean_aliases"]]
|
cands = [entry["_clean"], *entry["_clean_aliases"]]
|
||||||
score = _best_ratio(item.clean, cands)
|
score = _best_ratio(item.clean, cands)
|
||||||
if use_inner and item.inner_names:
|
if use_inner and item.inner_names:
|
||||||
inner_clean = [_clean_name(n) for n in item.inner_names[:10]]
|
inner_clean = [_clean_inner(n) for n in item.inner_names[:10]]
|
||||||
for c in inner_clean:
|
for c in inner_clean:
|
||||||
score = max(score, _best_ratio(c, cands) * 0.95) # inner slightly discounted
|
# no single-token containment boost for inner votes
|
||||||
|
score = max(score, _best_ratio(c, cands, allow_single=False) * 0.95)
|
||||||
if best is None or score > best[0]:
|
if best is None or score > best[0]:
|
||||||
best = (score, entry)
|
best = (score, entry)
|
||||||
return best
|
return best
|
||||||
@@ -565,9 +593,11 @@ def match_catalog(item: SortItem, catalog: list[dict], threshold: float) -> None
|
|||||||
if sys_best is not None and (best is None or sys_best[0] > best[0]):
|
if sys_best is not None and (best is None or sys_best[0] > best[0]):
|
||||||
best = sys_best
|
best = sys_best
|
||||||
# a retry hit from a *different* faction is only trusted when
|
# a retry hit from a *different* faction is only trusted when
|
||||||
# it is strong; weak inner-name votes must not re-home the file
|
# it is strong AND anchored by token containment; weak
|
||||||
|
# inner-name votes must not re-home the file
|
||||||
if best is not None 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:
|
and (best[0] < CROSS_SYSTEM_MIN
|
||||||
|
or not _contained_somewhere(item, best[1])):
|
||||||
best = None
|
best = None
|
||||||
elif system_known:
|
elif system_known:
|
||||||
sys_pool = [e for e in catalog if e.get("system") == item.system]
|
sys_pool = [e for e in catalog if e.get("system") == item.system]
|
||||||
@@ -576,13 +606,16 @@ def match_catalog(item: SortItem, catalog: list[dict], threshold: float) -> None
|
|||||||
best = _best_in_pool(item, sys_pool, use_inner=True)
|
best = _best_in_pool(item, sys_pool, use_inner=True)
|
||||||
else:
|
else:
|
||||||
# Neither system nor faction known: whole catalog, but only a
|
# Neither system nor faction known: whole catalog, but only a
|
||||||
# strong hit (CROSS_SYSTEM_MIN) may adopt another game's section;
|
# strong hit (CROSS_SYSTEM_MIN) with token containment may adopt
|
||||||
# weaker guesses stay in _unsorted.
|
# another game's section; weaker or unanchored guesses stay in
|
||||||
|
# _unsorted.
|
||||||
best = _best_in_pool(item, catalog, use_inner=False)
|
best = _best_in_pool(item, catalog, use_inner=False)
|
||||||
if best is None or best[0] < threshold:
|
if best is None or best[0] < threshold:
|
||||||
best = _best_in_pool(item, catalog, use_inner=True)
|
best = _best_in_pool(item, catalog, use_inner=True)
|
||||||
if best is not None and best[0] < CROSS_SYSTEM_MIN:
|
if best is not None and best[0] < CROSS_SYSTEM_MIN:
|
||||||
best = None
|
best = None
|
||||||
|
elif best is not None and not _contained_somewhere(item, best[1]):
|
||||||
|
best = None
|
||||||
if best and best[0] >= threshold:
|
if best and best[0] >= threshold:
|
||||||
entry = best[1]
|
entry = best[1]
|
||||||
item.unit = entry["name"]
|
item.unit = entry["name"]
|
||||||
@@ -596,7 +629,10 @@ def match_catalog(item: SortItem, catalog: list[dict], threshold: float) -> None
|
|||||||
# not *narrow* it: "Battleline" is GW's bucket for core infantry,
|
# not *narrow* it: "Battleline" is GW's bucket for core infantry,
|
||||||
# a keyword-derived Hero/Elite is more specific for a proxy file.
|
# a keyword-derived Hero/Elite is more specific for a proxy file.
|
||||||
catalog_klass = entry.get("class")
|
catalog_klass = entry.get("class")
|
||||||
if catalog_klass and item.klass in (None, "", UNCLASSIFIED_CLASS):
|
if catalog_klass:
|
||||||
|
# a matched catalog unit's class layer is authoritative for
|
||||||
|
# the whole subtree - a keyword-voted class ("warlock" seen
|
||||||
|
# somewhere -> Hero) must not survive next to a precise unit
|
||||||
item.klass = catalog_klass
|
item.klass = catalog_klass
|
||||||
item.match_score = best[0]
|
item.match_score = best[0]
|
||||||
item.match_kind = "catalog"
|
item.match_kind = "catalog"
|
||||||
@@ -627,7 +663,7 @@ def match_catalog(item: SortItem, catalog: list[dict], threshold: float) -> None
|
|||||||
item.group = entry["group"]
|
item.group = entry["group"]
|
||||||
elif best and best[0] >= WEAK_MIN and (
|
elif best and best[0] >= WEAK_MIN and (
|
||||||
_shares_rare_token(item.clean, best[1]["_clean"])
|
_shares_rare_token(item.clean, best[1]["_clean"])
|
||||||
or any(_shares_rare_token(_clean_name(n), best[1]["_clean"])
|
or any(_shares_rare_token(_clean_inner(n), best[1]["_clean"])
|
||||||
for n in item.inner_names[:10])):
|
for n in item.inner_names[:10])):
|
||||||
# Weak tier: a same-system hit below the threshold with at least
|
# Weak tier: a same-system hit below the threshold with at least
|
||||||
# one shared word, accepted only when nothing stronger exists.
|
# one shared word, accepted only when nothing stronger exists.
|
||||||
@@ -768,7 +804,7 @@ def sort_plan(
|
|||||||
match_catalog(probe, catalog, threshold)
|
match_catalog(probe, catalog, threshold)
|
||||||
fields = {k: getattr(probe, k) for k in (
|
fields = {k: getattr(probe, k) for k in (
|
||||||
"system", "group", "faction", "subfaction", "klass",
|
"system", "group", "faction", "subfaction", "klass",
|
||||||
"unit", "match_score", "match_kind",
|
"unit", "match_score", "match_kind", "confidence",
|
||||||
)}
|
)}
|
||||||
unit_cache[dest_key] = fields
|
unit_cache[dest_key] = fields
|
||||||
item = inspect(f)
|
item = inspect(f)
|
||||||
|
|||||||
@@ -83,10 +83,11 @@ def test_prov_dir_rehome():
|
|||||||
assert "Fly" in str(tgt["spider_body.stl"]), tgt["spider_body.stl"]
|
assert "Fly" in str(tgt["spider_body.stl"]), tgt["spider_body.stl"]
|
||||||
assert "Hero" not in str(tgt["spider_body.stl"])
|
assert "Hero" not in str(tgt["spider_body.stl"])
|
||||||
assert "files" in str(tgt["spider_arm.stl"])
|
assert "files" in str(tgt["spider_arm.stl"])
|
||||||
# junk-dir file stays under the pack unit folder
|
# junk-dir file rides along with the pack's adopted unit
|
||||||
assert "_unsorted/Multi Pack/HEADS" in str(tgt["head_a.stl"]).replace("\\", "/")
|
assert "Warp Spiders" in str(tgt["head_a.stl"])
|
||||||
# unmatched subtree keeps the pack unit
|
assert "HEADS" in str(tgt["head_a.stl"])
|
||||||
assert "Multi Pack" in str(tgt["t.stl"])
|
# unmatched subtree keeps the pack unit folder
|
||||||
|
assert "Warp Spiders" in str(tgt["t.stl"]) and "Retro GrimDark Thing" in str(tgt["t.stl"])
|
||||||
shutil.rmtree(root); shutil.rmtree(library)
|
shutil.rmtree(root); shutil.rmtree(library)
|
||||||
|
|
||||||
|
|
||||||
@@ -151,7 +152,8 @@ def test_weak_needs_shared_token():
|
|||||||
"Aeldari Pack": "Aeldari Pack.zip"}, root=root)
|
"Aeldari Pack": "Aeldari Pack.zip"}, root=root)
|
||||||
by_name = {i.name: i for i in items}
|
by_name = {i.name: i for i in items}
|
||||||
f = by_name["f.stl"]
|
f = by_name["f.stl"]
|
||||||
assert f.match_kind == "implicit", (f.match_kind, f.target)
|
# both files ride along with the pack-level weak adoption
|
||||||
|
assert f.match_kind == "weak" and f.confidence == "low", (f.match_kind, f.target)
|
||||||
assert "frontline squad" in str(f.target)
|
assert "frontline squad" in str(f.target)
|
||||||
g = by_name["g.stl"]
|
g = by_name["g.stl"]
|
||||||
assert g.match_kind == "weak" and g.confidence == "low"
|
assert g.match_kind == "weak" and g.confidence == "low"
|
||||||
|
|||||||
Reference in New Issue
Block a user