Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 41 additions & 1 deletion src/raven_toolbox/confidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
__all__ = [
"ConfidenceEntry",
"ReactionConfidence",
"annotate_confidence",
"clear_confidence",
"confidence_report",
"equation_exempt",
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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
21 changes: 21 additions & 0 deletions src/raven_toolbox/localization/curation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand All @@ -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()}
Expand Down Expand Up @@ -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]
Expand Down
26 changes: 26 additions & 0 deletions tests/test_confidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from raven_toolbox.confidence import (
ConfidenceEntry,
ReactionConfidence,
annotate_confidence,
confidence_report,
equation_exempt,
facet_summary,
Expand Down Expand Up @@ -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"])
40 changes: 40 additions & 0 deletions tests/test_localization_curation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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