@dsh-bio/dsh-bio-gem 0.1.12 → 0.1.14

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,269 @@
1
+ """Flux-space sampling primitives for the ``sample`` gem operation."""
2
+ import contextlib
3
+ import hashlib
4
+ import io
5
+ import os
6
+ import time
7
+
8
+ import numpy as np
9
+ from cobra.sampling import ACHRSampler
10
+ from cobra.util.solver import linear_reaction_coefficients
11
+
12
+ from gapfind import expand_medium, resolve_medium
13
+ from silentio import silent_read_sbml
14
+
15
+
16
+ EX_PREFIXES = ("EX_", "DM_", "SK_")
17
+ FLUX_TOLERANCE = 1e-9
18
+ MIN_SAMPLES = 10
19
+ MAX_SAMPLES = 20000
20
+
21
+
22
+ def _model_hash(path):
23
+ digest = hashlib.sha256()
24
+ with open(path, "rb") as handle:
25
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
26
+ digest.update(chunk)
27
+ return digest.hexdigest()[:16]
28
+
29
+
30
+ def _is_boundary(reaction):
31
+ return reaction.boundary or reaction.id.startswith(EX_PREFIXES)
32
+
33
+
34
+ def _configure_medium(model, medium):
35
+ """Apply only an explicitly supplied medium; preserve SBML defaults otherwise."""
36
+ if medium is not None and not isinstance(medium, dict):
37
+ raise ValueError("medium must be an object when provided")
38
+
39
+ expanded, preset = expand_medium(medium)
40
+ resolved, unresolved = resolve_medium(model, expanded) if expanded else ({}, [])
41
+ if medium is not None:
42
+ for reaction in model.reactions:
43
+ if _is_boundary(reaction):
44
+ reaction.lower_bound = 0.0
45
+ for reaction_id, lower_bound in resolved.items():
46
+ model.reactions.get_by_id(reaction_id).lower_bound = lower_bound
47
+ return {"preset": preset, "resolved_exchanges": len(resolved)}, unresolved
48
+
49
+
50
+ def _require_int(name, value, minimum=None, maximum=None):
51
+ if isinstance(value, bool) or not isinstance(value, int):
52
+ raise ValueError(f"{name} must be an integer")
53
+ if minimum is not None and value < minimum:
54
+ raise ValueError(f"{name} must be >= {minimum}")
55
+ if maximum is not None and value > maximum:
56
+ raise ValueError(f"{name} must be <= {maximum}")
57
+ return value
58
+
59
+
60
+ def _growth_reaction(model):
61
+ """Select the strongest positive linear objective reaction deterministically."""
62
+ coefficients = linear_reaction_coefficients(model)
63
+ if not coefficients:
64
+ raise ValueError("model needs a linear objective to identify the growth reaction")
65
+
66
+ candidates = [(reaction, float(coefficient)) for reaction, coefficient in coefficients.items()]
67
+ positive = [(reaction, coefficient) for reaction, coefficient in candidates if coefficient > 0]
68
+ pool = positive or candidates
69
+ reaction, _coefficient = sorted(pool, key=lambda item: (-abs(item[1]), item[0].id))[0]
70
+ return reaction
71
+
72
+
73
+ def _describe(values):
74
+ values = np.asarray(values, dtype=float)
75
+ return {
76
+ "median": float(np.quantile(values, 0.5)),
77
+ "mean": float(np.mean(values)),
78
+ "min": float(np.min(values)),
79
+ "max": float(np.max(values)),
80
+ "q05": float(np.quantile(values, 0.05)),
81
+ "q25": float(np.quantile(values, 0.25)),
82
+ "q75": float(np.quantile(values, 0.75)),
83
+ "q95": float(np.quantile(values, 0.95)),
84
+ }
85
+
86
+
87
+ def _reaction_stats(values):
88
+ values = np.asarray(values, dtype=float)
89
+ return {
90
+ "median": float(np.quantile(values, 0.5)),
91
+ "q05": float(np.quantile(values, 0.05)),
92
+ "q95": float(np.quantile(values, 0.95)),
93
+ "sign_probability": float(np.mean(values > FLUX_TOLERANCE)),
94
+ "near_zero_fraction": float(np.mean(np.abs(values) <= FLUX_TOLERANCE)),
95
+ }
96
+
97
+
98
+ def _selected_reactions(samples, growth_reaction_id, reactions):
99
+ if reactions is not None:
100
+ if not isinstance(reactions, list) or any(not isinstance(item, str) for item in reactions):
101
+ raise ValueError("reactions must be an array of reaction IDs")
102
+ selected = []
103
+ for reaction_id in reactions:
104
+ if reaction_id not in samples.columns:
105
+ raise ValueError(f"reaction not found in model: {reaction_id}")
106
+ if reaction_id not in selected:
107
+ selected.append(reaction_id)
108
+ return selected
109
+
110
+ iqr = samples.quantile(0.75) - samples.quantile(0.25)
111
+ ranked = sorted(
112
+ (reaction_id for reaction_id in samples.columns if reaction_id != growth_reaction_id),
113
+ key=lambda reaction_id: (-float(iqr[reaction_id]), reaction_id),
114
+ )
115
+ return [growth_reaction_id] + ranked[:20]
116
+
117
+
118
+ def _validate_requested_reactions(model, reactions):
119
+ """Reject malformed target lists before expensive ACHR warmup starts."""
120
+ if reactions is None:
121
+ return
122
+ if not isinstance(reactions, list) or any(not isinstance(item, str) for item in reactions):
123
+ raise ValueError("reactions must be an array of reaction IDs")
124
+ for reaction_id in reactions:
125
+ if reaction_id not in model.reactions:
126
+ raise ValueError(f"reaction not found in model: {reaction_id}")
127
+
128
+
129
+ def _method(method):
130
+ if method is None:
131
+ method = "auto"
132
+ if not isinstance(method, str):
133
+ raise ValueError("method must be one of auto, achr, optgp")
134
+ normalized = method.lower()
135
+ if normalized not in {"auto", "achr", "optgp"}:
136
+ raise ValueError("method must be one of auto, achr, optgp")
137
+ if normalized == "auto":
138
+ return "achr"
139
+ if normalized == "optgp" and os.name == "nt":
140
+ # OptGP creates a worker pool. gem_ops is deliberately import-safe,
141
+ # but this operation must never expose Windows callers to an accidental
142
+ # spawn loop until that execution route has its own verified harness.
143
+ raise ValueError("method 'optgp' is not supported on Windows; use 'achr' or 'auto'")
144
+ return normalized
145
+
146
+
147
+ def sample_fluxes(
148
+ model_path,
149
+ medium=None,
150
+ n=1000,
151
+ method="auto",
152
+ thinning=100,
153
+ growth_floor_fraction=None,
154
+ reactions=None,
155
+ seed=42,
156
+ export_csv=None,
157
+ ):
158
+ """Sample a model's feasible flux space with a deterministic ACHR default."""
159
+ if not model_path or not os.path.isfile(model_path):
160
+ raise ValueError(f"model file not found: {model_path}")
161
+ n = _require_int("n", n, MIN_SAMPLES, MAX_SAMPLES)
162
+ thinning = _require_int("thinning", thinning, 1)
163
+ seed = _require_int("seed", seed)
164
+ if export_csv is not None and not isinstance(export_csv, str):
165
+ raise ValueError("export_csv must be a path string when provided")
166
+ if growth_floor_fraction is not None:
167
+ if isinstance(growth_floor_fraction, bool) or not isinstance(growth_floor_fraction, (int, float)):
168
+ raise ValueError("growth_floor_fraction must be a number strictly between 0 and 1")
169
+ growth_floor_fraction = float(growth_floor_fraction)
170
+ if not 0.0 < growth_floor_fraction < 1.0:
171
+ raise ValueError("growth_floor_fraction must be strictly between 0 and 1")
172
+
173
+ method_used = _method(method)
174
+ started = time.perf_counter()
175
+ base_model = silent_read_sbml(model_path)
176
+ configured_model = base_model.copy()
177
+ medium_summary, unresolved_medium = _configure_medium(configured_model, medium)
178
+
179
+ growth_reaction = _growth_reaction(configured_model)
180
+ fba_solution = configured_model.optimize()
181
+ if fba_solution.status != "optimal":
182
+ raise ValueError(f"FBA under the selected medium is not optimal: {fba_solution.status}")
183
+ max_growth = float(fba_solution.fluxes[growth_reaction.id])
184
+
185
+ # Every sampling run gets an independent model copy. The growth-floor
186
+ # branch therefore cannot leak modified bounds into the caller or a retry.
187
+ sampling_model = configured_model.copy()
188
+ if growth_floor_fraction is not None:
189
+ if max_growth <= FLUX_TOLERANCE:
190
+ raise ValueError("cannot apply growth_floor_fraction because maximum growth is not positive")
191
+ sampling_growth = sampling_model.reactions.get_by_id(growth_reaction.id)
192
+ sampling_growth.lower_bound = growth_floor_fraction * max_growth
193
+
194
+ _validate_requested_reactions(sampling_model, reactions)
195
+
196
+ # The only fully supported execution route for the Windows target is ACHR.
197
+ # A non-Windows future caller can still ask for OptGP explicitly.
198
+ # Sampling libraries can emit backend diagnostics. Keep them off stdout so
199
+ # gem_ops retains its one-JSON-object protocol.
200
+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
201
+ if method_used == "achr":
202
+ sampler = ACHRSampler(sampling_model, thinning=thinning, seed=seed)
203
+ else:
204
+ from cobra.sampling import OptGPSampler
205
+
206
+ sampler = OptGPSampler(sampling_model, thinning=thinning, processes=1, seed=seed)
207
+ samples = sampler.sample(n, fluxes=True)
208
+ runtime_s = time.perf_counter() - started
209
+ if len(samples) != n:
210
+ raise RuntimeError(f"sampler returned {len(samples)} rows for n={n}")
211
+
212
+ validation_codes = sampler.validate(samples.to_numpy())
213
+ n_valid = int(np.sum(validation_codes == "v"))
214
+ selected = _selected_reactions(samples, growth_reaction.id, reactions)
215
+ reaction_summary = {
216
+ reaction_id: _reaction_stats(samples[reaction_id].to_numpy())
217
+ for reaction_id in selected
218
+ }
219
+ growth_summary = _describe(samples[growth_reaction.id].to_numpy())
220
+
221
+ if export_csv:
222
+ samples.to_csv(export_csv, index=False)
223
+
224
+ if growth_floor_fraction is None:
225
+ boundary_space = "full_feasible_space"
226
+ floor_note = "未施加 growth floor。"
227
+ else:
228
+ boundary_space = "growth_floor_constrained_space"
229
+ floor_note = (
230
+ f"已在独立 model.copy() 上将 {growth_reaction.id} 的下界设为 "
231
+ f"{growth_floor_fraction:.6g} × 当前 FBA 最大生长。"
232
+ )
233
+
234
+ boundary_note = (
235
+ "全空间均匀采样 ≠ 生物学上有意义的活跃状态;关心近最优生长态请传 "
236
+ "growth_floor_fraction(如 0.9)。"
237
+ f"当前介质下 {growth_reaction.id} 的 FBA 最大生长为 {max_growth:.9g}。{floor_note}"
238
+ )
239
+ feasibility_note = (
240
+ "n_valid 为 cobra sampler.validate 返回 'v'(同时满足稳态、上下界)的样本数;"
241
+ "validation_failures 为其余 l/u/e 代码的样本数。"
242
+ )
243
+ if unresolved_medium:
244
+ feasibility_note += " 未解析的介质成分未施加:" + ", ".join(sorted(unresolved_medium)) + "。"
245
+
246
+ return {
247
+ "model": model_path,
248
+ "model_hash": _model_hash(model_path),
249
+ "method_used": method_used,
250
+ "n_requested": n,
251
+ "n_samples": int(len(samples)),
252
+ "thinning": thinning,
253
+ "seed": seed,
254
+ "runtime_s": runtime_s,
255
+ "growth_reaction": growth_reaction.id,
256
+ "growth": growth_summary,
257
+ "reactions": reaction_summary,
258
+ "feasibility": {
259
+ "n_valid": n_valid,
260
+ "validation_failures": int(len(samples) - n_valid),
261
+ "note": feasibility_note,
262
+ },
263
+ "boundary": {
264
+ "space": boundary_space,
265
+ "growth_floor_fraction": growth_floor_fraction,
266
+ "note": boundary_note,
267
+ },
268
+ "medium": medium_summary,
269
+ }
@@ -7,7 +7,7 @@
7
7
  # 产物(目标汇连接组分),GAM 网格只动 stub(等比缩放 X/GAM_ORIG)。
8
8
  # 锚点: 基准组合与 essential_scan 完全同参数 -> 必须精确复现 155;且 155 全部在
9
9
  # always_essential ∪ conditionally_essential(基准在网格内故断言必成立)。
10
- # 生长/通量数值口径: 单点 FBA objective_value(mmol/gDW/h);区间制对比请用 gem_fluxscan。
10
+ # 生长数值口径: 单点 FBA objective_value = 比生长速率(1/h,biomass 归一化口径);区间制对比请用 gem_fluxscan。
11
11
  import os
12
12
  import sys
13
13
  import csv
@@ -391,7 +391,7 @@ def sensitivity(model_path, medium=None, biomass_scales=None, gam_grid=None,
391
391
  "component_sensitivity": {"top_sensitive": top_sensitive, "rows": comp_rows},
392
392
  "component_essentiality_drift": drift,
393
393
  "card_robustness_written": card_written,
394
- "units": "mmol/gDW/h",
394
+ "units": "1/h",
395
395
  "timing_seconds": round(time.time() - t_start, 1),
396
396
  }
397
397
  try:
@@ -1,6 +1,6 @@
1
1
  # validate.py — dsh-bio-gem 五道验证关卡(M1)
2
2
  # G1 加载统计 / G2 内部反应元素平衡 / G3 生长真实性 / G4 底物表型(条件) / G5 必需基因抽检(条件)
3
- # 规格: docs/ARCHITECTURE.md §5;判据口径 = FBA objective_value(mmol/gDW/h,不用 μ)
3
+ # 规格: docs/ARCHITECTURE.md §5;判据口径 = FBA objective_value(biomass 归一化 → 比生长速率 μ,单位 1/h)
4
4
  # 实现从 HANDOFF-03 五道关卡协议产品化(农杆菌项目验证过的逻辑)
5
5
  import re
6
6
  import os
@@ -197,7 +197,7 @@ class Validator:
197
197
  "ratio_vs_reference": round(ratio, 4) if ratio is not None else None,
198
198
  "checks": {"medium>0": ok_grow, "no_carbon==0": ok_noc, "closed==0": ok_closed},
199
199
  # 阶段A-M4 口径声明(只增):单点 FBA 值非硬结论
200
- "units": "mmol/gDW/h",
200
+ "units": "1/h",
201
201
  "point_value_note": "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定",
202
202
  }
203
203
  return rep
@@ -313,7 +313,7 @@ class Validator:
313
313
  results.append({"substrate": sub, "published": int(pub), "predicted": int(pred),
314
314
  "growth": round(g, 6), "exchange": exid or None, "match": bool(ok),
315
315
  # 阶段A-M4 口径声明(只增):每底物 growth 为单点 FBA 值
316
- "units": "mmol/gDW/h",
316
+ "units": "1/h",
317
317
  "point_value_note": "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"})
318
318
  rep = {
319
319
  "status": "PASS" if rows and matched / len(rows) >= 0.8 else ("WARN" if rows else "SKIP"),
@@ -373,7 +373,7 @@ class Validator:
373
373
  medium, _preset = expand_medium(medium)
374
374
  resolved_med, unresolved = resolve_medium(self.m, medium) if medium else ({}, [])
375
375
  report = {"model": self.path,
376
- "units": {"growth": "mmol/gDW/h", "note": "objective_value 是 FBA 通量(mmol/gDW/h),不是比生长速率 μ(h⁻¹)"},
376
+ "units": {"growth": "1/h", "note": "growth 为比生长速率 μ:biomass 反应归一化到 1 gDW 时其通量数值等于 μ(标准 GEM 约定);非归一化模型的 growth 应以 mmol/gDW/h 解读"},
377
377
  # G0 模型数据质量前置诊断:未映射前体等数据问题会让 G2/G3 与 gapfind 的
378
378
  # 结论失真(实测 iNX1344_v3:gapfind 报的 5 个 L3 缺口实为未映射前体所致)
379
379
  "g0": model_coherence(self.m),
@@ -12,6 +12,7 @@ language: mixed
12
12
  |---|---|
13
13
  | 已有 SBML 模型文件,想知道概要(基因/反应/复制子)| `gem_report`(model 参数=绝对路径)|
14
14
  | 验证模型质量(五道关卡:加载/元素平衡/生长真实性/表型/必需性抽检)| `gem_validate`(model + medium)|
15
+ | 模型质量审计(对标 MEMOTE 维度:blocked 反应/环路/元素平衡/孤儿与死端/覆盖率/连通性 + 启发式质量分)| `gem_quality`(model + 可选 medium;分项与 failed_checks 为准,quality_index 仅速览)|
15
16
  | 模型在目标培养基不长,想知道为什么 | `gem_gapfind`(model + medium + substrates)**;先跑 `gem_precursor_scan` 确认阻塞层次**——它报「卡在哪个前体」(可生长即返无阻塞),比缺口清单更贴近根因 |
16
17
  | gapfind 判 L3(内部路径)后自动补洞(白名单/MILP)| `gem_l3_fix`(model + medium + substrates;allow_math=true 才放数学连接;补后自动跑 G6 防能量循环)|
17
18
  | 看/改 biomass(FBA 目标函数)| `gem_biomass`(action=inspect 只读组分/对照参考;apply 显式 profile + 三联对照,原文件不动可回滚)|
@@ -20,6 +21,7 @@ language: mixed
20
21
  | 只有裸基因组 .fna,先要蛋白序列 | `gem_annotate`(fna → faa;官方优先 + pyrodigal 兜底)|
21
22
  | 需要模型的全量必需基因清单 | `gem_essentiality`(FVA 预筛 + 手工敲除;medium 推荐 AB)|
22
23
  | 跨条件通量对比(哪个反应真变了)| `gem_fluxscan`(区间制:FVA 区间+pFBA 点值,区间分离=硬结论,overlap=伪影禁止引用)|
24
+ | 通量分布采样(反应合理取值范围、稳态分布)| `gem_sample`(model + n + growth_floor_fraction;默认全空间采样,看近最优生长态传 0.9)|
23
25
  | 量化模型不确定性(biomass/GAM 扰动下预测稳不稳)| `gem_sensitivity`(22 组合网格+稳定性三分类+单组分漂移;action=probe 秒级探测)|
24
26
  | 查询/更新模型预测(必需性/表型预测追踪与实验兑现)| `gem_ledger`(list/query/update;预测默认 unverified,兑现后回填状态)|
25
27
  | 两个模型规范对比(论文级基准表)| `gem_benchmark`(model_a+model_b:六关并列/生长/biomass 断供探针/必需性对比[退化侧只报结构]/表型/账本回填;export_md 落盘)|
@@ -0,0 +1,152 @@
1
+ // dsh-bio-gem — capabilities 单源(single source of truth for tool manifest & capability metadata)
2
+ //
3
+ // 目的(外部评审共识,2026-09-21 裁决 §3.3):
4
+ // 1. 工具清单/能力分级/成本与副作用元数据集中一处,供 integration API(/v1/capabilities)
5
+ // 与 genie 宿主侧动态消费——消灭「工具数变化需同步 ≥12 处」的手工漂移。
6
+ // 2. 校验脚本 scripts/check-capabilities.mjs 强制本 MANIFEST 与 tools.js 真实注册集合一致;
7
+ // 文档计数(README/persona/面板)另有 count-audit 校验。
8
+ //
9
+ // 同步纪律:新增/删除工具时同步本文件(对应 category / capability / cost_class / network /
10
+ // mutability / summary),否则 check-capabilities 会报红。
11
+ //
12
+ // cost_class: light (<10s) | medium (10s–2min) | heavy (>2min)
13
+ // network: none | optional | required
14
+ // mutability: read_only | writes_output | model_mutating
15
+ // status: ready | experimental | data-not-initialized (依赖级状态由 integration 动态覆盖)
16
+ // requires: 动态依赖 id 列表(对齐 integration status 的 check id;缺省即 ready)
17
+
18
+ export const TOOLS_MANIFEST = [
19
+ // ---- build ----
20
+ { name: 'gem_build', capability: 'gem.build.genome-to-model', category: 'build',
21
+ cost_class: 'heavy', network: 'optional', mutability: 'writes_output', status: 'ready',
22
+ requires: ['python.cobra', 'runtime.carveme'],
23
+ summary: '基因组→GEM 构建(CarveMe 自动自举 / gapseq WSL 档);后台 job + 进度' },
24
+ { name: 'gem_annotate', capability: 'gem.build.annotate', category: 'build',
25
+ cost_class: 'medium', network: 'none', mutability: 'writes_output', status: 'ready',
26
+ summary: '基因组注释→蛋白 FASTA(官方优先 + pyrodigal 兜底,纯 Windows)' },
27
+ { name: 'gem_gapseq', capability: 'gem.build.gapseq-wsl', category: 'build',
28
+ cost_class: 'heavy', network: 'none', mutability: 'writes_output', status: 'experimental',
29
+ requires: ['runtime.gapseq'],
30
+ summary: 'gapseq 引擎原子四步(setup/launch/status/fetch,WSL2 依赖,实验性)' },
31
+
32
+ // ---- validate ----
33
+ { name: 'gem_validate', capability: 'gem.validate.six-gate', category: 'validate',
34
+ cost_class: 'medium', network: 'none', mutability: 'read_only', status: 'ready',
35
+ requires: ['python.cobra'],
36
+ summary: '六道验证关卡 G0-G6(加载/平衡/生长/表型/必需性/ATP 泄漏)' },
37
+ { name: 'gem_quality', capability: 'gem.validate.quality-report', category: 'validate',
38
+ cost_class: 'medium', network: 'none', mutability: 'read_only', status: 'ready',
39
+ requires: ['python.cobra'],
40
+ summary: '模型质量报告(gem-qi-v1:blocked/环路/平衡/孤儿/覆盖/连通性 + 启发式聚合分)' },
41
+
42
+ // ---- repair ----
43
+ { name: 'gem_gapfind', capability: 'gem.repair.gap-diagnose', category: 'repair',
44
+ cost_class: 'medium', network: 'none', mutability: 'read_only', status: 'ready',
45
+ requires: ['python.cobra'],
46
+ summary: '缺口分级诊断 L1 缺交换 / L2 缺转运 / L3 内部路径' },
47
+ { name: 'gem_gapfill', capability: 'gem.repair.gap-fill', category: 'repair',
48
+ cost_class: 'medium', network: 'none', mutability: 'writes_output', status: 'ready',
49
+ requires: ['python.cobra'],
50
+ summary: 'L1/L2 规则级自动补洞(provenance 打标 + 防过补四闸门)' },
51
+ { name: 'gem_l3_fix', capability: 'gem.repair.l3-pathway', category: 'repair',
52
+ cost_class: 'heavy', network: 'none', mutability: 'writes_output', status: 'ready',
53
+ requires: ['python.cobra'],
54
+ summary: 'L3 内部路径补洞(白名单 + BiGG 反应式移植 + 证据分级 + G6 回滚)' },
55
+ { name: 'gem_precursor_scan', capability: 'gem.repair.precursor-scan', category: 'repair',
56
+ cost_class: 'medium', network: 'none', mutability: 'read_only', status: 'ready',
57
+ requires: ['python.cobra'],
58
+ summary: '阻塞前体分析(不生长时逐前体移除测试定位阻塞点)' },
59
+ { name: 'gem_phenotype', capability: 'gem.repair.phenotype-calibrate', category: 'repair',
60
+ cost_class: 'heavy', network: 'none', mutability: 'writes_output', status: 'ready',
61
+ requires: ['python.cobra'],
62
+ summary: '表型回填迭代(Biolog/文献表校准 + L1/L2 自动修复 + 匹配率对比)' },
63
+ { name: 'gem_biomass', capability: 'gem.repair.biomass-refine', category: 'repair',
64
+ cost_class: 'heavy', network: 'none', mutability: 'writes_output', status: 'ready',
65
+ requires: ['python.cobra'],
66
+ summary: 'biomass 精修(inspect 组分对照 / apply 覆盖表 + 三联对照 + 回滚)' },
67
+
68
+ // ---- analysis ----
69
+ { name: 'gem_essentiality', capability: 'gem.analysis.essentiality', category: 'analysis',
70
+ cost_class: 'heavy', network: 'none', mutability: 'read_only', status: 'ready',
71
+ requires: ['python.cobra'],
72
+ summary: '全量必需基因扫描(FVA 预筛 + 手工敲除;带 GPR 覆盖警告)' },
73
+ { name: 'gem_fluxscan', capability: 'gem.analysis.flux-interval', category: 'analysis',
74
+ cost_class: 'heavy', network: 'none', mutability: 'read_only', status: 'ready',
75
+ requires: ['python.cobra'],
76
+ summary: '通量区间制(FVA 区间 + pFBA 点值;条件对比只认区间分离)' },
77
+ { name: 'gem_sensitivity', capability: 'gem.analysis.sensitivity', category: 'analysis',
78
+ cost_class: 'heavy', network: 'none', mutability: 'read_only', status: 'ready',
79
+ requires: ['python.cobra'],
80
+ summary: '结构性灵敏度(GAM×biomass 网格 + 稳定性三分类)' },
81
+ { name: 'gem_secretion', capability: 'gem.analysis.secretion', category: 'analysis',
82
+ cost_class: 'medium', network: 'none', mutability: 'read_only', status: 'ready',
83
+ requires: ['python.cobra'],
84
+ summary: '可分泌代谢物谱(production envelope 扫描;纯拓扑边界声明)' },
85
+ { name: 'gem_double_knockout', capability: 'gem.analysis.double-knockout', category: 'analysis',
86
+ cost_class: 'heavy', network: 'none', mutability: 'read_only', status: 'ready',
87
+ requires: ['python.cobra'],
88
+ summary: '双敲合成致死(GPR 穷尽先验 + 全扫预算)' },
89
+ { name: 'gem_enrichment', capability: 'gem.analysis.enrichment', category: 'analysis',
90
+ cost_class: 'light', network: 'none', mutability: 'read_only', status: 'ready',
91
+ requires: ['python.cobra'],
92
+ summary: '必需基因通路富集(超几何 + BH FDR;通路源 = SBML groups)' },
93
+ { name: 'gem_sample', capability: 'gem.analysis.sampling', category: 'analysis',
94
+ cost_class: 'heavy', network: 'none', mutability: 'read_only', status: 'ready',
95
+ requires: ['python.cobra'],
96
+ summary: '通量空间采样(ACHR/OptGP;growth_floor 受限空间;边界声明)' },
97
+
98
+ // ---- export ----
99
+ { name: 'gem_targets', capability: 'gem.export.targets', category: 'export',
100
+ cost_class: 'light', network: 'none', mutability: 'writes_output', status: 'ready',
101
+ summary: '靶点清单规范导出(11 字段锁定 schema;计数闭合)' },
102
+
103
+ // ---- assets ----
104
+ { name: 'gem_media_resolve', capability: 'gem.asset.medium-resolve', category: 'assets',
105
+ cost_class: 'light', network: 'none', mutability: 'read_only', status: 'ready',
106
+ requires: ['python.cobra'],
107
+ summary: '介质解析(自然名 → EX ID;跨引擎命名空间)' },
108
+ { name: 'gem_ledger', capability: 'gem.asset.ledger', category: 'assets',
109
+ cost_class: 'light', network: 'none', mutability: 'writes_output', status: 'ready',
110
+ summary: '预测账本(list/query/update;幂等;基率追踪)' },
111
+ { name: 'gem_benchmark', capability: 'gem.asset.benchmark', category: 'assets',
112
+ cost_class: 'heavy', network: 'optional', mutability: 'writes_output', status: 'ready',
113
+ requires: ['python.cobra'],
114
+ summary: '两模型基准对比(六关并列/生长/必需性/表型/账本回填;支持 bigg: 下载)' },
115
+ { name: 'gem_report', capability: 'gem.asset.report', category: 'assets',
116
+ cost_class: 'light', network: 'none', mutability: 'read_only', status: 'ready',
117
+ requires: ['python.cobra'],
118
+ summary: '模型摘要 + 账本基率' },
119
+ ]
120
+
121
+ export const CONTRACT_VERSION = '1'
122
+
123
+ /** 组装 capabilities 报告(静态 manifest + 动态依赖状态覆盖)。 */
124
+ export function buildCapabilitiesReport({ pluginVersion, checks = [] } = {}) {
125
+ const checkStatus = new Map(checks.map((c) => [c.id, c.status]))
126
+ const tools = TOOLS_MANIFEST.map((t) => {
127
+ const missingDeps = (t.requires ?? []).filter((id) => {
128
+ const st = checkStatus.get(id)
129
+ return st !== undefined && st !== 'ok'
130
+ })
131
+ const effectiveStatus = missingDeps.length > 0 ? 'unavailable' : (t.status ?? 'ready')
132
+ return {
133
+ name: t.name,
134
+ capability: t.capability,
135
+ category: t.category,
136
+ cost_class: t.cost_class,
137
+ network: t.network,
138
+ mutability: t.mutability,
139
+ status: effectiveStatus,
140
+ summary: t.summary,
141
+ ...(t.requires ? { requires: t.requires } : {}),
142
+ ...(missingDeps.length > 0 ? { missing_dependencies: missingDeps } : {}),
143
+ }
144
+ })
145
+ return {
146
+ contract_version: CONTRACT_VERSION,
147
+ plugin_id: 'dsh-bio-gem',
148
+ plugin_version: pluginVersion,
149
+ tool_count: tools.length,
150
+ tools,
151
+ }
152
+ }
package/src/index.js CHANGED
@@ -15,7 +15,7 @@ export const name = 'dsh-bio-gem'
15
15
  *
16
16
  * `webServer` 是**可选**服务(非 web 部署不提供),改用 apply 内的动态注入
17
17
  * `ctx.inject(['webServer'], cb)`(官方 dsh 插件同款模式):服务可用时注册
18
- * 只读 integration 路由,不可用时 21 个工具与 skill 照常注册。
18
+ * 只读 integration 路由,不可用时 23 个工具与 skill 照常注册。
19
19
  */
20
20
  export const inject = ['tools', 'skills']
21
21