diff --git a/CHANGELOG.md b/CHANGELOG.md index 51bdc78..f51ea24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ Milestones in the raven-toolbox port. For function-level status see value`). The sentinel is now treated as missing, as NaN already was, recognised whichever dtype the CSV round-trip produces; the new keyword-only `missing_value` (default `DELTA_G_MISSING`) tunes or disables it. Real ΔG coverage of yeast-GEM is 78.2%, not the 97.1% the loader previously implied. +* **Wire the confidence facets together.** `confidence.annotate_confidence(model, proposal=..., scores=...)` + runs every applicable scorer in one call and returns `{facet: reactions_scored}` — `equation` and + `gene_association` need only the model, `localization` runs only when a proposal and its scores are given + (skipped, not failed, otherwise). `curation_priority` now drops a placement a curator has settled with + `mark_curated` from the review queue (new `include_curated=False`), so a settled reaction stops + resurfacing; `include_curated=True` keeps it. The no-SBO-terms warning now names its remedy, + `raven_toolbox.annotation.add_sbo_terms(model)`. ## 0.3.0 — 2026-07-16 diff --git a/src/raven_toolbox/confidence.py b/src/raven_toolbox/confidence.py index f6bdf82..4a8326a 100644 --- a/src/raven_toolbox/confidence.py +++ b/src/raven_toolbox/confidence.py @@ -43,6 +43,7 @@ import json import math import warnings +from collections.abc import Iterable from dataclasses import dataclass, field from typing import Any @@ -54,6 +55,7 @@ __all__ = [ "ConfidenceEntry", "ReactionConfidence", + "annotate_confidence", "clear_confidence", "confidence_report", "equation_exempt", @@ -390,7 +392,8 @@ def _warn_if_no_sbo(model: cobra.Model) -> None: if model.reactions and not any(_sbo(r) for r in model.reactions): warnings.warn( "no reaction carries an SBO term, so biomass and pool pseudo-reactions cannot be told from " - "chemistry defects and will be scored as defects. Annotate SBO terms first.", + "chemistry defects and will be scored as defects. Annotate SBO terms first with " + "raven_toolbox.annotation.add_sbo_terms(model).", stacklevel=3, ) @@ -594,6 +597,42 @@ def score_gene_association_confidence(model, *, overwrite_curated: bool = False, return n +def annotate_confidence( + model: cobra.Model, + *, + proposal: Any = None, + scores: Any = None, + facets: Iterable[str] | None = None, + overwrite_curated: bool = False, + updated: str | None = None, +) -> dict[str, int]: + """Run every applicable confidence scorer in one call; return ``{facet: reactions_scored}``. + + ``equation`` and ``gene_association`` need only the model and always run; ``localization`` runs + only when both ``proposal`` (an :class:`~raven_toolbox.localization.AssignmentProposal`) and + ``scores`` (a :class:`~raven_toolbox.localization.LocalizationScores`) are given, and is otherwise + **skipped rather than failing** — the same abstain-rather-than-guess rule the scores follow, so a + caller without a localisation proposal still gets the other two facets. ``facets=[...]`` restricts + to a subset (names outside the facet set are ignored). ``overwrite_curated`` and ``updated`` pass + through to each scorer. + """ + requested = {"localization", "equation", "gene_association"} if facets is None else set(facets) + counts: dict[str, int] = {} + if "localization" in requested and proposal is not None and scores is not None: + counts["localization"] = score_localization_confidence( + model, proposal, scores, overwrite_curated=overwrite_curated, updated=updated + ) + if "equation" in requested: + counts["equation"] = score_equation_confidence( + model, overwrite_curated=overwrite_curated, updated=updated + ) + if "gene_association" in requested: + counts["gene_association"] = score_gene_association_confidence( + model, overwrite_curated=overwrite_curated, updated=updated + ) + return counts + + def _score(df, g: str, c: str) -> float: if g not in df.index or c not in df.columns: return 0.0 diff --git a/src/raven_toolbox/localization/curation.py b/src/raven_toolbox/localization/curation.py index 7f70839..abeceb5 100644 --- a/src/raven_toolbox/localization/curation.py +++ b/src/raven_toolbox/localization/curation.py @@ -130,6 +130,7 @@ def curation_priority( check_essential: bool = True, essential_candidate_threshold: float = 0.2, min_growth: float | None = None, + include_curated: bool = False, ): """Rank reaction placements and added transports by how likely each needs manual curation. @@ -155,6 +156,11 @@ def curation_priority( (heuristic) ``min_growth`` floor, and is skipped entirely if the materialised model cannot beat that floor (where the test could not discriminate). Set ``check_essential=False`` to skip the only non-cheap signal. + + A reaction whose ``localization`` confidence has been marked ``curated`` (via + :func:`~raven_toolbox.confidence.mark_curated`, e.g. after a curator settled its placement) is + dropped from the queue, closing the score -> review -> curate loop so a settled reaction stops + resurfacing. Pass ``include_curated=True`` to score it anyway. """ import pandas as pd @@ -165,6 +171,18 @@ def curation_priority( # gap-fills that are also (somehow) in placements are scored as placements, not duplicated as gapfill added_reactions = [r for r in proposal.added_reactions if r not in proposal.placements] + # Reactions a curator has settled (localization facet marked "curated"): drop them from the queue so + # they stop resurfacing, closing the score -> review -> curate loop. include_curated keeps them. + curated_localization: set[str] = set() + if not include_curated: + from raven_toolbox.confidence import get_confidence + for rid in {*proposal.placements, *added_reactions}: + if rid not in model.reactions: + continue + entry = get_confidence(model.reactions.get_by_id(rid)).facets.get("localization") + if entry is not None and entry.level == "curated": + curated_localization.add(rid) + def _cur(b): return _is_currency(b, extra_currency) @@ -207,6 +225,8 @@ def _gene_consensus(rid): # ---- placement signals: one row per (reaction, compartment) ---- for rid, cs in placements_of.items(): + if rid in curated_localization: + continue r = model.reactions.get_by_id(rid) noncur = [(base(m), coeff) for m, coeff in r.metabolites.items() if not _cur(base(m))] subs = [b for b, coeff in noncur if coeff < 0] @@ -259,6 +279,8 @@ def _gene_consensus(rid): # ---- H3 gap-fill: reactions pulled from the universal model (not in the draft's placements) ---- for rid in added_reactions: + if rid in curated_localization: + continue comp = default_compartment if universal is not None and rid in universal.reactions: csx = {mm.compartment for mm in universal.reactions.get_by_id(rid).metabolites} diff --git a/tests/test_confidence.py b/tests/test_confidence.py index 5d001c5..ecfed9b 100644 --- a/tests/test_confidence.py +++ b/tests/test_confidence.py @@ -7,6 +7,7 @@ from raven_toolbox.confidence import ( ConfidenceEntry, ReactionConfidence, + annotate_confidence, confidence_report, equation_exempt, facet_summary, @@ -447,3 +448,42 @@ def test_facet_summary_separates_scored_from_exempt(): assert eq["n"].sum() == 3 assert int(eq[eq["basis"] == "balanced"]["n"].iloc[0]) == 2 assert int(eq[eq["basis"] == "mass-imbalanced"]["n"].iloc[0]) == 1 # SPONT: H2O -> H + + +# --------------------------------------------------------------------------- annotate_confidence umbrella + +def _proposal_for(model_reaction_id="r1"): + return AssignmentProposal( + placements={model_reaction_id: ["c"]}, added_transports=[], added_reactions=[], + unplaced_reactions=[], min_growth=1.0, status="optimal", + ) + + +def test_annotate_confidence_runs_model_facets_and_skips_localization_without_inputs(): + m = _model() + counts = annotate_confidence(m) # no proposal/scores + # equation and gene_association need only the model; localization is skipped, not failed + assert set(counts) == {"equation", "gene_association"} + assert counts["equation"] == 1 and counts["gene_association"] == 1 # only r1 is non-boundary + assert "localization" not in get_confidence(m.reactions.r1).facets + + +def test_annotate_confidence_runs_localization_with_proposal_and_scores(): + m = _model() + scores = LocalizationScores(pd.DataFrame({"c": [0.8]}, index=["g1"])) + counts = annotate_confidence(m, proposal=_proposal_for("r1"), scores=scores) + assert set(counts) == {"localization", "equation", "gene_association"} + assert set(get_confidence(m.reactions.r1).facets) == {"localization", "equation", "gene_association"} + + +def test_annotate_confidence_respects_facets_arg(): + m = _model() + counts = annotate_confidence(m, facets=["equation"]) + assert set(counts) == {"equation"} + assert set(get_confidence(m.reactions.r1).facets) == {"equation"} + + +def test_sbo_warning_names_add_sbo_terms(): + m = _model() # carries no SBO terms + with pytest.warns(UserWarning, match=r"add_sbo_terms"): + score_equation_confidence(m) diff --git a/tests/test_localization_curation.py b/tests/test_localization_curation.py index caa0f61..b9d5cb2 100644 --- a/tests/test_localization_curation.py +++ b/tests/test_localization_curation.py @@ -369,3 +369,23 @@ def test_affected_for_transport_lists_dependent_reactions(): # the palmitoyl-CoA transport is coupled to the reactions that make/consume it (r0 in c, r1 in m) assert trow["n_affected"] >= 1 assert "r1" in trow["affected"] + + +def test_curation_priority_skips_curated_localization(): + """A reaction whose localization facet is mark_curated drops out of the review queue (closing the + score -> review -> curate loop); include_curated=True keeps it.""" + from raven_toolbox.confidence import mark_curated + + m = _linear() # r1: A_c -> B_c, gene g1 + scores = LocalizationScores(pd.DataFrame({"c": [0.4], "m": [0.9]}, index=["g1"])) + prop = _proposal({"r1": ["c"]}, unplaced=["r1"]) # no_evidence + override fire on r1 + + base = curation_priority(m, prop, scores, check_essential=False) + assert "r1" in set(base["target"]) # surfaces before curation + + mark_curated(m.reactions.get_by_id("r1"), facet="localization") + after = curation_priority(m, prop, scores, check_essential=False) + assert "r1" not in set(after["target"]) # dropped once curated + + kept = curation_priority(m, prop, scores, check_essential=False, include_curated=True) + assert "r1" in set(kept["target"]) # include_curated overrides