Skip to content

Flag terms their ontology marks Not4Curation (#70) - #72

Merged
caufieldjh merged 4 commits into
mainfrom
issue-70-not4curation
Sep 8, 2026
Merged

caufieldjh merged 4 commits into
mainfrom
issue-70-not4curation

Conversation

@caufieldjh

Copy link
Copy Markdown
Contributor

Closes #70.

Downstream prior art: monarch-initiative/dismech#8472 (the incident) and monarch-initiative/dismech#9097 (the stopgap scripts/not4curation_audit.py, which this replaces upstream).

What

Some ontologies keep terms for hierarchy completeness that they do not want used for annotation, and say so with a synonym, not a deprecation axiom. RGD's ontologies (XCO, CMO, MMO, RS) write Not4Curation; some other OBO ontologies write not_recommended_for_annotation. Such a term exists, has a matching label, is not obsolete, and is reachable, so every check here passed it. XCO:0000294 (estrogen/estrogen analog) reached three dismech curation batches this way.

This adds the check at the validator level, following the is_obsolete() pattern.

How

  • OntologyAccess.entity_aliases() and find_not4curation_markers(): every alias is folded to lowercase alphanumerics and substring-matched against a marker list (default not4curation, notforcuration, notrecommendedforannotation). Generic on purpose: no per-ontology allowlist. For OLS the synonyms come from the term payload the label lookup already fetched, so no extra round trip.
  • Fail by default, configurable. Three new ErrorModes (binding_not4curation, dynamic_enum_not4curation, permissible_value_not4curation) at ERROR; severity_overrides demotes them. check_not4curation / not4curation_markers are accepted by the plugin constructors, ValidationConfig, oak_config.yaml, and every CLI command (--check-not4curation/--no-check-not4curation).
  • Runs on the accepted value, in BindingValidationPlugin, DynamicEnumPlugin (both cache strategies), PermissibleValueMeaningPlugin, and EnumValidator (so validate-schema and validate-text-file too). A positive enum-cache hit therefore cannot hide the flag; no cache rebuild is needed to adopt it.
  • A degraded run does not look clean. A marker is a synonym, so a term whose synonyms could not be read (offline, no adapter, unsupported adapter, empty alias list) is tracked as unchecked. plugin.get_not4curation_unchecked() exposes it and the CLI prints a non-gating note listing the CURIEs.
  • In the binding plugin the check only consults an adapter where the pipeline already would (dynamic enum, configured prefix, or offline), so a static-enum binding under an unconfigured prefix never triggers an ontology download.

Verified against the real sqlite:obo:xco database: XCO:0000294 and XCO:0000950 are flagged in a binding, XCO:0000294 as a permissible value meaning, and XCO:0000000 passes.

Tests

tests/data/test_ontology.obo gains TEST:0000008 (Not4Curation) and TEST:0000009 (not_recommended_for_annotation) under biological_process. tests/test_not4curation.py (59 tests, offline) covers marker normalization, both conventions, the unchecked bookkeeping (offline, fake term, alias lookup failure, OLS payload), every plugin in both cache strategies, the cache-does-not-hide-it case, severity override and switch-off through constructor and oak_config.yaml, the out-of-scope static-enum case, and the CLI flag on all four commands. Full suite, doctests, mypy, ruff, and the OLS integration tests for obsolete/fake IDs pass.

Docs

Reference section in docs/plugin-reference.md (linked from the binding, data, schema, CLI, caching, and configuration pages), README feature line, and a CLAUDE.md note.

🤖 Generated with Claude Code

https://claude.ai/code/session_01X7nfDciLcyerPSeFwBWEH1

Some ontologies keep terms for hierarchy completeness that they do not
want used for annotation, and say so with a synonym (RGD's
`Not4Curation`; OBO's `not_recommended_for_annotation`) rather than an
obsoletion axiom. Such a term exists, has a matching label, and is
reachable, so every check here passed it. XCO:0000294 (estrogen) reached
three curation batches downstream before a human noticed.

OntologyAccess gains entity_aliases() and find_not4curation_markers():
aliases are folded to lowercase alphanumerics and substring-matched
against a configurable marker list. OLS reads the term payload the label
lookup already fetched, so no extra round trip there. A term whose
aliases could not be read (offline, no adapter, unsupported adapter, or
an empty alias list) is tracked as *unchecked*, never treated as clean.

All three plugins and EnumValidator run the check on every accepted
value, so a positive enum-cache hit cannot hide the flag, and emit new
ErrorModes (binding_not4curation, dynamic_enum_not4curation,
permissible_value_not4curation) at ERROR by default; severity_overrides
demotes them. check_not4curation / not4curation_markers are accepted by
the plugin constructors, ValidationConfig, oak_config.yaml, and every
CLI command as --check-not4curation/--no-check-not4curation. The CLI
prints the unchecked CURIEs as a non-gating note.

In the binding plugin the check only consults an adapter where the
pipeline already would (dynamic enum, configured prefix, or offline), so
a static-enum binding under an unconfigured prefix never triggers an
ontology download.

tests/data/test_ontology.obo gains TEST:0000008 (Not4Curation) and
TEST:0000009 (not_recommended_for_annotation) for offline tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7nfDciLcyerPSeFwBWEH1
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @caufieldjh's task in 3m 52s —— View job


Review: Flag terms their ontology marks Not4Curation

  • Gather context (read diff, key files)
  • Review core logic (oak_utils.py, base.py)
  • Review plugin integrations + CLI/config wiring
  • Review test coverage
  • Post review

This is a well-shaped change. The design decisions that matter most are the right ones: the check runs on the accepted value (so a positive enum-cache hit can't hide the flag), "couldn't read synonyms" is tracked as unchecked rather than folded into a pass, the marker match is generic rather than a per-ontology allowlist, and _not4curation_in_scope keeps the binding path from building an adapter where the pipeline wouldn't already. Docs and tests are thorough. Findings below, roughly in severity order.

Note: I could not run the test suite — uv sync/uv run pytest need approval in this environment. Everything below is from reading the code.


1. OLS: a label-only payload counts as clean, not unchecked (medium)

src/linkml_term_validator/utils/oak_utils.py:817-838 builds the alias list from label + synonyms + obo_synonym. Because the label is always present, the list is never empty for a resolvable term, so find_not4curation_markers always takes the "checked" branch — tests/test_not4curation.py:201 locks this in deliberately.

That is an asymmetry with the OAK path, where a thin/empty alias list is treated as unknown. If an OLS response ever omits or renames the synonym fields (OLS3 vs OLS4 shape differences, a client that drops the key, a _embedded.terms envelope that carries a label but not synonyms), every term silently passes — precisely the failure mode this feature exists to close, and with no unchecked note to hint at it.

Suggestion: require positive evidence that synonyms were actually present in the payload, e.g. return None (unchecked) when neither synonyms nor obo_synonym is a key in term, and []/aliases otherwise. A term genuinely without synonyms still normally carries "synonyms": [] or null, so the false-unchecked rate should be low.

Fix this →

2. Dynamic-enum path can force an ontology download that the cache previously avoided (performance)

is_value_in_enum (plugins/base.py:696-711) short-circuits on a complete enum cache with zero adapter use. DynamicEnumPlugin._check_not4curation then calls entity_aliases for every accepted value, which builds an adapter — for sqlite:obo: that is a potentially large download on a run that previously did no ontology I/O at all. The binding plugin guards exactly this with _not4curation_in_scope; the dynamic-enum plugin has no equivalent.

The PR frames this as intentional ("no cache rebuild is needed to adopt it"), and it is the right correctness call. But the cost profile change is worth documenting in docs/caching.md — "warm-cache data validation now touches the ontology once per unique term" is a surprise otherwise.

Related: aliases live only in _alias_cache (in-memory), never in the file cache alongside labels. So every offline run re-reports the same terms as unchecked forever, and the CLI's "Run once online to check these terms." only helps that one online run. Persisting the marker verdict next to the cached label would make both the offline note and the warm-cache cost go away. Reasonable as a follow-up rather than in this PR.

3. Duplicated markers in the message (low)

An OLS payload carrying the same synonym under both synonyms and obo_synonym yields is marked 'Not4Curation', 'Not4Curation'tests/test_not4curation.py:196 asserts the duplicate. Order-preserving dedupe (dict.fromkeys) in _ols_entity_aliases or in find_not4curation_markers fixes it.

Fix this →

4. Accessors hand out live internal sets (low)

get_not4curation_checked / get_not4curation_unchecked (oak_utils.py:876-891), and the delegating methods on BaseOntologyPlugin and EnumValidator, return the internal sets by reference. A caller doing unchecked = plugin.get_not4curation_unchecked(); unchecked.add(...) silently mutates validator state. set(...) copies are cheap here. (cli.py uses |= so it's fine today — this is about the public surface.)

5. Config file beats the new CLI flag, silently (low)

plugins/base.py:263-265 and validator.py:_load_not4curation_config make oak_config.yaml's check_not4curation win over the constructor. That matches the existing cache_strategy precedent, so it's defensible — but check_not4curation is now also a CLI flag, and --no-check-not4curation being silently ignored because a config file says check_not4curation: true is a surprising outcome for an explicit flag. Since the Typer default is True, you can't distinguish "user passed it" from "default". Making the CLI option Optional[bool] = None and only forwarding when not None would restore the usual "explicit flag wins" expectation. At minimum, docs/configuration.md should state which side wins.

6. Duplicated config parsing between plugin and validator (code quality)

EnumValidator._load_not4curation_config (validator.py:61-84) reimplements the bool coercion and list validation from BaseOntologyPlugin._parse_bool_config_value + _load_oak_config_extras. The semantics match today; they will drift. A single helper in utils (e.g. parse_not4curation_config(config) -> tuple[Optional[bool], Optional[tuple[str, ...]]]) consumed by both would remove ~30 lines and the drift risk.

7. EnumValidator hardcodes ERROR severity (minor / documentation)

validator.py:_not4curation_issue always emits SeverityLevel.ERROR. That is consistent with the rest of EnumValidator (ValidationConfig has no severity_overrides), but the practical consequence is asymmetric adoption: a project can demote binding_not4curation/dynamic_enum_not4curation on the data side, but validate-schema and validate-text-file are all-or-nothing (--no-check-not4curation). Worth a sentence in docs/plugin-reference.md, or a follow-up issue to bring severity_overrides to ValidationConfig.

8. Binding gate couples to an implementation detail (nit)

binding_plugin.py:355-384 skips the check when _validate_against_enum yielded anything. That is correct today because that method only yields membership failures — but it reads as "any result suppresses the flag", and if a future advisory result is ever added there, the Not4Curation flag disappears with no test failing. Gating on not any(r.type == "binding_validation" for r in enum_results) would say what you mean.

Test coverage

Strong: marker normalization, both conventions, unchecked bookkeeping across four degradation modes, both cache strategies, the cache-doesn't-hide-it case, severity override and switch-off via both constructor and oak_config.yaml, the out-of-scope static-enum case (including the _adapter_cache == {} assertion — nice), and the CLI flag on all four commands.

Two gaps worth considering:

  • No @pytest.mark.integration test against a real ontology (XCO:0000294 via sqlite:obo:xco or OLS). The PR verified this manually; encoding it would catch exactly the class of regression in finding Bump actions/setup-node from 3 to 6 #1, where the unit tests use a hand-built payload shape.
  • test_binding_custom_markers_via_oak_config uses discouraged, which matches a label, not a synonym. Worth one explicit test that a plain label match is intended behavior (aliases include the label), since that's a source of potential false positives on real ontologies with unlucky labels.

Also note tests/data/test_ontology.obo gains two new descendants of TEST:0000005 (biological_process). I couldn't run the suite to confirm, but any existing test asserting a closure size rather than membership would shift — worth a check that just test is green with the new terms.


Summary: no blocking bug found. Finding #1 is the one I'd want addressed before merge, since it is a silent-pass path in the exact scenario the feature targets; #2 deserves a docs line; the rest are cleanups.
· branch issue-70-not4curation

- OLS: a term payload with neither a `synonyms` nor an `obo_synonym` key
  is now reported as unchecked, not clean. The label alone made the alias
  list non-empty, so an unrecognized payload shape would have passed
  every term silently.
- Dedupe aliases order-preservingly so a synonym listed under both OLS
  fields is named once in the message.
- The unchecked/checked accessors return copies.
- Precedence: an explicit constructor argument or CLI flag now wins over
  oak_config.yaml, which wins over the default. The CLI switch is
  tri-state (None = unset) so --no-check-not4curation is never silently
  ignored by a config file.
- Both the plugin base and EnumValidator read the config keys through
  one shared parse_not4curation_config() helper.
- The binding gate names the membership result type instead of
  suppressing the flag on any result.
- Docs: precedence, the warm-cache cost, and the ERROR-only schema path.
- Tests: real OLS payload shape, empty-vs-absent synonym fields,
  accessor copy, label-as-alias, flag-beats-config in constructor, config
  and CLI, invalid config values, and integration tests for the three
  XCO terms from the issue over sqlite:obo:xco and ols:xco.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7nfDciLcyerPSeFwBWEH1
@caufieldjh

Copy link
Copy Markdown
Contributor Author

Review addressed in b74f191. All eight findings and both test gaps.

  1. OLS label-only payload → unchecked. _ols_entity_aliases returns None when the payload has neither a synonyms nor an obo_synonym key. A present-but-empty field ([] or null) stays clean. Tests cover both, plus the shape OLS4 actually returns for XCO:0000294 (synonyms: ['Not4Curation'], obo_synonym: null), which I confirmed live.
  2. Warm-cache cost is now documented in docs/caching.md and the plugin reference, with --no-check-not4curation as the escape. Persisting the verdict next to the cached label is noted as a follow-up; I did not change the cache format here.
  3. Duplicate markers are deduped order-preservingly in find_not4curation_markers.
  4. Accessors return copies, with a test that mutating the copy leaves the validator alone.
  5. Precedence. Explicit constructor argument or CLI flag > oak_config.yaml > default. The constructor and ValidationConfig fields are now Optional[bool] (None = unset), the CLI switch is tri-state, and the file fills only what was left unset. Tested in all three places, both directions.
  6. One parser. parse_not4curation_config() in utils is used by the plugin base and EnumValidator.
  7. ERROR-only schema path is stated in the plugin reference.
  8. Binding gate checks for a binding_validation result rather than any result.

Tests: @pytest.mark.integration cases for the three XCO terms from the issue over sqlite:obo:xco and for XCO:0000294 over ols:xco (5 pass locally), and an explicit test that the label counts as an alias. The full suite was green before and after the two new descendants of TEST:0000005; no test pins that closure's size.

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @caufieldjh's task in 3m 34s —— View job


Review: Flag terms their ontology marks Not4Curation (re-review after b74f191)

  • Read the follow-up commit
  • Verify each prior finding was addressed
  • Look for new issues in the changed code
  • Attempt to run the test suite (blocked — see note)
  • Post review

b74f191 addresses every finding from the previous round, and the fixes are the right ones rather than the minimal ones. Summary, then what's left (all low severity).

Note: I still could not run the suite — uv sync / uv run pytest need approval in this environment. Everything below is from reading the code. Please confirm just test is green.


Previous findings — status

# Finding Status
1 OLS label-only payload counted as clean Fixedoak_utils.py:893 returns None when neither synonyms nor obo_synonym is a key. Tests now pin the real OLS4 shape (synonyms list + obo_synonym: null), the explicitly-empty cases as clean, and the no-key case as unchecked.
2 Warm-cache cost change undocumented Fixeddocs/caching.md and docs/plugin-reference.md both state the per-unique-term adapter read and point at --no-check-not4curation.
3 Duplicated markers in the message Fixeddict.fromkeys dedupe at oak_utils.py:950, test updated to expect one.
4 Accessors handed out live sets Fixed — both return set(...) copies, with a test that mutating the snapshot doesn't move state.
5 Config file silently beat the CLI flag Fixed — CLI options are Optional[bool] = None, _not4curation_explicit records what the constructor was actually given, and the file fills only unset keys. Precedence is per-key, so check_not4curation=True + file markers still uses the file's markers — good granularity, and tested both directions through the CLI.
6 Duplicated config parsing Fixedparse_not4curation_config() in utils, consumed by both BaseOntologyPlugin and EnumValidator, with doctests. _parse_bool_config_value correctly stays for the other keys.
7 EnumValidator hardcodes ERROR Documenteddocs/plugin-reference.md now says the check is all-or-nothing behind validate-schema / validate-text-file.
8 Binding gate coupled to "any result" Fixedrejected = any(r.type == ErrorMode.BINDING_VALIDATION.value ...). I checked all four yields in _validate_against_enum (binding_plugin.py:537, 565, 596, plus the required-field one before it): all are binding_validation, so this is behaviorally identical today and now says what it means.

Both test gaps I raised are closed too: @pytest.mark.integration tests against real XCO (sqlite + OLS), and test_label_counts_as_an_alias making the label-is-an-alias behavior explicit with a comment about custom markers.

Also verified while reading: the NoneTrue resolution runs unconditionally in both BaseOntologyPlugin.__init__ (base.py:152) and EnumValidator._load_not4curation_config (which no longer early-returns on an empty config), so an absent oak_config.yaml can't leave the switch None/falsy. normalize_not4curation_markers([]) raises rather than silently matching nothing.


Remaining items (low)

a. The OLS integration test can fail for an unrelated reason. test_xco_flagged_term_via_ols (tests/test_not4curation.py:825) routes through a reachable_from closure with source_ontology: ols:xco. OAK's OLS adapter has thin graph support; if the closure comes back empty the binding yields binding_validation and the assertion fails on a membership problem, not the payload shape it exists to pin. Since integration tests are opt-in, that misdiagnosis could sit unnoticed. The purpose here is the OLS4 payload shape — asserting it directly (OntologyAccess(oak_adapter_string="ols:xco").find_not4curation_markers("XCO:0000294")) tests exactly that with no closure dependency. Fix this →

b. Two hard-coded XCO IDs are unverified. The PR body verified XCO:0000294, XCO:0000950 and XCO:0000000. The new integration tests add XCO:0000561 (asserted flagged) and XCO:0000004 (asserted a clean descendant of the root). Worth one runoak check before merge — a wrong guess here is a red integration suite that nobody sees until someone runs just test-full.

c. check_not4curation and cache_strategy now have opposite precedence. In the same oak_config.yaml, cache_strategy overrides the constructor while check_not4curation does not. That's the right call for a flag, but nothing in docs/configuration.md documents cache_strategy's precedence at all, so a reader has no way to know the two adjacent keys differ. One sentence next to the config example would close it.

d. A nonexistent term appears twice. In the binding path, _validate_not4curation runs before _validate_term_exists (binding_plugin.py:388 vs 400). A bogus CURIE under a configured prefix therefore lands in the unchecked set (no aliases) and then gets a term_not_found error — so the CLI's "could not be checked… run once online" note names a term that simply doesn't exist. Not a correctness bug (the run already fails), but the note reads better if term_not_found CURIEs are discarded from it.

e. Nits.

  • base.py:130self._not4curation_explicit = (check_not4curation is not None, not4curation_markers is not None) is an opaque bool 2-tuple unpacked ~150 lines later, and at 103 chars is the longest line in the file (ruff's default rule set excludes E501, so just format won't flag it). Two named attributes would read better at the use site.
  • find_not4curation_markers's Returns clause (oak_utils.py:932-938) still lists only the old unchecked reasons; the new "OLS payload with no synonym field" case belongs there.
  • test_unset_constructor_value_defaults_on takes an unused tmp_path.

Verdict: no blocking issue. The silent-pass path I flagged last round is closed, the precedence surprise is gone, and the degraded-run bookkeeping still holds. Items (a) and (b) are about integration tests that won't run in CI, so they're worth a few minutes now rather than a confusing failure later; (c)–(e) are polish.
· branch issue-70-not4curation

- The OLS integration test asserts the payload shape directly on
  OntologyAccess instead of through a reachable_from closure, so thin OLS
  graph support cannot turn a payload regression into a membership error.
- A bound CURIE that does not resolve is left out of the Not4Curation
  check entirely: it is absent, term_not_found reports it, and it no
  longer lands in the "could not be checked" note.
- docs/configuration.md states that cache_strategy (file overrides
  constructor) and check_not4curation (explicit wins over file) resolve
  in opposite directions, and why.
- Nits: two named "explicit" attributes on the plugin base, the Returns
  clause of find_not4curation_markers lists the OLS no-synonym-field
  case, and an unused tmp_path is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7nfDciLcyerPSeFwBWEH1
@caufieldjh

Copy link
Copy Markdown
Contributor Author

Second round addressed in 309469c.

  • (a) test_xco_flagged_term_via_ols now asserts straight on OntologyAccess(oak_adapter_string="ols:xco").find_not4curation_markers("XCO:0000294") == ["Not4Curation"], plus the root as clean and an empty unchecked set. No closure in the path.
  • (b) Both IDs verified against sqlite:obo:xco: XCO:0000561 is antidepressant with aliases ['antidepressant', 'Not4Curation'], and XCO:0000004 is walking on treadmill, not flagged, with XCO:0000000 among its ancestors. The five integration tests pass locally over both adapters.
  • (c) docs/configuration.md now says, next to the config example, that cache_strategy and its two siblings override the constructor while check_not4curation / not4curation_markers (and severity_overrides) resolve the other way, and why.
  • (d) _validate_not4curation returns before looking at aliases when the term has no label. A bogus CURIE is absent, term_not_found reports it, and it stays out of the unchecked note. Test added.
  • (e) Two named attributes (_check_not4curation_explicit, _not4curation_markers_explicit), the Returns clause lists the OLS no-synonym-field case, and the unused tmp_path is gone.

just test equivalent is green: 356 unit tests, 41 doctests, mypy, ruff.

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @caufieldjh's task in 3m 19s —— View job


Review: Not4Curation check (third pass, after 309469c)

  • Read the follow-up commit
  • Verify prior findings (a)–(e)
  • Look for new issues in the changed code
  • Attempt to run the suite (blocked — see note)
  • Post review

309469c closes (a), (c) and (e) cleanly. (b) I still can't confirm. (d) — the "nonexistent term shouldn't land in the unchecked note" fix — is where I'd push back: the implementation is broader than the problem, and the test that ships with it doesn't reach the new code.

Note: uv sync / uv run pytest and outbound curl/WebFetch all need approval in this environment, so everything below is from reading the code, and I could not verify the XCO IDs against OLS. Please confirm just test is green.


Previous round — status

# Finding Status
a OLS integration test routed the payload assertion through a reachable_from closure Fixedtest_xco_flagged_term_via_ols (tests/test_not4curation.py:836) now asserts on OntologyAccess directly, and adds the clean-root case. No closure dependency left.
b XCO:0000561 / XCO:0000004 unverified Open — see below.
c check_not4curation and cache_strategy have opposite precedence, undocumented Fixeddocs/configuration.md:31-39. I checked the claim against _load_oak_config_extras: cache_strategy / cache_enum_expansions / saturate_enum_caches assign unconditionally (file wins), the Not4Curation keys and severity_overrides merge per-key with the constructor on top. The doc is accurate, including the severity_overrides sentence.
d A nonexistent CURIE appeared in the unchecked note Fixed, with a caveat — see #1.
e Nits (opaque bool 2-tuple, stale Returns clause, unused tmp_path) All fixed_check_not4curation_explicit / _not4curation_markers_explicit read well at the use site (base.py:279-283), the Returns clause lists the OLS no-synonym-field case, tmp_path dropped.

1. The new guard skips the check on any unresolved label, not just an absent term (low–medium)

binding_plugin.py:718-722 gates on self.get_ontology_label(field_value) is None. But OntologyAccess.get_label returns None for three different situations (oak_utils.py:632-656):

  • the term is genuinely absent (adapter returns None, OLS 404) — the case you meant to exclude;
  • an adapter error that isn't a service outage: raise_if_service_unavailable declines, _log_lookup_failure logs, label = None;
  • a term that resolves but carries no rdfs:label.

In the default path (not strict, not offline) _validate_term_exists never runs, so in the latter two cases the Not4Curation check is now skipped with no result and no unchecked entry. That's the one property this feature is built around — "a degraded run does not look clean" — quietly not holding for the binding plugin. It's narrow (an adapter that fails at label() rather than at entity_aliases(); test_alias_lookup_failure_is_unchecked_not_crash covers the other order and still works), but it's the same class of silent pass as round-one finding #1.

My original suggestion was the subtractive form: let _validate_not4curation run as before and discard term_not_found CURIEs from the note afterwards. That keeps "couldn't read" and "isn't there" distinct, which the up-front guard collapses. If you prefer the guard, gate on the same predicate that makes _validate_term_exists emit term_not_found so the two can't disagree.

Fix this →

2. The test for the new guard never reaches it (low)

test_binding_nonexistent_term_is_not_found_not_unchecked (tests/test_not4curation.py:414) uses _validate_binding, whose schema binding_label_schema.yaml binds to BiologicalProcessEnum — a dynamic enum. TEST:0000999 therefore fails membership, _validate_against_enum yields binding_validation, and the rejected gate at binding_plugin.py:378 short-circuits before _validate_not4curation is ever called. Revert the guard and this test still passes.

The or in assert "term_not_found" in types or "binding_validation" in types reflects that: it's the binding_validation branch that fires, since _validate_term_exists is behind strict or offline and neither is set here. A test that actually exercises the guard needs a value that passes membership but doesn't resolve — a static enum with meaning: TEST:0000999 under the configured TEST prefix, or offline with the fake CURIE seeded into the enum cache.

Fix this →

3. No binding-plugin test asserts a non-empty unchecked set (low)

Every binding assertion on the bookkeeping (:322, :421, :824, :832) checks it's empty; the one positive case (:363) is the dynamic-enum plugin. So the binding path's degraded-run signal — now sitting behind the new guard — has no test holding it up. An offline binding run with a warm label cache (label cached, adapter refused → aliases None) is the natural case and would have caught #1 if the guard had been placed one line earlier.

4. XCO:0000561 and XCO:0000004 still unconfirmed (low)

The response to round two listed the fixes but didn't mention checking these two. They're asserted as flagged and clean-descendant-of-root respectively in @pytest.mark.integration tests that CI never runs, so a wrong ID is a red just test-full for whoever runs it next. One runoak -i sqlite:obo:xco info XCO:0000561 XCO:0000004 before merge settles it. (I tried OLS directly; outbound fetches need approval here.)

5. Nit: the guard costs a label lookup that wasn't happening

With validate_labels=False and non-strict, the binding path previously did no get_label call for an accepted value; it now does one per unique bound CURIE (memoized, and it writes to the label cache when cache_labels is on). Small next to the alias read it precedes, but it's a second adapter round trip for the same term when the alias source could have answered both questions — entity_aliases already returns the label as aliases[0] on the OAK path and reads it from the same OLS payload.


Verdict: no blocking bug. #1 and #2 are worth one more pass because together they mean a silent-skip path shipped with a test that would not notice its removal; #3#5 are optional. Everything from the first two rounds that mattered is genuinely closed.
· branch issue-70-not4curation

…missing label

The previous guard skipped the check whenever get_label returned None,
which also covers a logged adapter failure and a term with no rdfs:label.
In the lenient online path nothing else runs for those, so a degraded
adapter looked clean. Now the existence check runs first and the marker
check is skipped only when it actually emitted term_not_found. When
existence is not checked (lenient, online) an unresolvable term is
reported as unchecked, which is the truth. This also drops the extra
label lookup the guard had added.

Tests now reach the gate: a static-enum binding under the configured
prefix with a bogus CURIE (strict: term_not_found only; lenient:
unchecked), a flagged term through the same static enum, an adapter that
fails at label() and entity_aliases() (lenient: unchecked; strict:
term_not_found), and an offline binding run over warm label and enum
caches that accepts the flagged term and lists it as unchecked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7nfDciLcyerPSeFwBWEH1
@caufieldjh

Copy link
Copy Markdown
Contributor Author

Third round addressed in f00de7d. You were right on #1 and #2; the guard was wider than the problem and the test never reached it.

  • Bump actions/setup-node from 3 to 6 #1 The label guard is gone. _validate_term_exists now runs before the marker check, and the marker check is skipped only when it actually emitted term_not_found. Both gates name the result type they mean. When existence is not checked at all (lenient, online), an unresolvable term is reported as unchecked, since it is not known to be absent. That is the "couldn't read" vs "isn't there" distinction you asked for, decided by the same predicate the existence check uses.
  • Bump actions/setup-python from 5 to 6 #2 The test now binds through a static enum under the configured TEST prefix so membership passes. Strict: exactly ["term_not_found"], unchecked empty. Lenient: no results, unchecked {TEST:0000999}. Reverting the fix fails the strict case.
  • Bump astral-sh/setup-uv from 5 to 7 #3 Two positive binding-path assertions: an adapter whose label() and entity_aliases() both raise a non-service error leaves the term in the unchecked set (lenient) or surfaces as term_not_found (strict); and an offline binding run over warm label and enum caches accepts the flagged term and lists it as unchecked.
  • Bump actions/github-script from 6 to 8 #4 runoak -i sqlite:obo:xco info XCO:0000561 XCO:0000004 gives antidepressant and walking on treadmill; aliases shows oio:hasRelatedSynonym Not4Curation on the first only; ancestors -p i XCO:0000004 includes XCO:0000000 experimental condition. Both integration tests pass locally, as they did last round.
  • Bump actions/checkout from 4 to 5 #5 Gone with the guard: the lenient path makes no label call for an accepted value again.

just test equivalent is green: 360 unit tests, 41 doctests, mypy, ruff; 5 integration tests pass over both adapters.

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @caufieldjh's task in 5m 58s —— View job


Review: Not4Curation check (fourth pass, after f00de7d)

f00de7d fixes the two items that mattered, and it took the subtractive-in-spirit route I'd have picked: the existence check moves ahead of the marker check and only an actual term_not_found result suppresses it, so "isn't there" and "couldn't read" stay distinct. Nothing blocking left. Details, then five small observations.

Note: uv sync / uv run pytest need approval in this environment, so I could not execute the suite. Everything below is from reading the code. Please confirm just test is green.


Previous round — status

# Finding Status
1 The guard skipped the check on any None label, not just an absent term Fixedbinding_plugin.py:374-387 runs _validate_term_exists first into exists_results; binding_plugin.py:399 gates on not_found = any(r.type == ErrorMode.TERM_NOT_FOUND.value ...). The get_ontology_label(...) is None guard is gone from _validate_not4curation. So a logged adapter failure, or a resolvable term with no rdfs:label, no longer disappears from the bookkeeping: in the lenient path it is recorded as unchecked, which is the honest answer, and in strict/offline it is an ERROR. The comment at :388-397 explains the two gates and the lenient case accurately.
2 The test for the guard never reached it Fixed_STATIC_BINDING_SCHEMA (tests/test_not4curation.py:414) binds through a static enum whose permissible values list both TEST:0000999 and TEST:0000008, under the configured TEST prefix. Membership therefore passes and only existence + the marker check decide. I checked that the tests now bite: test_binding_nonexistent_term_is_not_found_not_unchecked asserts exactly ["term_not_found"] and an empty unchecked set, so removing the not_found gate makes it fail on the second assertion.
3 No binding test asserted a non-empty unchecked set Fixed — three now do: lenient fake CURIE (:474), the _LabelBoomAdapter case (:497), and the offline warm-cache twin (:526). The last one is the case I said would have caught #1.
4 XCO:0000561 / XCO:0000004 unverified Accepted — the round-two reply reports runoak-level detail (XCO:0000561 = antidepressant, aliases ['antidepressant', 'Not4Curation']; XCO:0000004 = walking on treadmill, XCO:0000000 among its ancestors). That's specific enough that I'll take it; I still can't reach OLS from here.
5 The guard added a label lookup Fixed — dropped with the guard. In strict/offline the one get_label call now happens in _validate_term_exists, where it always did.

I also traced test_binding_offline_warm_cache_accepts_but_reports_unchecked end to end, since nothing in greedy expansion (base.py:945-1046) or not4curation_markers_for writes the label cache. It works because BindingValidationPlugin.strict defaults to True: the online run's _validate_term_exists calls get_label, which save_to_caches TEST:0000008 into cache_dir, so the offline run finds it and emits no term_not_found. Worth knowing that the test's premise rests on that default rather than on anything explicit in the test.


Observations (all low)

1. The lenient "unresolvable → unchecked" path isn't in the docs. docs/plugin-reference.md:151-154 lists three reasons a term lands in the unchecked note (offline, no adapter, adapter without aliases). There's now a fourth: existence isn't being checked (strict=False, online) and the term doesn't resolve, so its aliases come back empty. That's the behavior test_binding_nonexistent_term_lenient_is_unchecked pins, and it's the one case where the note can name a CURIE that simply doesn't exist. One sentence would close the gap between the docs and binding_plugin.py:394-397. Fix this →

2. Result order changed. term_not_found now precedes binding_not4curation and the label results for the same value. I grepped the suite for order-sensitive assertions (results[0], [r.type for r in ...]) and found none that mix the two types, so this is safe in-tree — but a downstream consumer that reads results[0] (the dismech audit scripts this replaces, for instance) may see a different first result on a value that trips both.

3. Strict mode can report a flagged term as merely "not found." If an adapter fails at label() but would have answered entity_aliases(), strict mode emits term_not_found and the marker never surfaces. The run fails either way, so the outcome is right; only the diagnostic is less specific. Not worth code, and the alternative (running the marker check anyway) reintroduces the double-report you just removed.

4. exists_results is now materialized eagerly. list(...) instead of yield from at binding_plugin.py:375. Identical behavior — the LinkML validator drains the generator — and it's needed for the gate. Just noting the laziness loss is deliberate.

5. Nit: three tests now reach into plugin.ontology._adapter_cache (:162, :518, plus the OLS ones at :199-243). Consistent with the existing style, but a two-line _install_adapter(access, prefix, adapter) helper would make the intent obvious and give one place to change if the cache moves.


Verdict: ready to merge from my side. The silent-pass path is closed, the test that guards it now actually exercises the gate, and the binding path finally has positive coverage of its degraded-run signal. Item 1 is a docs sentence; 2–5 are informational. Since I can't run anything here, the only real precondition is your confirmation that just test (and ideally just pytest-integration for the five XCO cases) is green on f00de7d.
· branch issue-70-not4curation

@caufieldjh
caufieldjh merged commit 531b311 into main Sep 8, 2026
5 checks passed
@caufieldjh
caufieldjh deleted the issue-70-not4curation branch September 8, 2026 19:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Validation passes terms their own ontology flags Not4Curation

1 participant