fix(factor): codex review 修批——expression_match UnaryOp/keywords 编码+递归兜底签名+func名不独立计数+limit/爆栈防护;monthly_review 当月点带 ic_mean+非法 month 剔出逐年 [nas]

This commit is contained in:
2026-10-10 11:10:29 +08:00
parent ca264defbd
commit eaac48a1db
4 changed files with 150 additions and 30 deletions
+53 -25
View File
@@ -13,10 +13,10 @@ _COMMUTATIVE = (ast.Add, ast.Mult)
def _is_meta(node: ast.AST) -> bool:
"""元数据子节点:expr_context(Load/Store)与算子(Div/Mult...)——
前者是 Name 的语境标记,后者的类型已编码进 BinOp 的 key.
"""元数据子节点:expr_context(Load/Store)与算子(Div/Mult/USub...)——
前者是 Name 的语境标记,后者的类型已编码进 BinOp/UnaryOp 的 key.
不跳过则 ? 兜底签名全局撞车+虚增计数(Name 算 2/整树多 1)."""
return isinstance(node, (ast.expr_context, ast.operator))
return isinstance(node, (ast.expr_context, ast.operator, ast.unaryop))
def _node_size(node: ast.AST) -> int:
@@ -25,28 +25,46 @@ def _node_size(node: ast.AST) -> int:
def _key(node: ast.AST) -> str:
"""规范化结构签名:交换律 binop 左右子树排序后拼接."""
"""规范化结构签名:交换律 binop 左右子树排序后拼接.
兜底分支递归编码全部非元数据子节点——同类型未知节点只有子树也
同构才撞签,不再 `?TypeName` 全局等价(codex review CRITICAL).
"""
if isinstance(node, ast.BinOp) and isinstance(node.op, _COMMUTATIVE):
lk, rk = _key(node.left), _key(node.right)
a, b = sorted((lk, rk))
return f"({a}|{b}|{type(node.op).__name__})"
if isinstance(node, ast.BinOp):
return f"({_key(node.left)}>{_key(node.right)}|{type(node.op).__name__})"
if isinstance(node, ast.UnaryOp):
return f"{type(node.op).__name__}({_key(node.operand)})"
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
args = ",".join(_key(a) for a in node.args)
return f"{node.func.id}({args})"
kws = ",".join(f"{k.arg}={_key(k.value)}"
for k in sorted(node.keywords, key=lambda k: k.arg or ""))
return f"{node.func.id}({args};{kws})"
if isinstance(node, ast.Name):
return f"#{node.id}"
if isinstance(node, ast.Constant):
return f"#{node.value!r}"
return f"?{type(node).__name__}"
kids = ",".join(_key(c) for c in ast.iter_child_nodes(node)
if not _is_meta(c))
return f"?{type(node).__name__}<{kids}>"
def _call_func_ids(node: ast.AST) -> set[int]:
"""Call 的 func Name 节点 id 集:算子名已编码进 Call 签名,不再作为
独立子树参与匹配——否则仅共享算子名(ts_mean/f/cs_rank)也计公共
子树,产生 1/N 的地板噪声(codex review 修批实测)."""
return {id(c.func) for c in ast.walk(node) if isinstance(c, ast.Call)}
def _all_subtree_keys(node: ast.AST) -> dict[str, int]:
"""子树签名→节点数(同签名取最大)."""
skip = _call_func_ids(node)
out: dict[str, int] = {}
for sub in ast.walk(node):
if _is_meta(sub):
if _is_meta(sub) or id(sub) in skip:
continue
k = _key(sub)
out[k] = max(out.get(k, 0), _node_size(sub))
@@ -54,39 +72,49 @@ def _all_subtree_keys(node: ast.AST) -> dict[str, int]:
def similarity(expr_a: str, expr_b: str) -> float | None:
"""最大公共子树节点数 / 较小表达式节点数;任一解析失败=None.
"""最大公共子树节点数 / 较小表达式节点数;解析失败或深树爆栈=None.
取 .body 剥掉 Expression 包装节点——否则其签名落 ? 兜底桶,
任意两表达式的根都会撞签(子树=全树,相似度恒 1.0).
RecursionError 防护:factor_guard _DEPTH_MAX=12 挡真实注册深树,
此处捕爆栈如实 None,不 raise(codex review LOW).
"""
try:
ta = ast.parse(expr_a, mode="eval").body
tb = ast.parse(expr_b, mode="eval").body
except (SyntaxError, ValueError):
sa, sb = _node_size(ta), _node_size(tb)
if sa == 0 or sb == 0:
return None
keys_a = _all_subtree_keys(ta)
skip_b = _call_func_ids(tb)
best = 0
for sub in ast.walk(tb):
if _is_meta(sub) or id(sub) in skip_b:
continue
k = _key(sub)
if k in keys_a:
best = max(best, min(keys_a[k], _node_size(sub)))
return round(best / min(sa, sb), 4)
except (SyntaxError, ValueError, RecursionError):
return None
sa, sb = _node_size(ta), _node_size(tb)
if sa == 0 or sb == 0:
return None
keys_a = _all_subtree_keys(ta)
best = 0
for sub in ast.walk(tb):
if _is_meta(sub):
continue
k = _key(sub)
if k in keys_a:
best = max(best, min(keys_a[k], _node_size(sub)))
return round(best / min(sa, sb), 4)
def top_similar(expression: str, candidates: dict[str, str],
floor: float = 0.6) -> list[dict]:
"""对候选池按相似度排序,过滤低于 floor 的(提示非硬拒)."""
floor: float = 0.6, limit: int = 5) -> list[dict]:
"""对候选池按相似度排序,过滤低于 floor 的(提示非硬拒).
limit=5 上限:注册路径 registry 每条只存前 5 提示,防大池刷屏
(codex review MEDIUM).
"""
out: list[dict] = []
for name, expr in candidates.items():
if not expression or not expr:
continue
r = similarity(expression, expr)
try:
r = similarity(expression, expr)
except RecursionError:
continue
if r is not None and r >= floor:
out.append({"name": name, "ratio": r})
out.sort(key=lambda h: h["ratio"], reverse=True)
return out
return out[:limit]
+8 -2
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import argparse
import json
import os
import re
import socket
import sys
from datetime import datetime
@@ -88,7 +89,9 @@ def factor_stats(points: list[dict]) -> dict:
by_year: dict[str, list[float]] = {}
for p in points:
m, v = str(p.get("month") or ""), p.get("ic_mean")
if m and v is not None:
# 月串须 YYYY-MM 才进逐年(codex review:非法串切前 4 字符会造出
# "bad" 这类年键;ic 值聚合不含月串语义,照收不剔)
if v is not None and re.fullmatch(r"\d{4}-\d{2}", m):
by_year.setdefault(m[:4], []).append(v)
return {"icAll": round(mean_ic, 6), "tAll": t_all,
"positiveRatio": round(sum(1 for v in ics if v > 0) / n, 4),
@@ -108,7 +111,10 @@ def build_report(registry: dict, current_points: dict[str, dict],
for name, entry in registry["factors"].items():
cur = current_points.get(name) or {}
hist = list(history_points.get(name, []))
point = {"month": month_key, "t": cur.get("t")}
# 当月点带 ic_mean(codex review:只带 t 会让 factor_stats 漏最新
# 一期、首跑全空——t/ic_mean/count 同源于当月评估批)
point = {"month": month_key, "t": cur.get("t"),
"ic_mean": cur.get("ic_mean")}
if cur.get("t") is not None:
hist = [p for p in hist if p["month"] != month_key] + [point]
monthly_points[name] = sorted(hist, key=lambda p: p["month"])
+56 -1
View File
@@ -12,7 +12,9 @@ def test_identical_and_commutative():
def test_partial_common_subtree():
# 公共子树=ts_mean(close,5)(5节点);分母=较小表达式(7节点)
# 节点口径(Name/Constant/Call 各计 1,算子与 ctx 为元数据不计):
# 公共子树 ts_mean(close,5)=4 节点(Call+func+close+5);
# 整树=BinOp+Call+volume(turnover)=6 节点;ratio=4/6≈0.6667
r = similarity("ts_mean(close, 5) / volume",
"ts_mean(close, 5) * turnover")
assert r is not None and 0.6 < r < 1.0
@@ -32,3 +34,56 @@ def test_top_similar_floor():
assert all(h["ratio"] >= 0.6 for h in hits)
# 空表达式/坏 candidates 不炸
assert top_similar("close", {"fa_x": ""}) == []
# —— codex review 修批(2026-10-10):UnaryOp/keywords/limit/深树防护 ——
def test_unaryop_distinct_operands_not_similar():
"""-close vs -open:UnaryOp 显式编码,不再落 ? 兜底桶互相撞签."""
assert similarity("-close", "-open") == 0.0
assert similarity("-ts_mean(close,5)", "-ts_mean(open,99)") == 0.0
# 同构 UnaryOp 仍 1.0
assert similarity("-close", "-close") == 1.0
# containment 语义:close 整个是 -close 的子树,较小表达式全包含→1.0
# (口径=公共子树/较小表达式节点数,非对称同构判定)
assert similarity("-close", "close") == 1.0
def test_call_keywords_encoded():
"""Call 关键字参数须进签名:kw 值不同→不再 1.0(共享 close 操作数
仅余 1/5 地板噪声,低于提示阈 0.6)."""
r = similarity("ts_mean(close, window=5)", "ts_mean(close, window=99)")
assert r is not None and r < 0.6
# 同 kwargs 同构
assert similarity("f(close, w=5)", "f(close, w=5)") == 1.0
# kwargs 顺序无关(按名排序后拼接)
assert similarity("f(a=1, b=2)", "f(b=2, a=1)") == 1.0
# kwargs 有无也是结构差异:无 kw vs 有 kw 不撞签(仅共享 close,1/3)
r2 = similarity("f(close)", "f(close, w=5)")
assert r2 is not None and r2 < 0.6
def test_nested_call_common_subtree():
"""嵌套 Call:全同→1;算子名也不同→0;仅共享外层算子名→占比 1/6 低于提示阈."""
assert similarity("cs_rank(ts_mean(close, 5))",
"cs_rank(ts_mean(close, 5))") == 1.0
assert similarity("cs_rank(ts_mean(close, 5))",
"zscore(ts_mean(open, 9))") == 0.0
r = similarity("cs_rank(ts_mean(close, 5))", "cs_rank(ts_mean(open, 9))")
assert r is not None and r < 0.6
def test_top_similar_limit_and_empty():
"""limit=5 上限(注册路径 registry 只存前 5)+空候选不炸."""
cands = {f"fa_{i}": "close + open" for i in range(7)}
hits = top_similar("close + open", cands)
assert len(hits) == 5
assert top_similar("close", {}) == []
assert top_similar("", {"fa_a": "close"}) == []
def test_deep_expression_no_crash():
"""深表达式递归爆栈不 raise,如实 None(factor_guard _DEPTH_MAX=12 挡真实
注册,此处为纯防护——RecursionError 捕获返回 None)."""
deep = "close" + " + close" * 3000
assert similarity(deep, deep) is None
assert top_similar(deep, {"fa_a": deep}) == []
+33 -2
View File
@@ -105,8 +105,9 @@ def test_build_report_verdict_and_collective(eval_db, registry_yaml):
assert rep["collective"]["is_collective_decay"] is False
# 观察面: s01_buzz 无批记录 → red
assert rep["observations"]["s01_buzz"]["status"] == "red"
# 当月点已存入 monthly_points(下月续算原料)
assert rep["monthly_points"]["pledge_net_chg"][-1] == {"month": "2026-09", "t": 1.2}
# 当月点已存入 monthly_points(下月续算原料;codex review 后带 ic_mean)
assert rep["monthly_points"]["pledge_net_chg"][-1] == {
"month": "2026-09", "t": 1.2, "ic_mean": 0.028}
def test_history_points_extend_streak(eval_db, registry_yaml, tmp_path):
@@ -290,3 +291,33 @@ def test_factor_stats_empty_and_dirty():
s = factor_stats(dirty)
assert s["icAll"] == 0.1
assert s["byYear"] == {} # 空 month 不进逐年
# —— codex review 修批(2026-10-10):point 带 ic_mean/非法 month 校验/退化样本 ——
def test_factor_stats_degenerate_samples():
"""n=2 同值→方差 0→tAll 如实 None(不除零不造 0)."""
s = factor_stats(_ammo_pts(("2025-09", 0.05), ("2025-10", 0.05)))
assert s["icAll"] == 0.05 and s["tAll"] is None
assert s["positiveRatio"] == 1.0
def test_factor_stats_invalid_month_not_in_by_year():
"""非法 month(bad-2025)不切前 4 字符进 byYear;ic 值聚合不受月串影响."""
pts = [{"month": "bad-2025", "ic_mean": 0.1, "t": None},
{"month": "2025-09", "ic_mean": 0.04, "t": None}]
s = factor_stats(pts)
assert s["byYear"] == {"2025": 0.04} # bad-2025 整条剔出逐年
assert s["icAll"] == round((0.1 + 0.04) / 2, 6) # 值聚合照收
def test_build_report_current_point_carries_ic_mean():
"""当月 point 须带 ic_mean——否则 factor_stats 漏最新一期、首跑全空."""
reg = {"factors": {"fa_x": {"name": "fa_x", "hypothesis": "h",
"status": "assessable"}}}
rep = build_report(reg, {"fa_x": {"t": 2.0, "ic_mean": 0.05, "count": 100}},
history_points={}, as_of="2026-09-30", host="t")
assert rep["monthly_points"]["fa_x"][-1]["ic_mean"] == 0.05
s = rep["factor_stats"]["fa_x"]
assert s["icAll"] == 0.05 # 首跑单点即有弹药
assert s["positiveRatio"] == 1.0
assert s["tAll"] is None # n=1 无合成 t,如实