Skip to content

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

Merged
dbernstein merged 22 commits into
mainfrom
bugfix/unrecognized-bisac-codes-vote-nonfiction
Sep 18, 2026
Merged

dbernstein merged 22 commits into
mainfrom
bugfix/unrecognized-bisac-codes-vote-nonfiction

Conversation

@dbernstein

@dbernstein dbernstein commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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:

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

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

🤖 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:

  • 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

@dbernstein dbernstein added bug Something isn't working DB migration This PR contains a DB migration labels Sep 11, 2026
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @dbernstein's task in 12m 11s —— View job


Code review

  • Gather context (diff, changed files)
  • Review classifier changes (bisac.py)
  • Review Work.assign_genres change
  • Review the Celery task / script / startup task
  • Review tests
  • Post review

Summary

The core change is sound and carefully scoped: _apply_rulesets preserves the old stop/catch-all/keyword-fallback semantics exactly, the heading gate correctly exempts name-only subjects (Bibliotheca) and headings-in-the-identifier-field, the conditional N strip strictly narrows behaviour (I verified KeywordBasedClassifier ignores the identifier entirely, so it can only matter through the name fallback), and TOP_LEVEL_HEADINGS covers the legacy spellings that the csv no longer lists — Language Study/Literary Criticism are the canonical ones in bisac.csv, so the five added entries are the right five. The _keep_known_value refactor is behaviour-preserving for audience (with a non-None default_audience, WorkClassifier.audience() can never return None, so the removed post-hoc guard was dead in that path) and correctly moves the fiction default upstream of genres(). Two things below: one newly-reachable audience path, and one missing test.

Details

src/palace/manager/core/classifier/bisac.py:762-769

Given a work whose classifications all now return audience=None and which has no license-source classifications, the audience lands on Young Adult rather than the default. WorkClassifier.add() does self.audience_weights[subject.audience] += weight unconditionally, so a None audience still puts a key in the Counter; audience()'s early return default_audience is then skipped because the Counter is non-empty, every recognised bucket is 0, threshold = total_adult_weight * 2 = 0, and elif ya_weight >= threshold fires on 0 >= 0. Until this PR a BISAC subject always carried an audience (the AUDIENCE catch-all inferred Adult), so this was unreachable for BISAC-only works; abstention makes subject.audience = None possible for the first time. The usual case is covered — prepare_to_classify() adds audience_weights[ADULT] += 500 whenever direct_from_license_source is non-empty, which is true for the Feedbooks/Palace Marketplace population the repair touches — but a work whose BISAC classifications do not come from its license source gets no such boost. Cheapest guard is at the source:

# core/classifier/work.py, in add()
if subject.audience is not None:
    self.audience_weights[subject.audience] += weight

if not identifier or cls._has_canonical_heading(name):
for ruleset in rulesets:
result = ruleset.match(*name)
if result is cls.stop:
return None
if result is not None:
return result
return keyword_fallback(identifier, "/".join(name))

Minor: startup_tasks/2026_09_14_reclassify_non_bisac_nonfiction_subjects.py:50-54

The PR description lists tests/manager/scripts/test_startup.py as confirming this task "is discovered and satisfies the run() contract", but that file is not in the diff and nothing under tests/ references the module — the new startup task has no coverage at all. That matters more than usual here because both failure modes are silent: discover_startup_tasks catches an import error and continues, and _run_tasks catches a per-task exception and continues without recording the task, so a mistake in this file means the repair just never runs. A one-liner against the real directory would close it:

def test_real_startup_tasks_are_discoverable() -> None:
    assert (
        "2026_09_14_reclassify_non_bisac_nonfiction_subjects"
        in discover_startup_tasks(STARTUP_TASKS_DIR)
    )

def run(services: Services, session: Session, log: logging.Logger) -> Signature | None:
return chain(
reset_non_bisac_nonfiction_subjects.s(),
classify_unchecked_subjects.si(),
)

--- · branch bugfix/unrecognized-bisac-codes-vote-nonfiction

@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.67442% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 93.71%. Comparing base (7e44a87) to head (2d90632).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/palace/manager/core/classifier/bisac.py 96.15% 0 Missing and 1 partial ⚠️
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.
📢 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
dbernstein requested a review from tdilauro September 11, 2026 18:54
@dbernstein
dbernstein force-pushed the bugfix/unrecognized-bisac-codes-vote-nonfiction branch from abcc0ae to 956a363 Compare September 11, 2026 18:54

@tdilauro tdilauro left a comment

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.

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.

Comment on lines +3 to +31
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.

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.

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.

Comment on lines +691 to +697
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

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.

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.

Comment on lines +703 to +711
# 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

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.

See my comment on is_fiction above ^^.

Comment on lines +1403 to +1408
# 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

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.

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.

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.

Just noticed that target_age (above) is similar, but it's slightly different.

Comment on lines +429 to +433
subject = self._subject("INFEN000", "Science Fiction")
assert subject.fiction is True

subject = self._subject("INFEN000", "Nonfiction")
assert subject.fiction is False

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.

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.

Comment thread tests/manager/core/classifiers/test_classifier.py Outdated
Comment thread tests/manager/core/classifiers/test_classifier.py Outdated
assert subject.fiction is True

subject = self._subject("INFEN000", "Nonfiction")
assert subject.fiction is False

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.

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

Comment on lines +657 to +661
# 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]+$")

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.

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.

Comment on lines +657 to +661
# 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]+$")

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.

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.

@dbernstein
dbernstein force-pushed the bugfix/unrecognized-bisac-codes-vote-nonfiction branch from 58b76a4 to 00b340b Compare September 14, 2026 14:46
@dbernstein

Copy link
Copy Markdown
Contributor Author

Thanks for the review @tdilauro : I'll address your feedback this morning.

@dbernstein

Copy link
Copy Markdown
Contributor Author

@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 checked=false. The damage comes from whatever re-scores those subjects next. If an old-code classify_unchecked_subjects reaches them before the new classifier is live, it re-scores under the old rules and re-stamps checked=true. The reset is consumed rather than lost, so there is no error and nothing afterwards revisits those subjects — the repair silently did nothing, at the cost of a full reindex. Your redeploy scenarios (watchtower, ECS/Fargate auto_restart) are the realistic way to get there, and it is a broader point than the release-ordering note in the migration docstring: it is any window where old code is still running, not just a bad release order.

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 reclassify_null_audience_works from the null-audience repair, which is the same shape of problem:

  • a Celery task in celery/tasks/work.py carrying the same selection logic
  • a bin/ wrapper that queues it, modelled on bin/work_reclassify_null_audience
  • the selection logic shared with the migration rather than copied, so the two cannot drift

Everything else in your review is addressed in the commits here. Two notes on the substantive ones:

  • The _CODE_SHAPED regex is gone rather than tightened. You were right that it was asking the wrong question, and the single-word heading problem was wider than the one row you found — 29 of the 56 top-level headings are a single unpunctuated word, so Fiction, History, Humor and friends were all exposed, not just Juvenile. The guard now tests whether the name begins with a real top-level heading, which is the actual premise the catch-all rules rest on.
  • target_age is still not routed through the shared helper, but the reason changed. It was correctness; under a name-based guard it would be safe, so it is now scope. genre genuinely must stay out — a bare "Historical" is not a top-level heading, and skipping the rulesets would lose its Historical Fiction signal.

@dbernstein
dbernstein force-pushed the bugfix/unrecognized-bisac-codes-vote-nonfiction branch from fa78747 to 46d57e9 Compare September 14, 2026 17:33
@dbernstein
dbernstein requested a review from tdilauro September 14, 2026 17:33
dbernstein added a commit that referenced this pull request Sep 14, 2026
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>
@dbernstein

Copy link
Copy Markdown
Contributor Author

@tdilauro the follow-up for the race condition is up: #3736 (PP-5129). It is stacked on this branch, so it will retarget to main once this merges.

It came out as we discussed:

  • reset_non_bisac_nonfiction_subjects, a Celery task that re-applies the reset, plus 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 via bin/work_classify_unchecked_subjects. That keeps each task doing one thing, and avoids firing a large reindex the moment someone runs the repair.
  • The selection moved onto BISACClassifier as contradicts_stored_fiction, so the migration and the task cannot disagree about which rows are affected.

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.

@dbernstein
dbernstein force-pushed the bugfix/unrecognized-bisac-codes-vote-nonfiction branch from 46d57e9 to ef9e2dc Compare September 14, 2026 18:39
dbernstein added a commit that referenced this pull request Sep 14, 2026
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>
@greptile-apps

greptile-apps Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The behavioral changes appear sound, but the PR is not ready to merge because two outstanding findings place new code in deprecated packages contrary to an explicit repository requirement.

Findings

  1. P2 New code in deprecated package
  2. P2 New code in deprecated package

Summary

This PR prevents unrecognized BISAC codes from automatically voting nonfiction or Adult and preserves known work classifications when recalculation yields no determination.

  • Gates BISAC fiction and audience rules on recognized top-level headings while retaining keyword fallback behavior.
  • Preserves recognized trailing-N code aliases without mangling heading identifiers.
  • Adds a classifier-driven Celery repair that resets and reclassifies stale nonfiction subjects.
  • Retains known fiction and audience values early enough to keep derived genres consistent.
  • Adds classifier, repair-task, script, and work-model regression coverage.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[BISAC subject] --> B[Scrub identifier and name]
  B --> C{Canonical code or heading?}
  C -->|Yes| D[Apply BISAC fiction and audience rules]
  C -->|No| E[Apply keyword fallback]
  D --> F[Aggregate work classification]
  E --> F
  F --> G{New determination available?}
  G -->|Yes| H[Use new fiction and audience]
  G -->|No| I[Retain known work values]
  H --> J[Filter derived genres consistently]
  I --> J
  K[Startup repair] --> L[Reset stale checked subjects]
  L --> M[Reclassify unchecked subjects]
Loading

Reviews (10) · Last reviewed commit: "Restore the reset predicate's case cover..."

Comment thread alembic/versions/20260902_52d1bbdd4671_reset_checked_for_non_bisac_nonfiction_.py Outdated
Comment thread alembic/versions/20260902_52d1bbdd4671_reset_checked_for_non_bisac_nonfiction_.py Outdated
@dbernstein

Copy link
Copy Markdown
Contributor Author

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. ItemListParser.parse_genre_string emits the only subjects Bibliotheca produces — type=BISAC, identifier=None, name=<bare genre>, weight=TRUSTED_DISTRIBUTOR_WEIGHT — and none of its vocabulary ("Action & Adventure", "Magic", "Health", "Mystery & Detective") is a top-level heading, so the gate silenced all of it. A newly imported Bibliotheca title would have had no fiction evidence at all and a NULL fiction status; _keep_known_value cannot rescue a first import. The gate now applies only when an identifier was supplied, which is the case it was written for — a code that failed to resolve is the evidence that the leftover name is a fragment. The fix for unresolvable codes is unchanged.

2. scrub_identifier strips the trailing "N" 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, likewise RELIGION, EDUCATION, DESIGN, TRANSPORTATION. My four single-word tests were all title case, so they dodged it entirely. FBJUV000000N and the FBJUV009001N alias still resolve.

3. Added "Literary Criticism & Collections" to the deprecated-spelling set. My own analysis script had a single-line regex and silently skipped every multi-line Interchangeable definition; corrected, it finds 16 rather than 13, and that was the one genuine miss.

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 bisac.csv does not help either — the ambiguity is in BISAC itself: "Historical" and "Renaissance" each appear under 10 top-level headings, "Horror" under 6, "Action & Adventure" under 5.

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.

@tdilauro #3736 has been rebased onto this and is green.

@dbernstein
dbernstein force-pushed the bugfix/unrecognized-bisac-codes-vote-nonfiction branch from ef9e2dc to f3535a5 Compare September 14, 2026 20:33
@dbernstein
dbernstein force-pushed the bugfix/unrecognized-bisac-codes-vote-nonfiction branch from f3535a5 to 5c1cc0b Compare September 15, 2026 00:09
Comment thread src/palace/manager/core/classifier/bisac.py
dbernstein added a commit that referenced this pull request Sep 15, 2026
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>
dbernstein added a commit that referenced this pull request Sep 15, 2026
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>
@dbernstein dbernstein removed the DB migration This PR contains a DB migration label Sep 15, 2026
@dbernstein
dbernstein force-pushed the bugfix/unrecognized-bisac-codes-vote-nonfiction branch from 5c1cc0b to a19c2c7 Compare September 15, 2026 14:29
dbernstein added a commit that referenced this pull request Sep 15, 2026
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>
dbernstein added a commit that referenced this pull request Sep 18, 2026
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>
@dbernstein
dbernstein force-pushed the bugfix/unrecognized-bisac-codes-vote-nonfiction branch from 511b69c to 14ed678 Compare September 18, 2026 20:46
Comment on lines +658 to +758
# 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

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 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!

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.

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.

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.

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:

View rule

dbernstein and others added 22 commits September 18, 2026 13:51
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>
@dbernstein
dbernstein force-pushed the bugfix/unrecognized-bisac-codes-vote-nonfiction branch from 14ed678 to 2d90632 Compare September 18, 2026 20:52
dbernstein added a commit that referenced this pull request Sep 18, 2026
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>
@dbernstein
dbernstein merged commit c84f583 into main Sep 18, 2026
25 checks passed
@dbernstein
dbernstein deleted the bugfix/unrecognized-bisac-codes-vote-nonfiction branch September 18, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants