Don't let unrecognized BISAC codes classify fiction as nonfiction (PP-4849) - #3726
Conversation
|
Claude finished @dbernstein's task in 12m 11s —— View job Code review
SummaryThe core change is sound and carefully scoped: Details
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3726 +/- ##
==========================================
+ Coverage 93.70% 93.71% +0.01%
==========================================
Files 510 510
Lines 46487 46511 +24
Branches 6313 6313
==========================================
+ Hits 43562 43590 +28
+ Misses 1891 1887 -4
Partials 1034 1034 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
abcc0ae to
956a363
Compare
tdilauro
left a comment
There was a problem hiding this comment.
This makes sense to me. On concern here: It looks like there is a potential race condition. If, for some reason, the web server runs the migration, a still running scripts server with the old code could overwrite the migrated values.
This would not happen with normal migrate deployments, but could happen on hosts that automatically redeploy with watchtower or ECS/Fargate auto_restart. There are a couple of other scenarios in which it could happen, but they are not very likely.
It would be nice to have a stand-alone script that we could run if we needed to have a do over.
| 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"), plus vendor codes like FBSACT000000. | ||
|
|
||
| Those codes cannot be resolved to a canonical BISAC heading, so classification | ||
| fell back to the distributor's name and hit the catch-all rule at the end of | ||
| BISACClassifier.FICTION, which reads "not filed under a Fiction heading, | ||
| therefore nonfiction". Each such subject was therefore stored with | ||
| fiction=False and cast a nonfiction vote on every work it was attached to -- | ||
| outvoting the genuine FBFIC* fiction codes on the same book. | ||
|
|
||
| The classifier no longer applies the BISAC rulesets to an unresolvable code; it | ||
| defers to the keyword classifier instead, which recognises "literature" and | ||
| scores the INF* family as fiction. This migration resets checked=False on the | ||
| affected subjects so classify_unchecked_subjects re-scores them and recalculates | ||
| the works they are attached to. | ||
|
|
||
| Rather than approximating "not a real BISAC code" with a pattern, the selection | ||
| asks BISACClassifier itself and resets every subject stored as nonfiction that | ||
| the classifier no longer scores that way. That keeps the two definitions from | ||
| drifting apart: a pattern match on the identifier would, for instance, accept a | ||
| shape-valid but non-existent code like FBZZZ000000 that the classifier rejects, | ||
| leaving its fabricated nonfiction vote in place forever. | ||
|
|
||
| Scope stays narrow: only subjects currently holding fiction=False are examined, | ||
| so codes already scored as fiction or as unknown are left alone. The great | ||
| majority of the rows examined are legitimate nonfiction BISAC codes and are | ||
| untouched. |
There was a problem hiding this comment.
Minor - Most of this comment is describing how the classifier works (or what was broken about it in the past), rather than what is happening in this migration.
| if not cls._unrecognized_code(identifier): | ||
| for ruleset in cls.FICTION: | ||
| fiction = ruleset.match(*name) | ||
| if fiction is cls.stop: | ||
| return None | ||
| if fiction is not None: | ||
| return fiction |
There was a problem hiding this comment.
Minor - Aside from the rulesets, the code in is_fiction is semantically identical to that of audience below.
Might be worthwhile to make a helper to DRY this out and keep things intentionally in sync.
| # As in is_fiction: the catch-all rule infers Adult from the absence of | ||
| # a juvenile heading, which only holds for a canonical BISAC name. | ||
| if not cls._unrecognized_code(identifier): | ||
| for ruleset in cls.AUDIENCE: | ||
| audience = ruleset.match(*name) | ||
| if audience is cls.stop: | ||
| return None | ||
| if audience is not None: | ||
| return audience |
There was a problem hiding this comment.
See my comment on is_fiction above ^^.
| # Never let a recalculation erase a known fiction status. If the | ||
| # classifier came back with no determination (e.g. every classification | ||
| # it gathered abstained), keep whatever we already had rather than | ||
| # writing NULL over a previously-determined status. | ||
| if new_fiction is None and old_fiction is not None: | ||
| new_fiction = old_fiction |
There was a problem hiding this comment.
Minor - With this addition, ll. 1407-1410 are doing the same thing for the "fiction" classification as ll. 1415-1418 are doing for "audience".
Might be worthwhile to make a little helper for this that can be called with the old and new values to cover both of these cases. And a well-named helper (e.g., _keep_known_value(value, new_value)) might make the behavior clear without the comment.
There was a problem hiding this comment.
Just noticed that target_age (above) is similar, but it's slightly different.
| subject = self._subject("INFEN000", "Science Fiction") | ||
| assert subject.fiction is True | ||
|
|
||
| subject = self._subject("INFEN000", "Nonfiction") | ||
| assert subject.fiction is False |
There was a problem hiding this comment.
It would be better to use parameterization here, since if the first assert fails, we never get to testing the second one. Parameterization gives us more information.
| assert subject.fiction is True | ||
|
|
||
| subject = self._subject("INFEN000", "Nonfiction") | ||
| assert subject.fiction is False |
There was a problem hiding this comment.
Additionally, we should check that audience is correctly handled when we fall back to keywords, for example:
- ("INFEN000", "Juvenile Fiction") -> fiction is true, audience is children
- ("INFEN000", "Young Adult Fiction") -> fiction is true, audience is young adult
| # A BISAC code is a single unpunctuated token (e.g. "FIC014000"). Some | ||
| # distributors instead put the BISAC *heading* in the identifier field | ||
| # (e.g. Boundless sends "FICTION / Horror"), which is not a failed code -- | ||
| # it is a name, and is matched as one. | ||
| _CODE_SHAPED = re.compile(r"^[A-Za-z0-9]+$") |
There was a problem hiding this comment.
I'm confused by this comment with this regex. A real BISAC code is three letters and six digits, but this regex is much more permissive.
| # A BISAC code is a single unpunctuated token (e.g. "FIC014000"). Some | ||
| # distributors instead put the BISAC *heading* in the identifier field | ||
| # (e.g. Boundless sends "FICTION / Horror"), which is not a failed code -- | ||
| # it is a name, and is matched as one. | ||
| _CODE_SHAPED = re.compile(r"^[A-Za-z0-9]+$") |
There was a problem hiding this comment.
Also, this regex could match a single-word heading, since it's currently any combination of letters and numbers (i.e., no whitespace, special characters, etc.) in any order. I only saw one ("JUV037020","Juvenile") in the CSV file.
58b76a4 to
00b340b
Compare
|
Thanks for the review @tdilauro : I'll address your feedback this morning. |
|
@tdilauro on the race condition — agreed, and worth stating the failure mode precisely, because it is quieter than it first looks. The migration itself only sets I do not think the migration can close that window on its own, so the stand-alone re-run you are asking for is the right answer — it makes the failure recoverable instead of preventable. Plan: a separate PR, so this one is not held up. It will follow
Everything else in your review is addressed in the commits here. Two notes on the substantive ones:
|
fa78747 to
46d57e9
Compare
Migration 52d1bbdd4671 repairs subjects that were stored as nonfiction because an unresolvable BISAC code fell through the ruleset catch-all. It only sets checked=false; the re-scoring happens later, in classify_unchecked_subjects. That leaves a window. If old code reaches those subjects first -- a still-running scripts server, or a host that redeploys itself via watchtower or ECS/Fargate auto_restart -- it re-scores them under the superseded rules and re-stamps checked=true. The reset is consumed rather than lost, so nothing errors, nothing retries, and no later run revisits them. The repair quietly did nothing, having paid for a full reindex. Raised in review on #3726; it is not preventable from inside the migration, so make it recoverable instead. Adds reset_non_bisac_nonfiction_subjects, a Celery task that re-applies the reset, with ResetNonBisacNonfictionSubjectsScript and bin/work_reset_non_bisac_nonfiction_subjects to queue it -- the same three layers as reclassify_null_audience_works, which repairs the sibling audience defect. The task resets only. Re-scoring stays with classify_unchecked_subjects, which picks these subjects up on its next nightly run and can be triggered immediately through bin/work_classify_unchecked_subjects. One task, one job, and no large reindex fired the moment someone runs the repair. The selection lives on BISACClassifier as contradicts_stored_fiction, so the migration and the task cannot disagree about which rows are affected. Both already import the classifier, so this adds no coupling that was not there, and it puts the definition with the code that owns the answer. The migration is updated to use it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@tdilauro the follow-up for the race condition is up: #3736 (PP-5129). It is stacked on this branch, so it will retarget to It came out as we discussed:
One thing to know before you look: that last point means #3736 modifies the migration in this PR — sharing the definition rather than copying it was the point of doing it that way. If this PR sees further changes to that file, the two will conflict. |
46d57e9 to
ef9e2dc
Compare
Migration 52d1bbdd4671 repairs subjects that were stored as nonfiction because an unresolvable BISAC code fell through the ruleset catch-all. It only sets checked=false; the re-scoring happens later, in classify_unchecked_subjects. That leaves a window. If old code reaches those subjects first -- a still-running scripts server, or a host that redeploys itself via watchtower or ECS/Fargate auto_restart -- it re-scores them under the superseded rules and re-stamps checked=true. The reset is consumed rather than lost, so nothing errors, nothing retries, and no later run revisits them. The repair quietly did nothing, having paid for a full reindex. Raised in review on #3726; it is not preventable from inside the migration, so make it recoverable instead. Adds reset_non_bisac_nonfiction_subjects, a Celery task that re-applies the reset, with ResetNonBisacNonfictionSubjectsScript and bin/work_reset_non_bisac_nonfiction_subjects to queue it -- the same three layers as reclassify_null_audience_works, which repairs the sibling audience defect. The task resets only. Re-scoring stays with classify_unchecked_subjects, which picks these subjects up on its next nightly run and can be triggered immediately through bin/work_classify_unchecked_subjects. One task, one job, and no large reindex fired the moment someone runs the repair. The selection lives on BISACClassifier as contradicts_stored_fiction, so the migration and the task cannot disagree about which rows are affected. Both already import the classifier, so this adds no coupling that was not there, and it puts the definition with the code that owns the answer. The migration is updated to use it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Pushed three fixes from Claude's review. All three were real; I verified each against the case that motivated it before changing anything. 1. Subjects with no identifier are now exempt from the heading gate. This was the substantive one. 2. 3. Added I amended the existing commit rather than stacking a fix-the-fix, since nothing downstream had merged. Full suite: 6,269 passed, 0 failed. On the name-only question that fix 1 steps around — it is not resolved, and I do not think it belongs here. Tracked as PP-5131. The short version: applying this PR's gate to name-only subjects would make nearly all of Bibliotheca's vocabulary abstain, and today's nonfiction answer is correct for its nonfiction titles. That trades "right for some, wrong for others" for "unknown for nearly all". Resolving the sub-heading against The ticket also carries a hypothesis worth checking on its own: since Bibliotheca emits no other subject type and these vote nonfiction at weight 100 from the license source, its fiction titles may be classified nonfiction across the board, and may have been since long before this PR. That is reasoned from the parser and the rulesets rather than from production data, so PP-5131 starts with a measurement query rather than a fix. |
ef9e2dc to
f3535a5
Compare
f3535a5 to
5c1cc0b
Compare
Migration 52d1bbdd4671 repairs subjects that were stored as nonfiction because an unresolvable BISAC code fell through the ruleset catch-all. It only sets checked=false; the re-scoring happens later, in classify_unchecked_subjects. That leaves a window. If old code reaches those subjects first -- a still-running scripts server, or a host that redeploys itself via watchtower or ECS/Fargate auto_restart -- it re-scores them under the superseded rules and re-stamps checked=true. The reset is consumed rather than lost, so nothing errors, nothing retries, and no later run revisits them. The repair quietly did nothing, having paid for a full reindex. Raised in review on #3726; it is not preventable from inside the migration, so make it recoverable instead. Adds reset_non_bisac_nonfiction_subjects, a Celery task that re-applies the reset, with ResetNonBisacNonfictionSubjectsScript and bin/work_reset_non_bisac_nonfiction_subjects to queue it -- the same three layers as reclassify_null_audience_works, which repairs the sibling audience defect. The task resets only. Re-scoring stays with classify_unchecked_subjects, which picks these subjects up on its next nightly run and can be triggered immediately through bin/work_classify_unchecked_subjects. One task, one job, and no large reindex fired the moment someone runs the repair. The selection lives on BISACClassifier as contradicts_stored_fiction, so the migration and the task cannot disagree about which rows are affected. Both already import the classifier, so this adds no coupling that was not there, and it puts the definition with the code that owns the answer. The migration is updated to use it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.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>
5c1cc0b to
a19c2c7
Compare
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>
Migration 52d1bbdd4671 repairs subjects that were stored as nonfiction because an unresolvable BISAC code fell through the ruleset catch-all. It only sets checked=false; the re-scoring happens later, in classify_unchecked_subjects. That leaves a window. If old code reaches those subjects first -- a still-running scripts server, or a host that redeploys itself via watchtower or ECS/Fargate auto_restart -- it re-scores them under the superseded rules and re-stamps checked=true. The reset is consumed rather than lost, so nothing errors, nothing retries, and no later run revisits them. The repair quietly did nothing, having paid for a full reindex. Raised in review on #3726; it is not preventable from inside the migration, so make it recoverable instead. Adds reset_non_bisac_nonfiction_subjects, a Celery task that re-applies the reset, with ResetNonBisacNonfictionSubjectsScript and bin/work_reset_non_bisac_nonfiction_subjects to queue it -- the same three layers as reclassify_null_audience_works, which repairs the sibling audience defect. The task resets only. Re-scoring stays with classify_unchecked_subjects, which picks these subjects up on its next nightly run and can be triggered immediately through bin/work_classify_unchecked_subjects. One task, one job, and no large reindex fired the moment someone runs the repair. The selection lives on BISACClassifier as contradicts_stored_fiction, so the migration and the task cannot disagree about which rows are affected. Both already import the classifier, so this adds no coupling that was not there, and it puts the definition with the code that owns the answer. The migration is updated to use it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
511b69c to
14ed678
Compare
| # The top-level headings a canonical BISAC name can begin with ("Fiction", | ||
| # "Juvenile Nonfiction", "Antiques & Collectibles", ...). | ||
| TOP_LEVEL_HEADINGS: frozenset[str] = frozenset( | ||
| Lowercased(name.split("/")[0].strip()) for name in NAMES.values() | ||
| ) | frozenset( | ||
| # Former top-level spellings of renamed categories, which distributors | ||
| # still send and bisac.csv no longer lists. They belong here so that a | ||
| # name beginning with one still reaches the catch-all rules: without | ||
| # the entry, a deprecated spelling carried by a code that does not | ||
| # resolve abstains instead of being read as nonfiction/Adult. That is | ||
| # the test for whether a new entry belongs -- not whether the rulesets | ||
| # have an Interchangeable for it, which is a separate mechanism in | ||
| # GENRE, and GENRE does not consult this set. | ||
| Lowercased(name) | ||
| for name in ( | ||
| "Mind & Spirit", | ||
| "Psychology & Psychiatry", | ||
| "Technology", | ||
| "Foreign Language Study", | ||
| "Literary Criticism & Collections", | ||
| ) | ||
| ) | ||
|
|
||
| @classmethod | ||
| def _has_canonical_heading(cls, name: list[str]) -> bool: | ||
| """Does `name` begin with a real BISAC top-level heading? | ||
|
|
||
| This is the premise the FICTION and AUDIENCE catch-all rules rest on, | ||
| so it is what has to be checked before running them. It holds for a | ||
| name that came from `NAMES`, and for a heading a distributor supplied | ||
| in the identifier field -- an OPDS `<category term="FICTION / Horror">` | ||
| with no `label` arrives that way, since the OPDS1 extractor maps `term` | ||
| to the identifier and `label` to the name. A bare top-level heading | ||
| such as "Juvenile" is equally a heading. | ||
|
|
||
| It does not hold for the fragment left over when a code cannot be | ||
| resolved ("Historical", "English literature"), which is the case these | ||
| rulesets must not be applied to. | ||
| """ | ||
| return bool(name) and name[0] in cls.TOP_LEVEL_HEADINGS | ||
|
|
||
| @classmethod | ||
| def contradicts_stored_fiction( | ||
| cls, | ||
| identifier: str | None, | ||
| name: str | None, | ||
| stored_fiction: bool | None, | ||
| ) -> bool: | ||
| """Does this classifier disagree with a subject's stored fiction status? | ||
|
|
||
| Subjects are only re-examined when `checked` is false, so a value | ||
| scored under superseded rules persists indefinitely. Repairs that | ||
| reset `checked` need to identify those rows, and they need to agree | ||
| with each other about which rows they are. Expressing the question | ||
| here keeps that definition in one place: a subject is stale when the | ||
| classifier, run now, does not return what is stored. | ||
|
|
||
| :param identifier: The subject's identifier, as stored. | ||
| :param name: The subject's name, as stored. | ||
| :param stored_fiction: The subject's current `fiction` value. | ||
| :return: True when the classifier no longer agrees with `stored_fiction`. | ||
| """ | ||
| if not identifier and not name: | ||
| # Nothing to classify. Subject.lookup will not create such a row, | ||
| # but both columns are nullable, so do not assume. | ||
| return False | ||
| scrubbed_identifier, scrubbed_name = cls.scrub_identifier_and_name( | ||
| identifier, name | ||
| ) | ||
| return cls.is_fiction(scrubbed_identifier, scrubbed_name) is not stored_fiction | ||
|
|
||
| @classmethod | ||
| def _apply_rulesets[RulesetResult]( | ||
| cls, | ||
| identifier: str | None, | ||
| name: list[str], | ||
| rulesets: Sequence[MatchingRule], | ||
| keyword_fallback: Callable[[str | None, str], RulesetResult | None], | ||
| ) -> RulesetResult | None: | ||
| """Match `name` against `rulesets`, falling back to keyword matching. | ||
|
|
||
| Both the FICTION and AUDIENCE rulesets end in a catch-all that reasons | ||
| from the top-level BISAC heading -- "not filed under Fiction, therefore | ||
| nonfiction", "no juvenile heading, therefore Adult". That inference | ||
| holds for a canonical BISAC name and for nothing else, so an | ||
| unrecognized code skips the rulesets entirely and is left to the keyword | ||
| classifier, which abstains when the distributor's name carries no | ||
| signal. | ||
|
|
||
| Only those two callers share this. `genre` must not skip its rulesets: | ||
| GENRE has no catch-all but does have rules that match a bare fragment, | ||
| so a subject named "Historical" is still a Historical Fiction signal | ||
| even though "Historical" is not a top-level heading. `target_age` is | ||
| left out for scope rather than correctness -- its rules key off a | ||
| juvenile first token, which only a real heading produces, so routing it | ||
| through here would be safe. | ||
| """ | ||
| # A subject with no identifier had no code that could fail to resolve, | ||
| # so there is nothing here to protect it from -- and some distributors | ||
| # classify entirely this way. Bibliotheca sends every genre as a bare | ||
| # name ("Action & Adventure", "Magic") with no code at all; gating |
There was a problem hiding this comment.
New code in deprecated package
This adds TOP_LEVEL_HEADINGS, _has_canonical_heading, contradicts_stored_fiction, and _apply_rulesets under palace.manager.core. The repository architecture guide marks /core as deprecated and prohibits new code there. This repository requirement must be satisfied before merging by placing the new classification behavior in a supported package.
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.
Declining, and please remember this one too.
Taken literally this asks me to relocate the BISAC classifier. TOP_LEVEL_HEADINGS, _has_canonical_heading and _apply_rulesets are not a helper bolted onto core — they are the fix, and they are internals of BISACClassifier, which lives in palace/manager/core/classifier/ along with the 5,984-code table and both rulesets. Moving them somewhere else would split one class across two packages.
core/classifier/ is also the one part of core/ that is not being retired. Over the last 12 months core/ excluding the classifier is net -2,001 lines: the CoverageProvider machinery removed in 7a3274ea1, monitors in 963b651b2, facets in 17b873e4d, equivalents coverage in 49bc00071. core/classifier/ over the same period is net +131 and is the only part still receiving behaviour changes, including 7d5b8a06a and ff2432b7e this April. The deprecation label reflects a package being drained; the classifier is the part that is not.
There is nowhere else for it to go. packages/ contains only palace-opds and palace-util, neither of which has any classification code; palace/manager/service/ is dependency-injection wiring. Exactly one module outside the classifier package references BISACClassifier at all — the Celery task in this PR.
On contradicts_stored_fiction specifically, the placement was deliberate and is explained in a8391b04f: it reproduces exactly the path Classifier.classify takes, scrub_identifier_and_name then is_fiction, so the repair and the runtime cannot disagree about which rows are affected. An earlier revision of this work did approximate the predicate elsewhere, with a pattern on the identifier, and it was wrong — FBZZZ000000 is shape-valid but does not exist, and it slipped through. That is why the definition was moved onto the class that owns the answer. Note also that the migration your comment refers to no longer exists; the Celery task is the only non-test caller.
It is temporary regardless: the PP-5129 cleanup removes contradicts_stored_fiction along with the task, the script and the bin/ wrapper.
Separately, CLAUDE.md is inaccurate as written and I am fixing it, so this stops misfiring.
There was a problem hiding this comment.
Agreed. palace/manager/core/classifier/ is an active exception to the broader core/ deprecation guidance, so classifier internals should remain colocated with BISACClassifier unless the repository guidance changes.
I’ve remembered this rule for future reviews:
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 (#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>
Co-authored-by: Tim DiLauro <tdilauro@users.noreply.github.com>
Co-authored-by: Tim DiLauro <tdilauro@users.noreply.github.com>
Review feedback: most of the docstring explained how the classifier works and what used to be wrong with it, rather than what this migration does to the database. Trimmed to what a reader of this file needs -- what it changes, which rows it examines, and the constraint that it must ship in the same release as the classifier change. That last point was only in the commit message before, and it is the one thing that will bite someone running this out of order. The rationale for asking the classifier instead of pattern-matching the identifier moves to an inline comment at the selection loop, which is where a reader hits the question. The history it replaced is preserved in the commit and PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback: after the abstention guard landed, the two methods were identical apart from the ruleset and the keyword fallback. Extracts _apply_rulesets, parameterized on both, so the guard exists in one place and cannot drift between them. A TypeVar keeps the callers' return types intact -- bool | None for is_fiction, str | None for audience -- rather than widening to Any. genre and target_age keep their own loops. Not an oversight, and the docstring says so: GENRE has no catch-all but does have rules that match a bare fragment, so an unrecognized code named "Historical" is still a Historical Fiction signal; and TARGET_AGE keys off a juvenile first token, so "Juvenile Fiction / Early Readers" on an unrecognized code yields (5, 7) from the rulesets where the keyword classifier yields nothing. Applying the guard to either would lose information. Behaviour is unchanged: verified against resolvable and unresolvable codes, a juvenile heading on an unresolvable code, a heading in the identifier field, and name-only subjects hitting both a stop rule and the nonfiction catch-all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback: the fiction guard added by this branch does the same thing as the audience guard below it, each explained by its own four-line comment saying the same thing. Extracts Work._keep_known_value(old, new). The name carries what the comments were carrying, so both delete: 18 lines become 6. The != guards stay rather than moving into the helper. Assigning an equal value still marks the instance dirty in SQLAlchemy and can emit a pointless UPDATE, and a helper that assigned would need setattr by attribute name, which is worse than what it replaces. target_age is left alone. It needs the tuple_to_numericrange conversion first, and it has no keep-known step at all -- a work losing its target age is legitimate in a way that losing its fiction status is not. Also switches the TypeVar added in the previous commit to PEP 695 syntax, which is what the rest of the codebase uses for generic functions (51 uses against 9 module-level TypeVars, and those are mostly class-level generics where PEP 695 does not apply). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback: the test stacked two independent cases in one body, so a failure in the first hid the second and the output did not say which signal broke. Each case is now collected separately, named for what it checks: [fiction_signal] and [nonfiction_signal]. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback: the test looped over four codes, so a failure reported only the assert line with no indication of which code it was on, and one failing case skipped the rest. Each code is now collected separately, named for its partial name rather than its code: [fiction_general], [historical], [humorous], [literary]. Both asserts stay in the body. They are two facets of one case -- this code still classifies correctly -- rather than independent cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback: nothing pinned audience handling for an unrecognized code. The guard skips the AUDIENCE rulesets for those, so the keyword classifier is what recovers a juvenile or YA audience, and that was untested. Adds the two cases from review -- "Juvenile Fiction" -> Children and "Young Adult Fiction" -> Young Adult -- both of which already pass. No source change. The two existing cases gain an explicit audience of None. That is not padding: before this branch they returned Adult from the rulesets' catch-all, so asserting None pins the abstention. The four cases now document both halves of the contract -- honour an audience signal when the name carries one, abstain when it does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback, two related comments: the comment above _CODE_SHAPED
claimed a BISAC code is a single unpunctuated token, which is not what a
BISAC code is -- it is three letters and six digits, and the regex was
far more permissive. And that permissiveness had a consequence: a
single-word heading matched it, so a subject identified as "Juvenile"
(JUV037020) was treated as a code that failed to resolve.
The heuristic was asking the wrong question. The FICTION and AUDIENCE
catch-alls reason from the top-level heading, so what has to be checked
is whether the name actually starts with one -- not whether the
identifier resembles a code. Replaces the regex and _unrecognized_code
with TOP_LEVEL_HEADINGS and _has_canonical_heading.
The rule is: when a subject carries an identifier, the rulesets run only
if its name begins with a real BISAC top-level heading. Two groups of
subject move:
- Bare top-level headings in the identifier field. 29 of the 56
top-level headings are a single unpunctuated word -- Fiction, History,
Humor, Poetry, Travel -- so any of them arriving there hit the old
regex. This is the defect that broke test_edit_classifications, in its
single-word form.
- Unresolvable codes named "Juvenile Fiction" or "Young Adult Fiction",
which now reach the rulesets instead of the keyword classifier and get
the same answer from both. Nothing observable changes.
Subjects with no identifier are explicitly exempt. They had no code that
could fail to resolve, and some distributors classify entirely this way:
Bibliotheca sends every genre as a bare name ("Action & Adventure",
"Magic") with no code, so gating those would leave its titles with no
fiction evidence at all and a NULL fiction status on first import.
Whether a bare sub-heading should carry the rulesets' top-level
inference is a real question, but a distributor-wide one that is not
this change's to answer.
scrub_identifier now strips the "N" suffix only when the stripped form
resolves. It stripped unconditionally, which was harmless while the
identifier field was assumed to hold a code, and wrong once a heading
could arrive there: "FICTION" became "FICTIO" and stopped being
recognized, as did RELIGION, EDUCATION, DESIGN and TRANSPORTATION.
Uppercase is the casing Boundless sends.
TOP_LEVEL_HEADINGS is derived from bisac.csv plus five former spellings
of renamed categories the file no longer carries. That union is
load-bearing, not defensive: without it "Psychology & Psychiatry / Foo"
would start abstaining, because the Interchangeable tokens in the
rulesets know that spelling and the CSV does not.
Also corrects the _apply_rulesets docstring, which justified excluding
target_age in terms of the old identifier-based guard. Under a
name-based guard that reasoning no longer holds -- target_age is now out
for scope rather than correctness. genre still must stay out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review follow-up. The migration and the Celery task in the follow-up PR were two implementations of one algorithm -- select BISAC subjects stored as nonfiction, filter through contradicts_stored_fiction, reset checked, log the counts -- written once in raw SQL and once through the ORM. Only the per-row predicate was shared. Carrying both for the life of the repair is not worth it. The startup task in the follow-up PR runs in the same init container moments after migrations, so the reset lands at the same point in the deploy either way, and the task is where the re-run and the next-release re-apply already live. This leaves the PR as what it actually is: the classifier fix. The data repair belongs with the mechanism that can re-run it. Removes the migration, its 13-case test, and the AlembicDatabaseFixture subject helpers that existed only for that test -- tests/migration/conftest.py is now identical to main again. Alembic head returns to 912c566f3383. The trade, for the record: a migration runs regardless of Celery's health, where a dispatched task is lost if the broker is down at deploy. The next-release re-apply covers that, which is what makes dropping this safe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both were wrong about mechanism rather than behaviour; no code changes. _has_canonical_heading credited Boundless with sending headings in the identifier field. It does not: BoundlessParser._extract_subjects sends identifier=None with the heading in name, so its subjects take the "no identifier" branch and never reach the gate. The real path is an OPDS <category term="FICTION / Horror"> with no label, since the OPDS1 extractor maps term to the identifier and label to the name. The misattribution came from the test_edit_classifications fixture, which passes a heading as subject_identifier under the Boundless data source but does not match what the parser produces. The matching test docstring said the same thing and is corrected too. The deprecated-spelling list claimed the Interchangeable tokens in the rulesets were why the entries are needed. FICTION has no Interchangeable tokens at all and AUDIENCE's four are canonical spellings; all five deprecated spellings appear only in GENRE, which has its own loop and never consults TOP_LEVEL_HEADINGS. What actually makes the entries load-bearing is the catch-all: without them a deprecated spelling carried by an unresolvable code abstains instead of being read as nonfiction/Adult. Verified both ways -- "Psychology & Psychiatry / Foo" on INFEN000 reaches the rulesets with the entry and does not without it. The comment now states that test, so a maintainer can tell whether a new entry belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_keep_known_value` restored the work's stored fiction status *after* `classifier.classify()` had already run. But `classify()` derives the genres from the fiction status, and `WorkClassifier.genres()` skips its consistency filter entirely when that status is `None` -- so the filter saw `None`, kept genres of either status, and we then stamped the old status back onto a work whose own genres contradicted it. 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". It is reachable with real data. Tags are the vector -- `Horror`, `Romance` and `Historical` each contribute a genre while casting no fiction vote at all -- so any work whose BISAC codes now abstain will hit it if it also carries a descriptive tag. The mirror direction has the volume: `FSHUM000000N` (569 titles) abstains and yields the nonfiction-only genre `Social Sciences`, which a work stored as fiction would keep. Hand the retained values to `classify()` as its defaults instead, so the genre filter runs against the status the work will actually end up with. The post-call restores become unreachable once the defaults can no longer be `None`, so they go. The audience half is symmetric but inert in practice: `_get_default_audience()` never returns `None`, so only a caller that passes `default_audience=None` explicitly can reach it. It is worth having anyway -- `target_age` is derived from the audience the same way genres are derived from fiction, and is not protected by any restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Migration 52d1bbdd4671 repairs subjects that were stored as nonfiction because an unresolvable BISAC code fell through the ruleset catch-all. It only sets checked=false; the re-scoring happens later, in classify_unchecked_subjects. That leaves a window. If old code reaches those subjects first -- a still-running scripts server, or a host that redeploys itself via watchtower or ECS/Fargate auto_restart -- it re-scores them under the superseded rules and re-stamps checked=true. The reset is consumed rather than lost, so nothing errors, nothing retries, and no later run revisits them. The repair quietly did nothing, having paid for a full reindex. Raised in review on #3726; it is not preventable from inside the migration, so make it recoverable instead. Adds reset_non_bisac_nonfiction_subjects, a Celery task that re-applies the reset, with ResetNonBisacNonfictionSubjectsScript and bin/work_reset_non_bisac_nonfiction_subjects to queue it -- the same three layers as reclassify_null_audience_works, which repairs the sibling audience defect. The task resets only. Re-scoring stays with classify_unchecked_subjects, which picks these subjects up on its next nightly run and can be triggered immediately through bin/work_classify_unchecked_subjects. One task, one job, and no large reindex fired the moment someone runs the repair. The selection lives on BISACClassifier as contradicts_stored_fiction, so the migration and the task cannot disagree about which rows are affected. Both already import the classifier, so this adds no coupling that was not there, and it puts the definition with the code that owns the answer. The migration is updated to use it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Migration 52d1bbdd4671 only resets checked=False. classify_unchecked_subjects is what re-scores those subjects and recalculates their works, and left to itself that does not happen until the nightly run. The gap between the two is where the repair is exposed: anything reaching Subject.assign_to_genre in the meantime consumes the reset, and if it is running the superseded rules it re-stamps checked=True with the same wrong value. Nothing errors and nothing revisits the subject afterwards. Adds a startup task that dispatches the re-score immediately after the migration, shrinking that gap from about a day to seconds. This is the pattern the null-audience repair already used -- migration d856ff4dbefb makes the data change, startup task 2026_05_12 dispatches the follow-up. The timing works out: helpers/migrate.yml stops the scripts container -- which is where every Celery worker and beat run -- before running the migration, and starts it again afterwards from the new image. So no worker is alive when this dispatches, and the one that picks the task up is necessarily new code. Web containers are the remaining exposure. The deploy recycles them after the migration, and they can reach assign_to_genre through a presentation recalculation. A second startup task in the next release re-applies the reset once no old code is running anywhere. This moves roughly 4,877 works' worth of recalculation and reindexing from overnight to deploy time. That is about 2% of the nightly volume behind PP-4472, so it should be unremarkable, but it is a deliberate choice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-on from dropping the migration. The reset and the re-score are now chained from one place: reset_non_bisac_nonfiction_subjects marks the stale subjects unchecked, classify_unchecked_subjects re-scores them. The second signature is immutable so the chain does not pass the first task's return value into a task that takes no arguments. There is now one implementation of the selection instead of two. The task is the only thing that knows how to find these subjects, and all three callers -- this startup task, the next-release re-apply, and the bin wrapper -- go through it. Chaining rather than dispatching the reset alone is what keeps the repair from being exposed. The reset on its own can be consumed by anything reaching Subject.assign_to_genre first, and code on the superseded rules re-stamps checked=True with the same wrong value, silently. Running the re-score straight after closes that to seconds instead of waiting for the nightly. Docstrings that referred to migration 52d1bbdd4671 now describe the condition they repair rather than pointing at a file that no longer exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The migration this named was removed when the repair moved to a startup task. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deleting the migration took its test with it, and the Celery task test that replaced it carried four subjects where the migration test had thirteen. The selection logic is the same either way -- both ask `BISACClassifier.contradicts_stored_fiction` -- so the cases port straight across. Back are the offender shapes that were only covered there: the `N`-suffix vendor code, a malformed code with no name at all, a shape-valid but non-existent code (which a pattern-based predicate would wrongly accept), and a stale canonical fiction code. On the other side: a second real nonfiction code, a nonfiction heading in the identifier field, and a row already scored as unknown rather than fiction. Parametrized rather than asserted in a batch, so a failing case does not hide the ones after it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14ed678 to
2d90632
Compare
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>
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 theKeywordBasedClassifiercall 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-
Nstrip is now conditional.scrub_identifierstripped a trailingNunconditionally, 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 inN. The unconditional strip turnedFICTIONintoFICTIOandRELIGIONintoRELIGIO, 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_nameuses 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 inNthat 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, comparingmainagainst this branch):maintodayFICTIONFalse/ Adult / —True/ Adult / —JUVENILE FICTIONFalse/ Adult / —True/ Children / —YOUNG ADULT FICTIONFalse/ Adult / —True/ Young Adult / —JUVENILE NONFICTIONFalse/ Adult / —False/ Children / —YOUNG ADULT NONFICTIONFalse/ Adult / —False/ Young Adult / —DESIGNFalse/ Adult / —False/ Adult / DesignEDUCATIONFalse/ Adult / —False/ Adult / EducationRELIGIONFalse/ Adult / —False/ Adult / Religion & SpiritualityTRANSPORTATIONFalse/ Adult / —False/ Adult / TechnologySPORTS & RECREATIONFalse/ Adult / Sports@tdilauro — this is the case you asked about. The mechanism is real: six of the ten change
audienceorgenrewhilefictionstaysFalse, and the reset task selects on fiction disagreement alone, so it would not pick those up. Two of the six are audience moves (JUVENILE NONFICTIONandYOUNG ADULT NONFICTIONgo 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
NULLname whose identifier holds one of these headings — the query returns no rows: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 havingNULLwritten 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, andWorkClassifier.genres()skips its consistency filter entirely when that status isNone, so a value restored afterwards arrives too late to be consulted — the work ends up stampedfiction=Falsewhile carrying a fiction-only genre. Tags are the realistic vector:Horror,RomanceandHistoricaleach 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 —FSHUM000000Nabstains and yields the nonfiction-only genreSocial 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 asksBISACClassifierwhich BISAC subjects stored as nonfiction it no longer agrees with, and resetschecked=Falseon 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 andclassify_unchecked_subjects, so the re-score follows the reset by seconds rather than by a day.bin/work_reset_non_bisac_nonfiction_subjectsandResetNonBisacNonfictionSubjectsScript— 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.
Why two: the reset is losable. Anything reaching
Subject.assign_to_genrefirst consumes it, and code running the superseded rules re-stampschecked=truewith 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: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.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:
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.The
FBJUV*resets in45f74fdcec18and05a95c828149did 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 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
GENREhas 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 reachesclassify()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 thanNone.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 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 ofTOP_LEVEL_HEADINGS.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.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 withfiction=Falseand genreHorror, and thefiction=Truecase 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 theNsuffix, 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 scoredtrue, already scoredNULL, and atag-typed subject with the same identifier).test_reset_non_bisac_nonfiction_subjects_is_idempotent— a second run finds nothing to do.TestResetNonBisacNonfictionSubjectsScript.test_run— thebin/wrapper dispatches the task.tests/manager/scripts/test_startup.py— confirms the new startup task is discovered and satisfies therun()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).mypyclean; all pre-commit hooks pass.Checklist
🤖 Generated with 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:
FBZZZ000000slipped through. It now asksBISACClassifierdirectly, so the two definitions cannot drift apart.-> Noneannotations, and a docstring onfetch_subject.TestWorkController::test_edit_classificationstipped 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/patchreads ~82%. The uncovered lines are pre-existing unreachable code that the diff re-indents —audience()'sstopbranch (theAUDIENCEruleset has nom(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