comicbox.formats.base.online.matcher

source module comicbox.formats.base.online.matcher

Confidence-score matcher and policy resolution.

For M3 the matcher is metadata-only — cover hashing lives behind a hook that returns None until M4 wires up pHash.

Public surface:

  • OnlineMatcher.rank — score and sort candidates against a profile.
  • OnlineMatcher.resolve — apply the Match Resolution Policy (auto-write / prompt / skip / no-match).

Classes

  • ResolutionKind — Outcome of applying the Match Resolution Policy.

  • Resolution — Matcher’s final verdict for one (comic, source) pair.

  • OnlineMatcher — Stateless ranker + policy resolver.

Functions

  • metadata_score — Renormalised weighted sum of metadata signals, in [0, 1].

  • final_score — Blend metadata and cover scores. Cover-only-when-hashing case.

source class ResolutionKind()

Bases : StrEnum

Outcome of applying the Match Resolution Policy.

source class Resolution()

Matcher’s final verdict for one (comic, source) pair.

source metadata_score(profile: ComicProfile, candidate: Candidate) → float

Renormalised weighted sum of metadata signals, in [0, 1].

Phase K rev 2 (2026-05-14): a signal contributes when EITHER side has data. The total is renormalised over the contributing weights. Signals are skipped only when BOTH sides are empty/None — i.e. there is genuinely no comparison to make.

Why this matters:

  1. CV’s BasicIssue (what search returns) doesn’t expose publisher or page_count. Under the original formula those signals returned weak-prior values (0.5/0.6) that diluted the score even when series/issue/year all matched perfectly. A thumbnail-library comic with no profile-side publisher / pages would prompt for an otherwise-obvious match (“Wolverine #20 (2026)” → md capped at 0.91 < confidence_threshold 0.95).

  2. The fix is to renormalise — but only over the signals where data exists on at least one side. Truly-symmetric absence (both profile and candidate missing publisher) is dropped from the denominator. Asymmetric absence (profile knows year, candidate doesn’t) keeps the signal in the denominator and lets the signal function’s missing-data branch (s_year=0.3, s_publisher=0.5, s_pages=0.6) penalise the under-informed candidate.

Phase K rev 1 (the first cut of this function) skipped on EITHER side missing, which incorrectly lifted CV BasicIssue candidates with a null cover_date to md=1.0 over the actually-matching trade collection. Caught by bigmedia calibration: “Conan the Barbarian by Jim Zub: Land of the Lotus (2021)” preferred a 1.0-scored canonical Conan record with year=None over the year=2021 trade-collection record. Rev 2 keeps asymmetric signals; only both-None disappears.

Interaction with Phase E: solo-viable candidates can still reach md=1.0 in the symmetric-missing case (Wolverine prompt fix). For libraries with full profile metadata (publisher / page_count present), the asymmetric-skip stays as the s_publisher=0.5 / s_pages=0.6 weak prior, matching pre-Phase-K behaviour. So Phase E’s solo_confidence_threshold is the load-bearing protection only on thumbnail-library calibration runs.

source final_score(candidate: Candidate, *, hash_used: bool) → float

Blend metadata and cover scores. Cover-only-when-hashing case.

Phase K note: metadata_score now renormalises over contributing signals, so for hashed candidates the blended formula stays the same — _METADATA_WEIGHT_SUM (0.80) is still the metadata’s share of the blended budget, regardless of which signals contributed to producing the md value.

Known asymmetry (audited 2026-08-31, deliberately not re-tuned here — see the matcher scoring audit under tasks/online-tagging/calibration-notes/): an un-hashed candidate is scored on raw metadata while a hashed one is scored on 0.80*md + 0.20*cover, and both are compared against the same auto_threshold. Measuring a cover therefore moves the bar a candidate has to clear: it holds a bar T only while cover >= (T - 0.80*md) / 0.20. At the shipped T=0.95 that is cover >= 0.75 for a flawless md=1.0, and unreachable for any md < 0.9375 — while an un-hashed sibling with the same metadata keeps its md and clears T unmeasured. In the 2026-05-17 bigmedia run 48% of 548 measured cover scores were below 0.75, and the blend lowered the top candidate’s score in 114 of 306 hashed fixtures. Retuning the weights (or renormalising the un-hashed side) changes which files get written to, so it needs a calibration run, not a patch.

source class OnlineMatcher()

Stateless ranker + policy resolver.

Methods

  • rank — Score every candidate and return them sorted descending by score.

  • resolve — Apply the Match Resolution Policy.

source method OnlineMatcher.rank(profile: ComicProfile, candidates: list[Candidate], *, local_hash_provider: LocalHashProvider | None = None, candidate_hash_fetcher: CandidateHashFetcher | None = None, candidate_hash_batch_fetcher: CandidateHashBatchFetcher | None = None, threshold: float = _DEFAULT_CONFIDENCE_THRESHOLD, min_confidence: float = 0.5, disambiguation_margin: float = 0.1) → list[Candidate]

Score every candidate and return them sorted descending by score.

When local_hash_provider is provided and the metadata-only ranking is ambiguous (top below threshold or close call), invokes cover hashing on the top K candidates and re-ranks. Metron candidates carry a precomputed_cover_hash (string-compare); other sources fall through to candidate_hash_fetcher for download-and-hash with caching. Both kinds mix in the same ranking.

candidate_hash_batch_fetcher, when supplied, takes precedence for the download path: the whole top-K is resolved in one concurrent batch rather than one blocking GET at a time. The ranking it produces is identical.

source method OnlineMatcher.resolve(ranked: list[Candidate], settings: OnlineSettings, source_name: str) → Resolution

Apply the Match Resolution Policy.

source_name selects per-source overrides for policy, confidence_threshold, min_confidence, and disambiguation_margin (all fall back to the global setting if no per-source override is set).