fix(comicvine): relevance floor — drop unrelated volumes from search results (bookshelf-1725) #951
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-1725"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
scoreVolumesnow applies a Jaccard token-similarity floor (≥0.30) after sorting: any volume that has neither a nameMatch (exact/substring) nor sufficient token overlap with the query series is dropped. This prevents keyword-matched junk (e.g. Wimpy Demon King, Local Hero, Iron Man) from surfacing when ComicVine has no real match for the title (e.g. a manga it doesn't index).searchFreeTextgets the same filter, guarded by a ≥2 purely-alphabetic token check so single-word series ("Spawn") and bare issue queries ("#18") are not over-filtered.!sv.nameMatchearly-continue insearchStructuredNoYear(dead code —issuesByVolonly contains nameMatch=true entries).tokenJaccardandisAlphaToken.Test plan
make test— all 262 comicvine specs pass (zero regressions)make coverage— 100% statement coverage oninternal/metadata/comicvinegolangci-lint run ./internal/metadata/comicvine/...— 0 issuesScoreVolumesspecs prove: zero-overlap volumes dropped; reverse-contains nameMatch; all-junk filtered; partial match survivesTokenJaccardspecs cover all edge cases (identical, no overlap, case-insensitive, separator differences, partial, both empty)metadata.ErrNoMatch; legitimate single-word match → noErrNoMatchCloses bead bookshelf-1725 on merge.
scoreVolumes now drops any volume candidate whose Jaccard token-similarity with the query series is below 0.30 AND does not satisfy the existing nameMatch (substring) criteria. This prevents keyword-matched volumes like 'Wimpy Demon King', 'Local Hero', and 'Iron Man' from accumulating baseline score points (API position, recency, publisher) and appearing as results when ComicVine has no real match for the title (e.g. a manga it does not index). The free-text path (searchFreeText) gains the same relevance floor, but only when the query title has ≥2 purely-alphabetic tokens — single-word series ('Spawn') and bare issue queries ('#18') skip the filter entirely. New helpers in scoring.go: - tokenSet(norm) — word-bag from a normalised series name - tokenJaccard(a, b) — Jaccard similarity over token sets (0.30 floor) New helpers in search.go: - freeTextRelevanceApplies(title) — guards the free-text relevance floor - isAlphaToken(s) — classifies a token as 'significant' for the guard Also removes a redundant !sv.nameMatch early-continue in searchStructuredNoYear (issuesByVol only contains nameMatch=true entries so the inner loop is always a no-op for nameMatch=false volumes) and removes two unreachable dead-code guards. Test additions: - ScoreVolumes: floor drops zero-overlap volumes; reverse-contains nameMatch; all-junk filter; 'Invincible' survives as a partial match - TokenJaccard: identical, no-overlap, case-insensitive, separator-diff, partial-overlap, both-empty cases - Provider free-text: ErrNoMatch for all-junk results; no ErrNoMatch for a legitimate single-token match Existing test fixes: align provider_test.go volume names to avoid false filter hits from the new relevance gate. Closes bead bookshelf-1725 on merge.[MAJOR] internal/metadata/comicvine/export_test.go:102 — new export of unexported
tokenJaccardhelper for direct testingThe PR adds
var TokenJaccard = tokenJaccardin export_test.go and a dedicatedDescribe("TokenJaccard", ...)suite in structured_test.go that callscomicvine.TokenJaccard(...)directly. This is a new export of an unexported symbol added purely to test the helper directly (white-box) rather than through its public callers. Per the review standard a new unexported-symbol export is at least a [MAJOR]. The fix: deletevar TokenJaccard = tokenJaccardfrom export_test.go, delete theDescribe("TokenJaccard", ...)suite, and fold any edge cases not already covered into the existingScoreVolumesDescribe blocks — the scoring tests already drivetokenJaccardviaScoreVolumesfor all the meaningful cases (empty input, partial overlap, junk-drop). ThefreeTextRelevanceAppliespath is covered by the free-text relevance-floor Describe in provider_test.go.Note: the existing export_test.go already has similar patterns (
BuildTitle = buildTitle,RedactAPIKey = redactAPIKey) with explicit "white-box testing" comments; those are pre-existing debt. This PR adds a new one, which a review must flag.Security surface is otherwise clean:
strings.Fieldsis O(n); Jaccard intersection loop is O(|A|+|B|); no regex used in new code; candidate set is already bounded by the provider API response size.ErrNoMatchis returned as a proper domain sentinel (not a 500); confirmed handled as permanent in the wfengine adapter (simple_workflows.go:207), so no retry-on-permanent regression.scoring.goandsearch.goimport only stdlib +internal/metadata).REVIEW VERDICT: 0 blocker, 1 major, 0 minor
CODE REVIEW: APPROVED
Phase 0 — DEMO Verification
No interactive CLI demo block is present in the bead completion comment. The implementer reports "262 specs pass, 100% coverage, 0 lint issues" via CI. Per dispatch instructions, CI green is the source of truth; re-running tests is out of scope for this review.
Phase 1 — Spec Compliance
All requirements from
bookshelf-1725are addressed:scoreVolumes— junk volumes with no name-token overlap are dropped before returningsearchFreeTextwith the >=2-alpha-token guard#18), single-word series (Spawn), and"Series #N"patternsstrings.Contains(seriesNorm, nameNorm)).golangci.ymlexclusions;httptest.Serverboundary mock used throughoutPhase 2 — Code Quality
Correctness of the threshold — false-negative analysis
The 0.30 floor with
nameMatchbypass is well-calibrated. Any substring relationship (exact, volume-contains-query, or the new query-contains-volume) setsnameMatch=trueand bypasses the floor entirely — this handles virtually all legitimate subtitle/edition differences. The Jaccard fallback only matters for volumes where neither direction of substring holds, and 0.30 requires at least 1-in-3 token overlap, which is generous for junk while tight enough to reject zero-overlap results like{Wimpy Demon King}vs{From Betrayed Hero to Invincible Demon King}.Verified edge cases:
"Batman: Year One"vs query"Batman"-> substring contains ->nameMatch=true, floor bypassed"Batman"vs query"Batman: The Dark Knight Returns"-> reverse-contains ->nameMatch=true"Green Lantern"vs query"Green Arrow"-> Jaccard=1/3~=0.33 >= 0.30, passes (acceptable false-positive, not a false-negative)"X-Factor"vs query"X-Men"-> Jaccard=1/3~=0.33 passes (acceptable)Interaction with 9gbq and 3rny — no regression
The
applyFreeTextVolumeYearscall (9gbq) happens after the relevance filter insearchFreeText, so volume-year enrichment applies only to candidates that cleared the floor — more efficient, not less. The 3rny issue-number strictness lives in the structured path'sfetchIssuesByFiltercall which is unaffected by this diff.searchStructuredNoYear— removed!sv.nameMatchguard is safeThe second loop in
searchStructuredNoYear(result emission) dropped theif !sv.nameMatch { continue }guard (search.go:174). This is a no-op:issuesByVolis populated only frombatchIDs, which the first loop still gates onsv.nameMatch(search.go:146). Volumes that pass only the Jaccard floor (notnameMatch) are not inbatchIDs, not fetched, and not inissuesByVol— the inner loop produces nothing for them. Behavior is identical to the original.freeTextRelevanceApplies— normalization is deterministicIterates map keys for counting, which is safe since only the count matters (not order). The
isAlphaTokenguard correctly classifies#18(contains#, fails) and(2020)(contains parens/digits, fails). Verified the "TestSeries #18" -> 1 alpha token -> no filter path matches the docstring comment.[MINOR]
internal/metadata/comicvine/structured_test.go— "keeps a real partial match alongside junk"Itblock has twoExpectcalls in oneIt. Per project conventions, eachItshould assert exactly one behavior. Split intoIt("returns exactly one result")andIt("the retained result is Invincible Iron Man")sharing the sameBeforeEach/JustBeforeEach.[MINOR]
internal/metadata/comicvine/scoring.go:161— The newstrings.Contains(seriesNorm, nameNorm)reverse-contains branch is true whenevernameNormis empty (Go:strings.Contains(any, "")is always true). If a ComicVine volume returns an empty name, it getsnameMatch=trueand a spurious +25 bonus. In practice ComicVine always returns a name, and the effect is a false inclusion (not exclusion), so this is a very low-risk latent issue. A guardlen(nameNorm) > 0before the reverse-contains check would close it cleanly.[MINOR]
internal/metadata/comicvine/export_test.go:100—var TokenJaccard = tokenJaccardexports the unexported function via the white-box shim. TheTokenJaccardunit tests exercise the similarity math directly, but the same coverage is achievable throughScoreVolumeswith inputs whose floor behavior depends on specific Jaccard values (which the other scoring tests already do). Following the existingexport_test.gopattern so not a new category of violation, but worth noting for the burn-down.REVIEW VERDICT: 0 blocker, 0 major, 3 minor
37d9ebf159a251e2fa02Security Review — bookshelf-1725 / PR #951
Prior MAJOR resolved
The previously flagged
var TokenJaccard = tokenJaccardwhite-box export is confirmed removed. A search of every.gofile onbd-bookshelf-1725finds no reference toTokenJaccardanywhere. All new helpers (tokenSet,tokenJaccard,freeTextRelevanceApplies,isAlphaToken) are unexported. The prior [MAJOR] is closed.Findings
No new security findings.
Multi-user scoping: Not applicable — this is pure in-memory metadata matching; no per-user data is read or written.
Test package declarations: Both modified test files (
provider_test.go,structured_test.go) declarepackage comicvine_test. Black-box, correct.Unexported-symbol references in tests: All test references use public symbols only (
comicvine.New,comicvine.ScoreVolumes,comicvine.VolumeSearchResult,comicvine.ScoredVolume). No unexported symbol is referenced from the test package.Injection / SSRF: The new
tokenJaccard,freeTextRelevanceApplies, andisAlphaTokenfunctions operate entirely on in-memory strings. No URL construction, no outbound requests, no user-supplied data reaches the network.Sort determinism / flake: The insertion sort in
scoreVolumeswas pre-existing and is not modified by this diff. New test assertions ("floor passes high-overlap volume alongside junk"etc.) do not assert on order — they assert on length or on the sole surviving element after the floor filter drops all other candidates. No flake risk introduced.Architecture boundary:
internal/metadata/comicvine/search.goandscoring.goimport only stdlib,golang.org/x/time/rate, andinternal/metadata. No workflow-engine import. Boundary intact.Secrets / PII: Nothing logged; API key flows through
url.Values(already present), not added by this diff..golangci.yml: No changes — no new exclusions added.REVIEW VERDICT: 0 blocker, 0 major, 0 minor
7f60e987fe87e261acd0