feat(factor): 决议O①卡片死亡回写聚合化——全死才埋(反查卡下全部因子,部分死=卡留活跃池只影响展示;death_reason=全灭:触发因子x入墓园:死因;修D5任一死埋整卡在一卡多因子语境错位) [vps]
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user