@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,581 @@
1
+ # ledger.py — 阶段A-M3 prediction ledger(预测账本,可追踪可实验兑现)
2
+ # 文件: ~/.dsh/dsh-bio-gem/ledger/<模型名>.jsonl(一个模型一个账本,2026-08-31 用户决策)
3
+ # ——默认账本按模型文件 basename 推导(model_ledger_path),显式 ledger_path 仍可覆盖;
4
+ # ——无 model 无 path 的查询/摘要 = 聚合所有模型账本(全局视图,by_model 分布保留)。
5
+ # 迁移:旧全局 predictions.jsonl 已拆分为各模型账本(predictions.jsonl.legacy-20260831 保留备份,
6
+ # 不再作为活动账本)。
7
+ # 幂等: 同 model+condition+type+content 哈希去重,重复运行不追加。
8
+ # 完整性: 逐行 JSON 解析校验,损坏行跳过并在返回里报 corrupt_rows + 行号(不阻塞);
9
+ # 写入失败只 WARN 不使主流程失败。update 重写文件但保留损坏行原样(不删行)。
10
+ # 证据分级优先级(任务书锁定): EVIDENCE_literature > EVIDENCE_sequence > EVIDENCE_rule > EVIDENCE_math
11
+ import os
12
+ import re
13
+ import sys
14
+ import json
15
+ import hashlib
16
+ from datetime import datetime
17
+
18
+ LEDGER_DIR = os.path.join(os.path.expanduser("~"), ".dsh", "dsh-bio-gem", "ledger")
19
+ LEDGER_PATH = os.path.join(LEDGER_DIR, "predictions.jsonl") # 兼容 fallback(迁移后非活动账本)
20
+
21
+ TIER_PRIORITY = ["EVIDENCE_literature", "EVIDENCE_sequence", "EVIDENCE_rule", "EVIDENCE_math"] # 高→低
22
+ STATUSES = ("unverified", "literature_supported", "literature_contradicted", "experimentally_verified")
23
+ TYPES = ("essentiality", "phenotype", "synthetic_lethal", "secretion", "other")
24
+ DEFAULT_TIER = "EVIDENCE_rule"
25
+
26
+
27
+ def _now_iso():
28
+ return datetime.now().astimezone().isoformat(timespec="seconds")
29
+
30
+
31
+ def _content_hash(model, condition, rtype, content):
32
+ # 阶段D-E2E P1:Windows 路径斜杠/大小写差异("F:/a" vs "F:\a")会使同一模型的
33
+ # 重复登记漏过去重——hash 前做 normcase+normpath 归一化(首登记的存储格式不变)。
34
+ m = os.path.normcase(os.path.normpath(model)) if model else ""
35
+ raw = "\x1f".join([m, condition or "", rtype or "", content or ""])
36
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
37
+
38
+
39
+ def _norm_path(p):
40
+ """路径归一化(normcase+normpath)——防正/反斜杠差异导致前缀匹配失败。"""
41
+ return os.path.normcase(os.path.normpath(p or ""))
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # 账本文件解析:一个模型一个账本(2026-08-31 用户决策)
46
+ # ---------------------------------------------------------------------------
47
+ def model_ledger_path(model_path):
48
+ """默认账本 = ledger/<模型文件名去扩展名>.jsonl(一个模型一个账本)。"""
49
+ base = os.path.splitext(os.path.basename(model_path or ""))[0] or "default"
50
+ base = re.sub(r"[^A-Za-z0-9_.-]+", "_", base).strip("._") or "default"
51
+ return os.path.join(LEDGER_DIR, base + ".jsonl")
52
+
53
+
54
+ def _ledger_file_list():
55
+ """活动账本文件:ledger/*.jsonl(排除旧全局 predictions.jsonl 与 .bak/.legacy 备份)。"""
56
+ if not os.path.isdir(LEDGER_DIR):
57
+ return []
58
+ files = []
59
+ for x in sorted(os.listdir(LEDGER_DIR)):
60
+ if not x.endswith(".jsonl"):
61
+ continue
62
+ if x == "predictions.jsonl":
63
+ continue # 旧全局账本(迁移后仅作 legacy 备份,不算活动账本)
64
+ if ".bak" in x.lower() or ".legacy" in x.lower():
65
+ continue
66
+ files.append(os.path.join(LEDGER_DIR, x))
67
+ return files
68
+
69
+
70
+ def load_rows(path=None):
71
+ """读单个账本文件全部行;损坏行跳过。返回 (rows, corrupt:[{line,error}])。文件不存在 -> ([], [])。"""
72
+ p = path or LEDGER_PATH
73
+ rows, corrupt = [], []
74
+ if not os.path.exists(p):
75
+ return rows, corrupt
76
+ with open(p, encoding="utf-8") as f:
77
+ for i, line in enumerate(f, 1):
78
+ line = line.strip()
79
+ if not line:
80
+ continue
81
+ try:
82
+ rows.append(json.loads(line))
83
+ except Exception as e:
84
+ corrupt.append({"line": i, "error": str(e)[:120]})
85
+ return rows, corrupt
86
+
87
+
88
+ def load_all_rows():
89
+ """聚合所有活动账本(一个模型一个账本 -> 全局视图)。返回 (rows, corrupt)。"""
90
+ rows, corrupt = [], []
91
+ for f in _ledger_file_list():
92
+ r, c = load_rows(f)
93
+ rows.extend(r)
94
+ corrupt.extend(c)
95
+ return rows, corrupt
96
+
97
+
98
+ def _resolve_rows(path=None, model=None):
99
+ """按路径/模型解析要读的账本行:显式 path > model 账本 > 聚合所有。"""
100
+ if path:
101
+ return load_rows(path)
102
+ if model:
103
+ return load_rows(model_ledger_path(model))
104
+ return load_all_rows()
105
+
106
+
107
+ def _max_id_num(rows):
108
+ mx = 0
109
+ for r in rows:
110
+ pid = str(r.get("prediction_id") or "")
111
+ if pid.startswith("P") and pid[1:].isdigit():
112
+ mx = max(mx, int(pid[1:]))
113
+ return mx
114
+
115
+
116
+ def register_predictions(new_rows, path=None):
117
+ """追加式登记(幂等)。默认账本按新行 model 推导(一个模型一个账本);path 显式则覆盖。
118
+ 返回 {appended, skipped_duplicates, total_after, corrupt_rows, path,
119
+ prediction_ids, warn};写入失败仅 WARN(返回内附 warn 字段),绝不抛异常阻塞主流程。"""
120
+ if not path and new_rows:
121
+ path = model_ledger_path((new_rows[0] or {}).get("model"))
122
+ p = path or LEDGER_PATH
123
+ existing, corrupt = load_rows(p)
124
+ seen = {_content_hash(r.get("model"), r.get("condition"), r.get("type"), r.get("content"))
125
+ for r in existing}
126
+ out = {"appended": 0, "skipped_duplicates": 0, "total_after": len(existing),
127
+ "corrupt_rows": len(corrupt), "corrupt": corrupt, "path": p, "prediction_ids": [],
128
+ "warn": None}
129
+ to_add = []
130
+ for row in new_rows:
131
+ h = _content_hash(row.get("model"), row.get("condition"), row.get("type"), row.get("content"))
132
+ if h in seen:
133
+ out["skipped_duplicates"] += 1
134
+ continue
135
+ seen.add(h)
136
+ to_add.append(row)
137
+ if not to_add:
138
+ return out
139
+ try:
140
+ os.makedirs(os.path.dirname(p), exist_ok=True)
141
+ n = _max_id_num(existing)
142
+ with open(p, "a", encoding="utf-8") as f:
143
+ for row in to_add:
144
+ n += 1
145
+ row.setdefault("prediction_id", f"P{n:04d}")
146
+ row.setdefault("status", "unverified")
147
+ row.setdefault("source_refs", [])
148
+ row.setdefault("comparison_refs", [])
149
+ row.setdefault("created_at", _now_iso())
150
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
151
+ out["prediction_ids"].append(row["prediction_id"])
152
+ out["appended"] += 1
153
+ out["total_after"] = len(existing) + out["appended"]
154
+ except Exception as e: # 写入失败只 WARN 不使主流程失败
155
+ out["warn"] = f"ledger append failed: {type(e).__name__}: {e}"
156
+ sys.stderr.write(f"[ledger] WARN {out['warn']}\n")
157
+ return out
158
+
159
+
160
+ def query_ledger(rtype=None, status=None, condition=None, model=None,
161
+ limit=None, offset=0, path=None, deprecated=None):
162
+ """条件过滤(type/status/condition/model 前缀匹配、大小写不敏感、可组合)+ 分页。
163
+ 账本定位:显式 path > model(该模型自己的账本)> 聚合所有模型账本。
164
+ 阶段D-P2:deprecated 过滤(True=仅打标行;False=仅未打标行;缺省=全部)。"""
165
+ rows, corrupt = _resolve_rows(path=path, model=model)
166
+
167
+ def _pref(v, q):
168
+ return str(v or "").lower().startswith(str(q).lower())
169
+
170
+ def _pref_model(v, q):
171
+ # 模型匹配:路径前缀(归一化斜杠/大小写)或 basename 相同(一个模型一个账本的身份=模型名)
172
+ v, q = v or "", q or ""
173
+ if _norm_path(v).startswith(_norm_path(q)):
174
+ return True
175
+ return os.path.splitext(os.path.basename(v))[0] == os.path.splitext(os.path.basename(q))[0]
176
+
177
+ def _dep(r):
178
+ return bool(r.get("deprecated"))
179
+
180
+ hits = [r for r in rows
181
+ if (not rtype or _pref(r.get("type"), rtype))
182
+ and (not status or _pref(r.get("status"), status))
183
+ and (not condition or _pref(r.get("condition"), condition))
184
+ and (not model or _pref_model(r.get("model"), model))
185
+ and (deprecated is None or _dep(r) == bool(deprecated))]
186
+ sliced = hits[offset:] if not limit else hits[offset:offset + limit]
187
+ return {"total": len(rows), "matched": len(hits), "offset": offset,
188
+ "results": sliced, "corrupt_rows": len(corrupt), "corrupt": corrupt}
189
+
190
+
191
+ def mark_deprecated_duplicates(path=None, progress=None):
192
+ """阶段D-P2:跨斜杠风格重复打标(一次性运维用;不删行、不改既有字段)。
193
+ 识别规则:同一归一化 content hash 的行组内,model 含反斜杠且存在正斜杠同预测 -> 打标
194
+ deprecated=true + superseded_by=<正斜杠版 prediction_id>。path=None 时对每个活动账本执行。
195
+ 返回聚合计数。"""
196
+ files = [path] if path else _ledger_file_list()
197
+ total = {"path": None, "marked": 0, "rows_total": 0, "corrupt_rows": 0, "per_file": {}}
198
+ for p in files:
199
+ rows, corrupt = load_rows(p)
200
+ by_hash = {}
201
+ for r in rows:
202
+ h = _content_hash(r.get("model"), r.get("condition"), r.get("type"), r.get("content"))
203
+ by_hash.setdefault(h, []).append(r)
204
+ marks = {}
205
+ for group in by_hash.values():
206
+ if len(group) < 2:
207
+ continue
208
+ keepers = [r for r in group if "\\" not in (r.get("model") or "")]
209
+ dupes = [r for r in group if "\\" in (r.get("model") or "")]
210
+ if not keepers or not dupes:
211
+ continue
212
+ keeper = sorted(keepers, key=lambda r: r.get("prediction_id") or "")[0]
213
+ for d in dupes:
214
+ marks[d.get("prediction_id")] = keeper.get("prediction_id")
215
+ marked = 0
216
+ if marks:
217
+ with open(p, encoding="utf-8") as f:
218
+ lines = f.readlines()
219
+ with open(p, "w", encoding="utf-8", newline="") as f:
220
+ for line in lines:
221
+ stripped = line.strip()
222
+ try:
223
+ obj = json.loads(stripped)
224
+ except Exception:
225
+ f.write(line) # 损坏行原样保留
226
+ continue
227
+ pid = obj.get("prediction_id") if isinstance(obj, dict) else None
228
+ if pid in marks and not obj.get("deprecated"):
229
+ obj["deprecated"] = True
230
+ obj["deprecated_note"] = (f"Windows 路径斜杠风格重复;同预测见 "
231
+ f"prediction_id {marks[pid]}(正斜杠版)")
232
+ obj["superseded_by"] = marks[pid]
233
+ f.write(json.dumps(obj, ensure_ascii=False) + "\n")
234
+ marked += 1
235
+ else:
236
+ f.write(line if line.endswith("\n") else line + "\n")
237
+ if progress:
238
+ progress(f"[ledger] {os.path.basename(p)}: marked {marked} deprecated rows (of {len(rows)})")
239
+ total["marked"] += marked
240
+ total["rows_total"] += len(rows)
241
+ total["corrupt_rows"] += len(corrupt)
242
+ total["per_file"][os.path.basename(p)] = {"marked": marked, "rows": len(rows)}
243
+ total["path"] = path or LEDGER_DIR
244
+ return total
245
+
246
+
247
+ def _update_file(prediction_id, p, status=None, source_refs=None, comparison_refs=None):
248
+ """单文件内按 prediction_id 更新 status/source_refs/comparison_refs,维护 updated_at。
249
+ 文件重写但逐行保留(损坏行原样保留,不删行)。文件不存在 -> None(调用方继续找)。"""
250
+ if not os.path.exists(p):
251
+ return None
252
+ rows, corrupt = load_rows(p)
253
+ found = 0
254
+ updated_row = None
255
+ for r in rows:
256
+ if r.get("prediction_id") == prediction_id:
257
+ found += 1
258
+ if status is not None:
259
+ r["status"] = status
260
+ if source_refs is not None:
261
+ r["source_refs"] = source_refs
262
+ if comparison_refs is not None:
263
+ r["comparison_refs"] = comparison_refs
264
+ r["updated_at"] = _now_iso()
265
+ updated_row = r
266
+ if not found:
267
+ return {"ok": False, "error": f"prediction_id not found: {prediction_id}"}
268
+ try:
269
+ with open(p, encoding="utf-8") as f:
270
+ lines = f.readlines()
271
+ with open(p, "w", encoding="utf-8") as f:
272
+ for line in lines:
273
+ stripped = line.strip()
274
+ try:
275
+ obj = json.loads(stripped)
276
+ except Exception:
277
+ f.write(line) # 损坏行原样保留(不删行)
278
+ continue
279
+ if isinstance(obj, dict) and obj.get("prediction_id") == prediction_id:
280
+ f.write(json.dumps(updated_row, ensure_ascii=False) + "\n")
281
+ found -= 1
282
+ else:
283
+ f.write(line if line.endswith("\n") else line + "\n")
284
+ except Exception as e:
285
+ return {"ok": False, "error": f"ledger update write failed: {type(e).__name__}: {e}"}
286
+ return {"ok": True, "updated": prediction_id, "row": updated_row,
287
+ "corrupt_rows": len(corrupt)}
288
+
289
+
290
+ def update_row(prediction_id, status=None, source_refs=None, comparison_refs=None, path=None):
291
+ """按 prediction_id 更新。显式 path 定位单文件;path=None 时遍历所有活动账本找 id
292
+ (各账本独立编号后 prediction_id 只在账本内唯一——全局扫确保能找到)。"""
293
+ if status is not None and status not in STATUSES:
294
+ return {"ok": False, "error": f"invalid status {status!r}({STATUSES})"}
295
+ if path:
296
+ return _update_file(prediction_id, path, status=status,
297
+ source_refs=source_refs, comparison_refs=comparison_refs)
298
+ files = _ledger_file_list()
299
+ if os.path.exists(LEDGER_PATH) and LEDGER_PATH not in files:
300
+ files = files + [LEDGER_PATH] # 兼容旧全局账本(若还在)
301
+ for f in files:
302
+ r = _update_file(prediction_id, f, status=status,
303
+ source_refs=source_refs, comparison_refs=comparison_refs)
304
+ if r is not None:
305
+ return r
306
+ return {"ok": False, "error": f"prediction_id not found: {prediction_id} (no ledger file contains it)"}
307
+
308
+
309
+ def ledger_summary(path=None, model=None):
310
+ """账本摘要:{total, by_status, by_type, by_model, deprecated_count}。
311
+ 账本定位:显式 path > model(该模型自己的账本)> 聚合所有模型账本。
312
+ P1-3(2026-08-31):按模型给 own_model_entries=该模型账本条数,防「把全局账本当作本模型预测」误读。
313
+ 2026-08-31 用户决策后:一个模型一个账本,model 定位时 total 即该模型预测数。"""
314
+ rows, corrupt = _resolve_rows(path=path, model=model)
315
+ by_status, by_type, by_model = {}, {}, {}
316
+ dep = 0
317
+ own = None
318
+ if model:
319
+ own = len(rows) # 该模型账本的行数(一个模型一个账本)
320
+ for r in rows:
321
+ s = r.get("status") or "unspecified"
322
+ t = r.get("type") or "other"
323
+ mm = r.get("model") or ""
324
+ by_status[s] = by_status.get(s, 0) + 1
325
+ by_type[t] = by_type.get(t, 0) + 1
326
+ if r.get("deprecated"):
327
+ dep += 1
328
+ if mm:
329
+ by_model[mm] = by_model.get(mm, 0) + 1
330
+ rep = {"total": len(rows), "by_status": by_status, "by_type": by_type,
331
+ "by_model": by_model, "deprecated_count": dep, "corrupt_rows": len(corrupt)}
332
+ if model is not None or own is not None:
333
+ rep["own_model_entries"] = own
334
+ rep["own_model_note"] = ("own_model_entries=该模型账本条数(一个模型一个账本:"
335
+ "账本文件按模型名分,这里就是本模型预测全量;"
336
+ "聚合视图的全局分布见 by_model/其他账本)")
337
+ return rep
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # 自动登记(evidence_tier 取支撑反应 evidence 集合中最高者;无标注默认 EVIDENCE_rule 并注明)
342
+ # ---------------------------------------------------------------------------
343
+ def best_evidence_tier(reactions, default=DEFAULT_TIER):
344
+ """reactions: 支撑反应列表(cobra Reaction)。返回 (tier, defaulted)。"""
345
+ tiers = []
346
+ for r in reactions or []:
347
+ ev = (getattr(r, "notes", None) or {}).get("evidence")
348
+ if ev:
349
+ tiers.append(ev)
350
+ for t in TIER_PRIORITY:
351
+ if t in tiers:
352
+ return t, False
353
+ return default, True
354
+
355
+
356
+ def register_essentiality(model_path, scan_result, model=None, condition=None,
357
+ lineage_version=None, path=None):
358
+ """gem_essentiality 自动登记:每必需基因一条(type=essentiality,status=unverified)。
359
+ path=None 时按 model_path 写入该模型的账本(model_ledger_path)。"""
360
+ rows = []
361
+ for gid in (scan_result or {}).get("essential_genes") or []:
362
+ tier, defaulted = DEFAULT_TIER, True
363
+ try:
364
+ if model is not None:
365
+ tier, defaulted = best_evidence_tier(model.genes.get_by_id(gid).reactions)
366
+ except Exception:
367
+ pass
368
+ rows.append({
369
+ "type": "essentiality",
370
+ "content": f"{gid} 在 {condition} 培养基下必需",
371
+ "model": model_path,
372
+ "model_lineage_version": lineage_version,
373
+ "condition": condition,
374
+ "evidence_tier": tier,
375
+ "status": "unverified",
376
+ "source_refs": [],
377
+ "comparison_refs": [],
378
+ **( {"evidence_note": "支撑反应无 evidence 标注,默认 EVIDENCE_rule"}
379
+ if defaulted else {}),
380
+ })
381
+ return register_predictions(rows, path)
382
+
383
+
384
+ def register_phenotype(model_path, g4_results, condition=None, lineage_version=None,
385
+ model=None, path=None):
386
+ """gem_phenotype 自动登记:每底物一条(G4 结果;type=phenotype,status=unverified)。
387
+ evidence 取底物交换反应的 evidence 标注(无则默认 EVIDENCE_rule 并注明)。"""
388
+ rows = []
389
+ for r in g4_results or []:
390
+ sub = r.get("substrate")
391
+ if not sub:
392
+ continue
393
+ tier, defaulted = DEFAULT_TIER, True
394
+ try:
395
+ exid = r.get("exchange")
396
+ if model is not None and exid and exid in [x.id for x in model.reactions]:
397
+ tier, defaulted = best_evidence_tier([model.reactions.get_by_id(exid)])
398
+ except Exception:
399
+ pass
400
+ rows.append({
401
+ "type": "phenotype",
402
+ "content": (f"底物 {sub} 预测{'生长' if r.get('predicted') else '不生长'}"
403
+ f"(文献={r.get('published')},匹配={r.get('match')},"
404
+ f"growth={r.get('growth')} mmol/gDW/h)"),
405
+ "model": model_path,
406
+ "model_lineage_version": lineage_version,
407
+ "condition": condition,
408
+ "evidence_tier": tier,
409
+ "status": "unverified",
410
+ "source_refs": [],
411
+ "comparison_refs": [],
412
+ **( {"evidence_note": "底物交换反应无 evidence 标注,默认 EVIDENCE_rule"}
413
+ if defaulted else {}),
414
+ })
415
+ return register_predictions(rows, path)
416
+
417
+
418
+ def register_secretion(model_path, secretion_rows, condition=None,
419
+ lineage_version=None, path=None):
420
+ """gem_secretion 自动登记:每个可分泌代谢物一条(type=secretion,status=unverified,
421
+ evidence_tier=EVIDENCE_math——production envelope 线性规划结果)。"""
422
+ rows = []
423
+ for r in secretion_rows or []:
424
+ rows.append({
425
+ "type": "secretion",
426
+ "content": (f"代谢物 {r.get('met_id')}({r.get('name') or ''}) 在 {condition} 下"
427
+ f"模型预测可分泌(max_prod={r.get('max_prod')} mmol/gDW/h)"),
428
+ "model": model_path,
429
+ "model_lineage_version": lineage_version,
430
+ "condition": condition,
431
+ "evidence_tier": "EVIDENCE_math",
432
+ "status": "unverified",
433
+ "source_refs": [],
434
+ "comparison_refs": [],
435
+ "evidence_note": "production envelope 线性规划结果(纯拓扑),未考虑毒性/渗透压/调控",
436
+ })
437
+ return register_predictions(rows, path)
438
+
439
+
440
+ def register_synthetic_lethal(model_path, pair_rows, condition=None,
441
+ lineage_version=None, path=None):
442
+ """gem_double_knockout 自动登记:每合成致死对一条(type=synthetic_lethal,
443
+ status=unverified,evidence_tier=EVIDENCE_math——双敲 LP 判定)。"""
444
+ rows = []
445
+ for r in pair_rows or []:
446
+ ga, gb = r.get("gene_a"), r.get("gene_b")
447
+ rows.append({
448
+ "type": "synthetic_lethal",
449
+ "content": f"{ga} 与 {gb} 在 {condition} 下合成致死",
450
+ "model": model_path,
451
+ "model_lineage_version": lineage_version,
452
+ "condition": condition,
453
+ "evidence_tier": "EVIDENCE_math",
454
+ "status": "unverified",
455
+ "source_refs": [],
456
+ "comparison_refs": [],
457
+ "evidence_note": "双敲 LP 判定(单敲双活>1e-6 且双敲死<=1e-6);"
458
+ "假设生成供实验设计参考,非结论",
459
+ })
460
+ return register_predictions(rows, path)
461
+
462
+
463
+ if __name__ == "__main__":
464
+ # 双协议(stdin / argv 文件)+ selftest(临时路径,全功能演示)
465
+ if "--selftest" in sys.argv:
466
+ import tempfile
467
+ d = tempfile.mkdtemp(prefix="ledger-selftest-")
468
+ p = os.path.join(d, "predictions.jsonl")
469
+ mk = lambda i: {"type": "essentiality", "content": f"g{i} 在 AB 培养基下必需",
470
+ "model": "F:/m.xml", "model_lineage_version": "0.1.4", "condition": "AB",
471
+ "evidence_tier": "EVIDENCE_rule", "status": "unverified"}
472
+ r1 = register_predictions([mk(1), mk(2), mk(3)], path=p)
473
+ assert r1["appended"] == 3 and not r1["warn"], r1
474
+ # 幂等:同内容复跑不追加
475
+ r2 = register_predictions([mk(1), mk(2), mk(3)], path=p)
476
+ assert r2["appended"] == 0 and r2["skipped_duplicates"] == 3, r2
477
+ # 阶段D P1:model 路径斜杠/大小写不同但指向同一文件 -> 仍判重复
478
+ r2b = register_predictions([{**mk(1), "model": "f:\\m.XML"}], path=p)
479
+ assert r2b["appended"] == 0 and r2b["skipped_duplicates"] == 1, r2b
480
+ # 新增一条 + ID 连续
481
+ r3 = register_predictions([{**mk(4), "type": "phenotype",
482
+ "content": "底物 Sucrose 预测不生长(文献=1,匹配=False)"}], path=p)
483
+ assert r3["appended"] == 1 and r3["prediction_ids"] == ["P0004"], r3
484
+ # query 过滤(前缀、可组合)
485
+ q = query_ledger(rtype="essentiality", path=p)
486
+ assert q["matched"] == 3 and q["total"] == 4, q
487
+ q2 = query_ledger(condition="AB", status="unverified", path=p)
488
+ assert q2["matched"] == 4, q2
489
+ q3 = query_ledger(rtype="pheno", path=p) # 前缀匹配
490
+ assert q3["matched"] == 1, q3
491
+ # update status + updated_at
492
+ u = update_row("P0001", status="experimentally_verified",
493
+ source_refs=["doi:10.1000/test"], path=p)
494
+ assert u["ok"] and u["row"]["status"] == "experimentally_verified" and "updated_at" in u["row"], u
495
+ assert update_row("P0001", status="bogus", path=p)["ok"] is False
496
+ assert update_row("P9999", status="unverified", path=p)["ok"] is False
497
+ # corrupt 容错:手工塞坏行
498
+ with open(p, "a", encoding="utf-8") as f:
499
+ f.write('{"broken json...\n')
500
+ rows, corrupt = load_rows(p)
501
+ assert len(rows) == 4 and len(corrupt) == 1 and corrupt[0]["line"] == 5, corrupt
502
+ s = ledger_summary(path=p)
503
+ assert s["total"] == 4 and s["by_status"] == {"experimentally_verified": 1,
504
+ "unverified": 3}, s
505
+ # update 不破坏坏行(重写后坏行仍在)
506
+ update_row("P0002", status="literature_supported", path=p)
507
+ rows2, corrupt2 = load_rows(p)
508
+ assert len(corrupt2) == 1 and len(rows2) == 4, (corrupt2, rows2)
509
+ # 阶段D-P2:直写一条反斜杠 model 的历史态重复行(模拟归一化修复前的存量),
510
+ # mark_deprecated_duplicates 打标(不删行)+ query 过滤 + summary deprecated_count
511
+ n_before = len(load_rows(p)[0])
512
+ with open(p, "a", encoding="utf-8") as f:
513
+ hist = dict(mk(1))
514
+ hist["model"] = "f:\\m.xml" # 同预测、反斜杠风格(历史态)
515
+ f.write(json.dumps(hist, ensure_ascii=False) + "\n")
516
+ mk_res = mark_deprecated_duplicates(path=p)
517
+ assert mk_res["marked"] == 1, mk_res
518
+ rows3, _ = load_rows(p)
519
+ assert len(rows3) == n_before + 1 # 不删行:仅 +1 条历史行
520
+ dep_rows = [r for r in rows3 if r.get("deprecated")]
521
+ assert len(dep_rows) == 1 and dep_rows[0]["superseded_by"] == "P0001", dep_rows
522
+ q_dep = query_ledger(deprecated=True, path=p)
523
+ assert q_dep["matched"] == 1
524
+ q_ok = query_ledger(deprecated=False, rtype="essentiality", path=p)
525
+ assert q_ok["matched"] == 3 # 原始 3 条 essentiality 不受影响
526
+ s3 = ledger_summary(path=p)
527
+ assert s3["deprecated_count"] == 1 and s3["total"] == n_before + 1, s3
528
+ # 2026-08-31:一个模型一个账本——model_ledger_path 按 basename 推导
529
+ assert model_ledger_path("F:/x/C58.xml") == os.path.join(LEDGER_DIR, "C58.jsonl")
530
+ assert model_ledger_path("C:\\Users\\u\\.dsh\\dsh-bio-gem\\models\\LBA9402.xml") == \
531
+ os.path.join(LEDGER_DIR, "LBA9402.jsonl")
532
+ assert model_ledger_path("") == os.path.join(LEDGER_DIR, "default.jsonl")
533
+ # register path=None -> 该模型账本文件
534
+ dir2 = tempfile.mkdtemp(prefix="ledger-selftest-")
535
+ import importlib
536
+ _ld = os.path.dirname(os.path.abspath(__file__))
537
+ import tempfile as _tf
538
+ # 用临时目录覆盖 LEDGER_DIR 验证默认推导(不污染真实账本)
539
+ old_dir = LEDGER_DIR
540
+ try:
541
+ ledger_mod = sys.modules[__name__]
542
+ ledger_mod.LEDGER_DIR = dir2
543
+ ledger_mod.LEDGER_PATH = os.path.join(dir2, "predictions.jsonl")
544
+ rn = register_predictions([mk(1)])
545
+ assert rn["appended"] == 1 and rn["path"] == os.path.join(dir2, "m.jsonl"), rn
546
+ qn = query_ledger(model="F:/m.xml")
547
+ assert qn["total"] == 1 and qn["matched"] == 1, qn
548
+ sn = ledger_summary(model="F:/m.xml")
549
+ assert sn["total"] == 1 and sn["own_model_entries"] == 1, sn
550
+ finally:
551
+ ledger_mod.LEDGER_DIR = old_dir
552
+ ledger_mod.LEDGER_PATH = os.path.join(old_dir, "predictions.jsonl")
553
+ print(json.dumps({"ok": True, "result": {"selftest": "pass", "summary": s3}}))
554
+ else:
555
+ args = {}
556
+ if len(sys.argv) > 1:
557
+ with open(sys.argv[1], encoding="utf-8") as f:
558
+ args = json.load(f)
559
+ elif not sys.stdin.isatty():
560
+ args = json.loads(sys.stdin.read())
561
+ a = args.get("args", args)
562
+ action = a.get("action", "list")
563
+ lp = a.get("ledger_path")
564
+ if action == "list":
565
+ print(json.dumps({"ok": True, "result": query_ledger(
566
+ limit=a.get("limit"), offset=a.get("offset", 0), path=lp)}, ensure_ascii=False))
567
+ elif action == "query":
568
+ print(json.dumps({"ok": True, "result": query_ledger(
569
+ rtype=a.get("type"), status=a.get("status"), condition=a.get("condition"),
570
+ model=a.get("model"), limit=a.get("limit"), offset=a.get("offset", 0),
571
+ path=lp)}, ensure_ascii=False))
572
+ elif action == "update":
573
+ print(json.dumps({"ok": True, "result": update_row(
574
+ a.get("prediction_id"), status=a.get("status"),
575
+ source_refs=a.get("source_refs"), comparison_refs=a.get("comparison_refs"),
576
+ path=lp)}, ensure_ascii=False))
577
+ elif action == "summary":
578
+ print(json.dumps({"ok": True, "result": ledger_summary(
579
+ path=lp, model=a.get("model"))}, ensure_ascii=False))
580
+ else:
581
+ print(json.dumps({"ok": False, "error": f"unknown ledger action: {action}(list|query|update|summary)"}))