@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,76 @@
1
+ # DECISIONS — 2026-09-21 升级批次(外部评审裁决后 Phase 1)
2
+
3
+ > 本批决策依据:`D:\Program\dsh-plug-develop\21-gem升级-裁决与实施方案.md`(GPT/DS 双评审 + 本机 8 项实测核验)。
4
+ > 记录本批次的**语义演进与架构决策**,供下游与后续批次参考。
5
+
6
+ ## D1. growth 单位演进:mmol/gDW/h → 1/h(数值不变)
7
+
8
+ **决策**:growth / growth_rate / wt_growth / before-after 生长 等**比生长量**字段的单位标注由
9
+ `mmol/gDW/h` 改为 `1/h`;反应通量类保持 `mmol/gDW/h`。
10
+
11
+ **依据**(实测 + 社区约定):
12
+ - C58 的 bio1(Bacterial Gram-negative biomass reaction)产物系数 = 1.0 —— biomass 反应已按
13
+ **1 gDW 归一化**;此时其通量数值 = 比生长速率 μ(h⁻¹),这是 COBRA 社区的标准解读
14
+ (biomass flux through a normalized biomass reaction equals the specific growth rate)。
15
+ - 外部评审(GPT)指出原标注 `mmol/gDW/h` 对 biomass 反应在语义上不准确 → 列为 P0。
16
+ - straindesign 官方文档同样以 `growth rates above 0.5/h` 表述(旁证)。
17
+
18
+ **边界**:对**未归一化**的 biomass 反应(产物系数 ≠ 1)growth 应以 mmol/gDW/h 解读——见
19
+ validate 的 units.note 表述。数值本身在任何情况下不变,本决策只纠标注。
20
+
21
+ **影响面**:python/ 12 个模块(23 处)+ src/tools.js(16 处口径文案统一)+ 本文档;
22
+ `model_card.GROWTH_UNITS` 同步演进(旧卡兼容:growth_units 字段照读,数值口径不变)。
23
+
24
+ ## D2. 能力单源(capabilities.json → /v1/capabilities → 宿主动态消费)
25
+
26
+ **决策**:工具清单与能力元数据(cost_class / network / mutability / requires)以
27
+ `src/capabilities.js` 的 `TOOLS_MANIFEST` 为**唯一事实源**:
28
+ - integration API 新增 `GET /v1/capabilities`(features 声明 `capabilities`);
29
+ - 宿主 dsh-bio-genie 的 `handleDomainRequest` 在对方声明该 feature 时拉取并透传
30
+ (失败静默降级到静态清单 `GEM_TOOLS`——它已降级为 fallback 视图);
31
+ - 机器门:`test/check-capabilities.mjs`(manifest ↔ 真实注册一致)+
32
+ `test/check-counts.mjs`(文档计数 ↔ manifest 一致)。
33
+
34
+ **背景**:原「工具数变化需同步 ≥12 处」是两评审共同指出的工程债;本决策把它变成
35
+ 「单源 + 两道机器门」,文档数字仍手写但有门兜底。
36
+
37
+ ## D3. CarveMe 零手动部署(bootstrap_carveme.py)
38
+
39
+ **决策**:`gem_build(engine=carveme)` 首次调用自动完成运行时部署(幂等):
40
+ uv venv(uv 探测链:`GEM_UV` → genie 自举 uv → PATH)→ `uv pip install carveme` →
41
+ 下载 GitHub 官方 diamond 二进制(**固定 v2.2.8**,3.4MB)→ deep smoke → manifest 记录。
42
+
43
+ **关键设计**:
44
+ - **快速路径**:`carve.exe + diamond.exe + manifest.json` 齐备时秒过(实测 0.34s,不做子进程冒烟);
45
+ - 部署与复验才做 deep smoke(实测全量部署 45s:venv 31s + diamond 下载数秒 + 冒烟);
46
+ - 下载通道:直连 → 环境代理(HTTPS_PROXY/HTTP_PROXY)→ 失败给可执行指引(含手动放置路径);
47
+ - 这就是 P0「装完插件 ≠ 构建可用」的根治:契约要求用户零手动安装。
48
+
49
+ ## D4. gem_sample 的边界设计(全空间 vs 受限)
50
+
51
+ **决策**:默认采样**全 feasible space**(ACHR),但 `boundary` 字段**强制输出**边界声明;
52
+ 近最优生长状态必须显式传 `growth_floor_fraction`(如 0.9)。
53
+
54
+ **依据**(本机实测):C58 全空间采样 bio1 max ≈ 0.017 vs FBA 最优 0.7134(差 2 个数量级)——
55
+ 全空间均匀分布 ≠ 生物学上有意义的活跃状态;不声明边界就是「做了但没用」的典型。
56
+
57
+ **Windows 约束**:默认 ACHR(无多进程依赖,实测 init ~172s、采样秒级);OptGP 在 Windows
58
+ 下实验性——不可用时**显式安全拒绝**(不触发多进程陷阱)。
59
+
60
+ ## D5. quality_index(gem-qi-v1)不是 MEMOTE 分数
61
+
62
+ **决策**:`gem_quality` 输出 `quality_index`(0-100 启发式聚合)+ 分项 raw_metrics +
63
+ failed_checks + not_assessable_checks;**禁止**作为单一质量结论引用(notes 内置声明)。
64
+
65
+ **依据**:两评审共识——MEMOTE 的价值在其可分解测试体系;聚合分容易被误用。
66
+ 评分规则(写死在 `python/quality.py` 常量):见 WEIGHTS / 阈值常量与 note 输出。
67
+ **不安装 MEMOTE 整体**(PyPI 0.17.0 仅声明兼容到 Py3.11;本机 Py3.13 风险)。
68
+
69
+ ## D6. 本批新增工具(2)
70
+
71
+ | 工具 | capability | cost_class | 说明 |
72
+ |---|---|---|---|
73
+ | `gem_quality` | `gem.validate.quality-report` | medium | 质量审计(blocked/环路/平衡/孤儿/覆盖/连通性)|
74
+ | `gem_sample` | `gem.analysis.sampling` | heavy | 通量空间采样(ACHR/OptGP;边界声明)|
75
+
76
+ 工具数 21 → 23;op 数 21 → 23。两门(check-capabilities / check-counts)同步更新。
package/package.json CHANGED
@@ -1,52 +1,66 @@
1
- {
2
- "name": "@dsh-bio/dsh-bio-gem",
3
- "version": "0.1.12",
4
- "description": "基因组尺度代谢模型(GEM)构建插件:输入细菌全基因组(蛋白FASTA,支持多质粒/多染色体),自动构建+验证+补洞+出报告(SBML + 模型卡),供 dsh-bio-genie 消费工具加载使用 | Genome-scale metabolic model builder for dsh",
5
- "repository": {
6
- "type": "git",
7
- "url": "https://github.com/moonbowterfly/dsh-bio-gem.git"
8
- },
9
- "homepage": "https://github.com/moonbowterfly/dsh-bio-gem",
10
- "bugs": {
11
- "url": "https://github.com/moonbowterfly/dsh-bio-gem/issues"
12
- },
13
- "publishConfig": {
14
- "access": "public"
15
- },
16
- "type": "module",
17
- "main": "index.js",
18
- "exports": {
19
- ".": "./index.js",
20
- "./cordis.patch.yml": "./cordis.patch.yml",
21
- "./package.json": "./package.json"
22
- },
23
- "files": [
24
- "index.js",
25
- "cordis.patch.yml",
26
- "src/**/*.js",
27
- "python/**/*.py",
28
- "skills/**/*.md",
29
- "docs/**/*.md",
30
- "README.md",
31
- "LICENSE"
32
- ],
33
- "keywords": ["dsh", "deepseek-harness", "dsh-plugin", "metabolic-model", "GEM", "SBML", "carveme", "cobra", "bioinformatics"],
34
- "license": "MIT",
35
- "engines": { "node": "^22.19 || >=24" },
36
- "dsh": {
37
- "bundle": {
38
- "patch": "./cordis.patch.yml"
39
- }
40
- },
41
- "scripts": {
42
- "smoke": "node test/smoke.js",
43
- "test": "node test/smoke.js --skip-build && node test/integration.js && node --import ./test/register-dsh-tools.mjs test/optional-injection.js",
44
- "test:full": "node test/smoke.js && node test/integration.js && node --import ./test/register-dsh-tools.mjs test/optional-injection.js"
45
- },
46
- "peerDependencies": {
47
- "@deepseek-ai/dsh-tools": "*"
48
- },
49
- "peerDependenciesMeta": {
50
- "@deepseek-ai/dsh-tools": { "optional": true }
51
- }
52
- }
1
+ {
2
+ "name": "@dsh-bio/dsh-bio-gem",
3
+ "version": "0.1.14",
4
+ "description": "基因组尺度代谢模型(GEM)构建插件:输入细菌全基因组(蛋白FASTA,支持多质粒/多染色体),自动构建+验证+补洞+出报告(SBML + 模型卡),供 dsh-bio-genie 消费工具加载使用 | Genome-scale metabolic model builder for dsh",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/moonbowterfly/dsh-bio-gem.git"
8
+ },
9
+ "homepage": "https://github.com/moonbowterfly/dsh-bio-gem",
10
+ "bugs": {
11
+ "url": "https://github.com/moonbowterfly/dsh-bio-gem/issues"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "type": "module",
17
+ "main": "index.js",
18
+ "exports": {
19
+ ".": "./index.js",
20
+ "./cordis.patch.yml": "./cordis.patch.yml",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "index.js",
25
+ "cordis.patch.yml",
26
+ "src/**/*.js",
27
+ "python/**/*.py",
28
+ "skills/**/*.md",
29
+ "docs/**/*.md",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "keywords": [
34
+ "dsh",
35
+ "deepseek-harness",
36
+ "dsh-plugin",
37
+ "metabolic-model",
38
+ "GEM",
39
+ "SBML",
40
+ "carveme",
41
+ "cobra",
42
+ "bioinformatics"
43
+ ],
44
+ "license": "MIT",
45
+ "engines": {
46
+ "node": "^22.19 || >=24"
47
+ },
48
+ "dsh": {
49
+ "bundle": {
50
+ "patch": "./cordis.patch.yml"
51
+ }
52
+ },
53
+ "scripts": {
54
+ "smoke": "node test/smoke.js",
55
+ "test": "node test/check-version-source.mjs && node test/smoke.js --skip-build && node test/integration.js && node --import ./test/register-dsh-tools.mjs test/optional-injection.js && node --import ./test/register-dsh-tools.mjs test/check-capabilities.mjs && node test/check-counts.mjs",
56
+ "test:full": "node test/smoke.js && node test/integration.js && node --import ./test/register-dsh-tools.mjs test/optional-injection.js && node --import ./test/register-dsh-tools.mjs test/check-capabilities.mjs && node test/check-counts.mjs"
57
+ },
58
+ "peerDependencies": {
59
+ "@deepseek-ai/dsh-tools": "*"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "@deepseek-ai/dsh-tools": {
63
+ "optional": true
64
+ }
65
+ }
66
+ }
@@ -19,7 +19,7 @@ from sensitivity import find_biomass_gam
19
19
  from validate import Validator
20
20
 
21
21
  EPS = 1e-6
22
- UNITS_NOTE = "growth=mmol/gDW/h;必需判定=敲除生长<1e-6"
22
+ UNITS_NOTE = "growth=1/h(比生长速率;biomass 归一化口径,数值 = μ);必需判定=敲除生长<1e-6"
23
23
  DEG_MSG = "wt<=EPS:必需性判定恒真(v=0 使全部候选判'必需'),essential 集无生物学意义"
24
24
 
25
25
 
@@ -317,7 +317,7 @@ def write_md(path, out):
317
317
  L.append("\n## 3. 生长(声明介质,单点 FBA 口径)\n")
318
318
  for tag, name in (("a", "A"), ("b", "B")):
319
319
  g = out["growth"][tag]
320
- L.append(f"- **{name}**: growth=**{g['growth']}** mmol/gDW/h, resolved={g['resolved_exchanges']}, "
320
+ L.append(f"- **{name}**: growth=**{g['growth']}** 1/h, resolved={g['resolved_exchanges']}, "
321
321
  f"boundary_style={g['boundary_style']}, unresolved={g['unresolved']}")
322
322
  if g.get("resolved_display"):
323
323
  L.append(f" - resolved_display: {g['resolved_display']}")
@@ -492,7 +492,7 @@ def benchmark(model_a, model_b, medium=None, phenotype_table=None, reference_ess
492
492
  for tag, path in (("a", model_a), ("b", model_b)):
493
493
  wt, resolved, unresolved, preset, bstyle = growth_on(path, medium)
494
494
  entry = {"growth": wt, "resolved_exchanges": len(resolved), "unresolved": unresolved,
495
- "medium_preset": preset, "boundary_style": bstyle, "units": "mmol/gDW/h",
495
+ "medium_preset": preset, "boundary_style": bstyle, "units": "1/h",
496
496
  "point_value_note": "单点 FBA 值,非解空间硬结论;条件间对比用 gem_fluxscan(区间制)"}
497
497
  if bstyle:
498
498
  entry["resolved_display"] = [ex_display_name(silent_read_sbml(path), rid)
@@ -4,7 +4,7 @@
4
4
  # apply(显式): biomass_profile 覆盖表(op=set|add|remove)→ 副本替换 biomass → 强制 G1-G6 重验
5
5
  # + 三联对照(生长/表型/必需基因 delta)→ model_lineage 追加(有 card 时)
6
6
  # 原则: 默认不应用任何 profile;生长变差 WARN 不阻塞;C58 CarveMe AB 0.624 锚点保护(delta 如实报告)。
7
- # units: growth 一律 mmol/gDW/h。
7
+ # units: growth 一律 1/h(比生长速率;biomass 反应 gDW 归一化口径,数值 = μ)。
8
8
  import os
9
9
  import re
10
10
  import sys
@@ -18,7 +18,7 @@ import cobra
18
18
  EX_PREFIX = ("EX_", "DM_", "SK_")
19
19
  DEFAULT_UNIVERSAL = r"D:\Program\hermes\temp\gem_universal\iML1515.xml"
20
20
  DEFAULT_INX = r"F:\A_NGJ plan\Zcode\models\iNX1344_v4.xml"
21
- GROWTH_UNITS = "mmol/gDW/h"
21
+ GROWTH_UNITS = "1/h"
22
22
  EPS = 1e-6
23
23
 
24
24
  # 分类词表(name 规约匹配为主,id 规约为辅;覆盖 BiGG/ModelSEED/MetaCyc 常见命名)
@@ -0,0 +1,259 @@
1
+ # bootstrap_carveme.py — CarveMe 运行时「零手动部署」(探测 → uv venv → carveme → diamond → 冒烟 → manifest)
2
+ #
3
+ # 产品原则:用户零手动安装。gem_build(engine=carveme) 首次调用时自动补齐运行时:
4
+ # 1. 探测现有 venv(carve.exe + diamond.exe + 冒烟通过)→ 就绪则秒回
5
+ # 2. 缺则部署:uv venv(uv 探测链见下)→ uv pip install carveme → 下载 diamond 官方二进制 → 冒烟
6
+ # 3. 全过程写进度事件(progress.jsonl),失败给「可执行指引」而不是裸异常
7
+ #
8
+ # uv 探测链:GEM_UV env → genie 自举 uv(~/.dsh/dsh-bio-genie/bin/uv.exe)→ PATH uv
9
+ # diamond 来源:GitHub release 固定版本(v2.2.8, diamond-windows.zip, 3.4MB 纯二进制)
10
+ # 下载策略:直连 → 环境变量代理(HTTPS_PROXY/HTTP_PROXY)→ 失败给指引
11
+ #
12
+ # 幂等性:重复调用不重复部署;diamond 单独缺失时只补 diamond;manifest.json 记录版本与来源
13
+ import json
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ import urllib.request
20
+ import zipfile
21
+
22
+ DEFAULT_CARVE_VENV = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem", "venv-carveme")
23
+ GEM_ROOT = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem")
24
+ DIAMOND_VERSION = "v2.2.8"
25
+ DIAMOND_URL = f"https://github.com/bbuchfink/diamond/releases/download/{DIAMOND_VERSION}/diamond-windows.zip"
26
+ GENIE_UV = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-genie", "bin", "uv.exe")
27
+
28
+ VERIFY_TIMEOUT = 60
29
+ DOWNLOAD_TIMEOUT = 300
30
+
31
+
32
+ def _log(progress_path, event):
33
+ """进度事件(与 build.py 同格式;progress_path 为 None 时静默)。"""
34
+ if not progress_path:
35
+ return
36
+ try:
37
+ ev = {"ts": time.time(), **event}
38
+ with open(progress_path, "a", encoding="utf-8") as f:
39
+ f.write(json.dumps(ev, ensure_ascii=False) + "\n")
40
+ except Exception:
41
+ pass
42
+
43
+
44
+ def _run(cmd, timeout=VERIFY_TIMEOUT, env=None):
45
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout,
46
+ env=env, encoding="utf-8", errors="replace")
47
+
48
+
49
+ def find_uv():
50
+ """uv 探测链:GEM_UV → genie 自举 → PATH。返回 (uv_path, source) 或 (None, None)。"""
51
+ env_uv = os.environ.get("GEM_UV")
52
+ if env_uv and os.path.exists(env_uv):
53
+ return env_uv, "env:GEM_UV"
54
+ if os.path.exists(GENIE_UV):
55
+ return GENIE_UV, "genie-bootstrap"
56
+ which = shutil.which("uv")
57
+ if which:
58
+ return which, "PATH"
59
+ return None, None
60
+
61
+
62
+ def _carve_exe(venv=None):
63
+ return os.path.join(venv or DEFAULT_CARVE_VENV, "Scripts", "carve.exe")
64
+
65
+
66
+ def _diamond_exe(venv=None):
67
+ return os.path.join(venv or DEFAULT_CARVE_VENV, "Scripts", "diamond.exe")
68
+
69
+
70
+ def _probe(venv=None, deep=False):
71
+ """就绪探测。
72
+ deep=False(默认):文件存在 + manifest 信任 —— 秒级(gem_build 每次调用的日常路径);
73
+ deep=True:额外跑 diamond/carve 冒烟(部署完成后复验一次用)。
74
+ 返回 (ok, detail)。"""
75
+ carve, diamond = _carve_exe(venv), _diamond_exe(venv)
76
+ if not os.path.exists(carve):
77
+ return False, {"missing": "carve.exe"}
78
+ if not os.path.exists(diamond):
79
+ return False, {"missing": "diamond.exe"}
80
+ if not deep and _read_manifest(venv):
81
+ return True, {"mode": "fast-manifest"}
82
+ try:
83
+ r1 = _run([diamond, "--version"])
84
+ if r1.returncode != 0:
85
+ return False, {"diamond_broken": (r1.stderr or r1.stdout or "")[:200]}
86
+ # carve 无 --version:用 -h 验证可启动(exit 0)
87
+ r2 = _run([carve, "-h"])
88
+ if r2.returncode not in (0, 1): # argparse -h 正常为 0;某些版本非 0
89
+ return False, {"carve_broken": (r2.stderr or r2.stdout or "")[:200]}
90
+ return True, {"diamond": (r1.stdout or "").strip()[:80], "mode": "deep-smoke"}
91
+ except Exception as e:
92
+ return False, {"probe_error": f"{type(e).__name__}: {e}"}
93
+
94
+
95
+ def _download(url, dest, progress_path=None):
96
+ """下载(直连 → 环境代理 → 抛错含指引)。返回使用的通道。"""
97
+ headers = {"User-Agent": "dsh-bio-gem-bootstrap/1.0"}
98
+
99
+ def _get(opener=None):
100
+ req = urllib.request.Request(url, headers=headers)
101
+ op = opener.open(req, timeout=DOWNLOAD_TIMEOUT) if opener else urllib.request.urlopen(req, timeout=DOWNLOAD_TIMEOUT)
102
+ with op as resp, open(dest, "wb") as f:
103
+ shutil.copyfileobj(resp, f)
104
+
105
+ try:
106
+ _get()
107
+ return "direct"
108
+ except Exception as e1:
109
+ _log(progress_path, {"event": "diamond_direct_failed", "err": str(e1)[:200]})
110
+ proxy = (os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")
111
+ or os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy"))
112
+ if proxy:
113
+ try:
114
+ opener = urllib.request.build_opener(
115
+ urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
116
+ _get(opener)
117
+ return f"proxy:{proxy}"
118
+ except Exception as e2:
119
+ _log(progress_path, {"event": "diamond_proxy_failed", "err": str(e2)[:200]})
120
+ raise RuntimeError(
121
+ f"无法下载 diamond({url})。可执行的手动方案:\n"
122
+ f" 1) 设置代理后重试:set HTTPS_PROXY=http://127.0.0.1:端口\n"
123
+ f" 2) 或手动下载 diamond-windows.zip,把解压出的 diamond.exe 放到:\n"
124
+ f" {os.path.join(DEFAULT_CARVE_VENV, 'Scripts')}")
125
+
126
+
127
+ def ensure_carveme(venv=None, progress_path=None, force_diamond=False):
128
+ """确保 CarveMe 运行时可用(幂等)。返回 dict:
129
+ {ready, action: already|deployed|repaired|failed, carve, diamond, version, note?, error?}
130
+ """
131
+ venv = venv or DEFAULT_CARVE_VENV
132
+ ok, detail = _probe(venv)
133
+ if ok and not force_diamond:
134
+ return {"ready": True, "action": "already", "carve": _carve_exe(venv),
135
+ "diamond": _diamond_exe(venv), "note": detail.get("diamond", "")}
136
+
137
+ _log(progress_path, {"event": "bootstrap_start", "venv": venv, "probe": detail})
138
+ os.makedirs(os.path.dirname(venv), exist_ok=True)
139
+
140
+ uv, uv_src = find_uv()
141
+ if not uv:
142
+ return {"ready": False, "action": "failed", "error":
143
+ "未找到 uv(探测链:GEM_UV env → ~/.dsh/dsh-bio-genie/bin/uv.exe → PATH)。"
144
+ "请安装 uv(https://docs.astral.sh/uv/)或安装宿主插件 dsh-bio-genie 以复用其自举 uv。"}
145
+
146
+ # ---- 1) venv + carveme(carve.exe 缺失或损坏时) ----
147
+ need_env = not os.path.exists(_carve_exe(venv))
148
+ if need_env:
149
+ _log(progress_path, {"event": "uv_venv", "uv": uv, "uv_source": uv_src})
150
+ try:
151
+ r = _run([uv, "venv", venv, "--python", "3.13"], timeout=300)
152
+ if r.returncode != 0 or not os.path.exists(os.path.join(venv, "Scripts", "python.exe")):
153
+ return {"ready": False, "action": "failed",
154
+ "error": f"uv venv 创建失败 rc={r.returncode}: {(r.stderr or '')[-300:]}"}
155
+ except Exception as e:
156
+ return {"ready": False, "action": "failed", "error": f"uv venv 异常: {e}"}
157
+
158
+ py = os.path.join(venv, "Scripts", "python.exe")
159
+ _log(progress_path, {"event": "uv_pip_carveme", "detail": "uv pip install carveme"})
160
+ try:
161
+ r = _run([uv, "pip", "install", "--python", py, "carveme"], timeout=900)
162
+ if r.returncode != 0:
163
+ return {"ready": False, "action": "failed",
164
+ "error": f"carveme 安装失败 rc={r.returncode}: {(r.stderr or '')[-400:]}"}
165
+ except Exception as e:
166
+ return {"ready": False, "action": "failed", "error": f"carveme 安装异常: {e}"}
167
+
168
+ # ---- 2) diamond(缺失/损坏时补) ----
169
+ if not os.path.exists(_diamond_exe(venv)) or force_diamond:
170
+ scripts_dir = os.path.join(venv, "Scripts")
171
+ os.makedirs(scripts_dir, exist_ok=True)
172
+ tmp_zip = os.path.join(venv, "diamond-download.zip")
173
+ _log(progress_path, {"event": "diamond_download", "url": DIAMOND_URL})
174
+ try:
175
+ channel = _download(DIAMOND_URL, tmp_zip, progress_path)
176
+ except RuntimeError as e:
177
+ return {"ready": False, "action": "failed", "error": str(e)}
178
+ try:
179
+ with zipfile.ZipFile(tmp_zip) as z:
180
+ names = z.namelist()
181
+ if "diamond.exe" not in names:
182
+ return {"ready": False, "action": "failed",
183
+ "error": f"diamond zip 内容异常: {names}"}
184
+ z.extract("diamond.exe", scripts_dir)
185
+ except Exception as e:
186
+ return {"ready": False, "action": "failed", "error": f"diamond 解压失败: {e}"}
187
+ finally:
188
+ try:
189
+ os.remove(tmp_zip)
190
+ except OSError:
191
+ pass
192
+ _log(progress_path, {"event": "diamond_ready", "channel": channel})
193
+
194
+ # ---- 3) 冒烟复验(deep:真跑 diamond/carve 一次) ----
195
+ ok2, detail2 = _probe(venv, deep=True)
196
+ version = _read_carve_version(venv)
197
+ manifest = {
198
+ "carve_version": version,
199
+ "diamond_version": DIAMOND_VERSION,
200
+ "diamond_url": DIAMOND_URL,
201
+ "uv_source": uv_src,
202
+ "installed_at": time.strftime("%Y-%m-%d %H:%M:%S"),
203
+ }
204
+ try:
205
+ with open(os.path.join(venv, "manifest.json"), "w", encoding="utf-8") as f:
206
+ json.dump(manifest, f, ensure_ascii=False, indent=1)
207
+ except OSError:
208
+ pass
209
+
210
+ if not ok2:
211
+ return {"ready": False, "action": "failed",
212
+ "error": f"部署后冒烟未通过: {detail2}"}
213
+ action = "deployed" if need_env else "repaired"
214
+ _log(progress_path, {"event": "bootstrap_done", "action": action, "manifest": manifest})
215
+ return {"ready": True, "action": action, "carve": _carve_exe(venv),
216
+ "diamond": _diamond_exe(venv), "manifest": manifest}
217
+
218
+
219
+ def _read_carve_version(venv=None):
220
+ py = os.path.join(venv or DEFAULT_CARVE_VENV, "Scripts", "python.exe")
221
+ if not os.path.exists(py):
222
+ return None
223
+ try:
224
+ r = _run([py, "-c",
225
+ "import importlib.metadata as im; print(im.version('carveme'))"])
226
+ if r.returncode == 0:
227
+ return (r.stdout or "").strip()
228
+ except Exception:
229
+ pass
230
+ return None
231
+
232
+
233
+ def carveme_status(venv=None):
234
+ """只读状态(供诊断/工具面板)。"""
235
+ ok, detail = _probe(venv)
236
+ return {"ready": ok, "venv": venv or DEFAULT_CARVE_VENV,
237
+ "carve": _carve_exe(venv), "diamond": _diamond_exe(venv),
238
+ "detail": detail, "manifest": _read_manifest(venv),
239
+ "uv": find_uv()[1]}
240
+
241
+
242
+ def _read_manifest(venv=None):
243
+ p = os.path.join(venv or DEFAULT_CARVE_VENV, "manifest.json")
244
+ try:
245
+ with open(p, encoding="utf-8") as f:
246
+ return json.load(f)
247
+ except Exception:
248
+ return None
249
+
250
+
251
+ if __name__ == "__main__":
252
+ # CLI:python bootstrap_carveme.py [status|ensure]
253
+ action = sys.argv[1] if len(sys.argv) > 1 else "ensure"
254
+ if action == "status":
255
+ print(json.dumps(carveme_status(), ensure_ascii=False, indent=1))
256
+ else:
257
+ out = ensure_carveme()
258
+ print(json.dumps(out, ensure_ascii=False, indent=1))
259
+ sys.exit(0 if out.get("ready") else 1)
package/python/build.py CHANGED
@@ -138,6 +138,14 @@ def build(input_spec, name=None, medium=None, venv=None, out_dir=None, progress_
138
138
  os.makedirs(out_dir, exist_ok=True)
139
139
  out_xml = os.path.join(out_dir, name + ".xml")
140
140
 
141
+ # 0) 运行时自举(零手动安装):carve + diamond 缺失时自动部署(幂等,就绪时秒过)
142
+ from bootstrap_carveme import ensure_carveme
143
+ boot = ensure_carveme(venv=venv, progress_path=progress_path)
144
+ if not boot.get("ready"):
145
+ _log(progress_path, {"event": "bootstrap_failed", "error": boot.get("error")})
146
+ raise RuntimeError("CarveMe 运行时不可用:" + str(boot.get("error")))
147
+ _log(progress_path, {"event": "bootstrap_ok", "action": boot.get("action")})
148
+
141
149
  # 1) carve(自带 M9 gapfill,CarveMe 原生最小培养基)
142
150
  st = time.time()
143
151
  if not (os.path.exists(out_xml) and os.path.getmtime(out_xml) > os.path.getmtime(proteins)):
@@ -195,7 +203,7 @@ def build(input_spec, name=None, medium=None, venv=None, out_dir=None, progress_
195
203
  # 4) 模型卡(schema v2 起步:supported_mediums 由验证结果得出)
196
204
  supported = [
197
205
  {"medium_name": "M9", "ex_reactions": sorted(med_m9),
198
- "growth_rate": g3_m9.get("growth_medium"), "units": "mmol/gDW/h",
206
+ "growth_rate": g3_m9.get("growth_medium"), "units": "1/h",
199
207
  "validation_status": "verified_G3" if g3_m9.get("status") == "PASS" else "unverified"},
200
208
  ]
201
209
  if target and target.get("g3") == "PASS":
@@ -204,7 +212,7 @@ def build(input_spec, name=None, medium=None, venv=None, out_dir=None, progress_
204
212
  tname = medium.get("medium_name") or "custom"
205
213
  supported.append({
206
214
  "medium_name": tname, "ex_reactions": target.get("resolved_exchanges"),
207
- "growth_rate": target.get("growth"), "units": "mmol/gDW/h",
215
+ "growth_rate": target.get("growth"), "units": "1/h",
208
216
  "validation_status": "verified_G3_G4" if target.get("gapfixes_applied", 0) == 0 else "verified_G3_only",
209
217
  })
210
218
  # 模型卡(schema v2:init_card 统一基座 —— lineage v0.1.0 起始 + changelog=[build])
@@ -63,7 +63,7 @@ def double_knockout(model_path, medium=None, max_pairs=5000, export_csv=None,
63
63
  log(f"[dk] {model_path} medium={medium} wt={wt} max_pairs={max_pairs}")
64
64
 
65
65
  out = {"model": model_path, "medium": medium, "medium_preset": preset,
66
- "wt_growth": wt, "units": "mmol/gDW/h", "assumption_note": ASSUMPTION_NOTE,
66
+ "wt_growth": wt, "units": "1/h", "assumption_note": ASSUMPTION_NOTE,
67
67
  "max_pairs": max_pairs, "eps": EPS,
68
68
  "degenerate": wt <= EPS}
69
69
  if out["degenerate"]:
@@ -151,7 +151,7 @@ def essential_scan(model_path, medium=None, gene_subset=None, progress=None, led
151
151
  "medium_unresolved": unresolved,
152
152
  "note": "必需判定=A 培养基下敲除生长<1e-6;evidence 分级按基因支撑反应是否含 EVIDENCE_math(Q2)",
153
153
  # 阶段A-M4 口径声明(只增):wt_growth 为单点 FBA 值
154
- "units": "mmol/gDW/h",
154
+ "units": "1/h",
155
155
  "point_value_note": "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定",
156
156
  })
157
157
  # 模型卡 schema v2 回写(产物旁已有 card 才写;无卡不凭空造卡)
package/python/gem_ops.py CHANGED
@@ -309,6 +309,48 @@ def op_sensitivity(args):
309
309
  export_csv=args.get("export_csv"), baseline_check=baseline_check)}
310
310
 
311
311
 
312
+ # ---------------------------------------------------------------------------
313
+ # op: quality — gem-qi-v1 模型质量摘要(只读)
314
+ # ---------------------------------------------------------------------------
315
+ def op_quality(args):
316
+ from quality import quality_report
317
+ model = args.get("model")
318
+ if not model or not os.path.exists(model):
319
+ return {"ok": False, "error": f"model file not found: {model}"}
320
+ try:
321
+ result = quality_report(
322
+ model, medium=args.get("medium"), checks=args.get("checks"),
323
+ export_csv=args.get("export_csv"))
324
+ except (ValueError, OSError) as e:
325
+ return {"ok": False, "error": str(e)}
326
+ return {"ok": True, "result": result}
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # op: sample — COBRA 通量空间采样(默认 Windows-safe ACHR)
331
+ # ---------------------------------------------------------------------------
332
+ def op_sample(args):
333
+ from sampling import sample_fluxes
334
+ model = args.get("model")
335
+ if not model or not os.path.exists(model):
336
+ return {"ok": False, "error": f"model file not found: {model}"}
337
+ try:
338
+ result = sample_fluxes(
339
+ model,
340
+ medium=args.get("medium"),
341
+ n=args.get("n", 1000),
342
+ method=args.get("method", "auto"),
343
+ thinning=args.get("thinning", 100),
344
+ growth_floor_fraction=args.get("growth_floor_fraction"),
345
+ reactions=args.get("reactions"),
346
+ seed=args.get("seed", 42),
347
+ export_csv=args.get("export_csv"),
348
+ )
349
+ except (ValueError, OSError) as e:
350
+ return {"ok": False, "error": str(e)}
351
+ return {"ok": True, "result": result}
352
+
353
+
312
354
  # ---------------------------------------------------------------------------
313
355
  # 分发器
314
356
  # ---------------------------------------------------------------------------
@@ -323,6 +365,8 @@ OPS = {
323
365
  "annotate": op_annotate,
324
366
  "media_resolve": op_media_resolve,
325
367
  "l3_fix": op_l3_fix,
368
+ "quality": op_quality,
369
+ "sample": op_sample,
326
370
  }
327
371
 
328
372
 
@@ -536,4 +580,4 @@ def main():
536
580
 
537
581
 
538
582
  if __name__ == "__main__":
539
- main()
583
+ main()
package/python/l3_fix.py CHANGED
@@ -417,7 +417,7 @@ def l3_fix(model_path, medium=None, substrates=None, out=None,
417
417
  if exid and exid in m.reactions and g < 1e-6:
418
418
  l3.append({"substrate": sub, "exchange": exid, "growth_sole_before": round(g, 6),
419
419
  # 阶段A-M4 口径声明(只增)
420
- "units": "mmol/gDW/h",
420
+ "units": "1/h",
421
421
  "point_value_note": "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"})
422
422
 
423
423
  # 第五闸门(入口预检: 每底物至少 1 条新增预估)
@@ -493,7 +493,7 @@ def l3_fix(model_path, medium=None, substrates=None, out=None,
493
493
  "evidence": "EVIDENCE_math"})
494
494
  growth_a = _growth_sole(cur, resolved_med, exid)
495
495
  l3a["growth_sole_after"] = round(growth_a, 6)
496
- l3a["units"] = "mmol/gDW/h"
496
+ l3a["units"] = "1/h"
497
497
  l3a["point_value_note"] = "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"
498
498
  entry["l3a"] = l3a
499
499
 
@@ -556,12 +556,12 @@ def l3_fix(model_path, medium=None, substrates=None, out=None,
556
556
  _note(f"[l3b] {sub}: picked {len(picked)}, added {len(added_here)}")
557
557
  growth_b = _growth_sole(cur, resolved_med, exid)
558
558
  l3b["growth_sole_after"] = round(growth_b, 6)
559
- l3b["units"] = "mmol/gDW/h"
559
+ l3b["units"] = "1/h"
560
560
  l3b["point_value_note"] = "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"
561
561
  l3b["added"] = added_here
562
562
  entry["l3b"] = l3b
563
563
  entry["growth_sole_after"] = round(max(growth_a, growth_b), 6)
564
- entry["units"] = "mmol/gDW/h"
564
+ entry["units"] = "1/h"
565
565
  entry["point_value_note"] = "单点 FBA 值,非解空间硬结论;条件对比请用 gem_fluxscan 区间分离判定"
566
566
  entry["verdict"] = "fixed" if growth_b > 1e-6 else "not_fixable"
567
567
  if entry["verdict"] == "not_fixable":