From c9b448a4946b7adec66bd24a5081c2ff4cfea543 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sat, 10 Oct 2026 22:24:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(factor):=20=E5=86=B3=E8=AE=AEO=E2=91=A0?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E6=AD=BB=E4=BA=A1=E5=9B=9E=E5=86=99=E8=81=9A?= =?UTF-8?q?=E5=90=88=E5=8C=96=E2=80=94=E2=80=94=E5=85=A8=E6=AD=BB=E6=89=8D?= =?UTF-8?q?=E5=9F=8B(=E5=8F=8D=E6=9F=A5=E5=8D=A1=E4=B8=8B=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E5=9B=A0=E5=AD=90,=E9=83=A8=E5=88=86=E6=AD=BB=3D?= =?UTF-8?q?=E5=8D=A1=E7=95=99=E6=B4=BB=E8=B7=83=E6=B1=A0=E5=8F=AA=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E5=B1=95=E7=A4=BA;death=5Freason=3D=E5=85=A8=E7=81=AD?= =?UTF-8?q?:=E8=A7=A6=E5=8F=91=E5=9B=A0=E5=AD=90x=E5=85=A5=E5=A2=93?= =?UTF-8?q?=E5=9B=AD:=E6=AD=BB=E5=9B=A0;=E4=BF=AED5=E4=BB=BB=E4=B8=80?= =?UTF-8?q?=E6=AD=BB=E5=9F=8B=E6=95=B4=E5=8D=A1=E5=9C=A8=E4=B8=80=E5=8D=A1?= =?UTF-8?q?=E5=A4=9A=E5=9B=A0=E5=AD=90=E8=AF=AD=E5=A2=83=E9=94=99=E4=BD=8D?= =?UTF-8?q?)=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_api/routes_pipeline.py | 2 +- sanguo_factor/version_registry.py | 29 ++++++++---- tests/factor/test_version_registry.py | 67 +++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 15 deletions(-) diff --git a/sanguo_api/routes_pipeline.py b/sanguo_api/routes_pipeline.py index a7ac9377..1d8dbebb 100644 --- a/sanguo_api/routes_pipeline.py +++ b/sanguo_api/routes_pipeline.py @@ -782,7 +782,7 @@ def factor_verdict(name: str, body: dict) -> dict: # 复用 writeback_card_death(失败降级 stderr+death_writeback_missed # 附记,不阻断转移;pipeline_db 缺省 env SANGUO_PIPELINE_DB→ # data/pipeline.db,与 CLI 同源) - vr.writeback_card_death(None, entry, note, events_path=events_path) + vr.writeback_card_death(None, reg, entry, note, events_path=events_path) return {"ok": True, "action": action} diff --git a/sanguo_factor/version_registry.py b/sanguo_factor/version_registry.py index c690f572..505c42f9 100644 --- a/sanguo_factor/version_registry.py +++ b/sanguo_factor/version_registry.py @@ -167,22 +167,33 @@ def _append_writeback_missed(events_path: str, hyp_id: str, name: str) -> None: f.write(json.dumps(ev, ensure_ascii=False) + "\n") -def writeback_card_death(pipeline_db: str | None, entry: dict[str, Any], - cause: str | None, +def writeback_card_death(pipeline_db: str | None, + registry: dict[str, Any], + entry: dict[str, Any], cause: str | None, events_path: str = "config/registry_events.jsonl") -> bool: - """D5: 因子入墓园→回写假设卡片(graveyard+死因+因子名). + """决议O:因子入墓园→卡片聚合回写(全死才埋). 卡 id=entry.hypothesis 以 hyp- 开头才回写(旧 H- 编号非卡片,静默跳过); + registry=全量注册表(调用方在手),反查卡下全部因子——部分死=卡留活跃池 + (stderr 提示,非 miss 不附记 events);全 graveyard 才回写,death_reason= + 「全灭:触发因子 {name} 入墓园:{cause}」.其余(P2-10 miss 附记/失败不阻断 + 转移/相对路径绝对化)语义不变. pipeline_db 缺省走 env SANGUO_PIPELINE_DB→data/pipeline.db.失败不阻断 转移(只 stderr 告警)——注册表转移是正主,卡片回写是附属. - P2-10: 回写未命中不再零痕迹——库不在/卡不在库均 stderr+events 附记 - death_writeback_missed(含卡 id+因子名);相对路径警告后 os.path.abspath - 绝对化(不拒跑,落库位置明确);库不存在跳过回写不建空库 - (pipeline_store._connect 首触自举会静默 CREATE,预检挡在连接前). """ hyp_id = str(entry.get("hypothesis") or "") if not hyp_id.startswith("hyp-"): return False + # 决议O 聚合:反查卡下全部因子,全死才埋 + siblings = [e for e in registry["factors"].values() + if e.get("hypothesis") == hyp_id] + alive = [str(e.get("name")) for e in siblings + if e.get("status") != "graveyard"] + if alive: + print(f"[registry] ℹ️ 卡片 {hyp_id} 部分因子存活({len(alive)} 条)," + f"不埋卡(决议O 全死才埋): {', '.join(alive[:5])}", + file=sys.stderr) + return False if pipeline_db is None: pipeline_db = os.environ.get( "SANGUO_PIPELINE_DB", os.path.join("data", "pipeline.db")) @@ -201,7 +212,7 @@ def writeback_card_death(pipeline_db: str | None, entry: dict[str, Any], from sanguo_portfolio import pipeline_store result = pipeline_store.update_hypothesis_state( pipeline_db, hyp_id, "graveyard", - death_reason=f"因子 {name} 入墓园: {cause or '未注死因'}", + death_reason=f"全灭:触发因子 {name} 入墓园: {cause or '未注死因'}", factor_id=name) except ValueError as e: # 卡片已终态(graveyard/promoted)等非法迁移 print(f"[registry] ⚠️ 卡片 {hyp_id} 已终态,回写跳过: {e}", file=sys.stderr) @@ -292,7 +303,7 @@ def main(argv: list[str] | None = None) -> int: ev = append_event(args.events, entry, old, args.status) print(f"[registry] {args.name}: {old} → {args.status} (event ts={ev['ts']})") if args.status == "graveyard": - writeback_card_death(args.pipeline_db, entry, args.cause, + writeback_card_death(args.pipeline_db, reg, entry, args.cause, events_path=args.events) if args.status == "promoted": title, body = render_promotion_issue(args.name, entry) diff --git a/tests/factor/test_version_registry.py b/tests/factor/test_version_registry.py index 029be44b..c6b78814 100644 --- a/tests/factor/test_version_registry.py +++ b/tests/factor/test_version_registry.py @@ -18,6 +18,7 @@ from sanguo_factor.version_registry import ( save_registry, transition, upsert_factor, + writeback_card_death, ) @@ -221,8 +222,9 @@ def test_graveyard_transition_writes_back_card(tmp_path, monkeypatch): def test_writeback_skips_non_card_hypothesis(tmp_path): import sanguo_factor.version_registry as vr - assert vr.writeback_card_death(None, {"hypothesis": "H-2026-09-22-05", - "name": "f"}, "x") is False + assert vr.writeback_card_death(None, {"factors": {}}, + {"hypothesis": "H-2026-09-22-05", + "name": "f"}, "x") is False def _insert_card(db): @@ -246,7 +248,8 @@ def test_writeback_card_not_in_db_leaves_trace(tmp_path, capsys): _insert_card(db) # 库存在有表,但回写目标 hyp-missing 不在 events = str(tmp_path / "ev.jsonl") rc = vr.writeback_card_death( - db, {"hypothesis": "hyp-missing", "name": "f_ghost"}, "死因", + db, {"factors": {}}, {"hypothesis": "hyp-missing", + "name": "f_ghost"}, "死因", events_path=events) assert rc is False err = capsys.readouterr().err @@ -267,7 +270,8 @@ def test_writeback_relative_pipeline_db_absolutized(tmp_path, monkeypatch, capsy hyp = _insert_card(db) events = str(tmp_path / "ev.jsonl") rc = vr.writeback_card_death( - db, {"hypothesis": hyp, "name": "f_rel"}, "死因", events_path=events) + db, {"factors": {}}, {"hypothesis": hyp, "name": "f_rel"}, "死因", + events_path=events) assert rc is True err = capsys.readouterr().err assert "相对路径" in err @@ -284,7 +288,8 @@ def test_writeback_missing_db_skipped_not_created(tmp_path, capsys): db = str(tmp_path / "sub" / "pipeline.db") # 连同父目录都不存在 events = str(tmp_path / "ev.jsonl") rc = vr.writeback_card_death( - db, {"hypothesis": "hyp-404", "name": "f_lost"}, "死因", + db, {"factors": {}}, {"hypothesis": "hyp-404", + "name": "f_lost"}, "死因", events_path=events) assert rc is False err = capsys.readouterr().err @@ -332,3 +337,55 @@ def test_upsert_factor_description_built_once_not_overwritten(reg): # 不传 description=条目无该键(存量因子形态) upsert_factor(reg, "f2", "H-2") assert "description" not in reg["factors"]["f2"] + + +class TestWritebackAggregation: + """决议O:卡片死亡回写=全死才埋(任一因子死不再埋整卡).""" + + def _card_db(self, tmp_path): + from sanguo_portfolio import pipeline_store as ps + db = str(tmp_path / "pipeline.db") + ps.insert_hypothesis(db, { + "title": "高管增持后跑赢", "logic": "x", + "expected_sign": "positive", "falsifiable": "y"}) + with __import__("sqlite3").connect(db) as conn: + conn.execute( + "UPDATE hypotheses SET id='hyp-agg1' WHERE title='高管增持后跑赢'") + return db + + def test_partial_death_keeps_card_alive(self, tmp_path, capsys): + db = self._card_db(tmp_path) + reg = {"factors": { + "fa_a": {"name": "fa_a", "hypothesis": "hyp-agg1", + "status": "graveyard"}, # 死的那个(触发者) + "fa_b": {"name": "fa_b", "hypothesis": "hyp-agg1", + "status": "incubating"},}} # 活着 → 不埋卡 + ok = writeback_card_death(db, reg, reg["factors"]["fa_a"], "IC 两窗反号") + assert ok is False + out = capsys.readouterr().err + assert "部分因子存活" in out and "fa_b" in out + from sanguo_portfolio import pipeline_store as ps + card = [c for c in ps.list_hypotheses(db) if c["id"] == "hyp-agg1"][0] + assert card["state"] == "queued" # 卡留活跃池 + + def test_all_dead_buries_card_with_new_reason(self, tmp_path): + db = self._card_db(tmp_path) + reg = {"factors": { + "fa_a": {"name": "fa_a", "hypothesis": "hyp-agg1", + "status": "graveyard"}, + "fa_b": {"name": "fa_b", "hypothesis": "hyp-agg1", + "status": "graveyard"},}} # 全灭 → 埋 + ok = writeback_card_death(db, reg, reg["factors"]["fa_a"], "IC 两窗反号") + assert ok is True + from sanguo_portfolio import pipeline_store as ps + card = [c for c in ps.list_hypotheses(db) if c["id"] == "hyp-agg1"][0] + assert card["state"] == "graveyard" + assert card["deathReason"] == "全灭:触发因子 fa_a 入墓园: IC 两窗反号" + + def test_zero_sibling_factor_buries(self, tmp_path): + """卡下无其他因子(一卡一因子旧语境)→该因子死即全灭,照埋.""" + db = self._card_db(tmp_path) + reg = {"factors": { + "fa_a": {"name": "fa_a", "hypothesis": "hyp-agg1", + "status": "graveyard"},}} + assert writeback_card_death(db, reg, reg["factors"]["fa_a"], "x") is True