Resolve suppress-work identifier lookup through equivalencies (PP-5215) - #3759
Conversation
|
Claude finished @dbernstein's task in 2m 33s —— View job Code review
SummaryI found no correctness bugs in the pushed code. I checked both resolution paths against DetailsMinor:
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3759 +/- ##
=======================================
Coverage 93.74% 93.75%
=======================================
Files 510 510
Lines 46417 46470 +53
Branches 6300 6310 +10
=======================================
+ Hits 43513 43567 +54
+ Misses 1876 1875 -1
Partials 1028 1028 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ae75b12 to
d13b572
Compare
|
@tdilauro : this one is ready for review. I've run through multiple rounds of review with Claude and I think I've worked through the legitimate objections. I'm going to address the documentation issue above (Minor: src/palace/manager/scripts/suppress.py:197-205) before merging (as well as anything you come back with). |
d13b572 to
e8a7dd3
Compare
I’m not sure which documentation issue you mean. Claude updates its comment every time it runs, so it’s a good idea to quote-reply when referencing anything it says. Otherwise it might not be anywhere obvious later. |
tdilauro
left a comment
There was a problem hiding this comment.
I’ve added some specific comments below, but I have an overarching concern about kind of a dual standard for the acceptability of euivalents. A book with an equivalent ID will be suppressed if there is not an exact ID match present, but won’t be, if there is. And there’s no hint that this is happening. And it means that there is no determinism from the POV of a work (it depends on what else is there).
In the description, we say that equivalents come from vendors and that we can’t trust them as much as we do matching IDs. But in the absence of an exact ID, we seem to bump up our trust of the equivalents.
It seems like it would be useful to get each of the matching works and keep track of whether it is an exact or equivalent match. Then when we report out on what was suppressed or ambiguous, we’d be able to include that information to help understand what happened and why. It might also be useful to add a —allow-equivalents (or similar) option to have finer-grained control.
| matches: two pools of the same identifier are certainly the same | ||
| title, whereas an equivalency edge is only ever a vendor's | ||
| assertion, so widening an exact hit with equivalent works could | ||
| suppress an unrelated title. |
There was a problem hiding this comment.
This makes sense in a way, but we will use that equivalency if we don’t have an exact match. That means that in some cases the equivalent is okay, but in others it’s not?
There was a problem hiding this comment.
In almost every case cases it will be okay and if it's not it will likely be due to bad vendor data which is relatively rare. I think there is some value to suggest the probability that a title could be accidentally suppressed greater than zero.
There was a problem hiding this comment.
I think there is some value to suggest the probability that a title could be accidentally suppressed greater than zero.
I don’t understand what this means.
There was a problem hiding this comment.
Sorry - this is a missing "is" between "suppressed" and "zero". And it was not a very well written sentence even with the grammar corrected.
Anyway I think I misunderstood your point which is that we want consistency, determinism, and transparency. I'm going to work on this a little more - perhaps it should wait until the next release because I don't want to hold things up any longer.
There was a problem hiding this comment.
@tdilauro: I stepped back, did a little more investigation into the equivalency subsystem to make sure I'm not confusing myself. You're right that there's an inconsistency here: the PR justifies preferring exact matches on the grounds that equivalents are vendor assertions we trust less — and then, when there's no exact match, treats those same assertions as good enough to suppress on. The trust we place on an association ends up depending on what else happens to be in the data rather than on the association itself.
I've implemented the provenance half of your suggestion. Every match now carries whether it was exact or equivalency-derived, and that shows up in the output:
[SUPPRESSED] ISBN/9781433383670 -- The Great Gatsby (work id: 42, matched via equivalency)
[SUPPRESSED] Overdrive ID/abc123 -- Moby Dick (work id: 7, exact match)
[AMBIGUOUS] ISBN/111 -- A (work id: 1, matched via equivalency); B (work id: 2, matched via equivalency)
plus a "Matched via equivalency" count in the run summary, so a long --file run can't bury them in the detail lines. This doesn't change which works get suppressed.
On the determinism point : as far as I can see it only two things actually fix it: never use equivalents unless asked, or always union them and let the ambiguity guard sort out disagreements. We had the second behavior earlier in this PR and pulled it, because an exact match that happened to also be equivalent to something unrelated would then refuse to act.
So the flag question is really about the default. My inclination is to keep equivalents on with an --exact-only escape hatch rather than making them opt-in: staff almost always have ISBNs while pools are keyed on vendor ids, so opt-in means most runs come back NOT_FOUND and we're back to the bug that started this. But I don't feel strongly — and with --dry-run plus the new provenance you can now preview exactly which matches are inferred before committing to anything, which may cover most of what the flag would buy us. What sounds best to you?
There was a problem hiding this comment.
I'm okay with either default. We must just be sure to document it, either way. My main concern was making sure that we all understood what is re-articulated in the in the first paragraph, and responded appropriately.
| rather than being that LicensePool's own identifier. This uses the | ||
| same strict, high-confidence equivalency policy that | ||
| `Work.from_identifiers` applies by default everywhere else in the | ||
| codebase, so it won't walk into loosely-related works. |
There was a problem hiding this comment.
I don’t see a test verifying that a lower-confidence equivalency wouldn’t be used. It would be good to have something that proves this out.
There was a problem hiding this comment.
Every equivalency test used strength=1. Added a parametrized pair: 1 resolves, 0.85 doesn't. I picked 0.85 deliberately rather than an arbitrary low number — it's the strength the importer itself assigns when it links identifiers purely because their editions share a permanent work id, so those edges are all over production data. The pair is self-verifying: identical setup, only strength differs, so the passing high-confidence case proves the low-confidence NOT_FOUND comes from the threshold and not a broken fixture.
| if pool.work is not None and pool.collection_id in collection_ids | ||
| } | ||
| if direct_works: | ||
| return [direct_works[work_id] for work_id in sorted(direct_works)] |
There was a problem hiding this comment.
So, if we have a pool for an exact id, but the pool has no work, then we fall through and might suppress a different, equivalence-based book?
There was a problem hiding this comment.
Yes. You can't suppress a workless pool at all: suppressed_for is a Work↔Library relation, so with no Work there's nothing to attach a suppression to. That means the choice isn't "suppress the right book vs. the equivalent book" — it's "suppress the equivalent book vs. suppress nothing." Refusing would mean telling a librarian NOT FOUND for a title their library demonstrably carries (under the equivalent identifier, with a real Work), purely because some other pool of the same identifier happened to be mid-import. That's the worse outcome.
The "different book" risk is real if low, but it's the generic equivalency risk that the whole fallback carries — not something specific to this path — and it's bounded three ways: the 0.999 threshold, the AMBIGUOUS guard if more than one work matches, and the output naming the title and work id so a wrong hit is visible.
One residual limitation worth knowing, which nothing can fix at suppression time: when that workless pool does get its Work calculated later, the new Work won't be retroactively suppressed. Same partial-coverage hazard as the multi-collection case. I documented the fall-through in the load_works docstring and added a test pinning it, since your question showed the behavior was both unstated and untested.
To address this we could add a new scheduled celery task (in a separate PR) that periodically ensures that if one title is suppressed and an equivalent title is not, the equivalent title should be suppressed.
There was a problem hiding this comment.
adde93d to
3452dcd
Compare
SuppressWorkForLibraryScript looked up a Work via Identifier.work, which only follows LicensePools whose own identifier matches exactly. Librarians typically supply an ISBN, but a LicensePool is often keyed on a vendor identifier with the ISBN linked only via identifier equivalency, so the script reported NOT_FOUND for titles that are clearly circulating. suppress_work now resolves the identifier through Work.from_identifiers, the same strict, high-confidence equivalency lookup used elsewhere in the codebase. If an identifier's equivalency network resolves to more than one distinct Work, the script reports a new AMBIGUOUS result and suppresses nothing rather than guessing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
suppress_work now returns a SuppressOutcome(result, title) instead of a bare SuppressResult, so the per-identifier output line and log messages can show which title was affected -- useful for confirming the right book was found, especially since identifiers now resolve through equivalencies. For AMBIGUOUS, all candidate titles are listed to help a human pick the right one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An identifier that owns a LicensePool directly is an exact match and needs no equivalency lookup. Previously load_works always consulted Work.from_identifiers, so an identifier with its own LicensePool that was also equivalent (e.g. via a Bibliotheca-style ISBN equivalency) to a different, unmerged Work would report AMBIGUOUS and suppress nothing -- a regression versus the pre-fix behavior, which correctly suppressed the exact match. Equivalency is now only consulted as a fallback when there's no direct match. Also add a docstring to suppress_work per project convention, and include each candidate work's id (and a "[no title]" placeholder) in the AMBIGUOUS output, since two distinct works can otherwise render as a single indistinguishable title. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An AMBIGUOUS result is often not bad data: non-open-access LicensePools never merge into a shared Work (LicensePool.calculate_work()'s own docstring says so), so a library running more than one collection for the same title (e.g. OverDrive + Bibliotheca) legitimately ends up with one ISBN equivalent, at full strength, to two permanently separate Works. An operator who already knows that's the situation has no way to suppress both without looking up each work's own identifier and running the script twice. --suppress-ambiguous opts into suppressing the identifier in every candidate work instead of refusing. The default stays conservative (refuse and report) since the same ambiguity shape can also mean mismatched/incorrect equivalency data, where auto-suppressing every candidate would silently pull an unrelated title. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four fixes from review: - load_works's equivalency fallback is now scoped to the target library's own active collections. Without this, a title licensed to two different libraries through two different collections (e.g. library A via OverDrive, library B via Bibliotheca) reported AMBIGUOUS when suppressing for either library alone, even though only one of the two candidate works was ever licensed to that library. The direct-match path is untouched, so identifiers that already resolved before this PR behave exactly as before. - A fully-covered set of candidates (every work already suppressed for the library) now short-circuits to ALREADY_SUPPRESSED before the ambiguity check runs, whether or not --suppress-ambiguous is passed. Previously, re-running an already-fully-suppressed identifier without the flag reported AMBIGUOUS, even though there was nothing left to decide. - --suppress-ambiguous's reported title now describes only the works that actually changed in this run, not every candidate. Previously a partially-already-suppressed run could read as though every candidate had just been suppressed. - The single-work success/already-suppressed path now includes the work id alongside the title (matching the ambiguous-path format), so an operator has what they need to look the work up in the admin UI regardless of which path resolved it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up to the library scoping added in f82085d, from review: - Scope collections with an EXISTS over the work's pools rather than a filter on the joined pool row. A Work can own pools in several collections (open-access pools merge across collections by permanent work id), in which case the pool carrying the equivalent identifier and the pool in this library's collection are different pools of the same Work. Filtering the joined row required one pool to satisfy both conditions, so those works resolved to NOT_FOUND -- the very failure this branch set out to fix. - Apply the same scope to the direct match: a direct match whose pool isn't in any of the library's collections now falls through to the equivalency lookup instead of being suppressed on that library's behalf. This matters for collections keyed on ISBN (ODL/OPDS), where an ISBN owned by another library's collection would otherwise be suppressed while this library's own equivalent work went unexamined. - Scope by associated_collections rather than active_collections. suppressed_for is a durable flag; it shouldn't depend on where today falls in a collection's subscription window. - Rename SuppressOutcome.title to description, since it holds "<title> (work id: <id>)" rather than a bare title. - Drop the unreachable from_identifiers None branch (it returns None only for an empty identifier list) and cover the reachable no-collections early return with a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The direct-match path resolved through Identifier.work, which returns the first pool's work and stops. But one vendor identifier can be licensed separately by two of a library's own collections -- a consortium's OverDrive collection and that library's OverDrive Advantage collection both carry the same OverDrive id, and OverdriveAdvantageAccount.to_collection() creates the Advantage collection as a distinct child Collection. Each non-open-access pool gets its own permanent Work, so the script suppressed whichever work it happened to find first, reported [SUPPRESSED], and left the title circulating through the other collection. The direct set is now built from the identifier's pools in the library's collections, so a multi-collection match reaches the same AMBIGUOUS guard (and the same --suppress-ambiguous escape hatch) as the equivalency path. Verified that dropping the old single-work lookup loses nothing: an identifier is always a member of its own equivalent set -- fn_recursive_equivalents seeds the CTE with it at strength 1, independent of the threshold -- so a work whose pool for this identifier sits outside the library but which has another pool inside is still found by the equivalency query. There's a test for that case, and the two new multi-collection tests both fail against the old logic. Also from review: - Distinguish "this library doesn't carry it" from "no such work" with a NOT_IN_LIBRARY result, so an operator scanning a batch can tell a title in someone else's collection from a typo'd identifier. - Cache the library's collection ids for the life of the script rather than issuing the same SELECT once per identifier. - Order candidates by work id so operator-facing output is stable between runs. Messages that described multi-work matches as coming "via identifier equivalency" are reworded, since that's no longer the only way to get there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From review: the file repeated the same setup in a lot of places. Merged into parametrized cases where the tests differed only in inputs and expectations: CSV parsing, the parse_command_line flags, do_run flag threading, the single-work already-suppressed/dry-run matrix, the two-collection pair, the with/without-flag idempotency pair, and the _print_results cases. Pulled the two setups that recur with genuinely different assertions into helpers instead, since parametrizing those would have meant branching on the parameter inside the test body: an ISBN linked by equivalency to N works, and two works plus a CSV naming them. Left the regression tests that build deliberately unusual topologies alone -- each documents a distinct shape in its docstring, and folding them into a shared harness would hide the thing they exist to show. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From review: - Nothing proved that a lower-confidence equivalency is excluded, even though the docstring claims the strict policy will not walk into loosely-related works. The new test pins both sides of the threshold. 0.85 is not an arbitrary low number: it is the strength the importer assigns when it links two identifiers purely because their editions share a permanent work id (BibliographicData), so those edges are all over production data and a suppression must not ride one. - A pool whose Work has not been calculated cannot be suppressed, since suppression is a Work/Library relation, so such a pool does not count as an exact match and resolution falls through to equivalency. That was unstated and untested; refusing instead would leave a title the library demonstrably carries unsuppressed because some other pool of the same identifier was mid-import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The docstring claimed a consortium OverDrive collection plus an Advantage collection gives two works for one OverDrive id. The model layer enforces the opposite: calculate_work requires all LicensePools with a given identifier to share a work, and points every pool in licensed_through at it. test_all_licensepools_with_same_identifier_get _same_work covers exactly this, two pools of one identifier in different collections, asserting one work. The earlier reasoning conflated two rules -- non-open-access pools are not merged across *different* identifiers by permanent work id, which is true, with pools of the *same* identifier not merging, which is not. The code is unchanged: returning every matching work still guards against inconsistent data, a state calculate_work itself warns about and repairs. Only the justification was wrong, so the docstring and the test that builds that state by hand now say what they actually cover. The legitimate multi-work case is different vendor identifiers sharing an ISBN, which goes through the equivalency path. Also create _collection_ids in __init__ rather than lazily behind hasattr, so the annotation is true for any reader rather than only through its accessor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3452dcd to
04e7802
Compare
Description
SuppressWorkForLibraryScript(src/palace/manager/scripts/suppress.py) resolved an identifier to a work viaIdentifier.work, which only followsLicensePools whose ownidentifier_idmatches exactly, and returns the first one it finds.load_worksnow answers a more precise question — which works does this library carry for this identifier? — in two steps:Work.from_identifierswith the strict default policy (equivalent_identifier_levels=1,equivalent_identifier_threshold=0.999) already used inedition.py,customlist.pyandwork.py, scoped to the library's collections.Exact matches deliberately aren't merged with equivalency matches: two pools of the same identifier are certainly the same title, whereas an equivalency edge is only ever a vendor's assertion, so widening an exact hit could suppress an unrelated title.
Suppression itself is unchanged and still scoped to exactly the one library passed via
-l/--library. Resolving through equivalency only changes which works are found, never which libraries they're suppressed for.Output now names the work(s) involved, e.g.
[SUPPRESSED] ISBN/9781433383670 -- The Great Gatsby (work id: 42), ordered by work id so it's stable between runs.Why one identifier can legitimately mean more than one work
Different vendor identifiers sharing an ISBN. Standard ingestion links each vendor's own identifier to the book's ISBN at full strength —
BibliographicData._handle_identifier_equivalencies(used by Overdrive, Boundless/Axis 360) plus Bibliotheca's importer directly. Each of those vendor identifiers has its own Work, so one ISBN can reach several of them through theequivalentsgraph. This is routine ingestion, not bad data.Note
IdentifierData.weightdefaults to1.0unconditionally, so "strength 1" means "the vendor's feed said so", not a computed confidence. A shared ISBN can therefore also mean bad data — publisher ISBN reuse or misattribution, or an ebook/audiobook split (Palace's own merge logic excludes cross-medium matches) — where the candidate works really are unrelated.The same identifier in two collections is not one of these cases:
LicensePool.calculate_work()requires that "All LicensePools with a given Identifier must share a work" and points every pool inlicensed_throughat it, so a consortium's OverDrive collection and a library's own Advantage collection share one Work for a given OverDrive id, and suppressing it covers both. The exact-match path still returns every work it finds rather than the first, but purely as a guard against inconsistent data — the statecalculate_workwarns about as "more than one Work between them" and repairs.Why the script holds off, and how to override it
Because the script can't tell a title legitimately reachable under several vendor identifiers from two unrelated books joined by a bad equivalency edge, an identifier resolving to more than one work reports a new
AMBIGUOUSresult and suppresses nothing, listing every candidate with its work id so a human can look before acting:A suppression script is exactly the kind of catalog-wide, hard-to-audit action where guessing wrong — silently pulling a book the library meant to keep — is worse than asking someone to confirm.
For the common legitimate case, where an operator already knows why a title resolves to more than one work and wants it gone from all of them,
--suppress-ambiguoussuppresses every candidate instead of refusing. Its output names only the works that actually changed, so a partially-already-suppressed run doesn't read as though everything was just suppressed.Both resolution paths feed this guard. An earlier revision only guarded the equivalency path, so an exact match resolving to several works would have suppressed whichever was found first and printed
[SUPPRESSED]while the others kept circulating.Other behavior changes worth noting
NOT_IN_LIBRARY— an identifier that resolves to a work the library doesn't carry is now reported separately from one that matches nothing at all. Scoping resolution to the library means an identifier that previously "worked" can now legitimately find nothing, and an operator scanning a batch should be able to tell a title in someone else's collection from a typo.Library.associated_collectionsissues a freshSELECTon each access).Workhasn't been calculated yet can't be suppressed — suppression is a Work/Library relation, so there's nothing to attach it to. Such a pool therefore doesn't count as an exact match and resolution falls through to equivalency, rather than refusing and leaving a title the library demonstrably carries unsuppressed.suppressed_foris a durable flag and shouldn't depend on where today falls in a collection's subscription window.Motivation and Context
A librarian ran the script with an ISBN they could see in the catalog and got
NOT_FOUND, even though the title was circulating. That ISBN'sidentifiersrow had noLicensePoolof its own — the actual pool is keyed on a vendor identifier, with the ISBN linked only through anequivalentsrow.Identifier.workdoesn't traverse equivalencies, so the script couldn't find a work that plainly exists and is licensed. This is the usual shape for identifiers library staff have on hand, so it affected ISBN-based suppression broadly, not just this one title.How Has This Been Tested?
tests/manager/scripts/test_suppress.py— 64 tests passing, covering:strength=1finds the work,0.85doesn't. 0.85 isn't an arbitrary low number — it's the strength the importer assigns when it links two identifiers purely because their editions share a permanent work id, so those edges are widespread in production data.calculate_workwarns about and repairs — reachingAMBIGUOUS, and--suppress-ambiguouscovering both.fn_recursive_equivalentsseeds the CTE with it at strength 1, independent of the threshold).NOT_IN_LIBRARYvsNOT_FOUND; a pool whose work hasn't been calculated, both on its own (nothing to suppress) and alongside an equivalent work (falls through and suppresses that one); a library with no collections.ALREADY_SUPPRESSED, notAMBIGUOUS, with or without the flag), dry runs, and_print_resultsoutput for every status.The multi-work and library-scoping tests were each checked against the pre-fix code to confirm they fail without the change.
Setup that recurred across tests is parametrized, or pulled into a helper where the cases share setup but assert different things. The regression tests that build deliberately unusual topologies are left standalone, since each documents a distinct shape.
mypy— clean.pre-commit— clean.Checklist
🤖 Generated with Claude Code