f740999848
- I-1 routes_pipeline: decompose 端点 building 态卡片二次分解不再走 update_hypothesis_state(D3 矩阵无自迁移边 raise ValueError→500 半成功); building 态=纯增量注册语义,注册照常落 yaml - I-2 factor_guard: _OPERATOR_ARITY 35 算子必需位置参数表(逐个对 vnpy ts/cs/math/ta_function+cs_neutralize 实现核实)→缺参/多参 bad_arity 拦截; Tuple/List/Set 入 _BLOCKED_NODES;表达式零非函数位 Name→no_data_column - M-1 version_registry: writeback_card_death 卡片已终态(ValueError)单独 stderr 注记可辨,与真失败分流 - M-2 hypothesis_decompose: build_decompose_messages 加 existing_names 关键字参,system 模板注入已占用名字清单(截 120 防爆 prompt),初代+ feedback 轮均带 - M-3: 两处 chat_json max_tokens 2000→4000(8 因子候选+justification 截断) - 测试: guard 缺参/多参/元组/纯常量/合法不误拦 3 案+building 二次分解 200 双因子在库+在库名单渲染(初代+feedback 轮) Co-Authored-By: Claude Code <noreply@anthropic.com>
106 lines
4.4 KiB
Python
106 lines
4.4 KiB
Python
"""反馈循环(C3):违规只重生成失败者+max_rounds 上限+通过者跨轮保有."""
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from sanguo_api import hypothesis_decompose as hd
|
|
from sanguo_factor.factor_guard import Violation
|
|
|
|
CARD = {"title": "高管增持后动量延续", "logic": "If 高管增持,则信息优势延续",
|
|
"expectedSign": "positive", "falsifiable": "若 60 日超额<=0 则证伪",
|
|
"dataNeeds": ["bars_daily"], "id": "hyp-1", "state": "queued"}
|
|
|
|
GOOD = {"name": "llm_mom20", "expression": "cs_rank(ts_mean(close, 20))",
|
|
"justification": "20 日均值动量承载延续假设"}
|
|
BAD_COL = {"name": "llm_bad", "expression": "cs_rank(pledge_pct)",
|
|
"justification": "j"}
|
|
|
|
|
|
class FakeClient:
|
|
def __init__(self, queue):
|
|
self.queue, self.calls = list(queue), []
|
|
|
|
async def chat_json(self, messages, **k):
|
|
self.calls.append(messages)
|
|
out = self.queue.pop(0)
|
|
if isinstance(out, Exception):
|
|
raise out
|
|
return out
|
|
|
|
|
|
def test_normalize_candidates_shape():
|
|
out = hd.normalize_candidates({"factors": [GOOD]})
|
|
assert out == [GOOD]
|
|
with pytest.raises(ValueError):
|
|
hd.normalize_candidates({"factors": []}) # 0 条
|
|
with pytest.raises(ValueError):
|
|
hd.normalize_candidates({"factors": [dict(GOOD, name="")]})
|
|
with pytest.raises(ValueError):
|
|
hd.normalize_candidates([GOOD]) # 顶层非 object
|
|
|
|
|
|
def test_messages_carry_whitelist_and_card():
|
|
msgs = hd.build_decompose_messages(CARD)
|
|
system = msgs[0]["content"]
|
|
assert "cs_rank" in system and "ts_mean" in system # 算子白名单
|
|
assert "close" in system and "roe_ttm" in system # 列白名单
|
|
assert CARD["title"] in msgs[1]["content"]
|
|
|
|
|
|
def test_loop_first_round_all_pass():
|
|
c = FakeClient([{"factors": [GOOD]}])
|
|
r = asyncio.run(hd.run_decompose(c, CARD, existing_names=set(),
|
|
library_exprs={}))
|
|
assert r["rounds"] == 1 and len(r["passed"]) == 1 and r["failed"] == []
|
|
assert len(c.calls) == 1
|
|
|
|
|
|
def test_loop_regenerates_only_failures():
|
|
c = FakeClient([{"factors": [GOOD, BAD_COL]}, {"factors": [GOOD]}])
|
|
r = asyncio.run(hd.run_decompose(c, CARD, existing_names=set(),
|
|
library_exprs={}))
|
|
assert r["rounds"] == 2
|
|
assert [p["name"] for p in r["passed"]] == ["llm_mom20"]
|
|
assert r["failed"] == []
|
|
# 第二轮 prompt 只带失败者反馈
|
|
fb = c.calls[1][1]["content"]
|
|
assert "llm_bad" in fb and "pledge_pct" in fb and "llm_mom20" not in fb
|
|
|
|
|
|
def test_loop_stops_at_max_rounds():
|
|
c = FakeClient([{"factors": [BAD_COL]}] * 3)
|
|
r = asyncio.run(hd.run_decompose(c, CARD, existing_names=set(),
|
|
library_exprs={}))
|
|
assert r["rounds"] == 3 and r["passed"] == []
|
|
assert r["failed"][0]["violations"][0]["code"] == "unknown_column"
|
|
assert len(c.calls) == 3 # 上限即停(C3)
|
|
|
|
|
|
def test_loop_name_claimed_by_passed():
|
|
# 轮1: GOOD 过门+BAD 违规;轮2 重生成者抢了 GOOD 已占有的名字→name_taken;
|
|
# 轮3 达上限停,passed 只剩轮1 的 GOOD
|
|
bad = {"name": "llm_bad", "expression": "cs_rank(pledge_pct)",
|
|
"justification": "j"}
|
|
stealer = {"name": "llm_mom20", "expression": "cs_rank(ts_std(close, 20))",
|
|
"justification": "j"}
|
|
c = FakeClient([{"factors": [GOOD, bad]}, {"factors": [stealer]},
|
|
{"factors": [stealer]}])
|
|
r = asyncio.run(hd.run_decompose(c, CARD, existing_names=set(),
|
|
library_exprs={}))
|
|
assert r["rounds"] == 3
|
|
assert [p["name"] for p in r["passed"]] == ["llm_mom20"]
|
|
codes = [v["code"] for v in r["failed"][0]["violations"]]
|
|
assert "name_taken" in codes
|
|
|
|
|
|
def test_messages_carry_taken_names():
|
|
msgs = hd.build_decompose_messages(CARD, existing_names={"fund_roe_ttm"})
|
|
assert "已占用名字" in msgs[0]["content"]
|
|
assert "fund_roe_ttm" in msgs[0]["content"]
|
|
# 反馈轮同带(M-2:run_decompose feedback 轮也传 existing_names)
|
|
fb = hd.build_decompose_messages(
|
|
CARD, feedback=[{"name": "llm_bad", "expression": "cs_rank(pledge_pct)",
|
|
"violations": [Violation("unknown_column", "列 pledge_pct 不在底座可用清单")]}],
|
|
existing_names={"fund_roe_ttm"})
|
|
assert "fund_roe_ttm" in fb[0]["content"]
|