fix(comicvine): relevance floor — drop unrelated volumes from search results (bookshelf-1725) #951

Merged
zombor merged 3 commits from bd-bookshelf-1725 into main 2026-07-05 12:22:18 +00:00
Owner

Summary

  • scoreVolumes now 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).
  • searchFreeText gets 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.
  • Removes a redundant !sv.nameMatch early-continue in searchStructuredNoYear (dead code — issuesByVol only contains nameMatch=true entries).
  • Removes two unreachable dead-code guards in tokenJaccard and isAlphaToken.

Test plan

  • make test — all 262 comicvine specs pass (zero regressions)
  • make coverage — 100% statement coverage on internal/metadata/comicvine
  • golangci-lint run ./internal/metadata/comicvine/... — 0 issues
  • New ScoreVolumes specs prove: zero-overlap volumes dropped; reverse-contains nameMatch; all-junk filtered; partial match survives
  • New TokenJaccard specs cover all edge cases (identical, no overlap, case-insensitive, separator differences, partial, both empty)
  • New provider free-text specs prove: all-junk query → metadata.ErrNoMatch; legitimate single-word match → no ErrNoMatch

Closes bead bookshelf-1725 on merge.

## Summary - `scoreVolumes` now 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). - `searchFreeText` gets 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. - Removes a redundant `!sv.nameMatch` early-continue in `searchStructuredNoYear` (dead code — `issuesByVol` only contains nameMatch=true entries). - Removes two unreachable dead-code guards in `tokenJaccard` and `isAlphaToken`. ## Test plan - [ ] `make test` — all 262 comicvine specs pass (zero regressions) - [ ] `make coverage` — 100% statement coverage on `internal/metadata/comicvine` - [ ] `golangci-lint run ./internal/metadata/comicvine/...` — 0 issues - [ ] New `ScoreVolumes` specs prove: zero-overlap volumes dropped; reverse-contains nameMatch; all-junk filtered; partial match survives - [ ] New `TokenJaccard` specs cover all edge cases (identical, no overlap, case-insensitive, separator differences, partial, both empty) - [ ] New provider free-text specs prove: all-junk query → `metadata.ErrNoMatch`; legitimate single-word match → no `ErrNoMatch` Closes bead bookshelf-1725 on merge.
fix(comicvine): add relevance floor to prevent junk results on unindexed titles (bookshelf-1725)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m32s
/ E2E API (pull_request) Successful in 2m11s
/ Lint (pull_request) Successful in 3m27s
/ Integration (pull_request) Successful in 3m10s
/ E2E Browser (pull_request) Successful in 3m28s
/ Test (pull_request) Successful in 4m6s
37d9ebf159
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.
Author
Owner

[MAJOR] internal/metadata/comicvine/export_test.go:102 — new export of unexported tokenJaccard helper for direct testing
The PR adds var TokenJaccard = tokenJaccard in export_test.go and a dedicated Describe("TokenJaccard", ...) suite in structured_test.go that calls comicvine.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: delete var TokenJaccard = tokenJaccard from export_test.go, delete the Describe("TokenJaccard", ...) suite, and fold any edge cases not already covered into the existing ScoreVolumes Describe blocks — the scoring tests already drive tokenJaccard via ScoreVolumes for all the meaningful cases (empty input, partial overlap, junk-drop). The freeTextRelevanceApplies path 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:

  • No ReDoS: strings.Fields is 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.
  • No injection: all filtering is in-memory over already-fetched results; nothing new reaches SQL or URL construction.
  • ErrNoMatch is 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.
  • No workflow-engine import in domain packages (scoring.go and search.go import only stdlib + internal/metadata).
  • No secrets/PII in new log paths.

REVIEW VERDICT: 0 blocker, 1 major, 0 minor

[MAJOR] internal/metadata/comicvine/export_test.go:102 — new export of unexported `tokenJaccard` helper for direct testing The PR adds `var TokenJaccard = tokenJaccard` in export_test.go and a dedicated `Describe("TokenJaccard", ...)` suite in structured_test.go that calls `comicvine.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: delete `var TokenJaccard = tokenJaccard` from export_test.go, delete the `Describe("TokenJaccard", ...)` suite, and fold any edge cases not already covered into the existing `ScoreVolumes` Describe blocks — the scoring tests already drive `tokenJaccard` via `ScoreVolumes` for all the meaningful cases (empty input, partial overlap, junk-drop). The `freeTextRelevanceApplies` path 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: - No ReDoS: `strings.Fields` is 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. - No injection: all filtering is in-memory over already-fetched results; nothing new reaches SQL or URL construction. - `ErrNoMatch` is 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. - No workflow-engine import in domain packages (`scoring.go` and `search.go` import only stdlib + `internal/metadata`). - No secrets/PII in new log paths. REVIEW VERDICT: 0 blocker, 1 major, 0 minor
Author
Owner

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-1725 are addressed:

  • Jaccard relevance floor (>=0.30) added to scoreVolumes — junk volumes with no name-token overlap are dropped before returning
  • Same floor added to searchFreeText with the >=2-alpha-token guard
  • Guard correctly bypasses the floor for bare issue-number queries (#18), single-word series (Spawn), and "Series #N" patterns
  • Reverse-contains name match extended (strings.Contains(seriesNorm, nameNorm))
  • 100% coverage maintained; no new .golangci.yml exclusions; httptest.Server boundary mock used throughout

Phase 2 — Code Quality

Correctness of the threshold — false-negative analysis

The 0.30 floor with nameMatch bypass is well-calibrated. Any substring relationship (exact, volume-contains-query, or the new query-contains-volume) sets nameMatch=true and 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 applyFreeTextVolumeYears call (9gbq) happens after the relevance filter in searchFreeText, 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's fetchIssuesByFilter call which is unaffected by this diff.

searchStructuredNoYear — removed !sv.nameMatch guard is safe

The second loop in searchStructuredNoYear (result emission) dropped the if !sv.nameMatch { continue } guard (search.go:174). This is a no-op: issuesByVol is populated only from batchIDs, which the first loop still gates on sv.nameMatch (search.go:146). Volumes that pass only the Jaccard floor (not nameMatch) are not in batchIDs, not fetched, and not in issuesByVol — the inner loop produces nothing for them. Behavior is identical to the original.

freeTextRelevanceApplies — normalization is deterministic

Iterates map keys for counting, which is safe since only the count matters (not order). The isAlphaToken guard 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" It block has two Expect calls in one It. Per project conventions, each It should assert exactly one behavior. Split into It("returns exactly one result") and It("the retained result is Invincible Iron Man") sharing the same BeforeEach/JustBeforeEach.

[MINOR] internal/metadata/comicvine/scoring.go:161 — The new strings.Contains(seriesNorm, nameNorm) reverse-contains branch is true whenever nameNorm is empty (Go: strings.Contains(any, "") is always true). If a ComicVine volume returns an empty name, it gets nameMatch=true and 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 guard len(nameNorm) > 0 before the reverse-contains check would close it cleanly.

[MINOR] internal/metadata/comicvine/export_test.go:100var TokenJaccard = tokenJaccard exports the unexported function via the white-box shim. The TokenJaccard unit tests exercise the similarity math directly, but the same coverage is achievable through ScoreVolumes with inputs whose floor behavior depends on specific Jaccard values (which the other scoring tests already do). Following the existing export_test.go pattern so not a new category of violation, but worth noting for the burn-down.


REVIEW VERDICT: 0 blocker, 0 major, 3 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-1725` are addressed: - Jaccard relevance floor (>=0.30) added to `scoreVolumes` — junk volumes with no name-token overlap are dropped before returning - Same floor added to `searchFreeText` with the >=2-alpha-token guard - Guard correctly bypasses the floor for bare issue-number queries (`#18`), single-word series (`Spawn`), and `"Series #N"` patterns - Reverse-contains name match extended (`strings.Contains(seriesNorm, nameNorm)`) - 100% coverage maintained; no new `.golangci.yml` exclusions; `httptest.Server` boundary mock used throughout ### Phase 2 — Code Quality **Correctness of the threshold — false-negative analysis** The 0.30 floor with `nameMatch` bypass is well-calibrated. Any substring relationship (exact, volume-contains-query, or the new query-contains-volume) sets `nameMatch=true` and 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 `applyFreeTextVolumeYears` call (9gbq) happens after the relevance filter in `searchFreeText`, 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's `fetchIssuesByFilter` call which is unaffected by this diff. **`searchStructuredNoYear` — removed `!sv.nameMatch` guard is safe** The second loop in `searchStructuredNoYear` (result emission) dropped the `if !sv.nameMatch { continue }` guard (`search.go:174`). This is a no-op: `issuesByVol` is populated only from `batchIDs`, which the first loop still gates on `sv.nameMatch` (`search.go:146`). Volumes that pass only the Jaccard floor (not `nameMatch`) are not in `batchIDs`, not fetched, and not in `issuesByVol` — the inner loop produces nothing for them. Behavior is identical to the original. **`freeTextRelevanceApplies` — normalization is deterministic** Iterates map keys for counting, which is safe since only the count matters (not order). The `isAlphaToken` guard 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" `It` block has two `Expect` calls in one `It`. Per project conventions, each `It` should assert exactly one behavior. Split into `It("returns exactly one result")` and `It("the retained result is Invincible Iron Man")` sharing the same `BeforeEach`/`JustBeforeEach`. [MINOR] `internal/metadata/comicvine/scoring.go:161` — The new `strings.Contains(seriesNorm, nameNorm)` reverse-contains branch is true whenever `nameNorm` is empty (Go: `strings.Contains(any, "")` is always true). If a ComicVine volume returns an empty name, it gets `nameMatch=true` and 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 guard `len(nameNorm) > 0` before the reverse-contains check would close it cleanly. [MINOR] `internal/metadata/comicvine/export_test.go:100` — `var TokenJaccard = tokenJaccard` exports the unexported function via the white-box shim. The `TokenJaccard` unit tests exercise the similarity math directly, but the same coverage is achievable through `ScoreVolumes` with inputs whose floor behavior depends on specific Jaccard values (which the other scoring tests already do). Following the existing `export_test.go` pattern so not a new category of violation, but worth noting for the burn-down. --- REVIEW VERDICT: 0 blocker, 0 major, 3 minor
zombor force-pushed bd-bookshelf-1725 from 37d9ebf159
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m32s
/ E2E API (pull_request) Successful in 2m11s
/ Lint (pull_request) Successful in 3m27s
/ Integration (pull_request) Successful in 3m10s
/ E2E Browser (pull_request) Successful in 3m28s
/ Test (pull_request) Successful in 4m6s
to a251e2fa02
Some checks failed
/ E2E API (pull_request) Successful in 2m42s
/ JS Unit Tests (pull_request) Failing after 1m50s
/ Integration (pull_request) Successful in 4m46s
/ Lint (pull_request) Successful in 4m48s
/ E2E Browser (pull_request) Failing after 3m40s
/ Test (pull_request) Successful in 6m18s
2026-07-05 02:26:02 +00:00
Compare
docs(comicvine): fix stale tokenJaccard comment after dead-branch removal
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m31s
/ E2E API (pull_request) Successful in 3m2s
/ Lint (pull_request) Successful in 5m10s
/ Integration (pull_request) Successful in 4m6s
/ E2E Browser (pull_request) Successful in 4m15s
/ Test (pull_request) Successful in 5m51s
7f60e987fe
The comment said 'Returns 1 when both are empty' but that branch was
removed in the preceding commit (the both-empty case is unreachable via
any public caller path). Update to accurately document the contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Security Review — bookshelf-1725 / PR #951

Prior MAJOR resolved

The previously flagged var TokenJaccard = tokenJaccard white-box export is confirmed removed. A search of every .go file on bd-bookshelf-1725 finds no reference to TokenJaccard anywhere. 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) declare package 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, and isAlphaToken functions 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 scoreVolumes was 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.go and scoring.go import only stdlib, golang.org/x/time/rate, and internal/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

## Security Review — bookshelf-1725 / PR #951 ### Prior MAJOR resolved The previously flagged `var TokenJaccard = tokenJaccard` white-box export is **confirmed removed**. A search of every `.go` file on `bd-bookshelf-1725` finds no reference to `TokenJaccard` anywhere. 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`) declare `package 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`, and `isAlphaToken` functions 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 `scoreVolumes` was 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.go` and `scoring.go` import only stdlib, `golang.org/x/time/rate`, and `internal/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
zombor force-pushed bd-bookshelf-1725 from 7f60e987fe
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m31s
/ E2E API (pull_request) Successful in 3m2s
/ Lint (pull_request) Successful in 5m10s
/ Integration (pull_request) Successful in 4m6s
/ E2E Browser (pull_request) Successful in 4m15s
/ Test (pull_request) Successful in 5m51s
to 87e261acd0
All checks were successful
/ E2E API (pull_request) Successful in 2m49s
/ JS Unit Tests (pull_request) Successful in 1m39s
/ Lint (pull_request) Successful in 4m29s
/ Integration (pull_request) Successful in 4m28s
/ Test (pull_request) Successful in 5m22s
/ E2E Browser (pull_request) Successful in 3m40s
2026-07-05 12:15:58 +00:00
Compare
zombor merged commit 830125e26c into main 2026-07-05 12:22:18 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!951
No description provided.