Don't let unrecognized BISAC codes classify fiction as nonfiction (PP-4849) - #3710
dbernstein wants to merge 5 commits into
Conversation
Greptile SummaryThe PR makes unrecognized BISAC identifiers defer to keyword classification, preserves an existing fiction determination when recalculation abstains, and adds a migration to rescore affected subjects.
Confidence Score: 4/5The migration should be corrected before merging because it can leave stale nonfiction votes on shape-valid identifiers that the runtime classifier considers unrecognized. Runtime recognition uses canonical heading-map membership, but the migration uses only identifier syntax, so demonstrated inputs such as Files Needing Attention: alembic/versions/20260902_52d1bbdd4671_reset_checked_for_non_bisac_nonfiction_.py, tests/migration/test_20260902_52d1bbdd4671_reset_checked_for_non_bisac_.py
|
| Filename | Overview |
|---|---|
| src/palace/manager/core/classifier/bisac.py | Correctly separates runtime recognition from distributor-provided names and sends unknown codes through keyword fallback. |
| src/palace/manager/sqlalchemy/model/work.py | Adds intentional retention of an existing fiction determination when recalculation produces no evidence. |
| alembic/versions/20260902_52d1bbdd4671_reset_checked_for_non_bisac_nonfiction_.py | Resets malformed identifiers but misses syntactically valid codes absent from the runtime canonical heading map. |
| tests/manager/core/classifiers/test_bisac.py | Covers recognized and unrecognized classifier behavior, including a shape-valid unknown code that exposes the migration mismatch. |
| tests/migration/test_20260902_52d1bbdd4671_reset_checked_for_non_bisac_.py | Covers malformed and canonical identifiers but lacks a shape-valid identifier absent from BISACClassifier.NAMES. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[Stored BISAC subject] --> B{Identifier matches migration regex?}
B -->|No| C[Set checked=false]
B -->|Yes| D[Remain checked]
C --> E[Nightly reclassification]
E --> F{Scrubbed code in NAMES?}
F -->|Yes| G[BISAC rules]
F -->|No| H[Keyword fallback]
D --> I[Shape-valid unknown code keeps stale vote]
Reviews (1): Last reviewed commit: "Reclassify subjects holding a fabricated..." | Re-trigger Greptile
| # distributors add an "FB" prefix and/or an "N" suffix, both of which | ||
| # BISACClassifier.scrub_identifier strips before looking the code up. Anything | ||
| # that does not match this shape is not a BISAC code. | ||
| CANONICAL_BISAC_CODE = r"^(FB)?[A-Z]{3}[0-9]{6}N?$" |
There was a problem hiding this comment.
Migration recognition criteria diverge
If a stored subject has a shape-valid but unknown identifier such as FBZZZ000000, this regex treats it as canonical and leaves it checked, while the runtime classifier treats it as unrecognized. The nightly task therefore never rescores its stale fiction=false value, allowing the incorrect nonfiction vote to remain on attached works.
There was a problem hiding this comment.
Good catch, and it was a real hole. FBZZZ000000 is shape-valid, so the regex left it checked, while the classifier calls it unrecognized — its fabricated fiction=false would have survived indefinitely.
Rather than tightening the pattern, I removed it. The migration now asks BISACClassifier directly and resets every subject stored as nonfiction that the classifier no longer scores that way, so the two definitions cannot drift apart by construction. Precedent for importing application code into a migration is already here (Identifier, Timestamp, BaseCoverageRecord), and a pure-logic classifier is a safer import than an ORM model.
Added shape_valid_but_nonexistent (FBZZZ000000) to the migration test as a regression guard for exactly this case, plus stale_canonical_fiction_code — a real FIC* code stored as nonfiction, which the old predicate also missed.
| def test_unrecognized_bisac_codes_do_not_imply_nonfiction( | ||
| self, work_classifier_fixture: TestWorkClassifierFixture | ||
| ): |
There was a problem hiding this comment.
The new test omits the required -> None return annotation, making its signature inconsistent with the repository's type-hinting requirement for new functions.
| def test_unrecognized_bisac_codes_do_not_imply_nonfiction( | |
| self, work_classifier_fixture: TestWorkClassifierFixture | |
| ): | |
| def test_unrecognized_bisac_codes_do_not_imply_nonfiction( | |
| self, work_classifier_fixture: TestWorkClassifierFixture | |
| ) -> None: |
Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Fixed. Added -> None here and to test_assign_genres_does_not_overwrite_fiction_with_null, which had the same omission.
Both were written to match the immediately-surrounding tests in those files, which are unannotated — but CLAUDE.md is explicit about new functions, so the convention wins over local consistency.
|
|
||
| # An official BISAC code is three letters followed by six digits. Some | ||
| # distributors add an "FB" prefix and/or an "N" suffix, both of which | ||
| # BISACClassifier.scrub_identifier strips before looking the code up. Anything |
There was a problem hiding this comment.
Public functions lack docstrings
The new public upgrade, downgrade, and fetch_subject functions omit the required reStructuredText docstrings, leaving their function-level contracts undocumented.
Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Half taken. I added the docstring to fetch_subject — that one belongs, since its sibling helpers on the fixture are documented.
Declining on upgrade/downgrade, though. Those are alembic framework entry points rather than public API, and the convention here is that the module docstring explains a migration: none of the ~50 revisions in alembic/versions/ documents either function. Adding them to this one would make it the odd one out, and the module docstring already covers what the migration does and why. Worth remembering for future migrations in this repo.
There was a problem hiding this comment.
That distinction makes sense, and I agree the module docstring is sufficient for Alembic’s upgrade()/downgrade() entry points while fetch_subject() follows the documented helper convention. I’ll apply that standard to future migrations.
571ffd4 to
f94bb1b
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3710 +/- ##
=======================================
Coverage 93.61% 93.61%
=======================================
Files 514 514
Lines 47065 47075 +10
Branches 6408 6412 +4
=======================================
+ Hits 44060 44070 +10
+ Misses 1944 1941 -3
- Partials 1061 1064 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Note on the red
|
Everything on the Palace Marketplace / Feedbooks category scheme is
stored with type='BISAC', including codes that are not BISAC at all --
language and territory categories such as INFEN000 ("English
literature") and INFENUSA ("American and Canadian literature").
Such a code cannot be resolved to a canonical BISAC heading, so
classification fell back to the name the distributor supplied and hit
the catch-all rule closing BISACClassifier.FICTION, which reads "not
filed under a Fiction heading, therefore nonfiction". That inference is
sound for a real BISAC name and unsound for anything else, so an
unresolvable code cast a nonfiction vote on every work it touched --
outvoting the genuine FBFIC* fiction codes on the same book. The
AUDIENCE ruleset has the same catch-all, inferring Adult; that is the
shape of the bug fixed for FBJUV* codes in PP-4128.
is_fiction() and audience() now skip the BISAC rulesets when the
identifier did not resolve, deferring instead to the KeywordBasedClassifier
call already sitting at the end of both methods (unreachable until now,
because the catch-alls always matched first). The keyword classifier
recognizes "literature", so the INF* family goes from voting nonfiction
to voting fiction; codes carrying no signal abstain. Subjects that
supply a name but no identifier are unaffected.
Work.assign_genres() gains the guard its audience handling already has,
so a recalculation that reaches no fiction determination keeps the
status the work already had rather than writing NULL over it. This
matters more now that abstaining is common.
Genre and target_age are left alone deliberately: the same reasoning
applies, but changing genre assignment moves books between lanes and
deserves its own change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Repairs the stored side of the preceding fix. Subjects are global and are only re-examined when checked=false, so a subject that was scored under the old rules keeps its value indefinitely; the FBJUV* resets in 45f74fdcec18 and 05a95c828149 did not cover these codes. Resets checked=false for BISAC subjects whose identifier is not a valid BISAC code shape and which currently hold fiction=false, so classify_unchecked_subjects re-scores them and recalculates the works they are attached to. Measured against production: 2,418 subject rows, 4,877 works recalculated -- roughly 2% of the nightly volume behind the reindex surge in PP-4472, so no scheduled window is needed. Scope is deliberately narrow. Canonical BISAC codes are untouched; the great majority of those are legitimately nonfiction, and there is no evidence the FBFIC* rows are stale. Non-canonical codes already holding fiction=true or NULL are untouched as well: they are not implicated, and re-scoring them through a different classifier risks regressions while roughly doubling the reindex. This migration must ship in the same release as the classifier fix. Run on its own, the nightly task would re-score these subjects with the old rules and re-stamp checked=true, paying for a full reindex that changes nothing. Adds subject() and fetch_subject() helpers to AlembicDatabaseFixture, following the existing identifier() and data_source() helpers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The abstention guard asked only whether the identifier was absent from NAMES, which made a heading indistinguishable from a code that failed to resolve. Some distributors put the heading in the identifier field -- Boundless sends "FICTION / Horror" rather than "FIC015000" -- so those subjects stopped voting, and a work relying on them for its Adult audience tipped to Young Adult (TestWorkController::test_edit_classifications). A BISAC code is a single unpunctuated token, so require that shape before concluding a code failed to resolve. A value containing spaces or slashes is a name and is matched as one, which is what the rulesets expect. The offenders this change exists for are unaffected: neither INFEN000 nor INFENUSA contains punctuation. Note that requiring a digit would not work -- INFENUSA has none. Pins the behaviour with a unit test over four heading shapes rather than leaving an admin controller test as the only guard, and adds the -> None annotations CLAUDE.md asks for on the two new tests that were written to match their unannotated neighbours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The migration approximated "not a real BISAC code" with a pattern on the identifier, which does not agree with what the classifier considers recognizable. A shape-valid but non-existent code such as FBZZZ000000 passed the pattern and was left checked, so its fabricated fiction=False would never have been re-scored. The pattern also missed a real FIC* code stored as nonfiction, which is stale for the same reason. Select via BISACClassifier instead: reset every subject stored as nonfiction that the classifier no longer scores that way. The predicate is then the definition of the problem rather than an approximation of it, and the two cannot drift apart. Migrations here already import application code (Identifier, Timestamp, BaseCoverageRecord); a pure-logic classifier over a static table is a safer import than an ORM model. Scope is unchanged: only subjects currently holding fiction=False are examined. The count reset can now exceed the 2,418 measured against production, by however many shape-valid-but-unknown codes are stored; the migration logs what it touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
912c566f3383 (ThePalaceProject#3694) merged while this PR was open, taking the same down_revision this migration had. Two heads meant every migration test failed with "Multiple heads are present; please specify a single target revision" on all three Python versions. Re-points down_revision at 912c566f3383, restoring a single head. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f94bb1b to
6c69e22
Compare
|
@dbernstein you might want to reopen this on from as a PR from the org repo instead of your own fork, so we get full docker build and testing on it. That way the CI won't stay red on it. |
|
oops - good catch. |
|
Closing in favour of #3726, which is the same work branched in-repo rather than from a fork, so the required checks that fork PRs cannot run (Docker build, Integration test, Migration test, Unit tests) are able to report. Same five commits, rebased onto current |
|
Understood. Closing this PR in favor of #3726 is appropriate, and carrying the fixes and review feedback forward there should allow the previously unavailable required checks to run. |
…-4849) (#3726) ## Description Three changes in this PR: **1. An unrecognized BISAC code no longer votes nonfiction.** `BISACClassifier.is_fiction()` and `.audience()` now skip the BISAC rulesets when the subject identifier did not resolve to a canonical BISAC heading, deferring instead to the `KeywordBasedClassifier` call already sitting at the end of both methods. That call was unreachable until now, because the catch-all rules always matched first. **1b. The trailing-`N` strip is now conditional.** `scrub_identifier` stripped a trailing `N` unconditionally, because Palace Marketplace appends one to real codes (`FBJUV000000N` -> `JUV000000`). But the identifier field does not always hold a code — a heading can arrive there too, and **10 of the 56 top-level headings end in `N`**. The unconditional strip turned `FICTION` into `FICTIO` and `RELIGION` into `RELIGIO`, which match nothing. It now strips only when the result is a code we know. This is a companion to change 1 rather than an independent fix. When a subject has no name, `scrub_identifier_and_name` uses the scrubbed identifier *as* the name, so a mangled heading would fail the new heading gate and the subject would abstain. **Reach.** Measured against the classifier, not estimated. The change bites only on a subject with **no name** whose identifier holds one of those ten headings. Everything else is bit-identical: real codes carrying the suffix still resolve (`FBJUV000000N` -> Children / Literary Fiction, unchanged), and non-code identifiers ending in `N` that *do* have a name — `FSHUM000000N`, `INFSPSAN`, `INFASJPN`, `INFASCHN` — are untouched by this change. Those four move because of change 1. For the ten that do change (`fiction` / `audience` / `genre`, comparing `main` against this branch): | identifier, no name | `main` today | this PR | reset selects it? | |---|---|---|---| | `FICTION` | `False` / Adult / — | **`True`** / Adult / — | **yes** | | `JUVENILE FICTION` | `False` / Adult / — | **`True`** / **Children** / — | **yes** | | `YOUNG ADULT FICTION` | `False` / Adult / — | **`True`** / **Young Adult** / — | **yes** | | `JUVENILE NONFICTION` | `False` / Adult / — | `False` / **Children** / — | no | | `YOUNG ADULT NONFICTION` | `False` / Adult / — | `False` / **Young Adult** / — | no | | `DESIGN` | `False` / Adult / — | `False` / Adult / **Design** | no | | `EDUCATION` | `False` / Adult / — | `False` / Adult / **Education** | no | | `RELIGION` | `False` / Adult / — | `False` / Adult / **Religion & Spirituality** | no | | `TRANSPORTATION` | `False` / Adult / — | `False` / Adult / **Technology** | no | | `SPORTS & RECREATION` | `False` / Adult / Sports | unchanged | n/a | @tdilauro — this is the case you asked about. The mechanism is real: six of the ten change `audience` or `genre` while `fiction` stays `False`, and the reset task selects on fiction disagreement alone, so it would not pick those up. Two of the six are audience moves (`JUVENILE NONFICTION` and `YOUNG ADULT NONFICTION` go Adult -> Children / Young Adult), which is the shape you were concerned about. **Measured, though, there is nothing there.** Illinois has no BISAC subject with a `NULL` name whose identifier holds one of these headings — the query returns no rows: ```sql SELECT s.identifier, s.fiction, s.audience, count(DISTINCT w.id) AS works FROM subjects s LEFT JOIN classifications c ON c.subject_id = s.id LEFT JOIN identifiers i ON i.id = c.identifier_id LEFT JOIN licensepools lp ON lp.identifier_id = i.id LEFT JOIN works w ON w.id = lp.work_id WHERE s.type = 'BISAC' AND s.name IS NULL AND upper(regexp_replace(s.identifier, '^FB', '')) IN ( 'DESIGN','EDUCATION','FICTION','JUVENILE FICTION','JUVENILE NONFICTION', 'RELIGION','SPORTS & RECREATION','TRANSPORTATION', 'YOUNG ADULT FICTION','YOUNG ADULT NONFICTION') GROUP BY 1, 2, 3; ``` So no stored classification moves because of this change, and the reset has nothing to miss. It is forward-looking on two counts: it stops a name-less heading in the identifier field from being mangled if one does arrive, and it keeps change 1's heading gate from being defeated by the scrubbing that runs just before it. If such rows ever do show up, covering them would mean selecting on audience and genre disagreement as well as fiction — a bigger predicate and a bigger reindex, and a separate decision. **2. A recalculation can no longer erase a known fiction status — or contradict it.** `Work.assign_genres()` gains the guard its audience handling already has: when the classifier reaches no fiction determination, the work keeps the status it had rather than having `NULL` written over it. That retained status is handed to `classifier.classify()` as its *default* rather than restored after the fact. `classify()` derives the genres from the fiction status, and `WorkClassifier.genres()` skips its consistency filter entirely when that status is `None`, so a value restored afterwards arrives too late to be consulted — the work ends up stamped `fiction=False` while carrying a fiction-only genre. Tags are the realistic vector: `Horror`, `Romance` and `Historical` each contribute a genre while casting no fiction vote at all, so any work whose BISAC codes now abstain hits this if it also carries a descriptive tag. The mirror direction has the volume — `FSHUM000000N` abstains and yields the nonfiction-only genre `Social Sciences`, which a work stored as fiction would keep. Both halves of this PR are needed to reach that state: abstention leaves fiction undetermined, and the restore turns "undetermined plus a fiction genre" into "nonfiction plus a fiction genre". **3. The data repair.** Fixing the classifier does not fix the stored data. Subjects are global and are only re-examined when `checked=false`, so a subject scored under the old rules keeps its value indefinitely. This PR adds: - **`reset_non_bisac_nonfiction_subjects`** — a Celery task that asks `BISACClassifier` which BISAC subjects stored as nonfiction it no longer agrees with, and resets `checked=False` on exactly those. It logs how many it reset out of how many it examined. - **`startup_tasks/2026_09_14_reclassify_non_bisac_nonfiction_subjects.py`** — chains that reset and `classify_unchecked_subjects`, so the re-score follows the reset by seconds rather than by a day. - **`bin/work_reset_non_bisac_nonfiction_subjects`** and `ResetNonBisacNonfictionSubjectsScript` — to re-run the reset on demand. There is deliberately **no migration**. An earlier draft did the repair in a migration and carried the same selection logic twice; that has been removed in favour of one implementation. > [!NOTE] > **This is the first of two stacked PRs.** > > | | what it adds | when it merges | > |---|---|---| > | **#3726** (this) | the classifier fix and the data repair | first | > | **#3737** | a second startup task that re-applies the reset a release later, once no old code is running anywhere | **draft** — hold until the release containing this PR has shipped | > > Why two: the reset is losable. Anything reaching `Subject.assign_to_genre` first consumes it, and code running the superseded rules re-stamps `checked=true` with the same wrong value — silently. Chaining the reset and the re-score here shrinks that gap from a day to seconds; #3737 re-applies the reset a release later, when no old code is left anywhere to lose it to. ## Motivation and Context Everything on the Palace Marketplace / Feedbooks category scheme is stored with `type='BISAC'`, including codes that are not BISAC at all. In production (illinois), **3,172 of 7,726 BISAC subject rows (41%) are not BISAC codes** — mostly Feedbooks' literature-by-language taxonomy: | identifier | name | titles | |---|---|---| | `INFEN000` | English literature | 3,749 | | `INFENUSA` | American and Canadian literature | 3,105 | | `INFENGBR` | British literature | 593 | | `FSHUM000000N` | Human science | 565 | | `FBSACT000000` | News and investigations | 258 | Such a code cannot be resolved, so classification fell back to the distributor's name and hit the catch-all closing `BISACClassifier.FICTION` — *"not filed under a Fiction heading, therefore nonfiction"*. That inference is sound for a real BISAC name and unsound for anything else, so each of these cast a **nonfiction vote** on every work it touched, outvoting the genuine `FBFIC*` fiction codes on the same book. The worst of it is that these are *literature* categories: the codes most likely to appear on a novel were the ones voting against it. The `AUDIENCE` ruleset has the same catch-all, inferring Adult. That is exactly the bug fixed for `FBJUV*` codes in PP-4128 — fixed there by making those particular codes resolve, which left the underlying behaviour intact. Because the keyword classifier recognises "literature", the `INF*` family does not merely abstain — it flips to voting fiction: | identifier | name | before | after | |---|---|---|---| | `INFEN000` | English literature | `False` | **`True`** | | `INFENUSA` | American and Canadian literature | `False` | **`True`** | | `INFENGBR` | British literature | `False` | **`True`** | | `FSHUM000000N` | Human science | `False` | `None` (genre `Social Sciences` retained) | | `FBSACT000000` | News and investigations | `False` | `None` | | `SOCO32000` | *(no name)* | `False` | `None` | | `FBFIC014000` | Historical | `True` | unchanged | The trade-off is visible: `FBSACT000000` loses a nonfiction vote it was arguably entitled to. But the code cannot be resolved, so abstaining is the honest answer — and it is 258 titles against 7,447. Change 2 matters more once change 1 lands, since abstaining becomes common: a work whose only BISAC evidence is unresolvable codes would otherwise have its existing status nulled, and — once the status is kept — would keep genres that contradict it. ### Repair scope and blast radius In Illinois, measured against production: **2,418 subject rows, 4,877 works recalculated** — roughly 2% of the nightly volume behind the reindex surge in PP-4472, so no scheduled window is needed. Treat that as a floor. It was measured with a pattern-based predicate; the classifier-based one the task actually uses additionally catches shape-valid-but-non-existent codes and real `FIC*` codes stored as nonfiction. It can only add rows, and every row it adds is one holding a value the classifier disagrees with. Scope is deliberately narrow: - **Canonical BISAC codes are untouched.** 3,803 of them legitimately hold `fiction=false` (real `HIS*`/`BUS*` codes), and there is no evidence the `FBFIC*` rows are stale. - **Non-canonical codes already at `true` (693) or `NULL` (61) are untouched.** They are not implicated, and re-scoring them through a different classifier risks regressions while roughly doubling the reindex. The `FBJUV*` resets in `45f74fdcec18` and `05a95c828149` did not cover these codes. The repair and the classifier fix are in the same PR on purpose. Run on its own, the reset would simply be re-scored by the old rules and re-stamped `checked=true`, paying for a full reindex that changes nothing. ### Known remainder Of 1,871 works that carry an `FBFIC*` code but are not marked fiction, this reset reaches 1,356. The other 515 carry no non-canonical nonfiction voter, so their nonfiction votes come from real BISAC codes or they have no votes at all — some of those are likely correct (a book carrying both `FIC*` and `HIS*` codes). Widening the predicate to force them would mean resetting correct subject rows for an unknown benefit. Better to re-measure after this lands. ### Out of scope - **Genre voting.** `GENRE` has its own catch-all and does not consult the top-level-heading set, so an unresolvable code can still cast a genre vote. Change 2 keeps the genres a work *ends up with* consistent with its fiction status; it does not stop the vote. Changing that moves books between lanes and deserves its own change. - **`target_age`.** It is derived from the audience the same way genres are derived from fiction, and nothing restores it. Change 2 closes the ordering half — the audience default now reaches `classify()` before the target age is computed — but a run that reaches no range still writes `(None, None)` over an existing one. The fiction/audience guard does not transfer, because the classifier returns a `(None, None)` tuple rather than `None`. - **The root cause.** `http://www.feedbooks.com/categories` maps wholesale to `BISAC` in `Subject.by_uri`, which is how non-subject codes reach the BISAC classifier in the first place. Typing them as `tag` on import would address it, and is a larger change. ## How Has This Been Tested? Classifier: - `TestBISACClassifier.test_heading_in_identifier_field_is_matched_as_a_name` — parametrized over eight heading shapes. Some distributors put the BISAC heading in the identifier field; that is a name, not a code that failed to resolve, and must still be matched against the rulesets. - `TestBISACClassifier.test_unrecognized_code_abstains` — parametrized over no name, language name, territory name, FB-prefixed, and a partial BISAC heading. - `TestBISACClassifier.test_unrecognized_code_still_uses_keyword_fallback` — abstaining is not the same as ignoring the name; a name that does carry a signal is still honoured, for audience as well as fiction. - `TestBISACClassifier.test_name_only_subject_still_reaches_the_rulesets` — Bibliotheca sends every genre as a bare name with no code at all; a subject with no identifier had no code that could fail to resolve, so it must not abstain. - `TestBISACClassifier.test_top_level_headings_recognized` / `test_fragments_are_not_top_level_headings` — pin the membership of `TOP_LEVEL_HEADINGS`. - `TestBISACClassifier.test_recognized_code_unaffected_by_abstention` — regression anchor on `FBFIC000000/014000/016000/019000`, whose partial names ("Historical", "Literary") would each vote nonfiction if the canonical lookup ever missed. - `TestBISACClassifier.test_contradicts_stored_fiction` — the predicate the reset task selects on. - `TestWorkClassifier.test_unrecognized_bisac_codes_do_not_imply_nonfiction` — two junk codes produce no vote and no determination; one resolvable code is then decisive. Work: - `TestWork.test_assign_genres_does_not_overwrite_fiction_with_null`. - `TestWork.test_assign_genres_filters_genres_against_the_retained_fiction_status` — parametrized both directions. It fails on the previous commit with `fiction=False` and genre `Horror`, and the `fiction=True` case passes either way, so it pins the fix without pinning the bug. Repair: - `test_reset_non_bisac_nonfiction_subjects` — 7 cases, one per offender shape seen in production: a literature-by-language code, a territory code, a vendor code, a vendor code carrying the `N` suffix, a malformed code with no name at all, a shape-valid but non-existent code (`FBZZZ000000`, which a pattern-based predicate would wrongly accept), and a stale canonical fiction code. - `test_reset_non_bisac_nonfiction_subjects_leaves_everything_else_checked` — 6 contrast cases: two real nonfiction codes, a nonfiction heading in the identifier field, and rows outside the examined set (already scored `true`, already scored `NULL`, and a `tag`-typed subject with the same identifier). - `test_reset_non_bisac_nonfiction_subjects_is_idempotent` — a second run finds nothing to do. - `TestResetNonBisacNonfictionSubjectsScript.test_run` — the `bin/` wrapper dispatches the task. - `tests/manager/scripts/test_startup.py` — confirms the new startup task is discovered and satisfies the `run()` contract. Full suite locally against Postgres + Valkey: **6,268 passed, 0 failed.** 104 errors, all infrastructure gaps from not running two of the tox service containers — OpenSearch (`test_search.py`, `test_lane.py::test_search`, `test_delete_work_not_in_search_end2end`) and MinIO/S3 (`test_marc.py`, `test_s3.py`). `mypy` clean; all pre-commit hooks pass. ## Checklist - [x] I have updated the documentation accordingly. - [x] All new and existing tests passed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Supersedes #3710, which was opened from a fork. This one is branched in-repo so the required checks that fork PRs cannot run (Docker build, Integration test, Migration test, Unit tests) are able to report. Review feedback on #3710 has been addressed and is folded into the commits here: - Greptile P1 — the repair approximated "not a real BISAC code" with a pattern on the identifier, which disagreed with the classifier. A shape-valid but non-existent code such as `FBZZZ000000` slipped through. It now asks `BISACClassifier` directly, so the two definitions cannot drift apart. - Greptile P2 — missing `-> None` annotations, and a docstring on `fetch_subject`. - A regression caught by CI, not by review: the abstention guard treated a BISAC *heading* in the identifier field as a code that failed to resolve, so those subjects stopped voting and `TestWorkController::test_edit_classifications` tipped from Adult to Young Adult. The guard now asks whether the name begins with a real BISAC top-level heading. One note carried over: `codecov/patch` reads ~82%. The uncovered lines are pre-existing unreachable code that the diff re-indents — `audience()`'s `stop` branch (the `AUDIENCE` ruleset has no `m(stop, ...)` rules) and the loop-exhaustion branches (both rulesets end in a catch-all that always matches). Nothing in the change is untested, and project coverage is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Tim DiLauro <tdilauro@users.noreply.github.com>
Description
Two changes, which must ship together.
1. An unrecognized BISAC code no longer votes nonfiction.
BISACClassifier.is_fiction()and.audience()now skip the BISAC rulesets when the subject identifier did not resolve to a canonical BISAC heading, deferring instead to theKeywordBasedClassifiercall already sitting at the end of both methods. That call was unreachable until now, because the catch-all rules always matched first.2. A recalculation can no longer erase a known fiction status.
Work.assign_genres()gains the guard its audience handling already has: when the classifier reaches no fiction determination, the work keeps the status it had rather than havingNULLwritten over it.3. A migration repairs the stored side.
Resets
checked=falsefor every subject stored as nonfiction thatBISACClassifierno longer scores that way, soclassify_unchecked_subjectsre-scores them and recalculates the works they are attached to. The migration asks the classifier directly rather than pattern-matching the identifier, so the migration's notion of "not a real BISAC code" cannot drift from the runtime's.Motivation and Context
Everything on the Palace Marketplace / Feedbooks category scheme is stored with
type='BISAC', including codes that are not BISAC at all. In production (illinois), 3,172 of 7,726 BISAC subject rows (41%) are not BISAC codes — mostly Feedbooks' literature-by-language taxonomy:INFEN000INFENUSAINFENGBRFSHUM000000NFBSACT000000Such a code cannot be resolved, so classification fell back to the distributor's name and hit the catch-all closing
BISACClassifier.FICTION— "not filed under a Fiction heading, therefore nonfiction". That inference is sound for a real BISAC name and unsound for anything else, so each of these cast a nonfiction vote on every work it touched, outvoting the genuineFBFIC*fiction codes on the same book. The worst of it is that these are literature categories: the codes most likely to appear on a novel were the ones voting against it.The
AUDIENCEruleset has the same catch-all, inferring Adult. That is exactly the bug fixed forFBJUV*codes in PP-4128 — fixed there by making those particular codes resolve, which left the underlying behaviour intact.Because the keyword classifier recognises "literature", the
INF*family does not merely abstain — it flips to voting fiction:INFEN000FalseTrueINFENUSAFalseTrueINFENGBRFalseTrueFSHUM000000NFalseNone(genreSocial Sciencesretained)FBSACT000000FalseNoneSOCO32000FalseNoneFBFIC014000TrueThe trade-off is visible:
FBSACT000000loses a nonfiction vote it was arguably entitled to. But the code cannot be resolved, so abstaining is the honest answer — and it is 258 titles against 7,447.Fix 2 matters more once Fix 1 lands, since abstaining becomes common and a work whose only BISAC evidence is unresolvable codes would otherwise have its existing status nulled.
Migration scope and blast radius
Measured against production: 2,418 subject rows, 4,877 works recalculated — roughly 2% of the nightly volume behind the reindex surge in PP-4472, so no scheduled window is needed.
Treat that as a floor. It was measured with a pattern-based predicate; the classifier-based one now in the migration additionally catches shape-valid-but-non-existent codes and real
FIC*codes stored as nonfiction. It can only add rows, and every row it adds is one holding a value the classifier disagrees with. The migration logs each subject it resets plus a total.Scope is deliberately narrow:
fiction=false(realHIS*/BUS*codes), and there is no evidence theFBFIC*rows are stale.true(693) orNULL(61) are untouched. They are not implicated, and re-scoring them through a different classifier risks regressions while roughly doubling the reindex.Subjects are global and are only re-examined when
checked=false, so a subject scored under the old rules keeps its value indefinitely. TheFBJUV*resets in45f74fdcec18and05a95c828149did not cover these codes.Important
The migration must ship in the same release as the classifier fix. Run on its own, the nightly task would re-score these subjects with the old rules and re-stamp
checked=true, paying for a full reindex that changes nothing.Known remainder
Of 1,871 works that carry an
FBFIC*code but are not marked fiction, this reset reaches 1,356. The other 515 carry no non-canonical nonfiction voter, so their nonfiction votes come from real BISAC codes or they have no votes at all — some of those are likely correct (a book carrying bothFIC*andHIS*codes). Widening the predicate to force them would mean resetting correct subject rows for an unknown benefit. Better to re-measure after this lands.Out of scope
genreandtarget_agehave the same structural issue, but changing genre assignment moves books between lanes and deserves its own change.http://www.feedbooks.com/categoriesmaps wholesale toBISACinSubject.by_uri, which is how non-subject codes reach the BISAC classifier in the first place. Typing them astagon import would address the root cause, and is a larger change.How Has This Been Tested?
New tests:
TestBISACClassifier.test_heading_in_identifier_field_is_matched_as_a_name— parametrized over four heading shapes. Some distributors put the BISAC heading in the identifier field (Boundless sends"FICTION / Horror", not"FIC015000"); that is a name, not a code that failed to resolve, and must still be matched against the rulesets.TestBISACClassifier.test_unrecognized_code_abstains— parametrized over no name, language name, territory name, FB-prefixed, and a partial BISAC heading.TestBISACClassifier.test_unrecognized_code_still_uses_keyword_fallback— abstaining is not the same as ignoring the name; a name that does carry a signal is still honoured.TestBISACClassifier.test_recognized_code_unaffected_by_abstention— regression anchor onFBFIC000000/014000/016000/019000, whose partial names ("Historical", "Literary") would each vote nonfiction if the canonical lookup ever missed.TestWorkClassifier.test_unrecognized_bisac_codes_do_not_imply_nonfiction— two junk codes produce no vote and no determination; one resolvable code is then decisive.TestWork.test_assign_genres_does_not_overwrite_fiction_with_null.tests/migration/test_20260902_52d1bbdd4671_...— 12 cases. Five confirm the reset fires (each offender shape from production, including one with aNULLname); seven confirm it does not — canonical codes in all three prefix/suffix forms, a real nonfiction code, non-canonical rows already attrueorNULL, and atag-typed subject with the same identifier.Full suite locally against Postgres + Valkey: 6,203 passed, 0 failed. 104 errors, all infrastructure gaps from not running two of the tox service containers — OpenSearch (
test_search.py,test_delete_work_not_in_search_end2end) and MinIO/S3 (test_marc.py, which errors withValidationError: 3 validation errors for S3UploaderIntegrationConfiguration). Verified earlier by reverting the source changes and reproducing identical setup errors.tests/migration: 31 passed, including the built-in up/down checks, so the revision chain is intact.mypyclean across 1,169 files; all pre-commit hooks pass.Checklist
🤖 Generated with Claude Code