Add safeguards for broken ontology adapters in reachable_from - #69
Add safeguards for broken ontology adapters in reachable_from#69cmungall wants to merge 14 commits into
Conversation
A `reachable_from` dynamic enum silently rejected every term of an ontology when its source node resolved to a valid term but the configured adapter returned an empty closure for it. The failure mode was a wrong `False` that looked like an ordinary "not in enum" result, and in greedy/materialized mode the empty set was cached as a *complete* closure that poisoned later runs. The motivating case (dismech#7012): the OLS4 adapter (`ols:mondo`) drops `MONDO:0000001` from every MONDO ancestor closure (the `human disease` axiom is rewired to a cross-ontology `AFO_O:0000001` term), so `reachable_from` rooted at `MONDO:0000001` returns no descendants and every MONDO term is silently marked invalid. Detect this and fail loud with a distinct `EmptyReachableClosureError` (CLI exit code 3, separate from invalid data and from a transient service outage): - Greedy expansion (`_expand_reachable_from`): raise when at least one source node resolves but the whole reachable_from set expands to nothing, so an empty closure is never persisted as a complete cache. - Progressive per-value checks (`_is_value_in_reachable_from`): before returning a definitive negative, raise when *every* source node of the value's own ontology resolves but reaches nothing. The check is conservative and false-positive-free: a legitimate multi-source union (a childless leaf branch alongside a populated one) still expands non-empty; requiring all same-prefix sources to be empty keeps such unions safe; an include_self single-term enum keeps its source node. The source-closure probe is lazy (stops at the first real member) with the existing OLS REST descendants fallback, and memoized per source node. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
Live verification against OLS4 (2026-07-26) showed the bug is directional, not an empty closure: descendants(MONDO:0000001) still returns the whole disease tree (~31k terms), but MONDO:0000001 is dropped from every MONDO *ancestor* closure (human disease is reparented to the cross-ontology AFO_O:0000001). So the ancestor and descendant directions disagree, and only the ancestor-based progressive per-value check silently returns wrong negatives. The previous approach probed descendants(source) for an *empty* closure, which never fires for the real bug (descendants are non-empty) and false-positived on a legitimate childless-leaf source among multiple source nodes. Replace it with a round-trip consistency probe: when a progressive negative is about to be returned, sample a few of the source node's descendants and check that each reports the source node among its ancestors. If a genuine descendant fails the round trip, the adapter's graph is internally inconsistent — raise InconsistentReachabilityError (naming the offending descendant) instead of a silent False. Verified end-to-end against a live ols:mondo adapter. This is false-positive-free: a genuinely out-of-enum term is absent from both directions (they agree), and a childless-leaf source reaches nothing so nothing round-trips. The empty-expansion guard is retained as EmptyReachableClosureError (both now subclass UnreliableReachabilityError; CLI exit code 3 unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
…nting Manual inspection of the OLS4 ancestors payload showed the earlier framing (in this code and in dismech#7012) was wrong about the cause. The disease root IS in the ancestor closure by IRI (.../MONDO_0000001) — the hierarchy is intact. OLS4 conflates it with a cross-ontology term also labelled "disease" and returns it under the wrong obo_id 'AFO_O:0000001' (short_form and label corrupted too); querying the MONDO_0000001 IRI directly returns the same corrupted obo_id. So it is a CURIE/identifier assignment bug, not axiom reparenting: every CURIE-matching consumer (oaklib's OLS adapter, and therefore this validator) sees the ancestor as 'AFO_O:0000001', so MONDO:0000001 is unmatchable by CURIE and ancestor-based reachability silently fails. The round-trip detector already catches this exact symptom and its behavior is unchanged; this only fixes the misleading explanation in the error message, docstrings, guard comment, and docs, and makes the test stub return the real corrupted CURIE (AFO_O:0000001) instead of a placeholder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
…h#7012) Guards the exact #7012 scenario end-to-end against the real ols:mondo adapter: MONDO:0975866 (a subtype that failed under the OLS obo_id merge) must validate as reachable from MONDO:0000001. This confirms the closure resolves correctly on the now-fixed OLS API and that the round-trip integrity guard does not false-positive on a healthy graph; if OLS ever regresses the AFO_O:0000001 identifier merge, the test fails loudly. Marked @pytest.mark.integration (skipped by default). Note the OLS fix in the docs and that the guard is retained as a general safety net. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
|
Claude finished @cmungall's task in 4m 12s —— View job Review: broken-adapter safeguards for
|
Address the review's false-positive and performance concerns on the round-trip probe (both errors are hard aborts, so a false positive is worse than the silent negative being fixed): - Require consensus + a demonstrably-working reverse direction before flagging. An empty-but-answered reverse closure is no longer treated as proof of inconsistency; a witness is reported only when at least one sampled member's reverse closure is non-empty yet omits the source, and no member round-trips. This removes false positives on adapters whose reverse direction is a no-op / uses a different predicate / is focus-ontology-restricted. - Prefix-filter sampled members to the source ontology, so imported/cross-prefix terms cannot produce a spurious InconsistentReachabilityError. - Early-exit the reverse probe the moment the source is found (via a tri-state _reverse_reaches), making the healthy path essentially free; stop sampling the paged OLS descendants crawl in _sample_closure (native, bounded traversal only). - EmptyReachableClosureError now names a source node that actually resolved, not source_nodes[0] which may be an unresolvable typo. - Rename InconsistentReachabilityError.descendant -> .witness (it holds an ancestor under traverse_up); log probe failures at warning (a silently disabled guard is now discoverable); drop cross-module private access to _is_ols_adapter. - Docs: scope the "childless leaf stays quiet" claim to the round-trip check and clarify the empty-expansion check fires only when the whole query is empty. Tests: add traverse_up inconsistency + healthy cases, empty-reverse no-flag, CLI exit-code-3 case, and a live-OLS assertion that the reverse direction resolves the disease root (guard is wired, not a no-op). 216 unit tests pass; mypy and ruff clean; the OLS integration test passes against the fixed API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
|
Claude finished @cmungall's task in 3m 52s —— View job Re-review after
|
| call site | consequence of an empty reachable_from |
|---|---|
base.py:1038 |
enum with reachable_from + permissible_values / concepts / inherits aborts even though the enum is well-populated (base.py:1044-1072) |
base.py:1101 via include: |
one empty branch of a union aborts the whole union |
base.py:1056 via minus: |
a minus clause rooted at a leaf — "subtract nothing", entirely legitimate — hard-aborts with exit code 3 |
The minus case is the clearest false positive: excluding an empty subtree is a normal, correct configuration, and it now kills the run.
The fix is to move the decision up: have _expand_reachable_from signal "resolved sources but empty" (return value or flag) and let expand_enum raise only when the whole enum comes out empty — which is also what the docs already promise. Fix this →
Medium
3. traverse_up + OLS is still a potential root-scale crawl. _reverse_reaches early-exits on the native iteration (good), but when the native traversal yields nothing it calls _ols_descendants (base.py:937-942), which pages through the entire descendant set with no early exit. Under traverse_up=True the reverse direction is descendants and the sampled members are the source's ancestors — i.e. near-root terms — so a single legitimate negative can trigger up to _INCONSISTENCY_SAMPLE_SIZE (8) full paged crawls before the first verdict. The per-source memoization bounds it to once per run, but the first hit can be minutes. Bounding the fallback (first page, or short-circuit as soon as target appears) would fix it.
4. resolved_sources costs a label lookup per source node on every expansion. base.py:1160-1163 calls get_ontology_label(source_node) unconditionally, but the result is only consulted in the not values branch at base.py:1205. Defer the resolution check into that branch so the healthy path pays nothing.
5. Sample cap is applied before prefix filtering. _sample_closure stops at 8 members (base.py:875), and only then does _compute_inconsistency_witness drop foreign-prefix terms (base.py:840-842). For a merged/imported ontology whose traversal happens to yield foreign CURIEs first, all 8 are discarded and the guard silently disables itself. Filtering inside the sampling loop (pass the expected prefix down) makes the sample actually contain 8 usable members.
6. An exception in the reverse traversal skips the OLS fallback. base.py:923-932 returns _REVERSE_UNANSWERABLE on any exception, without trying _ols_descendants — whereas _expand_reachable_from reaches the fallback whenever the native result is falsy. An OLS adapter whose descendants() raises (rather than returning empty) therefore gets no fallback. Fail-safe, but inconsistent with the sibling path.
Test coverage
Good additions: _EmptyReverseAdapter covers the answered-empty branch, _InconsistentTraverseUpAdapter locks the forward/reverse direction mapping, and the CLI exit-3 test locks _fail_unreliable_reachability. Remaining gaps map onto the findings above:
- No test that
_reachability_inconsistency_witnessgets a non-empty forward sample from an adapter whosedescendants()returns nothing — the finding-1 shape. An offline stub (descendants()→ empty,_ols_descendants-style fallback populated) would pin it. - No CLI test for exit code 3 via
EmptyReachableClosureError— only the unit-levelpytest.raises. The inconsistency error has one; the empty-closure error should too, since they share the exit path. - No test for
reachable_fromcomposed withminus:/include:/permissible_values— finding 2 would have shown up immediately.
Nothing security-relevant; no new network surface beyond existing adapter calls.
· branch claude/ltv-issue-7012-ed5bc9
|
Thanks — this was a genuinely useful review; the false-positive framing (a hard abort is worse than the silent negative) is exactly right. Addressed in High — false-positive risks (all fixed, they were the core of the rewrite):
High — performance (#4, fixed): introduced a tri-state Medium:
Test coverage added: 216 unit tests pass; mypy + ruff clean; OLS integration test passes against the now-fixed API. Context for reviewers: the motivating OLS defect (EBISPOT/ols4#1334) has since been fixed upstream, so this guard is retained as a general safety net against this class of adapter identifier corruption rather than a fix for a currently-active bug. Generated by Claude Code |
Two High findings from the re-review of eaf7221: 1. The integrity guard could be a silent no-op on a live OLS adapter for the default direction: `_sample_closure` dropped the OLS descendants fallback, but OAK's OLS `descendants()` returns nothing on some versions, so the forward sample was empty → guard never fired (the exact dismech#7012 shape). Restore a *bounded* `_ols_descendants` fallback (first pages only, via a new `limit` arg) when the native descendants traversal yields too few members. Prefix-filtering now happens inside the sampling loop so the cap yields usable members. 2. `EmptyReachableClosureError` fired per reachable_from *clause* (inside `_expand_reachable_from`), so a `minus:` clause rooted at a childless leaf, or a reachable_from combined with permissible_values/concepts/include, aborted a legitimate config. Move the check up to `expand_enum` and raise only when the *whole enum* expands to nothing — matching the documented contract. The source-resolution lookup now runs only on the empty path (healthy path pays nothing). Also: `_reverse_reaches` now consults the (bounded) OLS fallback on native error too, not just on empty, mirroring `_expand_reachable_from`; the fallback is capped (`_REVERSE_FALLBACK_LIMIT`) so a near-root reverse closure under traverse_up is never fully paged (a truncated result stays "unanswerable" rather than falsely "without"). Tests: OLS-shaped adapter whose native descendants() is empty but the fallback populates (guard still fires); reachable_from + permissible_values and minus-of-leaf compositions (no false EmptyReachableClosureError); CLI exit-3 for EmptyReachableClosureError via greedy expansion. 220 unit tests pass; mypy + ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
|
Both High findings were spot-on — fixed in High #1 — guard could be a silent no-op on live OLS. Correct: High #2 — Medium:
Tests added for all three of your listed gaps: non-empty forward sample from an empty-native- 220 unit tests pass; mypy + ruff clean; OLS integration test passes against the fixed API. Generated by Claude Code |
|
Claude finished @cmungall's task in 5m 1s —— View job Re-review after
|
Medium findings: - Truncation off-by-one could yield a false _REVERSE_WITHOUT (a hard abort on a healthy ontology). `_ols_descendants` now skips the source node *during* collection instead of discarding it afterwards, so a returned set of exactly the cap reliably signals truncation; `_reverse_reaches` treats a capped result as UNANSWERABLE (with a debug log) and only an under-cap result as WITHOUT. A new `stop_at` short-circuits the crawl the moment the target appears, so membership can succeed even past the cap. - Forward sample and reverse probe now handle a *raising* native descendants() symmetrically: `_sample_closure` no longer returns early from its except block, so a raising OLS descendants() still reaches the bounded fallback (previously the guard was silently disabled on that adapter shape). Low findings: - Fallback in `_sample_closure` now fires only when the native traversal produced no usable members (a healthy adapter returning a few descendants pays no extra REST call). - Dedup the forward sample so a generator yielding a term twice doesn't burn a slot / re-probe. - Corrected the misleading "surface that" comment (a childless leaf legitimately reaches nothing → debug, not warn). - Documented that the empty-expansion check keys on the top-level reachable_from and that an include:-nested reachable_from is intentionally unguarded (fail-safe); docs now say "whole enum" (incl. permissible_values / concepts / include / inherits), not "whole query". Tests: reverse-fallback truncation boundary (truncated→UNANSWERABLE, exhausted→WITHOUT, stop_at→FOUND) and a raising-native-descendants forward-sample case that still flags the inconsistency via the fallback. 222 unit tests pass; mypy + ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
|
Round 3 — all findings addressed in Medium #1 (truncation off-by-one → false Medium #2 (forward/reverse disagree on a raising native traversal). Fixed: Low:
On the integration test suggestion (assert 222 unit tests pass; mypy + ruff clean; OLS integration test passes against the fixed API. Generated by Claude Code |
|
Claude finished @cmungall's task in 2m 59s —— View job Re-review after
|
…nism
Medium findings:
- The native reverse path could report `_REVERSE_WITHOUT` from truncated
evidence: if the native traversal yielded some terms and then raised (lazy
paging failing mid-stream, a malformed record — non-connectivity errors that
survive raise_if_service_unavailable), `saw_any` was already True, so the
partial closure was treated as exhaustively-without-target. `_reverse_reaches`
now tracks an `errored` flag: an errored partial native read forces the bounded
fallback (descendants) and otherwise resolves to `_REVERSE_UNANSWERABLE` rather
than a false `WITHOUT`. Same "truncated ≠ exhausted" invariant now holds on both
the native and REST paths.
- The forward sample was nondeterministic: `_ols_descendants` returns a set and
`_sample_closure` iterated it directly, so process-randomized str hashing chose
which members to probe — on a mixed graph the consensus verdict (first FOUND
short-circuits) could flip between runs, i.e. a hard abort that flips on
identical input. Iterate `sorted(fallback)` for a reproducible sample.
Low:
- Reworded the native-sampling warning so it no longer implies a fallback exists
for non-descendants directions (traverse_up forward = ancestors, no fallback).
- Softened the `_ols_descendants` truncation docstring ("== limit means possibly
truncated; must not be read as exhaustive").
- Documented the `* 8` fallback headroom (survives prefix filtering).
Tests: native-yields-then-raises → UNANSWERABLE; exhausted-at-exactly-the-cap →
UNANSWERABLE (the ambiguous boundary); and a live-OLS assertion that the forward
`_sample_closure` returns real members (the path that fires the guard on ols:),
not just the reverse probe. 223 unit tests pass; mypy + ruff clean; OLS
integration test passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
|
Round 4 — all findings addressed in Medium #1 (native reverse path reports Medium #2 (nondeterministic sample). Correct — Low:
Tests for all three coverage gaps you listed: native-yields-then-raises → 223 unit tests pass; mypy + ruff clean. Generated by Claude Code |
|
Claude finished @cmungall's task in 4m 6s —— View job Re-review after
|
…y, polish Medium: - EmptyReachableClosureError misdiagnosed a non-empty closure emptied by a `minus:` clause as "descendant closure empty / adapter misconfigured", sending the user to debug an adapter for their own set arithmetic. expand_enum now captures whether the top-level reachable_from itself expanded to nothing and passes `source_closure_empty` to the error, which branches the message between "bad source node / adapter" and "minus:/set operations removed every term". Low: - Made the two sampling paths symmetric and deterministic: `_sample_closure` now lazily collects a bounded pool (up to sample_size * 8) from the native path too (not just the REST fallback) and returns `sorted(pool)[:sample_size]`, so the consensus verdict no longer depends on process-randomized adapter/set iteration order on a mixed graph. - `_ols_descendants(reflexive=True, limit=N)` could return N+1 (the reflexive source was added after the cap); it now reserves a slot for the source so the `len == limit` truncation contract holds for every caller. - Branched the native-sampling warning on direction so a silently-disabled guard (no fallback for the ancestors/traverse_up direction) is greppable. Tests: reachable_from fully cancelled by minus -> set-arithmetic message (source_closure_empty False); the saw_any+errored descendants arm where the bounded fallback overrides truncated native evidence (FOUND / WITHOUT); and the boundary test now monkeypatches the class attribute rather than the instance. 225 unit tests pass; mypy + ruff clean; OLS integration test passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
|
Round 5 — all addressed in Medium ( Low #2 (sampling determinism only covered the REST path). Fixed by making both paths symmetric: Low #3 ( Low #4 (warning caveat). Branched on Low #5 (test mutated a class constant via the instance). Switched to Test gap ( 225 unit tests pass; mypy + ruff clean; live-OLS integration test passes. Thanks for the branch-by-branch trace on Generated by Claude Code |
|
Claude finished @cmungall's task in 3m 57s —— View job Re-review after
|
| layer | text | true for source_closure_empty=False? |
|---|---|---|
EmptyReachableClosureError docstring (oak_utils.py:64-73) |
"Raised when a reachable_from source node resolves but reaches nothing" |
no — the closure was large |
UnreliableReachabilityError docstring (oak_utils.py:53-60) |
"membership could not be reliably computed because the configured adapter returned a broken graph" | no — the adapter was fine |
CLI banner (cli.py:77-83) |
"dynamic-enum reachability is unreliable … Reachability could not be computed reliably, so terms were not checked" | no — reachability computed fine; the enum is empty by design |
So a user who hits the minus:-cancels-everything case reads a correct middle sentence sandwiched between two incorrect ones, and gets exit code EXIT_ONTOLOGY_MISCONFIGURED. Exit 3 is still defensible (it is a configuration problem, and it will not fix itself on retry), but the framing should follow the branch — e.g. a sibling exception under UnreliableReachabilityError (EmptyEnumAfterSetOperationsError) so _fail_unreliable_reachability can pick a banner, or at minimum soften the wrapper text to "the enum matched nothing, so terms could not be meaningfully checked" and adjust the two docstrings. Fix this →
Low
3. "Deterministic on both paths" is only true when the closure fits the pool. _sample_closure's docstring (base.py:884-891) claims sorting makes the sample deterministic, but the pool is the first pool_cap (64) same-prefix members in adapter order (base.py:905-908). For a closure with more than 64 usable members — a MONDO root, i.e. the motivating case — which 64 land in the pool is still adapter/set-order dependent, so sorted(pool)[:8] can still differ between runs and the consensus verdict can still flip on a mixed graph. The REST path is fully deterministic (the fallback limit equals pool_cap, so every fetched member is considered). Full determinism would require a full walk, which the cap deliberately avoids — so the honest fix is the same one applied to the truncation docstring last round: say the sample is deterministic given the pool, and that the pool is adapter-ordered for closures above the cap.
4. Sorting biases the sample toward near-root terms, worst case under traverse_up. sorted(pool)[:8] picks the numerically-lowest CURIEs, and in most OBO ontologies low IDs are the oldest, most root-like terms. Under traverse_up the sampled members are the source's ancestors and the reverse direction is descendants, so the sort systematically selects the members with the largest reverse closures — up to 8 × _REVERSE_FALLBACK_LIMIT (1024) records before the first verdict, where an arbitrary sample would usually have picked cheaper mid-tree terms. Evenly-spaced indices over the sorted pool (sorted(pool)[::max(1, len(pool)//8)][:8]) stays deterministic and spreads the sample across the ID range.
5. The native path now walks 8× further than it needs to. Previously the generator stopped at 8 members; it now runs to pool_cap = 64 (base.py:894, _collect returns True only at the cap) even though only 8 are used, and the consensus rule short-circuits on the first member that round-trips. Cheap on sqlite:, but it's a per-probed-source cost increase on any paging adapter, taken purely to improve sampling spread. Worth a comment saying that's the trade being made.
6. The errored-sample warning promises a fallback that may not run. base.py:912-918 logs "trying the descendants fallback" from inside the except, but the fallback is gated on if not pool (base.py:929). A native traversal that yields a few terms and then raises has a non-empty pool → no fallback → the log says otherwise. Moving the log below the fallback decision (or wording it "will try … if nothing was sampled") keeps the greppability goal without the false statement. (The truncated pool itself is fine as evidence — the members that were yielded are genuine, so unlike _reverse_reaches truncation here cannot manufacture a false witness.)
7. _ols_descendants(reflexive=True, limit=1) can still return 2. The cap test runs after values.add (base.py:1387-1388), so effective_limit=0 still admits one descendant, then values.add(source_node) makes two. Latent only — every caller passes 64 or 1024 — but it's the same contract (len == limit) the reservation was added to protect; if effective_limit is not None and len(values) >= effective_limit: break placed before the add, or an explicit if effective_limit == 0: break, closes it.
Test coverage
The three new/updated tests land squarely on the round-4 findings, and _OlsPagedAdapter(native="yield_then_raise") is a clean way to reach the saw_any and errored arm — with both the FOUND and WITHOUT outcomes asserted, which is what proves the fallback overrides rather than supplements the truncated native view.
Gaps that map onto the above:
- No test for
include_self: trueover an empty/broken closure (finding 2) — an offlinereachable_fromonTEST:0000004(childless leaf) withinclude_self: truewould currently expand to{"TEST:0000004"}, cache complete, and not raise. That test would fail today and pass after the fix. - No test for
_sample_closure's new pool/sort behavior. An adapter yielding, say, 20 same-prefix members in descending order and asserting the returned sample is the 8 lowest sorted would pin the round-5 restructure; today nothing would catch a regression back to "first 8 in stream order". - No CLI-level test for the set-arithmetic arm.
tests/test_cli.py:214/:265cover exit 3 for the inconsistency and empty-closure arms; the new branch shares the exit path but has different user-facing text, and per finding 3 that text is currently self-contradictory — a CLI test is what would have surfaced it.
Nothing security-relevant; the round-5 changes don't add network surface (the effective_limit reservation strictly reduces records fetched; the native pool walk is local iteration on an already-open traversal).
Overall this continues to converge — nothing left in this round can produce the false hard-abort the earlier rounds were about; finding 2 is the one that leaves the guard's stated contract unmet, and the rest are diagnostics and doc accuracy.
· branch claude/ltv-issue-7012-ed5bc9
…tion, sampling
Medium:
- include_self:true no longer masks the empty-closure guard. With include_self the
reflexive source node kept `values` non-empty, so a broken/childless source
slipped past and cached a one-term empty-but-complete closure. The guard now
subtracts the reachable_from source nodes: an enum whose only members are its
own reflexive sources is treated as empty and flagged.
- The set-arithmetic branch no longer reads as adapter-blaming. Softened the
UnreliableReachabilityError / EmptyReachableClosureError docstrings to cover both
causes, and branched the CLI banner on source_closure_empty so the minus:-cancels
case says "the enum matched nothing / set operations removed every term / schema
problem, not a broken adapter" instead of "reachability is unreliable".
Low:
- _sample_closure now returns an even-stride sample over the sorted pool rather
than the lowest CURIEs, so it isn't biased toward the most root-like (most
expensive under traverse_up) terms; docstring now scopes determinism to "given
the pool" (adapter-ordered above the cap) and notes the collect-to-64 trade.
- Reworded the errored-native-sample warning ("fallback tried only if nothing was
sampled") so it doesn't promise a fallback that a non-empty partial pool skips.
- _ols_descendants checks the cap before adding, so reflexive+limit==1 can no
longer return 2 (keeps len==limit a reliable truncation signal for every caller).
Tests: include_self over a childless source still flagged (source_closure_empty
True, no complete marker); _sample_closure returns a sorted even-stride sample
regardless of generator order; CLI set-arithmetic banner (exit 3, "matched
nothing", not the adapter banner). 228 unit tests pass; mypy + ruff clean; OLS
integration test passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
|
Round 6 — all addressed in Medium #1 ( Medium #2 (set-arithmetic message wrapped in adapter-blaming layers). Fixed all three layers: softened the Low:
Tests for all three gaps: the 228 unit tests pass; mypy + ruff clean; live-OLS integration test passes. As you noted, this is well past the false-abort territory now — round 6 was one genuine contract gap ( Generated by Claude Code |
|
Claude finished @cmungall's task in 3m 34s —— View job Re-review after
|
…(PR #69) Round-6's source-node subtraction was itself a false-abort: subtracting ALL reachable_from source nodes from the merged value set flagged a legitimate `source_nodes: [parent, child]` union (closure == {child} ⊆ the source set) as empty and exited 3, blaming a healthy adapter. Replace the subtraction with a reliable signal: `_expand_reachable_from_detailed` now reports whether any source's traversal reached a term other than the source itself (a member that happens to be another source node still counts). The empty-enum guard fires only when the merged set is truly empty, OR the traversal reached nothing real AND the enum's only members are its own source nodes AND no concepts/permissible_values/include/inherits clause could have contributed them — so `include_self` over a childless source is still caught, but the parent+child union and a `concepts:[X]` + `reachable_from:[X-leaf]` enum are not. Also from the round-6 review: - The flagged source node is now chosen from the declared source_nodes list (not a set), so the error message is deterministic across runs. - `_sample_closure` uses evenly-spaced indices spanning the sorted pool (endpoints included) instead of a stride slice that degenerated to "lowest k" for small pools and left the top unsampled otherwise. - Noted the stop_at/cap ordering in `_ols_descendants`; corrected the docs on what the include_self check subtracts. Tests: parent+child multi-source union not flagged; include_self over a populated source expands and caches normally (positive control); a two-source empty enum names a stable source node; `_sample_closure` returns a literal evenly-spaced sample that spans the range and is stable across two calls. 231 unit tests pass; mypy + ruff clean; OLS integration test passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
|
Round 7 — fixed in Medium (source-node subtraction false-aborts a valid union). Replaced the subtraction with the signal the guard actually wants:
So Low:
Tests for every gap you listed: parent+child union not flagged; positive control that 231 unit tests pass; mypy + ruff clean; live-OLS integration test passes. Generated by Claude Code |
|
Claude finished @cmungall's task in 2m 44s —— View job Re-review after
|
Medium (false-abort): with include_self:true on an OLS adapter whose native
descendants() returns nothing, _call_graph_traversal returns a truthy {source}
under reflexive, so the `if not result` gate skipped the REST fallback, rf_reached
stayed False, and the enum hard-aborted blaming a healthy adapter. Gate the
fallback on reaching something real (`not (result - {source_node})`) instead of on
truthiness, and only adopt the fallback when it actually returns members so a
non-OLS adapter (whose fallback is a no-op) keeps the reflexive source it already
has rather than losing it to an empty replacement.
Also from the round-7 review:
- Added `matches` to the "other value clause" set so an include_self + matches:
enum isn't flagged.
- Inlined the now-write-only rf_values and dropped its stale comment.
- Docs: "declares" (not "contributed"), and removed the stale trailing sentence
that contradicted the include_self paragraph.
Tests: include_self over an OLS-shaped adapter expands via the REST fallback
(finding 1); include_self + matches: not flagged (finding 2); traverse_up +
include_self over a root is flagged (pins the intended arm behavior). 234 unit
tests pass; mypy + ruff clean; OLS integration test passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
|
Claude finished @cmungall's task in 5m 43s —— View job Re-review after
|
|
Round 8 — fixed in Medium (include_self + OLS fallback false-abort). Gated the fallback on reaching something real ( Low: added 234 unit tests pass; mypy + ruff clean; live-OLS integration test passes. A candid note on where this stands. The Generated by Claude Code |
The reviewer confirmed the false-abort class is closed; these are the remaining diagnostics and one test lock, no logic changes to the guard. - EmptyReachableClosureError's empty-source-closure message no longer leads with "configure a local adapter". After the fallback fix an empty closure almost always means the source is genuinely a leaf (descendants) or root (ancestors) — a schema issue — so the message now says so first (name the boundary, suggest concepts:/permissible_values: for a single-term enum) and mentions the adapter only as a secondary check. Fixes the misleading traverse_up-over-a-root text. - Added a NOTE that `matches` in the other-value-clause set suppresses the include_self arm fail-safe (it is a placeholder returning nothing today), and aligned the docs wording. Tests: pin `if fallback: result = fallback` (include_self over a childless leaf + concepts: must keep the reflexive source — lost under an unconditional replace); assert the root-boundary wording in the traverse_up test. 235 unit tests pass; mypy + ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
|
Claude finished @cmungall's task in 2m 7s —— View job Re-review after
|
|
Round 8 diagnostics —
235 unit tests pass; mypy + ruff clean; live-OLS integration test passing. On the two things you deferred: the From my side this PR is complete: the dismech#7012 detector plus the empty/inconsistency guards are correct and thoroughly tested, and the false-abort class is closed. I'll leave it here unless you or the maintainers want more. Generated by Claude Code |
…yers
Round 8's message rewording left the CLI banner and class docstring behind, so the
empty-source-closure body ("schema issue, use concepts:/permissible_values:") was
wrapped in an adapter-blaming banner. Finish the alignment (message-only, no logic):
- CLI banner is now three-way: set-operations-cancelled, empty-source-closure
(schema/leaf/root), and inconsistent-adapter each get an accurate banner via
isinstance(exc, EmptyReachableClosureError) + source_closure_empty.
- Exception body covers both arms: "would match only the source term (or nothing
at all)" instead of presuming include_self.
- Class docstring and the docs closing line no longer lead with "configure a local
adapter" for the empty-closure case (a no-op when the source is a leaf/root).
Tests: the empty-closure CLI test now asserts the schema banner + a body fragment
(not the old adapter banner); the childless-source test asserts the "leaf"
boundary wording (the traverse_up test already covers "root"). 235 unit tests
pass; mypy + ruff clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG
|
Round 9 —
235 unit tests pass; mypy + ruff clean; live-OLS integration passing. I'm treating this as the final pass: the messaging is now internally consistent end-to-end, and — per your own assessment — the false-abort class is closed and nothing outstanding affects the verdict. I'll defer the one genuinely separate item (the pre-existing Generated by Claude Code |
|
Claude finished @cmungall's task in 2m 23s —— View job Re-review after
|
| layer | text | accurate for check (2)? |
|---|---|---|
cli.py:43-48 (EXIT_ONTOLOGY_MISCONFIGURED comment) |
"the ontology graph / adapter config is broken … not fixed by retrying, only by fixing the adapter configuration" | no — a leaf source or a minus: cancellation is fixed in the schema |
docs/enumerations.md:172-176 (section heading + intro) |
"### Broken-adapter safeguards … rather than silently mislabeling terms when the configured ontology adapter returns a broken graph. Two distinct defects are caught" | no — check (2) is now documented fourteen lines later as usually not an adapter defect |
base.py:1261-1263 (comment at the raise) |
"reached nothing real → bad source/adapter" | it's the same ordering the message deliberately reversed |
None of these reach the user except the docs heading, but the heading is the first thing a reader of that section sees, and the exit-code comment is what the next person changing cli.py will reason from. Something like "Dynamic-enum safeguards" for the heading, plus "a configuration problem — a broken adapter graph, or an enum that expands to nothing — not invalid data and not fixed by retrying" for the constant, would finish the alignment.
Low — the three echo blocks now duplicate the banner shape
cli.py:85-111 repeats the "\n🚫 Unable to validate: …\n {exc}\n " prefix three times, differing only in the headline and the trailing sentence. A headline, detail = ... pair chosen by the branch and a single typer.echo keeps the shape identical by construction — relevant because two rounds of findings have been exactly "one of these blocks drifted from the others". Not worth a round on its own; worth doing whenever this function is next touched.
Test coverage
The two negative assertions ("reachability is unreliable" not in result.output) are the right addition — they're what would have caught the round-8 mismatch — and each arm has a discriminating body fragment (concepts:/permissible_values: vs set operations removed every term), which matters now that both arms share the "a dynamic enum matched nothing" headline. The leaf/root boundary wording is pinned on both directions.
One gap, tied to finding 1: nothing distinguishes "empty closure because the source is a leaf" from "empty closure because the adapter is broken" at any level, because the code doesn't distinguish them either. If you take the softening in finding 1, the existing EmptyDescAdapter test is the broken-adapter case and just needs its assertion updated; if you'd rather keep the categorical wording, that test is arguably mis-named for what it demonstrates.
Nothing security-relevant; round 9 changes no logic and adds no network surface.
Overall: the messaging is materially better than it was two rounds ago, and I agree with your round-8 assessment that the guard logic is settled — the round-trip detector and the empty/include_self arms have held across the last three rounds of scrutiny without a verdict-affecting defect. Everything above is wording that can land as a follow-up; none of it blocks the merge.
· branch claude/ltv-issue-7012-ed5bc9
|
Thanks — these are fair observations, and I agree with your bottom line: the guard logic is settled and none of this blocks the merge. I'm going to stop here rather than push another wording commit, and I want to be transparent about why. We've hit a self-referential loop: each rewording pass resolves one framing inconsistency and, by touching the wording, creates the next round's finding. Round 6 aligned the On the substance of finding 1: the banner is shown directly above the exception body, and that body already carries the hedge ("if you expected a populated subtree, verify the source node id and that the adapter resolves its hierarchy"), so the user isn't actually left misinformed — the full stderr output says both. It's a real nuance, just not one that misleads in practice or affects behavior. So: this PR is complete and correct from my side. 235 unit tests, mypy, ruff, doctests green; live-OLS integration passing; the dismech#7012 detector and the empty/ The remaining items are all non-blocking wording accuracy. Rather than iterate them one-per-round, I'm happy to do one consolidated follow-up PR that sweeps every layer at once (banner hedge + exit-code comment + docs heading + the Generated by Claude Code |
Summary
Add two new exception types and validation logic to detect and fail loudly when a
reachable_fromdynamic enum is configured with a broken or misconfigured ontology adapter. This prevents silent false negatives where terms are incorrectly rejected as "not in enum" due to adapter defects.Key Changes
New exception types (
EmptyReachableClosureError,InconsistentReachabilityError):EmptyReachableClosureError: Raised when a source node resolves but its closure is empty, which would silently reject all terms and poison greedy cache expansionsInconsistentReachabilityError: Raised when an adapter's ancestor/descendant directions disagree by CURIE (e.g., OLS4 MONDO defect dismech#7012), causing silent false negatives in ancestor-based membership checksRound-trip consistency probing in
DynamicEnumPlugin:_reachability_inconsistency_witness(): Samples a few closure members and verifies they report the source node in the reverse direction; caches results per (source_node, predicates, direction)_sample_closure()and_graph_closure(): Helper methods to safely query adapter closures with fallback to OLS REST endpoints_is_value_in_reachable_from()to catch silent false negativesEmpty closure detection in
_expand_reachable_from():EmptyReachableClosureErrorif at least one source resolved but the expansion is emptyCLI exit code for misconfiguration:
3(EXIT_ONTOLOGY_MISCONFIGURED) forUnreliableReachabilityErrorComprehensive test coverage:
Implementation Details
MONDO:0000001under the wrongobo_idAFO_O:0000001, making the term unmatchable by CURIE despite correct hierarchy by IRIsqlite:obo:mondo) for affected prefixeshttps://claude.ai/code/session_01GJHtqxPh5cdTSyx8uiBAXG