Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 40 additions & 1 deletion src/raven_toolbox/confidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import json
import math
import warnings
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Any

Expand All @@ -54,6 +55,7 @@
__all__ = [
"ConfidenceEntry",
"ReactionConfidence",
"annotate_confidence",
"clear_confidence",
"confidence_report",
"equation_exempt",
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 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,
include_curated: bool = False,
):
"""Rank reaction placements and added transports by how likely each needs manual curation.

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

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

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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}
Expand Down
40 changes: 40 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,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)
20 changes: 20 additions & 0 deletions tests/test_localization_curation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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