Skip to content

Don't let unrecognized BISAC codes classify fiction as nonfiction (PP-4849) - #3710

Closed
dbernstein wants to merge 5 commits into
ThePalaceProject:mainfrom
dbernstein:bugfix/unrecognized-bisac-codes-vote-nonfiction
Closed

dbernstein wants to merge 5 commits into
ThePalaceProject:mainfrom
dbernstein:bugfix/unrecognized-bisac-codes-vote-nonfiction

Conversation

@dbernstein

@dbernstein dbernstein commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Description

Two changes, which must ship together.

1. An unrecognized BISAC code no longer votes nonfiction.

BISACClassifier.is_fiction() and .audience() now skip the BISAC rulesets when the subject identifier did not resolve to a canonical BISAC heading, deferring instead to 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.

2. A recalculation can no longer erase a known fiction status.

Work.assign_genres() gains the guard its audience handling already has: when the classifier reaches no fiction determination, the work keeps the status it had rather than having NULL written over it.

3. A migration repairs the stored side.

Resets checked=false for every subject stored as nonfiction that BISACClassifier no longer scores that way, so classify_unchecked_subjects re-scores them and recalculates the works they are attached to. The migration asks the classifier directly rather than pattern-matching the identifier, so the migration's notion of "not a real BISAC code" cannot drift from the runtime's.

Motivation and Context

Everything on the Palace Marketplace / Feedbooks category scheme is stored with type='BISAC', including codes that are not BISAC at all. In production (illinois), 3,172 of 7,726 BISAC subject rows (41%) are not BISAC codes — mostly Feedbooks' literature-by-language taxonomy:

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.

Fix 2 matters more once Fix 1 lands, since abstaining becomes common and a work whose only BISAC evidence is unresolvable codes would otherwise have its existing status nulled.

Migration scope and blast radius

Measured against production: 2,418 subject rows, 4,877 works recalculated — roughly 2% of the nightly volume behind the reindex surge in PP-4472, so no scheduled window is needed.

Treat that as a floor. It was measured with a pattern-based predicate; the classifier-based one now in the migration additionally catches shape-valid-but-non-existent codes and real FIC* codes stored as nonfiction. It can only add rows, and every row it adds is one holding a value the classifier disagrees with. The migration logs each subject it resets plus a total.

Scope is deliberately narrow:

  • 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.

Subjects are global and are only re-examined when checked=false, so a subject scored under the old rules keeps its value indefinitely. The FBJUV* resets in 45f74fdcec18 and 05a95c828149 did not cover these codes.

Important

The migration must ship in the same release as the classifier fix. Run on its own, the nightly task would re-score these subjects with the old rules and re-stamp checked=true, paying for a full reindex that changes nothing.

Known remainder

Of 1,871 works that carry an FBFIC* code but are not marked fiction, this reset reaches 1,356. The other 515 carry no non-canonical nonfiction voter, so their nonfiction votes come from real BISAC codes or they have no votes at all — some of those are likely correct (a book carrying 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 and target_age have the same structural issue, but changing genre assignment moves books between lanes and deserves its own change.
  • More fundamentally, 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 the root cause, and is a larger change.

How Has This Been Tested?

New tests:

  • TestBISACClassifier.test_heading_in_identifier_field_is_matched_as_a_name — parametrized over four heading shapes. Some distributors put the BISAC heading in the identifier field (Boundless sends "FICTION / Horror", not "FIC015000"); that is a name, not a code that failed to resolve, and must still be matched against the rulesets.

  • TestBISACClassifier.test_unrecognized_code_abstains — parametrized over no name, language name, territory name, FB-prefixed, and a partial BISAC heading.

  • TestBISACClassifier.test_unrecognized_code_still_uses_keyword_fallback — abstaining is not the same as ignoring the name; a name that does carry a signal is still honoured.

  • TestBISACClassifier.test_recognized_code_unaffected_by_abstention — regression anchor on FBFIC000000/014000/016000/019000, whose partial names ("Historical", "Literary") would each vote nonfiction if the canonical lookup ever missed.

  • TestWorkClassifier.test_unrecognized_bisac_codes_do_not_imply_nonfiction — two junk codes produce no vote and no determination; one resolvable code is then decisive.

  • TestWork.test_assign_genres_does_not_overwrite_fiction_with_null.

  • tests/migration/test_20260902_52d1bbdd4671_... — 12 cases. Five confirm the reset fires (each offender shape from production, including one with a NULL name); seven confirm it does not — canonical codes in all three prefix/suffix forms, a real nonfiction code, non-canonical rows already at true or NULL, and a tag-typed subject with the same identifier.

Full suite locally against Postgres + Valkey: 6,203 passed, 0 failed. 104 errors, all infrastructure gaps from not running two of the tox service containers — OpenSearch (test_search.py, test_delete_work_not_in_search_end2end) and MinIO/S3 (test_marc.py, which errors with ValidationError: 3 validation errors for S3UploaderIntegrationConfiguration). Verified earlier by reverting the source changes and reproducing identical setup errors.

tests/migration: 31 passed, including the built-in up/down checks, so the revision chain is intact. mypy clean across 1,169 files; all pre-commit hooks pass.

Checklist

  • I have updated the documentation accordingly.
  • All new and existing tests passed.

🤖 Generated with Claude Code

@dbernstein dbernstein added bug Something isn't working DB migration This PR contains a DB migration labels Sep 2, 2026
@dbernstein dbernstein changed the title Don't let unrecognized BISAC codes classify fiction as nonfiction Don't let unrecognized BISAC codes classify fiction as nonfiction (PP-4849) Sep 2, 2026
@greptile-apps

greptile-apps Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR makes unrecognized BISAC identifiers defer to keyword classification, preserves an existing fiction determination when recalculation abstains, and adds a migration to rescore affected subjects.

  • Skips BISAC catch-all fiction and audience rules for identifiers absent from the canonical heading map.
  • Prevents an indeterminate recalculation from clearing Work.fiction.
  • Resets affected subject rows for nightly reclassification, although its shape-based predicate misses well-formed identifiers unknown to the runtime classifier.

Confidence Score: 4/5

The migration should be corrected before merging because it can leave stale nonfiction votes on shape-valid identifiers that the runtime classifier considers unrecognized.

Runtime recognition uses canonical heading-map membership, but the migration uses only identifier syntax, so demonstrated inputs such as FBZZZ000000 are excluded from the required reclassification.

Files Needing Attention: alembic/versions/20260902_52d1bbdd4671_reset_checked_for_non_bisac_nonfiction_.py, tests/migration/test_20260902_52d1bbdd4671_reset_checked_for_non_bisac_.py

Important Files Changed

Filename Overview
src/palace/manager/core/classifier/bisac.py Correctly separates runtime recognition from distributor-provided names and sends unknown codes through keyword fallback.
src/palace/manager/sqlalchemy/model/work.py Adds intentional retention of an existing fiction determination when recalculation produces no evidence.
alembic/versions/20260902_52d1bbdd4671_reset_checked_for_non_bisac_nonfiction_.py Resets malformed identifiers but misses syntactically valid codes absent from the runtime canonical heading map.
tests/manager/core/classifiers/test_bisac.py Covers recognized and unrecognized classifier behavior, including a shape-valid unknown code that exposes the migration mismatch.
tests/migration/test_20260902_52d1bbdd4671_reset_checked_for_non_bisac_.py Covers malformed and canonical identifiers but lacks a shape-valid identifier absent from BISACClassifier.NAMES.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[Stored BISAC subject] --> B{Identifier matches migration regex?}
    B -->|No| C[Set checked=false]
    B -->|Yes| D[Remain checked]
    C --> E[Nightly reclassification]
    E --> F{Scrubbed code in NAMES?}
    F -->|Yes| G[BISAC rules]
    F -->|No| H[Keyword fallback]
    D --> I[Shape-valid unknown code keeps stale vote]
Loading

Reviews (1): Last reviewed commit: "Reclassify subjects holding a fabricated..." | Re-trigger Greptile

# distributors add an "FB" prefix and/or an "N" suffix, both of which
# BISACClassifier.scrub_identifier strips before looking the code up. Anything
# that does not match this shape is not a BISAC code.
CANONICAL_BISAC_CODE = r"^(FB)?[A-Z]{3}[0-9]{6}N?$"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Migration recognition criteria diverge

If a stored subject has a shape-valid but unknown identifier such as FBZZZ000000, this regex treats it as canonical and leaves it checked, while the runtime classifier treats it as unrecognized. The nightly task therefore never rescores its stale fiction=false value, allowing the incorrect nonfiction vote to remain on attached works.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and it was a real hole. FBZZZ000000 is shape-valid, so the regex left it checked, while the classifier calls it unrecognized — its fabricated fiction=false would have survived indefinitely.

Rather than tightening the pattern, I removed it. The migration now asks BISACClassifier directly and resets every subject stored as nonfiction that the classifier no longer scores that way, so the two definitions cannot drift apart by construction. Precedent for importing application code into a migration is already here (Identifier, Timestamp, BaseCoverageRecord), and a pure-logic classifier is a safer import than an ORM model.

Added shape_valid_but_nonexistent (FBZZZ000000) to the migration test as a regression guard for exactly this case, plus stale_canonical_fiction_code — a real FIC* code stored as nonfiction, which the old predicate also missed.

Comment on lines +830 to +832
def test_unrecognized_bisac_codes_do_not_imply_nonfiction(
self, work_classifier_fixture: TestWorkClassifierFixture
):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test lacks return annotation

The new test omits the required -> None return annotation, making its signature inconsistent with the repository's type-hinting requirement for new functions.

Suggested change
def test_unrecognized_bisac_codes_do_not_imply_nonfiction(
self, work_classifier_fixture: TestWorkClassifierFixture
):
def test_unrecognized_bisac_codes_do_not_imply_nonfiction(
self, work_classifier_fixture: TestWorkClassifierFixture
) -> None:

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added -> None here and to test_assign_genres_does_not_overwrite_fiction_with_null, which had the same omission.

Both were written to match the immediately-surrounding tests in those files, which are unannotated — but CLAUDE.md is explicit about new functions, so the convention wins over local consistency.


# An official BISAC code is three letters followed by six digits. Some
# distributors add an "FB" prefix and/or an "N" suffix, both of which
# BISACClassifier.scrub_identifier strips before looking the code up. Anything

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Public functions lack docstrings

The new public upgrade, downgrade, and fetch_subject functions omit the required reStructuredText docstrings, leaving their function-level contracts undocumented.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half taken. I added the docstring to fetch_subject — that one belongs, since its sibling helpers on the fixture are documented.

Declining on upgrade/downgrade, though. Those are alembic framework entry points rather than public API, and the convention here is that the module docstring explains a migration: none of the ~50 revisions in alembic/versions/ documents either function. Adding them to this one would make it the odd one out, and the module docstring already covers what the migration does and why. Worth remembering for future migrations in this repo.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That distinction makes sense, and I agree the module docstring is sufficient for Alembic’s upgrade()/downgrade() entry points while fetch_subject() follows the documented helper convention. I’ll apply that standard to future migrations.

View rule

@dbernstein
dbernstein force-pushed the bugfix/unrecognized-bisac-codes-vote-nonfiction branch from 571ffd4 to f94bb1b Compare September 8, 2026 16:06
@codecov

codecov Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.81818% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.61%. Comparing base (a55b97e) to head (6c69e22).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
src/palace/manager/core/classifier/bisac.py 80.00% 1 Missing and 3 partials ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3710   +/-   ##
=======================================
  Coverage   93.61%   93.61%           
=======================================
  Files         514      514           
  Lines       47065    47075   +10     
  Branches     6408     6412    +4     
=======================================
+ Hits        44060    44070   +10     
+ Misses       1944     1941    -3     
- Partials     1061     1064    +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dbernstein

Copy link
Copy Markdown
Contributor Author

Note on the red codecov/patch check

Patch coverage reads 81.8% — 1 missing line and 3 partials, all in bisac.py. Every one of them is pre-existing unreachable code that this diff re-indents rather than introduces; nothing in the change is actually untested. Project coverage is unchanged at 93.61%, and codecov/patch is not one of main's required checks.

The specific items:

  • audience()'s if audience is cls.stop: return None — the AUDIENCE ruleset contains zero m(stop, ...) rules, so the branch cannot be taken. FICTION has three, which is why the equivalent line there is covered.
  • The loop-exhaustion branches on for ruleset in cls.FICTION and cls.AUDIENCE — both rulesets end in a catch-all m(..., anything) that always matches, so neither loop can run to completion.

Both predate this PR. The diff only shifts their indentation by one level, which is enough for codecov to attribute them to the patch. target_age() and genre() carry the same dead stop check with no stop rules in their rulesets, and those lines show as uncovered too — they just fall outside this diff.

I have deliberately not papered over it. Adding # pragma: no cover would introduce a convention the repo does not use anywhere today (zero occurrences under src/), and deleting the dead stop handling is a cleanup worth doing across all four methods at once rather than in the single method this PR happens to touch. Happy to do either if preferred.

For reference, mergeStateStatus is BLOCKED on the required approving review plus the deploy-workflow checks (Docker build, Integration test, Migration test), not on codecov.

dbernstein and others added 5 commits September 9, 2026 19:48
Everything on the Palace Marketplace / Feedbooks category scheme is
stored with type='BISAC', including codes that are not BISAC at all --
language and territory categories such as INFEN000 ("English
literature") and INFENUSA ("American and Canadian literature").

Such a code cannot be resolved to a canonical BISAC heading, so
classification fell back to the name the distributor supplied and hit
the catch-all rule closing BISACClassifier.FICTION, which reads "not
filed under a Fiction heading, therefore nonfiction". That inference is
sound for a real BISAC name and unsound for anything else, so an
unresolvable code cast a nonfiction vote on every work it touched --
outvoting the genuine FBFIC* fiction codes on the same book. The
AUDIENCE ruleset has the same catch-all, inferring Adult; that is the
shape of the bug fixed for FBJUV* codes in PP-4128.

is_fiction() and audience() now skip the BISAC rulesets when the
identifier did not resolve, deferring instead to the KeywordBasedClassifier
call already sitting at the end of both methods (unreachable until now,
because the catch-alls always matched first). The keyword classifier
recognizes "literature", so the INF* family goes from voting nonfiction
to voting fiction; codes carrying no signal abstain. Subjects that
supply a name but no identifier are unaffected.

Work.assign_genres() gains the guard its audience handling already has,
so a recalculation that reaches no fiction determination keeps the
status the work already had rather than writing NULL over it. This
matters more now that abstaining is common.

Genre and target_age are left alone deliberately: the same reasoning
applies, but changing genre assignment moves books between lanes and
deserves its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Repairs the stored side of the preceding fix. Subjects are global and
are only re-examined when checked=false, so a subject that was scored
under the old rules keeps its value indefinitely; the FBJUV* resets in
45f74fdcec18 and 05a95c828149 did not cover these codes.

Resets checked=false for BISAC subjects whose identifier is not a valid
BISAC code shape and which currently hold fiction=false, so
classify_unchecked_subjects re-scores them and recalculates the works
they are attached to.

Measured against production: 2,418 subject rows, 4,877 works
recalculated -- roughly 2% of the nightly volume behind the reindex
surge in PP-4472, so no scheduled window is needed.

Scope is deliberately narrow. Canonical BISAC codes are untouched; the
great majority of those are legitimately nonfiction, and there is no
evidence the FBFIC* rows are stale. Non-canonical codes already holding
fiction=true or NULL are untouched as well: they are not implicated, and
re-scoring them through a different classifier risks regressions while
roughly doubling the reindex.

This migration must ship in the same release as the classifier fix. Run
on its own, the nightly task would re-score these subjects with the old
rules and re-stamp checked=true, paying for a full reindex that changes
nothing.

Adds subject() and fetch_subject() helpers to AlembicDatabaseFixture,
following the existing identifier() and data_source() helpers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The abstention guard asked only whether the identifier was absent from
NAMES, which made a heading indistinguishable from a code that failed to
resolve. Some distributors put the heading in the identifier field --
Boundless sends "FICTION / Horror" rather than "FIC015000" -- so those
subjects stopped voting, and a work relying on them for its Adult
audience tipped to Young Adult
(TestWorkController::test_edit_classifications).

A BISAC code is a single unpunctuated token, so require that shape
before concluding a code failed to resolve. A value containing spaces or
slashes is a name and is matched as one, which is what the rulesets
expect. The offenders this change exists for are unaffected: neither
INFEN000 nor INFENUSA contains punctuation. Note that requiring a digit
would not work -- INFENUSA has none.

Pins the behaviour with a unit test over four heading shapes rather than
leaving an admin controller test as the only guard, and adds the -> None
annotations CLAUDE.md asks for on the two new tests that were written to
match their unannotated neighbours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The migration approximated "not a real BISAC code" with a pattern on the
identifier, which does not agree with what the classifier considers
recognizable. A shape-valid but non-existent code such as FBZZZ000000
passed the pattern and was left checked, so its fabricated fiction=False
would never have been re-scored. The pattern also missed a real FIC* code
stored as nonfiction, which is stale for the same reason.

Select via BISACClassifier instead: reset every subject stored as
nonfiction that the classifier no longer scores that way. The predicate
is then the definition of the problem rather than an approximation of it,
and the two cannot drift apart. Migrations here already import
application code (Identifier, Timestamp, BaseCoverageRecord); a
pure-logic classifier over a static table is a safer import than an ORM
model.

Scope is unchanged: only subjects currently holding fiction=False are
examined. The count reset can now exceed the 2,418 measured against
production, by however many shape-valid-but-unknown codes are stored;
the migration logs what it touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
912c566f3383 (ThePalaceProject#3694) merged while this PR was open, taking the same
down_revision this migration had. Two heads meant every migration test
failed with "Multiple heads are present; please specify a single target
revision" on all three Python versions.

Re-points down_revision at 912c566f3383, restoring a single head.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dbernstein
dbernstein force-pushed the bugfix/unrecognized-bisac-codes-vote-nonfiction branch from f94bb1b to 6c69e22 Compare September 9, 2026 17:48
@dbernstein
dbernstein requested a review from tdilauro September 9, 2026 20:14
@jonathangreen

Copy link
Copy Markdown
Contributor

@dbernstein you might want to reopen this on from as a PR from the org repo instead of your own fork, so we get full docker build and testing on it. That way the CI won't stay red on it.

@dbernstein

Copy link
Copy Markdown
Contributor Author

oops - good catch.

@dbernstein

Copy link
Copy Markdown
Contributor Author

Closing in favour of #3726, which is the same work branched in-repo rather than from a fork, so the required checks that fork PRs cannot run (Docker build, Integration test, Migration test, Unit tests) are able to report.

Same five commits, rebased onto current main. All review feedback from this PR is carried over and summarised in the new description — thanks @greptile-apps, both P1 and P2 findings are addressed there.

@dbernstein dbernstein closed this Sep 11, 2026
@greptile-apps

greptile-apps Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Understood. Closing this PR in favor of #3726 is appropriate, and carrying the fixes and review feedback forward there should allow the previously unavailable required checks to run.

dbernstein added a commit that referenced this pull request Sep 18, 2026
…-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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working DB migration This PR contains a DB migration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants