Skip to content

Re-apply the non-BISAC nonfiction reset a release later (PP-5129) - #3737

Open
dbernstein wants to merge 5 commits into
mainfrom
bugfix/reapply-non-bisac-nonfiction-reset
Open

dbernstein wants to merge 5 commits into
mainfrom
bugfix/reapply-non-bisac-nonfiction-reset

Conversation

@dbernstein

@dbernstein dbernstein commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Description

One startup task that re-applies the non-BISAC nonfiction reset a release after the original repair, plus the first test coverage of the real startup_tasks/ directory. Two files, 76 lines.

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

No migration and no schema change anywhere in this workstream: the reset is a Celery task, reset_non_bisac_nonfiction_subjects, added in #3726 and dispatched by both startup tasks. Hence no DB migration label.

The task key is 2026_10_02_reapply_non_bisac_nonfiction_reset — dated to the merge, not to when the file was written. Keys come from the filename and discovery runs them in sorted order, so the original 2026_09_14_ prefix sorted this ahead of the 2026_09_14_reclassify_... repair it re-applies. Only reachable on a database where both are pending, where there is nothing to repair anyway, but the listing read backwards. Nothing has recorded the old key, so the rename cost nothing.

Motivation and Context

#3726 shipped in v48.0.0, so the release N repair — startup task 2026_09_14_reclassify_non_bisac_nonfiction_subjects, which resets the affected subjects and chains the re-score behind it — has gone out and this is now unblocked.

Repairing a subject means resetting checked=False so it gets re-scored. Anything reaching Subject.assign_to_genre before the re-score lands consumes that reset, and code running the superseded rules re-stamps checked=True with the same wrong value. Nothing errors, nothing retries, and no later run revisits the subject: the repair silently did nothing, having paid for a reindex to do it.

Chaining the re-score shrinks that window to seconds, but does not close it. Deployment ordering below is from the hosting-playbook repo (helpers/migrate.yml); the container layout is from docker/ here.

path exposed? why
Celery workers no the playbook stops the scripts container before the migrate step and restarts it from the new image afterwards. Every worker and beat runs there — docker/runit-scripts/ holds beat, worker-default, worker-high, worker-apply — so no old worker is alive when the reset is dispatched.
Web containers yes only pre-pulled before the migrate step; the rolling recycle happens after it. docker/runit-web/ runs nginx and uwsgi, and the admin work editor reaches classification directly: WorkController.edit and .edit_classifications both call calculate_presentation with a classify=True policy, which runs assign_genres and so Subject.assign_to_genre.
ECS / Fargate yes the playbook branches on selectattr('fargate', 'true') and does no docker work for those. Not governed by that file at all.

Running the reset once more a release later closes both, because by then no old code is live anywhere. It needs no deployment-topology argument to hold — which is the point, given that two of those three rows depend on infrastructure detail that can change without anyone touching this repo.

The task is idempotent and self-selecting: it recomputes which subjects the classifier no longer agrees with rather than replaying a stored list. If the release N repair took, this finds nothing and the run is a no-op.

Re-scoring is deliberately left to the nightly classify_unchecked_subjects. The release N task chains it immediately because at that moment the reset is racing old code; here there is none to lose it to, so dispatching would only move a reindex into deploy time for nothing.

This is the second half of a shape the repo has used before. For the sibling null-audience defect, startup task 2026_06_17 re-ran a release later what 2026_05_12 had dispatched; startup_tasks/2026_06_17_repair_remaining_null_audience_works.py is the direct model for this file, down to being a one-line .s() dispatcher.

Cleanup

The docstring carries a TODO naming the full removal surface for PP-5129, to be done once this has run on all deployments: this task, the release N startup task, reset_non_bisac_nonfiction_subjects with its script and bin/ wrapper, BISACClassifier.contradicts_stored_fiction (whose only non-test caller is that task), and the second of the two tests added here.

How Has This Been Tested?

tests/manager/scripts/test_startup.py — 20 passed under tox -e py312-docker, including two new tests. mypy clean; all pre-commit hooks pass.

Nothing previously imported the shipped startup_tasks/ directory. Every test in that file points discover_startup_tasks at tmp_path, and coverage source (pyproject.toml:149) does not include the directory — so a task that failed to import would have shipped green. Verified by appending a syntax error to this task and re-running: the suite stayed green. This PR closes that:

  • test_discover_shipped_startup_tasks — discovery over the real STARTUP_TASKS_DIR. Discovery logs and skips a module it cannot import, so asserting only that the results are callable would still pass on a broken task; the test compares the discovered keys against the files on disk. Covers all six tasks, and any added later.
  • test_reapply_non_bisac_nonfiction_reset — this task loads from the real directory and returns a signature for reset_non_bisac_nonfiction_subjects, and does not chain the re-score.

Both were confirmed to fail against a deliberately broken copy of the task before being kept.

The task being dispatched is covered in #3726 by test_reset_non_bisac_nonfiction_subjects (7 offender shapes), test_reset_non_bisac_nonfiction_subjects_leaves_everything_else_checked (6 contrast cases) and test_reset_non_bisac_nonfiction_subjects_is_idempotent — the last of which is what makes re-running it here safe.

Checklist

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

🤖 Generated with Claude Code

@dbernstein dbernstein added the bug Something isn't working label Sep 14, 2026
@claude

claude Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @dbernstein's task in 1m 30s —— View job


Summary

The code is correct and I found nothing blocking. run() matches the 2026_06_17 precedent and returns a bare reset_non_bisac_nonfiction_subjects.s(). I checked the claims the change relies on:

  • A second run is a no-op. The reset only looks at subjects with checked=True AND fiction=False, then filters them through contradicts_stored_fiction, so it finds nothing once the first repair has worked.
  • Re-scoring is covered. classify_unchecked_subjects is in the beat schedule at 22:30, so leaving the re-score to the nightly run is safe.
  • Fresh installs are handled. They stamp the task MARKED without running it.
  • Ordering is right. The 2026_10_02 key sorts after 2026_09_14.

The new test_discover_shipped_startup_tasks is a good addition. Until now, nothing imported the real startup_tasks/ directory, and discovery logs and skips a module that fails to import, so a broken task file would have passed CI. The test asserts signature.task == reset_non_bisac_nonfiction_subjects.name, so it would also fail if someone added a chain, because a chain's .task is celery.chain. That matches what its docstring promises.

I couldn't run pytest here because the sandbox wouldn't allow it. CI's Test workflow was still running when I posted this.

Details

Minor: tests/manager/scripts/test_startup.py:99-133

The PR description no longer matches the diff. It says "That is the whole diff — one file, 37 lines", "Startup tasks have no per-task tests in this repo; all six rely on that discovery suite", and "18 passed". This push adds two tests to test_startup.py, including one aimed at this task. Please update the description and the "How Has This Been Tested?" section to mention both new tests and the new pass count, so reviewers aren't working from the earlier version.

def test_discover_shipped_startup_tasks(self) -> None:
"""Every task we actually ship imports and exposes a usable run().
The other tests here point discovery at `tmp_path`, so nothing else
imports `startup_tasks/` -- a task that fails to load would ship green.
Discovery logs and skips a module it cannot import, so compare against
the files on disk rather than just checking what came back.
"""
expected = {
path.stem
for path in STARTUP_TASKS_DIR.glob("*.py")
if not path.stem.startswith("_")
}
result = discover_startup_tasks(STARTUP_TASKS_DIR)
assert expected, f"No startup tasks found in {STARTUP_TASKS_DIR}."
assert set(result) == expected
assert all(callable(run) for run in result.values())
def test_reapply_non_bisac_nonfiction_reset(self) -> None:
"""The re-apply task queues the reset, and only the reset.
Unlike the release N task it deliberately does not chain the re-score.
TODO: Remove with the rest of the repair (PP-5129).
"""
run = discover_startup_tasks(STARTUP_TASKS_DIR)[
"2026_10_02_reapply_non_bisac_nonfiction_reset"
]
signature = run(MagicMock(), MagicMock(), logging.getLogger())
assert isinstance(signature, Signature)
assert signature.task == reset_non_bisac_nonfiction_subjects.name

@greptile-apps

greptile-apps Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Adds a database repair task to run at startup.

The PR appears safe to merge; the later-release reset has a distinct startup identity and its dispatch contract is directly covered.

Summary

Adds a later-release startup task that re-dispatches the idempotent non-BISAC nonfiction reset after superseded application code is no longer expected to be running.

  • Returns only the existing reset_non_bisac_nonfiction_subjects Celery signature, leaving rescoring to the nightly process.
  • Adds coverage that imports every shipped startup task and verifies exact discovery parity.
  • Adds a task-specific assertion that the new entry point dispatches the intended reset task without chaining a rescore.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Startup[Startup task runner] --> Discover[Discover 2026_10_02 task]
  Discover --> Reset[Queue reset_non_bisac_nonfiction_subjects]
  Reset --> Subjects[Mark affected subjects unchecked]
  Subjects --> Nightly[Nightly classify_unchecked_subjects]
  Nightly --> Rescore[Reclassify and reindex subjects]
Loading

Reviews (10) · Last reviewed commit: "Re-date the task key and cover the real ..."

Comment thread startup_tasks/2026_10_02_reapply_non_bisac_nonfiction_reset.py
@dbernstein
dbernstein force-pushed the bugfix/rerun-non-bisac-nonfiction-reset branch from f99d214 to 76f13e8 Compare September 15, 2026 00:14
@dbernstein
dbernstein force-pushed the bugfix/reapply-non-bisac-nonfiction-reset branch from b598a55 to b99b337 Compare September 15, 2026 00:15
@dbernstein
dbernstein force-pushed the bugfix/reapply-non-bisac-nonfiction-reset branch from b99b337 to 03b5199 Compare September 15, 2026 14:40
@dbernstein
dbernstein force-pushed the bugfix/rerun-non-bisac-nonfiction-reset branch from 1a85d67 to 5769c9a Compare September 16, 2026 20:58
@dbernstein
dbernstein force-pushed the bugfix/reapply-non-bisac-nonfiction-reset branch from 03b5199 to fe37adb Compare September 16, 2026 20:58
Comment thread startup_tasks/2026_10_02_reapply_non_bisac_nonfiction_reset.py
@codecov

codecov Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.75%. Comparing base (c360582) to head (bef4f14).
⚠️ Report is 5 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3737   +/-   ##
=======================================
  Coverage   93.75%   93.75%           
=======================================
  Files         510      510           
  Lines       46477    46477           
  Branches     6312     6312           
=======================================
  Hits        43574    43574           
  Misses       1875     1875           
  Partials     1028     1028           

☔ View full report in Codecov by Harness.
📢 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 force-pushed the bugfix/reapply-non-bisac-nonfiction-reset branch from fe37adb to b72348a Compare September 17, 2026 17:10
@dbernstein
dbernstein changed the base branch from bugfix/rerun-non-bisac-nonfiction-reset to bugfix/unrecognized-bisac-codes-vote-nonfiction September 17, 2026 17:11
@dbernstein
dbernstein force-pushed the bugfix/reapply-non-bisac-nonfiction-reset branch from b72348a to 3356082 Compare September 18, 2026 20:33
@dbernstein
dbernstein force-pushed the bugfix/unrecognized-bisac-codes-vote-nonfiction branch 2 times, most recently from 14ed678 to 2d90632 Compare September 18, 2026 20:52
@dbernstein
dbernstein force-pushed the bugfix/reapply-non-bisac-nonfiction-reset branch from 3356082 to a9e764b Compare September 18, 2026 20:52
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>
Base automatically changed from bugfix/unrecognized-bisac-codes-vote-nonfiction to main September 18, 2026 21:46
dbernstein and others added 3 commits October 2, 2026 11:06
Hold in draft. Merge only after the release containing #3726 and #3736 has
gone out.

The release N repair -- migration 52d1bbdd4671 plus the startup task that
dispatches the re-score -- is exposed for as long as any old code is running.
Anything reaching Subject.assign_to_genre before the re-score lands consumes
the reset, and code on the superseded rules re-stamps checked=True with the
same wrong value.

helpers/migrate.yml stops the scripts container, where every Celery worker
runs, before migrating, so the worker path is safe. The web containers are not:
they are recycled after the migration and can reach assign_to_genre through a
presentation recalculation. Fargate deployments are not governed by that
playbook at all.

Running the reset again a release later closes both, because by then no old
code is live anywhere. The task is idempotent, so if the release N repair took,
this is a no-op.

Re-scoring is left to the nightly classify_unchecked_subjects: with no old code
to lose the reset to, dispatching it here would buy nothing.

Same shape as the null-audience repair, where startup task 2026_06_17 re-ran
what 2026_05_12 had dispatched a release earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release N repair is now a startup task rather than a migration, so the
reference to migration 52d1bbdd4671 no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review nit: the TODO named this task, the release N startup task and
reset_non_bisac_nonfiction_subjects, but not
BISACClassifier.contradicts_stored_fiction. That method's only non-test caller
is the Celery task, so removing the three listed items would have orphaned it.

Adds it, plus the script and bin wrapper, so the repair lifts out in one pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dbernstein
dbernstein force-pushed the bugfix/reapply-non-bisac-nonfiction-reset branch from a9e764b to 3b42a47 Compare October 2, 2026 18:07
@dbernstein
dbernstein marked this pull request as ready for review October 2, 2026 18:46
dbernstein and others added 2 commits October 2, 2026 11:52
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The key comes from the filename, so 2026_09_14_reapply sorted ahead of the
2026_09_14_reclassify repair it re-applies. Nothing has recorded the key yet,
so re-dating it to the merge date is free and puts the two in deploy order.

Also cover startup_tasks/ for the first time: every existing test points
discovery at tmp_path, so a shipped task that fails to import would go green.
Discovery skips a module it cannot import, so the new test compares the
discovered keys against the files on disk rather than trusting what came back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant