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. See tasks/online-tagging/06-api-budget-spec.md for the threshold rationale (Phase B picks the real numbers; Phase A ships with the lever dormant).

Functions

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

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.