@dsh-bio/dsh-bio-gem 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,641 @@
1
+ # l3_fix.py — B' 后半:L3 内部路径补洞(两级)
2
+ # L3a 模型内连通性: 先"全内部反应放开方向"LP 预检(快速严谨判负),可行才用 cobra GapFiller
3
+ # universal=模型自身反应池(放开方向副本,NEW id),MILP 选最小集 → 对原反应放宽 bounds(不复制反应)。
4
+ # 注: cobra 0.32.1 GapFiller(universal=None) 语义是"空反应池"(只加 demand),不是"模型自身反应池"
5
+ # ——故本实现显式构造自身反应池(2026-08-29 读 cobra 源码确认)。
6
+ # L3b 白名单 + BiGG 反应式: 白名单命中集(build_whitelist B0/B1,缓存 ~/.dsh/dsh-bio-gem/whitelist/)
7
+ # → MetaCyc rxn ID 桥(EC 号/名字规约 + gapseq all-Reactions.tbl 增强)→ iML1515 反应式
8
+ # → 代谢物移植(名字规约匹配 -> COFACTOR_BRIDGE 静态桥[公式/电荷校验] -> 随反应引入新代谢物)。
9
+ # 无匹配不强补。PTS 型反应一律排除(Rhizobiaceae 等 PTS-less 机体守则)。
10
+ # 证据分级: EVIDENCE_sequence(白名单桥接)/ EVIDENCE_math(LP/MILP 连通性,最弱);
11
+ # L1/L2 规则补洞为 EVIDENCE_rule(见 gapfill.py)。notes["evidence"] + notes["source"]=gem-l3fix。
12
+ # 防过补第五闸门: budget.py(累计新增 ≤ max(5, 5%·总反应)),超限 confirm_required=true 才放行。
13
+ # 补后重验: validate G1-G6 全跑;G6 WARN/FAIL → 回滚本批全部改动(删新增反应 + 还原 bounds)。
14
+ # 协议: stdout 仅最后一行 JSON(进度走 stderr);-I 隔离模式 sys.path 显式插入。
15
+ import os
16
+ import re
17
+ import sys
18
+ import json
19
+ import time
20
+ import hashlib
21
+
22
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
+
24
+ import cobra
25
+ from cobra.flux_analysis.gapfilling import GapFiller
26
+
27
+ EX_PREFIX = ("EX_", "DM_", "SK_")
28
+ SOURCE_TAG = "gem-l3fix"
29
+ NEW_RXN_SUFFIX = "_l3fix"
30
+
31
+ # BiGG 基名 -> ModelSEED cpd 号(2026-08-29 本机以 C58 名字+公式+电荷逐一验证;
32
+ # 映射时仍做公式/电荷校验,不一致即弃用防静默污染化学计量)
33
+ COFACTOR_BRIDGE = {
34
+ "h2o": "00001", "atp": "00002", "nad": "00003", "nadh": "00004",
35
+ "nadp": "00005", "nadph": "00006", "o2": "00007", "adp": "00008",
36
+ "pi": "00009", "coa": "00010", "co2": "00011", "nh4": "00013",
37
+ "glu__L": "00023", "akg": "00024", "gln__L": "00053", "pyr": "00020",
38
+ "accoa": "00022", "succ": "00036", "so4": "00048", "pep": "00061",
39
+ "h": "00067", "f6p": "00072", "g6p": "00079", "e4p": "00236",
40
+ "mal__L": "00130", "r5p": "00101", "ru5p": "00171", "xu5p": "00198",
41
+ }
42
+ COMP_MAP = {"c": "c0", "e": "e0", "p": "p0"} # BiGG 区室后缀 -> gapseq 区室 id
43
+
44
+ WHITELIST_DIR = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem", "whitelist")
45
+ # 本地白名单数据库(license 守则: 仅本机,不进 git/发布包;GEM_WHITELIST_DB_DIR 可覆盖)
46
+ RXN_DB_DIR = os.environ.get("GEM_WHITELIST_DB_DIR", r"D:\Program\hermes\temp\gem_whitelist")
47
+ DEFAULT_UNIVERSAL = r"D:\Program\hermes\temp\gem_universal\iML1515.xml"
48
+
49
+ PTS_RE = re.compile(r"(?i)\bpts\b|phosphotransferase|pep:pyr")
50
+
51
+
52
+ def _note(*a):
53
+ print(*a, file=sys.stderr)
54
+
55
+
56
+ def norm_name(s):
57
+ return "".join(ch for ch in (s or "").lower() if ch.isalnum())
58
+
59
+
60
+ def met_name_key(met):
61
+ """代谢物名规约键:去常见区室尾巴(-c0/-c/-e0 等)后小写字母数字。"""
62
+ nm = (met.name or "").strip()
63
+ nm = re.sub(r"[-_ ]?c[0ep]0?$", "", nm, flags=re.I)
64
+ return norm_name(nm)
65
+
66
+
67
+ def _bigg_base_comp(met_id):
68
+ mm = re.match(r"^(.*)__?([cepn])$", met_id)
69
+ if mm:
70
+ return mm.group(1), mm.group(2)
71
+ parts = met_id.rsplit("_", 1)
72
+ return (parts[0], "c") if len(parts) == 2 and parts[1] in COMP_MAP else (met_id, "c")
73
+
74
+
75
+ def _formula_ok(met_a, met_b):
76
+ fa, fb = (met_a.formula or "").replace(" ", ""), (met_b.formula or "").replace(" ", "")
77
+ if fa and fb and fa.upper() != fb.upper():
78
+ return False
79
+ if met_a.charge is not None and met_b.charge is not None and met_a.charge != met_b.charge:
80
+ return False
81
+ return True
82
+
83
+
84
+ def build_met_name_index(m):
85
+ idx = {}
86
+ for x in m.metabolites:
87
+ idx.setdefault(met_name_key(x), []).append(x)
88
+ return idx
89
+
90
+
91
+ def _has_carbon(formula):
92
+ """元素级含碳判断(裸子串会把 Ca/Cl/Co/Cu 误判为含碳——2026-08-29 实测氯缺失致 sole 全灭)。"""
93
+ from validate import parse_formula
94
+ return "C" in parse_formula(formula or "")
95
+
96
+
97
+ def sole_medium(m, resolved_med, ex_id, lb=-10.0):
98
+ """唯一碳源语义: 基底介质去掉含碳交换 + 目标底物交换打开。返回 {EX_id: lb}。"""
99
+ med = {}
100
+ for rid, v in (resolved_med or {}).items():
101
+ if rid in m.reactions:
102
+ met = list(m.reactions.get_by_id(rid).metabolites)[0]
103
+ if met.formula and _has_carbon(met.formula):
104
+ continue
105
+ med[rid] = v
106
+ if ex_id:
107
+ med[ex_id] = lb
108
+ return med
109
+
110
+
111
+ def _set_medium(m, med):
112
+ for r in m.reactions:
113
+ if r.id.startswith(EX_PREFIX) or r.boundary:
114
+ r.lower_bound = 0.0
115
+ for rid, v in (med or {}).items():
116
+ if rid in m.reactions:
117
+ m.reactions.get_by_id(rid).lower_bound = v
118
+
119
+
120
+ def _growth_sole(m, resolved_med, ex_id, lb=-10.0):
121
+ with m:
122
+ _set_medium(m, sole_medium(m, resolved_med, ex_id, lb))
123
+ return m.optimize().objective_value or 0.0
124
+
125
+
126
+ def _relax_all_internal(m):
127
+ for r in m.reactions:
128
+ if r.id.startswith(EX_PREFIX) or r.boundary:
129
+ continue
130
+ r.bounds = (-1000.0, 1000.0)
131
+
132
+
133
+ def _rollback(cur, applied_all, relaxed_all):
134
+ """回滚本批改动: 删除本批新增反应(孤儿代谢物一并回收)+ 还原放宽的 bounds。"""
135
+ if applied_all:
136
+ cur.remove_reactions([cur.reactions.get_by_id(a["rxn"]) for a in applied_all
137
+ if a["rxn"] in cur.reactions], remove_orphans=True)
138
+ for rb in relaxed_all:
139
+ r0 = cur.reactions.get_by_id(rb["rxn"])
140
+ r0.bounds = tuple(rb["old_bounds"])
141
+ r0.notes.pop("bound_relaxed_by", None)
142
+ r0.notes.pop("evidence", None)
143
+ return cur
144
+
145
+
146
+ def _hop_reactions(m, ex_id, hops=2):
147
+ """底物交换反应的邻域(k 跳反应-代谢物二部图),限定 L3a MILP 规模。"""
148
+ if ex_id not in m.reactions:
149
+ return set()
150
+ seen_r = {ex_id}
151
+ frontier_m = {x.id for x in m.reactions.get_by_id(ex_id).metabolites}
152
+ seen_m = set(frontier_m)
153
+ for _ in range(hops):
154
+ nxt_r = set()
155
+ for mid in frontier_m:
156
+ nxt_r |= {r.id for r in m.metabolites.get_by_id(mid).reactions}
157
+ nxt_r -= seen_r
158
+ seen_r |= nxt_r
159
+ frontier_m = set()
160
+ for rid in nxt_r:
161
+ frontier_m |= {x.id for x in m.reactions.get_by_id(rid).metabolites}
162
+ frontier_m -= seen_m
163
+ seen_m |= frontier_m
164
+ return seen_r
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # 白名单加载与缓存(重复调用免重跑 diamond)
169
+ # ---------------------------------------------------------------------------
170
+ def _sha1_file(path, chunk=1 << 20):
171
+ h = hashlib.sha1()
172
+ with open(path, "rb") as f:
173
+ for b in iter(lambda: f.read(chunk), b""):
174
+ h.update(b)
175
+ return h.hexdigest()
176
+
177
+
178
+ def load_whitelist(species=None, faa=None, whitelist_json=None, force_rebuild=False):
179
+ """白名单命中集 {rxn_id: [seq_ids]}。
180
+ 优先级: whitelist_json(现成命中集导入+缓存)> 缓存(faa 内容哈希键)> 现场 diamond(B1)。
181
+ 缓存: ~/.dsh/dsh-bio-gem/whitelist/<species>-<sha1[:12]>.json。"""
182
+ os.makedirs(WHITELIST_DIR, exist_ok=True)
183
+ if whitelist_json and os.path.exists(whitelist_json):
184
+ key = _sha1_file(whitelist_json)[:12]
185
+ species = species or os.path.splitext(os.path.basename(whitelist_json))[0]
186
+ cache = os.path.join(WHITELIST_DIR, f"{species}-{key}.json")
187
+ if os.path.exists(cache) and not force_rebuild:
188
+ with open(cache, encoding="utf-8") as f:
189
+ return json.load(f)["rxn_hits"], cache, {"source": "cache"}
190
+ with open(whitelist_json, encoding="utf-8") as f:
191
+ raw = json.load(f)
192
+ rxn_hits = raw.get("rxn_hits", raw) if isinstance(raw, dict) else {}
193
+ json.dump({"species": species, "sha1": key, "built_at": time.strftime("%Y-%m-%d %H:%M:%S"),
194
+ "source": whitelist_json, "rxn_hits": rxn_hits},
195
+ open(cache, "w", encoding="utf-8"), ensure_ascii=False)
196
+ return rxn_hits, cache, {"source": f"imported:{whitelist_json}"}
197
+ if not faa or not os.path.exists(faa):
198
+ raise FileNotFoundError("whitelist 需要 faa(目标蛋白 fasta)或 whitelist_json(现成命中集)")
199
+ species = species or os.path.splitext(os.path.basename(faa))[0]
200
+ key = _sha1_file(faa)[:12]
201
+ cache = os.path.join(WHITELIST_DIR, f"{species}-{key}.json")
202
+ if os.path.exists(cache) and not force_rebuild:
203
+ with open(cache, encoding="utf-8") as f:
204
+ return json.load(f)["rxn_hits"], cache, {"source": "cache"}
205
+ from build_whitelist import diamond_whitelist
206
+ db_dir = RXN_DB_DIR if os.path.isdir(RXN_DB_DIR) else WHITELIST_DIR
207
+ r = diamond_whitelist(faa, out_dir=WHITELIST_DIR, db_path=os.path.join(db_dir, "rxn_all.dmnd"),
208
+ rxn_fa=os.path.join(db_dir, "rxn_all.fa"))
209
+ json.dump({"species": species, "faa": faa, "sha1": key,
210
+ "built_at": time.strftime("%Y-%m-%d %H:%M:%S"),
211
+ "params": {"evalue": r.get("evalue"), "min_bitscore": r.get("min_bitscore")},
212
+ "rxn_hits": r["rxn_hits"]},
213
+ open(cache, "w", encoding="utf-8"), ensure_ascii=False)
214
+ return r["rxn_hits"], cache, {"source": f"diamond:{r.get('hits_tsv')}"}
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # ID 桥: 白名单 rxn(MetaCyc 风格)-> iML1515 反应
219
+ # ---------------------------------------------------------------------------
220
+ def _parse_tbl(tbl_path):
221
+ """gapseq all-Reactions.tbl: rxn(MetaCyc id) -> {name, ec}。"""
222
+ out = {}
223
+ if not tbl_path or not os.path.exists(tbl_path):
224
+ return out
225
+ import csv
226
+ with open(tbl_path, encoding="utf-8", errors="ignore") as f:
227
+ rd = csv.DictReader((ln for ln in f if not ln.startswith("#")), delimiter="\t")
228
+ for row in rd:
229
+ rid = (row.get("rxn") or "").strip()
230
+ if not rid:
231
+ continue
232
+ d = out.setdefault(rid, {"name": "", "ec": set()})
233
+ if row.get("name") and not d["name"]:
234
+ d["name"] = row["name"].strip()
235
+ if row.get("ec"):
236
+ d["ec"].add(row["ec"].strip())
237
+ return out
238
+
239
+
240
+ def bridge_iML1515(rxn_ids, universal, tbl=None):
241
+ """白名单 rxn id -> iML1515 反应候选。
242
+ 规则: ① id 即 EC(如 1.1.1.127-RXN)→ universal EC 注释;② id 规约名 == universal 反应名规约;
243
+ ③ tbl(gapseq 判定表)补 rxn->EC。返回 {wl_id: {"rxns": [uid], "rule": str}}。"""
244
+ name_idx, ec_idx = {}, {}
245
+ for r in universal.reactions:
246
+ name_idx.setdefault(norm_name(r.name), []).append(r.id)
247
+ for ec in ((r.annotation or {}).get("ec-code") or []):
248
+ ec_idx.setdefault(ec, []).append(r.id)
249
+ bridged = {}
250
+ for rid in rxn_ids:
251
+ cands, rule = set(), None
252
+ mm = re.match(r"^(\d+(?:\.\d+)+)-RXN$", rid)
253
+ ecs = {mm.group(1)} if mm else set()
254
+ if rid in tbl:
255
+ ecs |= {e for e in tbl[rid]["ec"] if re.match(r"^\d+(\.\d+)+$", e)}
256
+ for ec in ecs:
257
+ got = ec_idx.get(ec)
258
+ if got:
259
+ cands |= set(got)
260
+ rule = rule or f"EC {ec}"
261
+ got = name_idx.get(norm_name(rid.replace("-RXN", "")))
262
+ if got:
263
+ cands |= set(got)
264
+ rule = f"{rule}; name" if rule else "name"
265
+ if cands:
266
+ bridged[rid] = {"rxns": sorted(cands), "rule": rule}
267
+ return bridged
268
+
269
+
270
+ def port_reaction(u_rxn, target_m, met_idx, new_met_cache):
271
+ """iML1515 反应 -> 目标模型命名空间(不直接改 target)。
272
+ 代谢物三层解析: 名字规约唯一匹配(公式校验) -> COFACTOR_BRIDGE(公式校验) -> 新代谢物(共享缓存)。
273
+ 返回 (reaction_or_None, info)。"""
274
+ if PTS_RE.search(u_rxn.name or "") or PTS_RE.search(u_rxn.id):
275
+ return None, {"skipped": "PTS route excluded (PTS-less organism guard)"}
276
+
277
+ def _resolve(x):
278
+ base, comp = _bigg_base_comp(x.id)
279
+ comp_id = COMP_MAP.get(comp, comp)
280
+ cands = [c for c in met_idx.get(met_name_key(x), []) if c.compartment == comp_id]
281
+ if len(cands) == 1 and _formula_ok(x, cands[0]):
282
+ return cands[0], "name"
283
+ cpd = COFACTOR_BRIDGE.get(base)
284
+ if cpd:
285
+ tid = f"cpd{cpd}_{comp_id}"
286
+ if tid in target_m.metabolites:
287
+ tgt = target_m.metabolites.get_by_id(tid)
288
+ # 桥表条目经人工核验;BiGG/ModelSEED 质子记账惯例不同(H/±1 常见),
289
+ # 故此处不做严格公式断言,只记录差异供审计(公式完全不同族才拒收:碳数必须一致)
290
+ fx, ft = (x.formula or ""), (tgt.formula or "")
291
+ cx, ct = re.findall(r"C(\d+)", fx), re.findall(r"C(\d+)", ft)
292
+ if cx and ct and cx[0] != ct[0]:
293
+ return None, "cofactor_carbon_mismatch"
294
+ if not _formula_ok(x, tgt):
295
+ mismatches.append({"u": x.id, "t": tgt.id, "u_formula": fx, "t_formula": ft})
296
+ return tgt, "cofactor_bridge"
297
+ key = f"{base}_{comp_id}"
298
+ if key in target_m.metabolites:
299
+ key += "_l3fix" # 同名 id 已被占用(名字匹配失败=语义不同)→ 造独立副本
300
+ if key not in new_met_cache:
301
+ nm = cobra.Metabolite(key, name=(x.name or base), compartment=comp_id,
302
+ formula=x.formula, charge=x.charge)
303
+ nm.notes["source"] = SOURCE_TAG
304
+ new_met_cache[key] = nm
305
+ return new_met_cache[key], "new"
306
+
307
+ mapped, hows, new_ids, mismatches = {}, {}, [], []
308
+ for x in u_rxn.metabolites:
309
+ tgt, how = _resolve(x)
310
+ if tgt is None:
311
+ return None, {"skipped": f"cofactor mapping failed: {x.id}"}
312
+ mapped[x.id] = tgt
313
+ hows[x.id] = how
314
+ if how == "new":
315
+ new_ids.append(tgt.id)
316
+ if not mapped:
317
+ return None, {"skipped": "no metabolite mappable"}
318
+ n_existing = sum(1 for v in mapped.values() if v.id in target_m.metabolites)
319
+ if n_existing == 0:
320
+ return None, {"skipped": "fully novel subnet (shares no model metabolite)"}
321
+ # 强制下限钳 0:补洞候选必须是"可选反应"(如 ATPM 的 lb=6.86 强制维持能会污染
322
+ # G3 无碳/全关检查与后续底物复测——2026-08-29 实测把甘露醇 sole 测出 -0.045)
323
+ lb, ub = u_rxn.lower_bound, u_rxn.upper_bound
324
+ lb_clamped = lb > 0
325
+ r = cobra.Reaction(u_rxn.id + NEW_RXN_SUFFIX, name=(u_rxn.name or u_rxn.id),
326
+ lower_bound=min(lb, 0.0), upper_bound=ub)
327
+ r.add_metabolites({mapped[x.id]: c for x, c in u_rxn.metabolites.items()})
328
+ return r, {"n_mapped": n_existing, "n_new": len(new_ids), "new_ids": new_ids,
329
+ "hows": hows, "lb_clamped": lb_clamped}
330
+
331
+
332
+ def build_pool(universal, target_m, bridged, allow_math):
333
+ """L3b 候选池: 桥接(白名单序列证据)+(可选)全 universal 数学池。
334
+ 证据口径: 白名单 id 自身 EC 型(X.X.X.X-RXN)或名字直配 → EVIDENCE_sequence;
335
+ 经 tbl 间接 EC 桥(如 XYLISOM-RXN→ARAI 的双 EC 注释链)降级 EVIDENCE_math + sequence_hint
336
+ (防证据虚高——命中的是 A 酶序列、加的是 B 酶方程)。
337
+ 返回 (pool_model, seq_backed_ids, port_report, new_met_cache)。"""
338
+ met_idx = build_met_name_index(target_m)
339
+ wl_uids = {u for v in bridged.values() for u in v["rxns"]}
340
+
341
+ def _strict_seq(wl_id, rule):
342
+ if rule == "name":
343
+ return True
344
+ if rule and rule.startswith("EC") and re.match(r"^\d+(\.\d+)+-RXN$", wl_id):
345
+ return True
346
+ return False
347
+
348
+ strict_uids = {u for w, v in bridged.items() for u in v["rxns"] if _strict_seq(w, v["rule"])}
349
+ hint_uids = wl_uids - strict_uids
350
+ cand_uids = set(wl_uids)
351
+ if allow_math:
352
+ cand_uids |= {r.id for r in universal.reactions
353
+ if not r.boundary and not r.id.startswith(EX_PREFIX)}
354
+ pool_rxns, new_met_cache = [], {}
355
+ report = {"bridged": len(bridged), "ported": 0, "excluded_pts": 0, "skipped": []}
356
+ for uid in sorted(cand_uids):
357
+ if uid not in universal.reactions:
358
+ continue
359
+ r, info = port_reaction(universal.reactions.get_by_id(uid), target_m, met_idx, new_met_cache)
360
+ if r is None:
361
+ if "PTS" in (info.get("skipped") or ""):
362
+ report["excluded_pts"] += 1
363
+ elif len(report["skipped"]) < 12:
364
+ report["skipped"].append({"rxn": uid, "why": info.get("skipped")})
365
+ continue
366
+ pool_rxns.append(r)
367
+ report["ported"] += 1
368
+ pool_model = cobra.Model("l3b_pool")
369
+ if pool_rxns:
370
+ pool_model.add_reactions(pool_rxns)
371
+ n = len(NEW_RXN_SUFFIX)
372
+ seq_backed = {r.id for r in pool_rxns if r.id[:-n] in strict_uids} if pool_rxns else set()
373
+ seq_hinted = {r.id for r in pool_rxns if r.id[:-n] in hint_uids} if pool_rxns else set()
374
+ return pool_model, seq_backed, report, new_met_cache, seq_hinted
375
+
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # 主流程
379
+ # ---------------------------------------------------------------------------
380
+ def l3_fix(model_path, medium=None, substrates=None, out=None,
381
+ allow_math=False, confirm_budget=False,
382
+ whitelist=None, faa=None, species=None, max_iter=1, universal_path=None):
383
+ from silentio import silent_read_sbml, silent_write_sbml
384
+ from validate import validate_model
385
+ from gapfind import expand_medium, resolve_medium, match_ex, build_ex_index
386
+ from budget import budget_gate, prior_added, budget_for
387
+
388
+ t0 = time.time()
389
+ if not substrates:
390
+ return {"ok": False, "error": "substrates required(L3 由底物驱动诊断)"}
391
+ m = silent_read_sbml(model_path)
392
+ med, preset_name = expand_medium(medium or {})
393
+ resolved_med, unresolved = resolve_medium(m, med)
394
+ ex_idx = build_ex_index(m)
395
+
396
+ # 白名单(L3b 用;不可用不阻塞 L3a,记录原因)
397
+ rxn_hits, wl_cache, wl_src = None, None, None
398
+ try:
399
+ rxn_hits, wl_cache, wl_src = load_whitelist(species=species, faa=faa, whitelist_json=whitelist)
400
+ _note(f"[whitelist] {len(rxn_hits)} rxn hits via {wl_src['source']}")
401
+ except Exception as e:
402
+ _note(f"[whitelist] unavailable: {e}")
403
+
404
+ universal = None
405
+ upath = universal_path or DEFAULT_UNIVERSAL
406
+ if (rxn_hits or allow_math) and os.path.exists(upath):
407
+ universal = silent_read_sbml(upath)
408
+ _note(f"[universal] {upath}: {len(universal.reactions)} reactions")
409
+ tbl = _parse_tbl(os.path.join(os.path.dirname(model_path),
410
+ os.path.splitext(os.path.basename(model_path))[0] + "-all-Reactions.tbl"))
411
+
412
+ # L3 诊断(sole 语义)
413
+ l3 = []
414
+ for sub in substrates:
415
+ exid = match_ex(sub, ex_idx)
416
+ g = _growth_sole(m, resolved_med, exid)
417
+ if exid and exid in m.reactions and g < 1e-6:
418
+ l3.append({"substrate": sub, "exchange": exid, "growth_sole_before": round(g, 6),
419
+ # 阶段A-M4 口径声明(只增)
420
+ "units": "mmol/gDW/h",
421
+ "point_value_note": "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"})
422
+
423
+ # 第五闸门(入口预检: 每底物至少 1 条新增预估)
424
+ gate0 = budget_gate(m, planned=len(l3), confirm_budget=confirm_budget)
425
+ if gate0 and not rxn_hits and not allow_math:
426
+ return {"ok": False, **gate0}
427
+
428
+ bridged = {}
429
+ if rxn_hits and universal is not None:
430
+ bridged = bridge_iML1515(
431
+ [k for k in rxn_hits if k.endswith("-RXN") or re.match(r"^\d+(\.\d+)+-RXN$", k)
432
+ or k.startswith("RXN-")],
433
+ universal, tbl)
434
+ _note(f"[bridge] {len(bridged)} whitelist rxn ids -> iML1515")
435
+ uid_to_wlid = {u: w for w, v in bridged.items() for u in v["rxns"]}
436
+
437
+ pool_model, seq_backed, port_report, new_met_cache, seq_hinted = cobra.Model("l3b_pool"), set(), {
438
+ "bridged": 0, "ported": 0, "excluded_pts": 0, "skipped": []}, {}, set()
439
+ if universal is not None:
440
+ pool_model, seq_backed, port_report, new_met_cache, seq_hinted = build_pool(
441
+ universal, m, bridged, allow_math)
442
+ _note(f"[pool] {len(pool_model.reactions)} ported candidates "
443
+ f"(sequence-backed {len(seq_backed)}, seq-hint {len(seq_hinted)}, "
444
+ f"pts-excluded {port_report['excluded_pts']})")
445
+
446
+ results, applied_all, relaxed_all = [], [], []
447
+ cur = m
448
+ rolled = False
449
+ for item in l3:
450
+ sub, exid = item["substrate"], item["exchange"]
451
+ entry = dict(item)
452
+ growth_before = _growth_sole(cur, resolved_med, exid)
453
+
454
+ # ---- L3a: 模型内连通性(LP 预检 → MILP 放宽 bounds)----
455
+ l3a = {"attempted": True}
456
+ scratch = cur.copy()
457
+ _set_medium(scratch, sole_medium(scratch, resolved_med, exid))
458
+ _relax_all_internal(scratch)
459
+ g_relax = scratch.optimize().objective_value or 0.0
460
+ l3a["lp_relax_growth"] = round(g_relax, 6)
461
+ if g_relax < 1e-6:
462
+ l3a["verdict"] = "not_fixable_in_model(全内部放开方向仍不生长→内部路径缺失,需外部反应式)"
463
+ else:
464
+ hop_rids = _hop_reactions(cur, exid, hops=2)
465
+ uni = cobra.Model("self_universal")
466
+ copies = []
467
+ for rid in sorted(hop_rids):
468
+ r0 = cur.reactions.get_by_id(rid)
469
+ cp = cobra.Reaction("l3a_" + rid, name=r0.name,
470
+ lower_bound=-1000.0, upper_bound=1000.0)
471
+ cp.add_metabolites(dict(r0.metabolites))
472
+ copies.append(cp)
473
+ uni.add_reactions(copies)
474
+ mc = cur.copy()
475
+ _set_medium(mc, sole_medium(mc, resolved_med, exid))
476
+ try:
477
+ sols = GapFiller(mc, universal=uni, lower_bound=0.05,
478
+ exchange_reactions=False, demand_reactions=False).fill(max_iter)
479
+ except Exception as e:
480
+ _note(f"[l3a] GapFiller failed: {e}")
481
+ sols = []
482
+ picked = [r for r in (sols[0] if sols else []) if r.id.startswith("l3a_")]
483
+ l3a["verdict"] = "no solution" if not picked else "relaxed bounds"
484
+ l3a["picked"] = [r.id[4:] for r in picked]
485
+ for pr in picked:
486
+ orig = pr.id[4:]
487
+ r0 = cur.reactions.get_by_id(orig)
488
+ old = r0.bounds
489
+ r0.bounds = (-1000.0, 1000.0)
490
+ r0.notes["bound_relaxed_by"] = SOURCE_TAG
491
+ r0.notes["evidence"] = "EVIDENCE_math"
492
+ relaxed_all.append({"rxn": orig, "old_bounds": list(old), "substrate": sub,
493
+ "evidence": "EVIDENCE_math"})
494
+ growth_a = _growth_sole(cur, resolved_med, exid)
495
+ l3a["growth_sole_after"] = round(growth_a, 6)
496
+ l3a["units"] = "mmol/gDW/h"
497
+ l3a["point_value_note"] = "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"
498
+ entry["l3a"] = l3a
499
+
500
+ # ---- L3b: 白名单/BiGG 反应式(MILP 从候选池取最小集)----
501
+ l3b = {"attempted": len(pool_model.reactions) > 0, "pool": port_report}
502
+ added_here = []
503
+ if len(pool_model.reactions) > 0 and growth_a < 1e-6:
504
+ mb = cur.copy()
505
+ _set_medium(mb, sole_medium(mb, resolved_med, exid))
506
+ try:
507
+ sols = GapFiller(mb, universal=pool_model, lower_bound=0.05,
508
+ exchange_reactions=False, demand_reactions=False).fill(max_iter)
509
+ except Exception as e:
510
+ _note(f"[l3b] GapFiller failed: {e}")
511
+ sols = []
512
+ picked = [p for p in (sols[0] if sols else []) if p.id.endswith(NEW_RXN_SUFFIX)]
513
+ gate = budget_gate(cur, planned=len(picked), confirm_budget=confirm_budget)
514
+ if gate:
515
+ entry["l3b"] = {**l3b, **gate}
516
+ results.append(entry)
517
+ break
518
+ for pr in picked:
519
+ src = pool_model.reactions.get_by_id(pr.id)
520
+ if pr.id in seq_backed:
521
+ ev = "EVIDENCE_sequence"
522
+ elif pr.id in seq_hinted:
523
+ ev = "EVIDENCE_math" # tbl 间接桥:有序列线索但非直接对应,保守降级
524
+ else:
525
+ ev = "EVIDENCE_math"
526
+ rid = pr.id if pr.id not in cur.reactions else pr.id + f"_{len(applied_all)}"
527
+ r = cobra.Reaction(rid, name=(pr.name or pr.id),
528
+ lower_bound=pr.lower_bound, upper_bound=pr.upper_bound)
529
+ mm = {}
530
+ for x, c in src.metabolites.items():
531
+ if x.id in cur.metabolites:
532
+ mm[cur.metabolites.get_by_id(x.id)] = c
533
+ else: # 新代谢物随反应引入(共享缓存对象,防同 id 异对象)
534
+ nm = new_met_cache.get(x.id)
535
+ if nm is None or nm.id != x.id:
536
+ nm = next((v for v in new_met_cache.values() if v.id == x.id), None)
537
+ if nm is None:
538
+ nm = cobra.Metabolite(x.id, name=x.name, compartment=x.compartment,
539
+ formula=x.formula, charge=x.charge)
540
+ nm.notes["source"] = SOURCE_TAG
541
+ new_met_cache[x.id] = nm
542
+ mm[nm] = c
543
+ r.add_metabolites(mm)
544
+ cur.add_reactions([r])
545
+ wlid = uid_to_wlid.get(pr.id[:-len(NEW_RXN_SUFFIX)])
546
+ r.notes["source"] = SOURCE_TAG
547
+ r.notes["evidence"] = ev
548
+ r.notes["reason"] = (f"L3b: ported from iML1515 {pr.id[:-len(NEW_RXN_SUFFIX)]}"
549
+ f"{' via whitelist ' + wlid if wlid else ''}"
550
+ f"{' (tbl-indirect bridge, sequence hint only)' if pr.id in seq_hinted and wlid else ''}"
551
+ f"; substrate {sub}")
552
+ added_here.append({"rxn": rid, "evidence": ev, "substrate": sub,
553
+ "sequence_backed": ev == "EVIDENCE_sequence",
554
+ "sequence_hint": pr.id in seq_hinted and wlid is not None})
555
+ applied_all.append(added_here[-1])
556
+ _note(f"[l3b] {sub}: picked {len(picked)}, added {len(added_here)}")
557
+ growth_b = _growth_sole(cur, resolved_med, exid)
558
+ l3b["growth_sole_after"] = round(growth_b, 6)
559
+ l3b["units"] = "mmol/gDW/h"
560
+ l3b["point_value_note"] = "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"
561
+ l3b["added"] = added_here
562
+ entry["l3b"] = l3b
563
+ entry["growth_sole_after"] = round(max(growth_a, growth_b), 6)
564
+ entry["units"] = "mmol/gDW/h"
565
+ entry["point_value_note"] = "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"
566
+ entry["verdict"] = "fixed" if growth_b > 1e-6 else "not_fixable"
567
+ if entry["verdict"] == "not_fixable":
568
+ entry["unfixable_evidence"] = [
569
+ f"sole 语义生长 before={growth_before:.6f}(有交换+转运但 FBA 不长,即 L3)",
570
+ f"L3a: {entry['l3a']['verdict']}(lp_relax_growth={entry['l3a'].get('lp_relax_growth')})",
571
+ (f"L3b: 候选池 {len(pool_model.reactions)} 条(白名单桥接 {port_report['bridged']}"
572
+ f"→移植 {port_report['ported']},PTS 排除 {port_report['excluded_pts']});"
573
+ f"MILP 未选出能恢复生长的集合"),
574
+ f"白名单缓存: {wl_cache or 'n/a'}({len(rxn_hits or {})} 命中;桥规则=EC/名字规约)",
575
+ ]
576
+ results.append(entry)
577
+
578
+ resp = {"ok": True, "l3_input": results, "medium_preset": preset_name,
579
+ "medium_unresolved": unresolved,
580
+ "whitelist": {"cache": wl_cache, "source": (wl_src or {}).get("source"),
581
+ "n_hits": len(rxn_hits or {})},
582
+ "budget": {"prior_added": prior_added(m), "budget": budget_for(m),
583
+ "added_this_run": len(applied_all) + len(relaxed_all)},
584
+ "applied": applied_all, "bound_relaxed": relaxed_all,
585
+ "rolled_back": False, "out": None, "elapsed_s": None}
586
+
587
+ # ---- 落盘 + G1-G6 重验(G6 能量循环哨兵,失败回滚)----
588
+ if applied_all or relaxed_all:
589
+ if not out:
590
+ out = model_path[:-4] + "_l3.xml" if model_path.endswith(".xml") else model_path + "_l3.xml"
591
+ silent_write_sbml(cur, out)
592
+ rep = validate_model(out, medium=resolved_med)
593
+ g6 = rep.get("g6") or {}
594
+ resp["validate"] = {k: (rep.get(k) or {}).get("status") for k in ("g1", "g2", "g3", "g4", "g5", "g6")}
595
+ resp["g6_after"] = g6
596
+ if g6.get("status") != "PASS":
597
+ _rollback(cur, applied_all, relaxed_all)
598
+ silent_write_sbml(cur, out)
599
+ rep2 = validate_model(out, medium=resolved_med)
600
+ resp["rolled_back"] = True
601
+ resp["rollback_reason"] = f"G6 {g6.get('status')} atp_leak={g6.get('atp_leak_flux')}"
602
+ resp["validate"] = {k: (rep2.get(k) or {}).get("status") for k in ("g1", "g2", "g3", "g4", "g5", "g6")}
603
+ resp["g6_after"] = rep2.get("g6")
604
+ resp["applied"] = []
605
+ resp["bound_relaxed"] = []
606
+ resp["out"] = out
607
+ # 模型卡 lineage 追加(源模型旁有 card 才传播+追加;无卡不凭空造卡)
608
+ try:
609
+ from model_card import append_operation, load_card, propagate_card
610
+ propagate_card(model_path, out)
611
+ if load_card(out) is not None:
612
+ card = append_operation(out, "l3_fix", reactions_added=len(resp["applied"]),
613
+ detail={"bound_relaxed": len(resp["bound_relaxed"]),
614
+ "rolled_back": resp["rolled_back"],
615
+ "substrates": [i.get("substrate") for i in results],
616
+ "evidence": resp.get("evidence_summary")})
617
+ resp["card_version"] = (card or {}).get("model_lineage", {}).get("version")
618
+ except Exception:
619
+ pass
620
+ else:
621
+ resp["note"] = "no L3 fix applied(均为不可补或无候选;证据见 l3_input)"
622
+
623
+ evc = {"EVIDENCE_sequence": 0, "EVIDENCE_math": 0, "EVIDENCE_rule": 0}
624
+ for a in resp["applied"]:
625
+ evc[a["evidence"]] = evc.get(a["evidence"], 0) + 1
626
+ for rb in resp["bound_relaxed"]:
627
+ evc[rb["evidence"]] = evc.get(rb["evidence"], 0) + 1
628
+ resp["evidence_summary"] = evc
629
+ resp["elapsed_s"] = round(time.time() - t0, 1)
630
+ return resp
631
+
632
+
633
+ if __name__ == "__main__":
634
+ args = json.loads(open(sys.argv[1], encoding="utf-8").read()) if len(sys.argv) > 1 else {}
635
+ print(json.dumps(l3_fix(args.get("model"), args.get("medium"), args.get("substrates"),
636
+ args.get("out"), allow_math=args.get("allow_math", False),
637
+ confirm_budget=args.get("confirm_budget", False),
638
+ whitelist=args.get("whitelist"), faa=args.get("faa"),
639
+ species=args.get("species"), max_iter=args.get("max_iter", 1),
640
+ universal_path=args.get("universal_path")),
641
+ ensure_ascii=False, indent=2))