comicbox.formats.base.online.series_filter

source module comicbox.formats.base.online.series_filter

Pre-call series-name fuzzy filter for online sources.

Each source’s volume/series discovery step returns up to N hits for a free-text series query. CV’s full-text search will return “Lois Lane: A Celebration” and “Lois Lane and Friends” alongside the canonical “Lois Lane (2019)” — and the matcher’s s_series signal would score the non-matches near zero anyway. Issuing the per-volume list_issues API call for those non-matches is wasted budget under CV’s 200/hr cap.

This module provides the pre-call filter both sources consult before deciding to spend an API call on a discovered volume. The same normalize_series from signals.py is reused so a candidate that passes the filter scores predictably under s_series downstream.

Why fuzz.ratio and not fuzz.WRatio (which s_series uses): WRatio applies a partial-match boost that lifts any substring overlap to 90+, including “Lois Lane” vs “Adventures of Lois Lane and Friends” (both score 90). That’s fine for scoring — the matcher’s downstream signals can still rank candidates — but useless for filtering, where we need to drop substring-overlapping-but-different volumes before issuing an API call for them. Plain ratio (Levenshtein) gives:

Lois Lane vs Lois Lane (1986)              → 78
Lois Lane vs Watchmen (1986)               → variant year tail OK
Lois Lane vs Watchmen Annotated            → 62
Lois Lane vs Adventures of Lois Lane ...   → 41

A natural threshold around 0.70 separates “same series, possibly with a year/volume suffix” (keep) from “different series that happens to share words” (drop). The pre-filter is intentionally STRICTER than the matcher’s s_series; the matcher gets to be permissive because it sees the full candidate. The pre-filter has to decide before any API call goes out.

Driven by the Effort resolved per-source. The thresholds below are calibrated against the fixture set, not placeholders; each carries the measurement that chose it.

Functions

  • max_calls_for — Return the per-search issue-list call budget, or None for unlimited.

  • threshold_for — Return the per-budget filter threshold in [0, 1].

  • max_results_for — Return the per-budget cap on volumes/series to expand into issue queries.

  • should_keep_volume_name — Decide whether a discovered volume is worth a per-volume issue lookup.

source max_calls_for(budget: Effort) → int | None

Return the per-search issue-list call budget, or None for unlimited.

None means THOROUGH (or an unrecognized budget): no per-search cap, matching that mode’s “spend freely” contract.

source threshold_for(budget: Effort) → float

Return the per-budget filter threshold in [0, 1].

source max_results_for(budget: Effort, *, default: int) → int

Return the per-budget cap on volumes/series to expand into issue queries.

default is the source’s class-level production cap (20 today). Returns the per-budget override when set; otherwise the default.

source should_keep_volume_name(profile_series: str | None, volume_name: str | None, threshold: float, *, alt_names: Iterable[str] = ()) → bool

Decide whether a discovered volume is worth a per-volume issue lookup.

Returns True (keep, spend the API call) when either:

  • threshold <= 0 — the filter is configured as no-op for the resolved API budget. This is BALANCED until Phase B says otherwise.
  • profile_series is missing — we have no comparison anchor; never drop on missing data (would risk false-negatives for tagged-without-series fixtures).
  • volume_name is missing — likewise, can’t compare; keep.
  • The rapidfuzz ratio similarity between the two normalized names is >= threshold * 100. Same normalization as s_series (so naming conventions agree), different primitive (so we filter substring noise the matcher would still score high). See module docstring for rationale.
  • Any of the volume’s alt_names clears that same bar. Aliases are purely additive — they can rescue a volume filed under a localized or variant title (“Shingeki no Kyojin” for a comic tagged “Attack on Titan”) but never drop one the primary name would have kept.

Returns False (skip, save the call) otherwise. The caller is expected to log a debug-level reason when this happens so calibration runs can audit pre-filter false-negatives.