From d51fb6a558d5435c6632f9672134c4e4b8114a08 Mon Sep 17 00:00:00 2001 From: Eduard Kerkhoven Date: Sat, 18 Jul 2026 23:37:15 +0200 Subject: [PATCH] Wire the confidence facets together Three integration steps that turn the per-facet scorers into a pipeline, and close the loop between confidence and curation. * annotate_confidence(model, proposal=..., scores=...) runs every applicable scorer in one call and returns {facet: count}. equation and gene_association need only the model; localization runs only when a proposal and its scores are supplied, and is skipped (not failed) otherwise -- the same abstain-rather-than-guess rule the scores follow. The returned dict reports what actually ran. * curation_priority reads the confidence record: a placement a curator settled with mark_curated drops out of the review queue (new respect_curated=True), so score -> review -> curate -> stop-being-asked is a closed loop. The skip is targeted (only that reaction's placement rows) and reversible (respect_curated=False). The confidence import is lazy to avoid the localization <-> confidence cycle. * The no-SBO warning now names its remedy, raven_toolbox.annotation.add_sbo_terms(model). Tests cover the umbrella (facet selection, localization skipped without a proposal, unknown-facet error), the curated skip (suppressed then restored, targeted not global), and reuse the existing helpers. --- CHANGELOG.md | 8 +++++ src/raven_toolbox/confidence.py | 42 +++++++++++++++++++++- src/raven_toolbox/localization/curation.py | 21 +++++++++++ tests/test_confidence.py | 26 ++++++++++++++ tests/test_localization_curation.py | 40 +++++++++++++++++++++ 5 files changed, 136 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6c94f5..0ba9012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ Milestones in the raven-toolbox port. For function-level status see differed only in operand order. This brings `diff_models` in line with MATLAB RAVEN's `diffModels` ([RAVEN #686](https://github.com/SysBioChalmers/RAVEN/pull/686)); a rule cobra cannot parse falls back to the old string comparison, so malformed rules are still compared rather than silently equated. +* **Wire the confidence facets together.** `confidence.annotate_confidence(model, proposal=..., scores=...)` + runs every applicable scorer in one call and returns `{facet: count}` — `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 reads the confidence record: a placement a curator has + settled with `mark_curated` drops out of the review queue (new `respect_curated=True`), closing the + score → review → curate → stop-being-asked loop; transports and gap-fills are unaffected, and + `respect_curated=False` restores the old behaviour. The no-SBO 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 f544c0b..20652ea 100644 --- a/src/raven_toolbox/confidence.py +++ b/src/raven_toolbox/confidence.py @@ -54,6 +54,7 @@ __all__ = [ "ConfidenceEntry", "ReactionConfidence", + "annotate_confidence", "clear_confidence", "confidence_report", "equation_exempt", @@ -390,7 +391,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. Run " + "raven_toolbox.annotation.add_sbo_terms(model) first.", stacklevel=3, ) @@ -599,3 +601,41 @@ def _score(df, g: str, c: str) -> float: return 0.0 v = df.at[g, c] return 0.0 if v is None or (isinstance(v, float) and math.isnan(v)) else float(v) + + +# --------------------------------------------------------------------------- umbrella + +_ALL_FACETS = ("localization", "equation", "gene_association") + + +def annotate_confidence(model, *, proposal=None, scores=None, facets=None, + overwrite_curated: bool = False, updated: str | None = None) -> dict[str, int]: + """Run every applicable scorer over ``model`` in one call; return ``{facet: count scored}``. + + ``equation`` and ``gene_association`` need only the model. ``localization`` additionally needs the + assignment ``proposal`` and its ``scores`` table, so it runs only when **both** are given — and is + otherwise skipped rather than failed, the same abstain-rather-than-guess rule the scores themselves + follow. The returned dict therefore reports what actually ran: a facet that was skipped for want of + inputs is absent from it. + + ``facets`` restricts the set to score (an iterable of facet names; default all three). A facet named + there whose inputs are missing is skipped, not an error — asking for ``localization`` without a + proposal simply scores nothing for it. ``overwrite_curated`` and ``updated`` pass through to each + scorer. This is the one call a caller makes after :func:`~raven_toolbox.localization.assign_compartments` + to annotate a freshly-built model across every facet at once. + """ + todo = list(facets) if facets is not None else list(_ALL_FACETS) + unknown = [f for f in todo if f not in _ALL_FACETS] + if unknown: + raise ValueError(f"unknown facet(s) {unknown}; known facets are {list(_ALL_FACETS)}") + counts: dict[str, int] = {} + if "equation" in todo: + counts["equation"] = score_equation_confidence( + model, overwrite_curated=overwrite_curated, updated=updated) + if "gene_association" in todo: + counts["gene_association"] = score_gene_association_confidence( + model, overwrite_curated=overwrite_curated, updated=updated) + if "localization" in todo and proposal is not None and scores is not None: + counts["localization"] = score_localization_confidence( + model, proposal, scores, overwrite_curated=overwrite_curated, updated=updated) + return counts diff --git a/src/raven_toolbox/localization/curation.py b/src/raven_toolbox/localization/curation.py index 7f70839..980a78a 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, + respect_curated: bool = True, ): """Rank reaction placements and added transports by how likely each needs manual curation. @@ -155,6 +156,13 @@ 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. + + ``respect_curated`` (default ``True``) drops from the queue any reaction whose ``localization`` + confidence is :func:`~raven_toolbox.confidence.mark_curated`-stamped — a curator already settled its + compartment, so re-surfacing it for review is noise. This closes the loop with + :mod:`raven_toolbox.confidence`: score → review here → curate (``mark_curated``) → stop being asked. + Transports and gap-fills are unaffected (curation marks a reaction's placement, not a bridge). Set + ``False`` to rank every placement regardless. """ import pandas as pd @@ -170,6 +178,17 @@ def _cur(b): # full placement lists (ALL compartments, not just the first) for every movable reaction placements_of = {rid: list(cs) for rid, cs in proposal.placements.items() if cs} + + # Reactions a curator already settled: their localization confidence is `level == "curated"`. We drop + # their placement rows below so the queue stops re-asking about them. Lazy import -- confidence imports + # from localization, so a module-level import here would be circular. + curated_ids: set[str] = set() + if respect_curated: + from raven_toolbox.confidence import get_confidence + for rid in placements_of: + entry = get_confidence(model.reactions.get_by_id(rid)).facets.get("localization") + if entry is not None and entry.level == "curated": + curated_ids.add(rid) # rid -> set of compartments it occupies (movable from the proposal, pinned from the model), so the # neighbour signal sees the whole materialised layout including dual-localised reactions. comps_of: dict[str, set[str]] = {rid: set(cs) for rid, cs in placements_of.items()} @@ -207,6 +226,8 @@ def _gene_consensus(rid): # ---- placement signals: one row per (reaction, compartment) ---- for rid, cs in placements_of.items(): + if rid in curated_ids: # a curator already settled this placement -- do not re-ask + 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] diff --git a/tests/test_confidence.py b/tests/test_confidence.py index 5d001c5..de5b92d 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,28 @@ 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 test_annotate_confidence_skips_localization_without_a_proposal(): + m = _model() + counts = annotate_confidence(m) # model only + assert set(counts) == {"equation", "gene_association"} # localization skipped, not failed + assert "localization" not in get_confidence(m.reactions.r1).facets + + +def test_annotate_confidence_runs_all_facets_with_a_proposal(): + m, proposal, scores = _scored_proposal() + counts = annotate_confidence(m, proposal=proposal, scores=scores) + assert set(counts) == {"localization", "equation", "gene_association"} + assert counts["localization"] == 1 + facets = get_confidence(m.reactions.r1).facets + assert {"localization", "equation", "gene_association"} <= set(facets) + + +def test_annotate_confidence_facets_restriction_and_unknown(): + m = _model() + assert set(annotate_confidence(m, facets=["equation"])) == {"equation"} + with pytest.raises(ValueError, match="unknown facet"): + annotate_confidence(m, facets=["bogus"]) diff --git a/tests/test_localization_curation.py b/tests/test_localization_curation.py index caa0f61..fa41474 100644 --- a/tests/test_localization_curation.py +++ b/tests/test_localization_curation.py @@ -369,3 +369,43 @@ 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_respects_curated_placements(): + """A placement a curator has settled (mark_curated) drops out of the review queue -- closing the + score -> review -> curate loop -- and comes back with respect_curated=False.""" + from raven_toolbox.confidence import mark_curated + + m = _linear() + scores = LocalizationScores(pd.DataFrame({"c": [0.9], "m": [0.1]}, index=["g1"])) + prop = _proposal({"r1": ["c"]}, unplaced=["r1"]) # unplaced -> fires the no_evidence signal + + before = curation_priority(m, prop, scores, check_essential=False) + assert list(before["target"]) == ["r1"] # r1 is flagged for review + + mark_curated(m.reactions.r1) # curator settles r1's compartment + after = curation_priority(m, prop, scores, check_essential=False) + assert "r1" not in list(after["target"]) # ... so it no longer surfaces + + ignored = curation_priority(m, prop, scores, check_essential=False, respect_curated=False) + assert list(ignored["target"]) == ["r1"] # opt-out ranks it regardless + + +def test_curated_skip_is_targeted_not_global(): + """Curating one reaction suppresses only that reaction's rows -- another flagged placement stays.""" + from raven_toolbox.confidence import mark_curated + + m = _linear() + c = _met("C_c") + m.add_metabolites([c]) + r2 = cobra.Reaction("r2", lower_bound=0, upper_bound=1000) + r2.add_metabolites({m.metabolites.B_c: -1, c: 1}) + r2.gene_reaction_rule = "g2" + m.add_reactions([r2]) + scores = LocalizationScores(pd.DataFrame({"c": [0.9], "m": [0.1]}, index=["g1", "g2"])) + prop = _proposal({"r1": ["c"], "r2": ["c"]}, unplaced=["r1", "r2"]) # both fire no_evidence + + assert set(curation_priority(m, prop, scores, check_essential=False)["target"]) == {"r1", "r2"} + mark_curated(m.reactions.r1) # curate only r1 + left = set(curation_priority(m, prop, scores, check_essential=False)["target"]) + assert left == {"r2"} # r1 gone, r2 still reviewed