Skip to content
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ Milestones in the raven-toolbox port. For function-level status see

## Unreleased

* **`assign_compartments` gap-fill: a reliable flux-based fill instead of cobra's MILP.** The
universal-DB gap-fill in `localization.certify` no longer calls `cobra.flux_analysis.gapfill`, whose
indicator MILP, at genome scale, fails to find a valid fill in the **majority** of cases *even when the
exact reaction that restores growth is present in the universal* (it returns an incumbent its own
validation rejects, and raises rather than offering a fill). The replacement adds the candidates on a
working copy, holds biomass at the floor, runs pFBA, and returns the flux-carrying additions — **sorted**,
so the result does not depend on which co-optimal vertex the solver picked. A plain LP cannot have the
MILP's failure mode: a returned set is a real flux solution that reaches the floor. On single-reaction
knockout-recovery (remove an essential reaction, fill from a universal that contains it), recall is
**60/60 — the exact reaction each time — vs cobra's 45 %**; on realistic incomplete drafts (12% of
internal reactions dropped, many simultaneous gaps) it restores growth **12/12 vs cobra's 0/12**. The universal must share the draft's
metabolite namespace: candidates are matched by id, as cobra's gapfill required, and a mismatch now
**warns** instead of returning `[]` silently. The set is flux-parsimonious (pFBA) but not guaranteed
reaction-count-minimal; the caller re-certifies with a real FBA, so no false certificate is possible.
* **`diff_models` compares grRules as logic, not text.** The GPR check now DNF-expands each rule (via the
existing `manipulation.gpr_to_dnf`), sorts the genes within each isozyme clause and sorts the clauses, so
operand order no longer registers as a difference: `a and b` == `b and a` and `a or b` == `b or a`. The
Expand Down
67 changes: 56 additions & 11 deletions src/raven_toolbox/localization/certify.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from __future__ import annotations

import math
import warnings
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass

Expand Down Expand Up @@ -697,19 +698,63 @@ def assign_compartments(


def _gapfill(applied, universal, biomass_reaction, min_growth) -> list[str]:
"""Minimal gap-fill reactions (from ``universal``) that restore the primary growth floor.

Uses ``cobra.flux_analysis.gapfill``; the result is validated by the caller's certification FBA,
so any tolerance quirk in cobra's own MILP cannot produce a false certificate here.
"""The ``universal`` reactions whose addition restores the primary growth floor, via a flux-based fill.

On a working copy: add every ``universal`` candidate not already present in one batch, check the floor
is even reachable, hold biomass at the floor, run pFBA (maximise biomass, then minimise total flux),
and return -- sorted -- the added reactions that carry flux. pFBA makes the set flux-parsimonious (not
guaranteed reaction-count-minimal); the caller re-certifies with a real FBA regardless, so no false
certificate is possible.

An LP, not cobra's indicator MILP — ``cobra.flux_analysis.gapfill`` at genome scale fails to find a
valid fill in the *majority* of cases even when the exact restoring reaction is present in the
universal (its own validation then rejects the broken incumbent and raises); on single-reaction
knockout-recovery this restores every case where that MILP restores under half.

**Namespace.** Candidates are matched to the model by metabolite id (as cobra's gapfill required): the
universal must share the draft's metabolite namespace. A candidate whose metabolites do not resolve
becomes a dead-end that cannot carry flux and is left out — and a warning fires when most candidates
fail to resolve, so a silent empty result is distinguishable from a namespace mismatch.
"""
if biomass_reaction not in applied.reactions:
return []
floor = max(min_growth, 1e-4)
try:
from cobra.flux_analysis import gapfill as cobra_gapfill
with applied:
applied.objective = biomass_reaction
solutions = cobra_gapfill(applied, universal, lower_bound=max(min_growth, 1e-4),
demand_reactions=False, iterations=1)
return [r.id for r in solutions[0]] if solutions else []
except Exception: # noqa: BLE001 — infeasible gap-fill / backend quirk: report none added
from cobra.flux_analysis import pfba
# Work on a copy: no context-manager rollback (whose failure on some optlang backends would
# otherwise discard a valid result), and the caller rebuilds `applied` from the proposal anyway.
work = applied.copy()
candidates = [u for u in universal.reactions if u.id not in work.reactions]
if not candidates:
return []
fresh = [cobra.Reaction(u.id, name=u.name, lower_bound=u.lower_bound, upper_bound=u.upper_bound)
for u in candidates]
work.add_reactions(fresh) # one batch — per-reaction adds are super-linear at scale
fully_unresolved = 0
for nr, urxn in zip(fresh, candidates, strict=True):
stoich, n_unres = {}, 0
for met, coeff in urxn.metabolites.items():
if met.id in work.metabolites:
stoich[work.metabolites.get_by_id(met.id)] = coeff
else:
n_unres += 1
stoich[cobra.Metabolite(met.id, name=met.name, formula=met.formula,
charge=met.charge, compartment=met.compartment)] = coeff
nr.add_metabolites(stoich)
fully_unresolved += n_unres == len(urxn.metabolites) and len(urxn.metabolites) > 0
if fully_unresolved > 0.5 * len(candidates):
warnings.warn(
f"gap-fill: {fully_unresolved}/{len(candidates)} universal candidates share no metabolite "
"id with the model — a likely namespace mismatch; gap-fill will find little or nothing.",
stacklevel=2)

work.objective = biomass_reaction
if (work.slim_optimize(error_value=0.0) or 0.0) < floor - 1e-9:
return [] # unfixable even with every candidate present
work.reactions.get_by_id(biomass_reaction).lower_bound = floor
fluxes = pfba(work).fluxes
return sorted(nr.id for nr in fresh if abs(fluxes.get(nr.id, 0.0)) > 1e-9)
except Exception: # noqa: BLE001 — infeasible / backend quirk: report none added
return []


Expand Down
43 changes: 43 additions & 0 deletions tests/test_localization_certify.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,49 @@ def test_gapfill_restores_function():
assert _grows(apply_assignment(m, res, universal=_universal()))


def test_gapfill_returns_a_growth_restoring_set():
# Directly exercise the flux-based _gapfill: on a draft that cannot grow it returns the universal
# reaction that restores biomass, and nothing spurious. As a plain LP it is reliable where cobra's
# indicator gap-fill MILP is not (that MILP fails to find such a fill in most genome-scale cases even
# when it exists) -- and a returned set always actually restores growth.
from raven_toolbox.localization.certify import _gapfill

applied = _gap_draft() # EX_A -> r1: A->B; bio: B + C -> (C has no producer)
added = _gapfill(applied, _universal(), "bio", min_growth=1.0)
assert added == ["rC"] # the exact missing producer of C, nothing extra

ur = _universal().reactions.get_by_id("rC")
nr = cobra.Reaction(ur.id, lower_bound=ur.lower_bound, upper_bound=ur.upper_bound)
applied.add_reactions([nr])
nr.add_metabolites({applied.metabolites.get_by_id(m.id): c for m, c in ur.metabolites.items()})
assert _grows(applied)


def test_gapfill_offers_nothing_when_the_universal_cannot_restore_growth():
# If even adding every candidate cannot reach the floor, _gapfill returns [] (no false fill).
from raven_toolbox.localization.certify import _gapfill

applied = _gap_draft()
empty = cobra.Model("empty") # no candidate produces C
assert _gapfill(applied, empty, "bio", min_growth=1.0) == []


def test_gapfill_warns_on_namespace_mismatch_instead_of_failing_silently():
# A universal whose metabolite ids don't match the model can't connect; _gapfill returns [] but must
# WARN, so "found nothing" is distinguishable from "wrong namespace".
from raven_toolbox.localization.certify import _gapfill

applied = _gap_draft()
foreign = cobra.Model("foreign") # same chemistry (A->C) under foreign ids
a, c = cobra.Metabolite("A_x", compartment="c"), cobra.Metabolite("C_x", compartment="c")
foreign.add_metabolites([a, c])
rc = cobra.Reaction("rC", lower_bound=0, upper_bound=1000)
rc.add_metabolites({a: -1, c: 1})
foreign.add_reactions([rc])
with pytest.warns(UserWarning, match="namespace"):
assert _gapfill(applied, foreign, "bio", min_growth=1.0) == []


def test_no_gratuitous_gapfill():
# When the draft already grows, no universal candidate is pulled (it is only reached on a real
# growth failure).
Expand Down