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 imagehash.

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 : str, Enum

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.

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, 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.

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).