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: def _is_meta(node: ast.AST) -> bool:
"""元数据子节点:expr_context(Load/Store)与算子(Div/Mult...)—— """元数据子节点:expr_context(Load/Store)与算子(Div/Mult/USub...)——
前者是 Name 的语境标记,后者的类型已编码进 BinOp 的 key. 前者是 Name 的语境标记,后者的类型已编码进 BinOp/UnaryOp 的 key.
不跳过则 ? 兜底签名全局撞车+虚增计数(Name 算 2/整树多 1).""" 不跳过则 ? 兜底签名全局撞车+虚增计数(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: def _node_size(node: ast.AST) -> int:
@@ -25,28 +25,46 @@ def _node_size(node: ast.AST) -> int:
def _key(node: ast.AST) -> str: def _key(node: ast.AST) -> str:
"""规范化结构签名:交换律 binop 左右子树排序后拼接.""" """规范化结构签名:交换律 binop 左右子树排序后拼接.
兜底分支递归编码全部非元数据子节点——同类型未知节点只有子树也
同构才撞签,不再 `?TypeName` 全局等价(codex review CRITICAL).
"""
if isinstance(node, ast.BinOp) and isinstance(node.op, _COMMUTATIVE): if isinstance(node, ast.BinOp) and isinstance(node.op, _COMMUTATIVE):
lk, rk = _key(node.left), _key(node.right) lk, rk = _key(node.left), _key(node.right)
a, b = sorted((lk, rk)) a, b = sorted((lk, rk))
return f"({a}|{b}|{type(node.op).__name__})" return f"({a}|{b}|{type(node.op).__name__})"
if isinstance(node, ast.BinOp): if isinstance(node, ast.BinOp):
return f"({_key(node.left)}>{_key(node.right)}|{type(node.op).__name__})" 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): if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
args = ",".join(_key(a) for a in node.args) 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): if isinstance(node, ast.Name):
return f"#{node.id}" return f"#{node.id}"
if isinstance(node, ast.Constant): if isinstance(node, ast.Constant):
return f"#{node.value!r}" 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]: def _all_subtree_keys(node: ast.AST) -> dict[str, int]:
"""子树签名→节点数(同签名取最大).""" """子树签名→节点数(同签名取最大)."""
skip = _call_func_ids(node)
out: dict[str, int] = {} out: dict[str, int] = {}
for sub in ast.walk(node): for sub in ast.walk(node):
if _is_meta(sub): if _is_meta(sub) or id(sub) in skip:
continue continue
k = _key(sub) k = _key(sub)
out[k] = max(out.get(k, 0), _node_size(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: def similarity(expr_a: str, expr_b: str) -> float | None:
"""最大公共子树节点数 / 较小表达式节点数;任一解析失败=None. """最大公共子树节点数 / 较小表达式节点数;解析失败或深树爆栈=None.
取 .body 剥掉 Expression 包装节点——否则其签名落 ? 兜底桶, 取 .body 剥掉 Expression 包装节点——否则其签名落 ? 兜底桶,
任意两表达式的根都会撞签(子树=全树,相似度恒 1.0). 任意两表达式的根都会撞签(子树=全树,相似度恒 1.0).
RecursionError 防护:factor_guard _DEPTH_MAX=12 挡真实注册深树,
此处捕爆栈如实 None,不 raise(codex review LOW).
""" """
try: try:
ta = ast.parse(expr_a, mode="eval").body ta = ast.parse(expr_a, mode="eval").body
tb = ast.parse(expr_b, 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 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], def top_similar(expression: str, candidates: dict[str, str],
floor: float = 0.6) -> list[dict]: floor: float = 0.6, limit: int = 5) -> list[dict]:
"""对候选池按相似度排序,过滤低于 floor 的(提示非硬拒).""" """对候选池按相似度排序,过滤低于 floor 的(提示非硬拒).
limit=5 上限:注册路径 registry 每条只存前 5 提示,防大池刷屏
(codex review MEDIUM).
"""
out: list[dict] = [] out: list[dict] = []
for name, expr in candidates.items(): for name, expr in candidates.items():
if not expression or not expr: if not expression or not expr:
continue continue
r = similarity(expression, expr) try:
r = similarity(expression, expr)
except RecursionError:
continue
if r is not None and r >= floor: if r is not None and r >= floor:
out.append({"name": name, "ratio": r}) out.append({"name": name, "ratio": r})
out.sort(key=lambda h: h["ratio"], reverse=True) 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 argparse
import json import json
import os import os
import re
import socket import socket
import sys import sys
from datetime import datetime from datetime import datetime
@@ -88,7 +89,9 @@ def factor_stats(points: list[dict]) -> dict:
by_year: dict[str, list[float]] = {} by_year: dict[str, list[float]] = {}
for p in points: for p in points:
m, v = str(p.get("month") or ""), p.get("ic_mean") 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) by_year.setdefault(m[:4], []).append(v)
return {"icAll": round(mean_ic, 6), "tAll": t_all, return {"icAll": round(mean_ic, 6), "tAll": t_all,
"positiveRatio": round(sum(1 for v in ics if v > 0) / n, 4), "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(): for name, entry in registry["factors"].items():
cur = current_points.get(name) or {} cur = current_points.get(name) or {}
hist = list(history_points.get(name, [])) 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: if cur.get("t") is not None:
hist = [p for p in hist if p["month"] != month_key] + [point] hist = [p for p in hist if p["month"] != month_key] + [point]
monthly_points[name] = sorted(hist, key=lambda p: p["month"]) 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(): 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", r = similarity("ts_mean(close, 5) / volume",
"ts_mean(close, 5) * turnover") "ts_mean(close, 5) * turnover")
assert r is not None and 0.6 < r < 1.0 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) assert all(h["ratio"] >= 0.6 for h in hits)
# 空表达式/坏 candidates 不炸 # 空表达式/坏 candidates 不炸
assert top_similar("close", {"fa_x": ""}) == [] 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 assert rep["collective"]["is_collective_decay"] is False
# 观察面: s01_buzz 无批记录 → red # 观察面: s01_buzz 无批记录 → red
assert rep["observations"]["s01_buzz"]["status"] == "red" assert rep["observations"]["s01_buzz"]["status"] == "red"
# 当月点已存入 monthly_points(下月续算原料) # 当月点已存入 monthly_points(下月续算原料;codex review 后带 ic_mean)
assert rep["monthly_points"]["pledge_net_chg"][-1] == {"month": "2026-09", "t": 1.2} 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): 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) s = factor_stats(dirty)
assert s["icAll"] == 0.1 assert s["icAll"] == 0.1
assert s["byYear"] == {} # 空 month 不进逐年 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,如实