Re-apply the non-BISAC nonfiction reset a release later (PP-5129) - #3737
dbernstein wants to merge 5 commits into
Conversation
|
Claude finished @dbernstein's task in 1m 30s —— View job SummaryThe code is correct and I found nothing blocking.
The new I couldn't run pytest here because the sandbox wouldn't allow it. CI's DetailsMinor:
|
|
f99d214 to
76f13e8
Compare
b598a55 to
b99b337
Compare
b99b337 to
03b5199
Compare
1a85d67 to
5769c9a
Compare
03b5199 to
fe37adb
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3737 +/- ##
=======================================
Coverage 93.75% 93.75%
=======================================
Files 510 510
Lines 46477 46477
Branches 6312 6312
=======================================
Hits 43574 43574
Misses 1875 1875
Partials 1028 1028 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
fe37adb to
b72348a
Compare
b72348a to
3356082
Compare
14ed678 to
2d90632
Compare
3356082 to
a9e764b
Compare
…-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>
Hold in draft. Merge only after the release containing #3726 and #3736 has gone out. The release N repair -- migration 52d1bbdd4671 plus the startup task that dispatches the re-score -- is exposed for as long as any old code is running. Anything reaching Subject.assign_to_genre before the re-score lands consumes the reset, and code on the superseded rules re-stamps checked=True with the same wrong value. helpers/migrate.yml stops the scripts container, where every Celery worker runs, before migrating, so the worker path is safe. The web containers are not: they are recycled after the migration and can reach assign_to_genre through a presentation recalculation. Fargate deployments are not governed by that playbook at all. Running the reset again a release later closes both, because by then no old code is live anywhere. The task is idempotent, so if the release N repair took, this is a no-op. Re-scoring is left to the nightly classify_unchecked_subjects: with no old code to lose the reset to, dispatching it here would buy nothing. Same shape as the null-audience repair, where startup task 2026_06_17 re-ran what 2026_05_12 had dispatched a release earlier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release N repair is now a startup task rather than a migration, so the reference to migration 52d1bbdd4671 no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review nit: the TODO named this task, the release N startup task and reset_non_bisac_nonfiction_subjects, but not BISACClassifier.contradicts_stored_fiction. That method's only non-test caller is the Celery task, so removing the three listed items would have orphaned it. Adds it, plus the script and bin wrapper, so the repair lifts out in one pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a9e764b to
3b42a47
Compare
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The key comes from the filename, so 2026_09_14_reapply sorted ahead of the 2026_09_14_reclassify repair it re-applies. Nothing has recorded the key yet, so re-dating it to the merge date is free and puts the two in deploy order. Also cover startup_tasks/ for the first time: every existing test points discovery at tmp_path, so a shipped task that fails to import would go green. Discovery skips a module it cannot import, so the new test compares the discovered keys against the files on disk rather than trusting what came back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Description
One startup task that re-applies the non-BISAC nonfiction reset a release after the original repair, plus the first test coverage of the real
startup_tasks/directory. Two files, 76 lines.No migration and no schema change anywhere in this workstream: the reset is a Celery task,
reset_non_bisac_nonfiction_subjects, added in #3726 and dispatched by both startup tasks. Hence noDB migrationlabel.The task key is
2026_10_02_reapply_non_bisac_nonfiction_reset— dated to the merge, not to when the file was written. Keys come from the filename and discovery runs them in sorted order, so the original2026_09_14_prefix sorted this ahead of the2026_09_14_reclassify_...repair it re-applies. Only reachable on a database where both are pending, where there is nothing to repair anyway, but the listing read backwards. Nothing has recorded the old key, so the rename cost nothing.Motivation and Context
#3726 shipped in v48.0.0, so the release N repair — startup task
2026_09_14_reclassify_non_bisac_nonfiction_subjects, which resets the affected subjects and chains the re-score behind it — has gone out and this is now unblocked.Repairing a subject means resetting
checked=Falseso it gets re-scored. Anything reachingSubject.assign_to_genrebefore the re-score lands consumes that reset, and code running the superseded rules re-stampschecked=Truewith the same wrong value. Nothing errors, nothing retries, and no later run revisits the subject: the repair silently did nothing, having paid for a reindex to do it.Chaining the re-score shrinks that window to seconds, but does not close it. Deployment ordering below is from the hosting-playbook repo (
helpers/migrate.yml); the container layout is fromdocker/here.docker/runit-scripts/holdsbeat,worker-default,worker-high,worker-apply— so no old worker is alive when the reset is dispatched.docker/runit-web/runsnginxanduwsgi, and the admin work editor reaches classification directly:WorkController.editand.edit_classificationsboth callcalculate_presentationwith aclassify=Truepolicy, which runsassign_genresand soSubject.assign_to_genre.selectattr('fargate', 'true')and does no docker work for those. Not governed by that file at all.Running the reset once more a release later closes both, because by then no old code is live anywhere. It needs no deployment-topology argument to hold — which is the point, given that two of those three rows depend on infrastructure detail that can change without anyone touching this repo.
The task is idempotent and self-selecting: it recomputes which subjects the classifier no longer agrees with rather than replaying a stored list. If the release N repair took, this finds nothing and the run is a no-op.
Re-scoring is deliberately left to the nightly
classify_unchecked_subjects. The release N task chains it immediately because at that moment the reset is racing old code; here there is none to lose it to, so dispatching would only move a reindex into deploy time for nothing.This is the second half of a shape the repo has used before. For the sibling null-audience defect, startup task
2026_06_17re-ran a release later what2026_05_12had dispatched;startup_tasks/2026_06_17_repair_remaining_null_audience_works.pyis the direct model for this file, down to being a one-line.s()dispatcher.Cleanup
The docstring carries a TODO naming the full removal surface for PP-5129, to be done once this has run on all deployments: this task, the release N startup task,
reset_non_bisac_nonfiction_subjectswith its script andbin/wrapper,BISACClassifier.contradicts_stored_fiction(whose only non-test caller is that task), and the second of the two tests added here.How Has This Been Tested?
tests/manager/scripts/test_startup.py— 20 passed undertox -e py312-docker, including two new tests.mypyclean; all pre-commit hooks pass.Nothing previously imported the shipped
startup_tasks/directory. Every test in that file pointsdiscover_startup_tasksattmp_path, and coveragesource(pyproject.toml:149) does not include the directory — so a task that failed to import would have shipped green. Verified by appending a syntax error to this task and re-running: the suite stayed green. This PR closes that:test_discover_shipped_startup_tasks— discovery over the realSTARTUP_TASKS_DIR. Discovery logs and skips a module it cannot import, so asserting only that the results are callable would still pass on a broken task; the test compares the discovered keys against the files on disk. Covers all six tasks, and any added later.test_reapply_non_bisac_nonfiction_reset— this task loads from the real directory and returns a signature forreset_non_bisac_nonfiction_subjects, and does not chain the re-score.Both were confirmed to fail against a deliberately broken copy of the task before being kept.
The task being dispatched is covered in #3726 by
test_reset_non_bisac_nonfiction_subjects(7 offender shapes),test_reset_non_bisac_nonfiction_subjects_leaves_everything_else_checked(6 contrast cases) andtest_reset_non_bisac_nonfiction_subjects_is_idempotent— the last of which is what makes re-running it here safe.Checklist
🤖 Generated with Claude Code