Files
AgentLab/backend/tests/test_compare_engine.py
T
ThearchyHelios d176ff77ff 运行对比复核修正(第 1 轮):输入的出处追到合并查询的上游、Agent 引用的列和行段,没出处的数、数据没变数却变了的不再报成数据变化
出处判定(每个指标的分类靠它):
- 合并查询:读合并结果的指标按别名一路追到喂它的每一份上游快照(嵌套的合并也追),上游 SQL 结构变了是 sql_changed,
  不拆、不归因;合并节点多接、少接了输入也算结构变化
- 图里写死的字面量改了('paid' 改成 'refunded'、系数、LIMIT):执行的 SQL 只差字面量,但图里的 SQL 改过,算模板变化;
  图里一字未改(定时运行代入的日期)才是参数。图里的 SQL 只改了空白、大小写的不算改过
- Agent 按出处交来的字段:还要引用同一列、同一行(段),两边都引用整份结果的不算换了;换了列、换了行段的说不清
  (incomparable),不再把没引用的区域算成「数据没了」去归因
- 模型、人直接交出的值(Agent 没按出处交、模型调用、人工审批……):值变了说不清是数据还是模型本身,判 incomparable;
  它自己查的库结构变了、配置变了照常标出
- 代码等确定性节点:上游的查询结果两次一模一样、交出的值却变了的,判 incomparable,不再说「变化来自上游数据」;
  上游只有运行输入变了的算参数
- 图级变化落到节点上:连线有增删的终点节点、默认设置改了且没有自行设置这几项的模型类节点,都算改了配置;
  「模板和取数的 SQL 都没有变化」只在整张图都没变时说
- 运行输入是列表、对象的,按还原出来的完整值比,长度不变、内容变了的是参数变化
- 同一节点按 SQL 配对时先配字面量也相同的:Agent 换了先后查东区、南区,两条各自配上自己,不再配成两条「只有参数不同」
- 一边记不出输入是哪个节点写的,说清是哪一边没记录(不再写出「None」)

口径与数值:
- 口径变了的指标,喂它的取数也变了、说不清的照样写进说明(只在一边才有的输入是改表达式带来的,不重复说);
  分类次序写进 compare() 的说明
- 千分位和不分组互换只影响显示,写进说明、不算口径变化;比率与否不同才算
- 升级前的口径卡没记小数位、显示格式:按那次运行图里的指标定义核对,定义也没有就不比,不再误报「小数位由 未设置 改为 1」
- 百分比:基准极小、这次很大时十进制量化放不下,退回浮点算,不再整个对比报 500
- 没设小数位的指标拆分:三个数按同一精度量化(最多 15 位有效数字),转成浮点交出去后两效应之和仍逐位等于总变化
- 维度归因标出用的是哪个输入、两边各自哪份快照(path、base_artifact、target_artifact)

接口:已经结束、只是没有封存清单的运行,409 说「没有封存清单,不能用于对比」,不再说「运行结束后才能对比」。

测试:补上复核指出的必测项(只一边对得上不给占比、只截断基准一侧、换了数据源、按旧口径小数位取整、写它的节点多查一次、
口径和 SQL 一起变不归因、小数位和比率变化、日期类型和常量列不当维度、合计行浮点误差、只改名称、结束过又接着运行的不当
基准),以及上面每一处修正的用例。32 处变异逐条打红验证,全部被测试抓到。
2026-10-05 10:20:29 +08:00

1076 lines
59 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""运行对比的纯函数(engine/compare.py):同一个工作流的两次运行,每个指标的变化拆成数据、口径、模板 / SQL。
两次运行都是照真实运行的事件形状手写的虚构数据(区域、金额、日期都是编的):取数节点 fetch 查一条按区域汇总的
SQL,Agent 节点 bot 自己再查一次、把各区域的金额作为数组字段交给口径卡,口径卡算几个指标。工件放在字典里。守的是:
- 只数据变(SQL 一字不差、结果变了)是 data_only;只改日期字面量是 params_only;SQL 结构变了是 sql_changed、不拆
- 口径变了(表达式、口径版本)是 caliber_and_data:旧表达式的叶子路径在这次的输入里都取得到时严格拆分,
口径效应 + 数据效应 = 总变化(逐位相等);取不到就不拆,说明缺的是哪一个
- 维度归因:按维度键配对两边的行,按变化的绝对值取前 10;度量列之和等于输入的值(两边都等)才给占比,占比之和
为 1;对不上时占比为空并说明;截断的快照不归因,照实写
- 遮罩列的值绝不出现在结果里;用到遮罩列的指标不拆分
- 模板对比:新增、删除的节点,配置有变化的节点和配置项名
- 输入的出处:合并查询追到它的上游查询;Agent 按出处交的字段还要引用同一列、同一段行;没按出处交的数、上游数据
没变数却变了的,说不清变化从哪来(incomparable);图里写死的字面量改了、默认设置和连线变了都算模板变化
"""
from __future__ import annotations
import copy
import json
from decimal import Decimal
from app.engine import compare
from app.engine.merge_query import MERGE_SOURCE
from tests.fixtures.compare_runs import (
BASE_ROWS,
COLUMNS,
EAST,
NEXT_ROWS,
REFUND,
TOOL,
WEEK37,
Fake,
amounts_in,
cell_in,
chain,
default_metrics,
metric,
node,
region_sql,
scalar_in,
week,
weekly_graph,
)
def run_compare(base, target):
result = compare.compare(base.side(), target.side())
json.dumps(result, allow_nan=False) # 结果要能原样交给接口
return result
def by_id(result):
return {m["metric_id"]: m for m in result["metrics"]}
def exact(value):
return Decimal(repr(value)) if isinstance(value, float) else Decimal(value)
# --------------------------------------------------------------------------
# 分类:只数据变、只参数变、SQL 结构变
# --------------------------------------------------------------------------
def test_same_sql_new_rows_is_data_only():
result = run_compare(week("r1"), week("r2", NEXT_ROWS))
metrics = by_id(result)
east, total = metrics["east"], metrics["total"]
assert (east["classification"], total["classification"]) == ("data_only", "data_only")
assert (east["base"], east["target"], east["delta"]) == (1200.5, 1500.5, 300.0)
assert (east["base_rendered"], east["target_rendered"], east["delta_rendered"]) == ("1,200.5元", "1,500.5元", "+300.0元")
assert east["delta_pct"] == round(300 / 1200.5 * 100, 2)
assert east["card_id"] == "card" and east["card_title"] == "周报口径卡" and east["name"] == "东区金额"
assert east["decomposition"] is None and east["decomposition_note"] is None and east["reasons"]
assert total["delta"] == 200.0 and total["delta_rendered"] == "+200.0元"
assert result["summary"] == {"unchanged": 0, "data_only": 2, "caliber_and_data": 0, "sql_changed": 0,
"params_only": 0, "added": 0, "removed": 0, "incomparable": 0}
# 输入两边的值都列出来,带着各自的查询快照
[cell] = east["inputs"]
assert (cell["path"], cell["base"], cell["target"]) == (EAST, 1200.5, 1500.5)
assert cell["base_artifact"] != cell["target_artifact"]
# 查询:节点 + 第几次 + 工具配上对,SQL 一字不差
assert [(q["node_id"], q["ordinal"], q["structure"]) for q in result["queries"]] == [
("fetch", 1, "same"), ("bot", 1, "same")]
fetch = result["queries"][0]
assert fetch["node_title"] == "区域取数" and fetch["base_sql"] == fetch["target_sql"] == region_sql()
assert fetch["literals"] == [] and (fetch["base_rows"], fetch["target_rows"]) == (3, 3)
assert (fetch["base_truncated"], fetch["target_truncated"]) == (False, False)
assert result["template"]["same_graph"] is True and result["template"]["nodes"] == []
assert (result["base"]["id"], result["target"]["id"]) == ("r1", "r2")
def test_identical_runs_are_unchanged():
result = run_compare(week("r1"), week("r2"))
assert {m["classification"] for m in result["metrics"]} == {"unchanged"}
east = by_id(result)["east"]
assert east["delta"] == 0 and east["delta_rendered"] is None and east["attribution"] is None
assert result["summary"]["unchanged"] == 2
def test_only_date_literals_changed_is_params_only():
# 图里的 SQL 是模板(两次一样),执行时代进去的日期不同
graph = weekly_graph(region_sql(("{{ input.start }}", "{{ input.end }}")))
result = run_compare(week("r1", graph=graph), week("r2", NEXT_ROWS, sql=region_sql(WEEK37), graph=graph))
metrics = by_id(result)
assert {m["classification"] for m in metrics.values()} == {"params_only"}
assert any("参数" in r for r in metrics["east"]["reasons"])
fetch = result["queries"][0]
assert fetch["structure"] == "params_only"
assert fetch["literals"] == [{"base": "'2026-08-31'", "target": "'2026-09-07'"},
{"base": "'2026-09-06'", "target": "'2026-09-13'"}]
# 定时运行换了日期属于参数变化:模板(图)一样
assert result["summary"]["params_only"] == 2 and result["template"]["same_graph"] is True
def test_a_changed_sql_structure_is_flagged_not_split():
paid = region_sql(extra=" AND status = 'paid'")
result = run_compare(week("r1"), week("r2", NEXT_ROWS, sql=paid))
metrics = by_id(result)
assert {m["classification"] for m in metrics.values()} == {"sql_changed"}
east = metrics["east"]
assert east["decomposition"] is None and east["attribution"] is None
assert any("区域取数" in r and "SQL" in r for r in east["reasons"])
fetch = result["queries"][0]
assert fetch["structure"] == "changed" and fetch["literals"] == []
assert any("status = 'paid'" in d for d in fetch["differences"])
# 图里 fetch 的 SQL 也改了:模板对比列出它和改了的配置项
[changed] = result["template"]["nodes"]
assert (changed["node_id"], changed["change"], changed["keys"]) == ("fetch", "config", ["args.sql"])
assert result["template"]["same_graph"] is False
# --------------------------------------------------------------------------
# 口径变了:严格拆分
# --------------------------------------------------------------------------
def net_metrics(expression):
"""净额指标 net:表达式由调用方定(v1 只取金额,v2 减去退款)。"""
def build(fetch, bot, rows, bot_rows):
gmv, refund = rows[0][1], rows[0][2]
inputs = [cell_in(0, "gmv", gmv, fetch)]
value = gmv
if REFUND in expression:
inputs.append(cell_in(0, "refund", refund, fetch))
value = gmv - refund
return [metric("net", expression, value, inputs, name="东区净额")]
return build
def test_a_caliber_change_is_split_into_caliber_and_data_effects():
base = week("r1", metrics=net_metrics(EAST), version="v1")
target = week("r2", NEXT_ROWS, metrics=net_metrics(f"{EAST} - {REFUND}"), version="v2")
net = by_id(run_compare(base, target))["net"]
assert net["classification"] == "caliber_and_data"
assert any("表达式" in r for r in net["reasons"]) and any("v1" in r and "v2" in r for r in net["reasons"])
assert (net["base"], net["target"], net["delta"]) == (1200.5, 1380.5, 180.0)
split = net["decomposition"]
assert net["decomposition_note"] is None
# 口径效应 = f_new(x_new) − f_old(x_new) = 1380.5 − 1500.5;数据效应 = f_old(x_new) − f_old(x_old) = 1500.5 − 1200.5
assert (split["caliber_effect"], split["data_effect"]) == (-120.0, 300.0)
assert (split["caliber_rendered"], split["data_rendered"]) == ("-120.0元", "+300.0元")
assert exact(split["caliber_effect"]) + exact(split["data_effect"]) == exact(net["delta"])
def test_the_split_adds_up_exactly_with_awkward_decimals():
"""小数位不同、浮点相减有误差的数:两效应之和逐位等于总变化。"""
def build(expression, decimals):
def make(fetch, bot, rows, bot_rows):
gmv, refund = rows[0][1], rows[0][2]
inputs = [cell_in(0, "gmv", gmv, fetch), cell_in(0, "refund", refund, fetch)]
value = round(eval(expression.replace(EAST, repr(gmv)).replace(REFUND, repr(refund))), decimals)
return [metric("ratio", expression, value, inputs, decimals=decimals, unit="")]
return make
base_rows = [["东区", 1000.1, 0.3], *BASE_ROWS[1:]]
next_rows = [["东区", 1000.75, 0.2], *NEXT_ROWS[1:]]
base = week("r1", base_rows, metrics=build(f"{EAST} * 0.1 - {REFUND}", 2))
target = week("r2", next_rows, metrics=build(f"{EAST} * 0.3 + {REFUND}", 3), version="v2")
ratio = by_id(run_compare(base, target))["ratio"]
split = ratio["decomposition"]
assert split is not None, ratio["decomposition_note"]
# f_old(x_new) 按旧口径的小数位取整:1000.75 * 0.1 - 0.2 = 99.875,取 2 位是 99.88(按新口径的 3 位是 99.875)
assert exact(split["data_effect"]) == exact(99.88) - exact(ratio["base"])
assert exact(split["caliber_effect"]) + exact(split["data_effect"]) == exact(ratio["delta"])
assert exact(ratio["delta"]) == exact(ratio["target"]) - exact(ratio["base"])
def test_a_version_bump_with_the_same_formula_is_all_data():
base = week("r1", metrics=net_metrics(EAST), version="v1")
target = week("r2", NEXT_ROWS, metrics=net_metrics(EAST), version="v2")
net = by_id(run_compare(base, target))["net"]
assert net["classification"] == "caliber_and_data"
assert (net["decomposition"]["caliber_effect"], net["decomposition"]["data_effect"]) == (0, 300.0)
assert net["decomposition"]["caliber_rendered"] == "0.0元"
def test_no_split_when_an_old_leaf_is_not_recorded_this_time():
base = week("r1", metrics=net_metrics(f"{EAST} - {REFUND}"), version="v1")
target = week("r2", NEXT_ROWS, metrics=net_metrics(EAST), version="v2")
net = by_id(run_compare(base, target))["net"]
assert net["classification"] == "caliber_and_data" and net["decomposition"] is None
assert REFUND in net["decomposition_note"] and "无法严格拆分" in net["decomposition_note"]
# 总变化照算
assert net["delta"] == 1500.5 - (1200.5 - 100.0)
def test_no_split_when_the_sql_changed_too():
base = week("r1", metrics=net_metrics(EAST), version="v1")
target = week("r2", NEXT_ROWS, sql=region_sql(extra=" AND status = 'paid'"), metrics=net_metrics(EAST), version="v2")
net = by_id(run_compare(base, target))["net"]
assert net["classification"] == "caliber_and_data" and net["decomposition"] is None
assert "SQL" in net["decomposition_note"]
# 两条不同的查询的行不拿来按维度配对
assert net["attribution"] is None
def test_a_caliber_change_still_names_the_sql_change():
"""口径变了、SQL 结构也变了、旧口径的叶子这次又没记录:分类按口径,SQL 的变化照样写进说明。"""
base = week("r1", metrics=net_metrics(f"{EAST} - {REFUND}"), version="v1")
paid = region_sql(extra=" AND status = 'paid'")
target = week("r2", NEXT_ROWS, sql=paid, metrics=net_metrics(EAST), version="v2")
net = by_id(run_compare(base, target))["net"]
assert net["classification"] == "caliber_and_data" and net["decomposition"] is None
assert any("区域取数" in r and "SQL" in r for r in net["reasons"]), net["reasons"]
# 退款那一格只在旧口径里用到:改表达式带来的,不再另说一遍「只在一边有记录」
assert not any("只在一边" in r for r in net["reasons"]), net["reasons"]
def test_a_unit_change_is_not_subtracted():
def build(unit):
def make(fetch, bot, rows, bot_rows):
return [metric("east", EAST, rows[0][1], [cell_in(0, "gmv", rows[0][1], fetch)], unit=unit)]
return make
east = by_id(run_compare(week("r1", metrics=build("元")), week("r2", NEXT_ROWS, metrics=build("万元"))))["east"]
assert east["classification"] == "caliber_and_data" and any("单位" in r for r in east["reasons"])
assert east["delta"] is None and east["delta_rendered"] is None and east["decomposition"] is None
assert "单位" in east["decomposition_note"]
def test_display_settings_that_change_the_value_are_caliber_changes():
def build(**kw):
def make(fetch, bot, rows, bot_rows):
return [metric("east", EAST, rows[0][1], [cell_in(0, "gmv", rows[0][1], fetch)], **kw)]
return make
def east(before, after):
return by_id(run_compare(week("r1", metrics=build(**before)), week("r2", NEXT_ROWS, metrics=build(**after))))
# 小数位改变取整
found = east({"decimals": 1}, {"decimals": 0})
assert found["east"]["classification"] == "caliber_and_data" and any("小数位" in r for r in found["east"]["reasons"])
# 比率与否改变数值的含义
found = east({"unit": "", "fmt": "plain"}, {"unit": "", "fmt": "percent_of_ratio"})
assert found["east"]["classification"] == "caliber_and_data" and found["east"]["delta"] is None
# 千分位和不分组只影响显示:分类照数据算,变化写进说明
result = run_compare(week("r1", metrics=build(fmt="thousands")), week("r2", NEXT_ROWS, metrics=build(fmt="plain")))
assert by_id(result)["east"]["classification"] == "data_only"
assert any("显示格式" in n for n in result["notes"])
def test_an_old_card_without_display_settings_is_not_a_caliber_change():
"""升级前封存的运行:卡上的指标没记小数位、显示格式和输入(当时按定义里的小数位取过整)。"""
def old(run_id, value, graph=None):
run = Fake(run_id, graph=graph)
run.tool("fetch", region_sql())
run.agent("bot", region_sql())
run.card([{"id": "east", "name": "东区金额", "unit": "元", "value": value, "expression": EAST}])
return run
def new(run_id, value):
run = Fake(run_id)
fetch = run.tool("fetch", region_sql())
run.agent("bot", region_sql())
run.card([metric("east", EAST, value, [cell_in(0, "gmv", value, fetch)], name="东区金额")])
return run
east = by_id(run_compare(old("r1", 1200.5), new("r2", 1200.5)))["east"]
assert (east["classification"], east["reasons"]) == ("unchanged", [])
east = by_id(run_compare(old("r1", 1200.5), new("r2", 1500.5)))["east"]
assert east["classification"] == "incomparable" and east["reasons"] == [compare.NO_LINEAGE]
# 那次运行的图里写着定义:按定义里的小数位核对
graph = weekly_graph()
graph["nodes"][3]["data"]["config"]["metrics"] = [{"id": "east", "expression": EAST, "decimals": 2}]
east = by_id(run_compare(old("r1", 1200.5, graph), new("r2", 1200.5)))["east"]
assert east["classification"] == "caliber_and_data" and any("小数位由 2 改为 1" in r for r in east["reasons"])
def test_the_split_adds_up_in_json_without_fixed_decimals():
"""没设小数位的指标:三个数各自转成浮点也要逐位相加(接口按 JSON 交回的就是浮点)。"""
def build(expression):
def make(fetch, bot, rows, bot_rows):
gmv, refund = rows[0][1], rows[0][2]
inputs = [cell_in(0, "gmv", gmv, fetch), cell_in(0, "refund", refund, fetch)]
value = eval(expression.replace(EAST, repr(gmv)).replace(REFUND, repr(refund)))
return [metric("m", expression, value, inputs, decimals=None, unit="")]
return make
base_rows = [["东区", 0.1, 0.2], *BASE_ROWS[1:]]
next_rows = [["东区", 0.7, 0.1], *NEXT_ROWS[1:]]
base = week("r1", base_rows, metrics=build(f"{EAST} + {REFUND}")) # 0.30000000000000004
target = week("r2", next_rows, metrics=build(f"{EAST} * 3 + {REFUND}"), version="v2") # 2.1999999999999997
m = by_id(run_compare(base, target))["m"]
split = m["decomposition"]
assert split is not None, m["decomposition_note"]
assert exact(split["caliber_effect"]) + exact(split["data_effect"]) == exact(m["delta"])
assert abs(m["delta"] - (m["target"] - m["base"])) < 1e-12
def test_a_tiny_base_does_not_break_the_percentage():
"""基准是浮点误差那么小的数、这次很大:百分比写得出来(或者为空),对比不能整个失败。"""
def build(value):
def make(fetch, bot, rows, bot_rows):
m = metric("diff", EAST, 1.0, [cell_in(0, "gmv", rows[0][1], fetch)], decimals=None)
m["value"], m["rendered"] = value, "—"
return [m]
return make
diff = by_id(run_compare(week("r1", metrics=build(0.30000000000000004 - 0.3)),
week("r2", NEXT_ROWS, metrics=build(1e8))))["diff"]
assert diff["delta"] == 1e8 - (0.30000000000000004 - 0.3)
assert diff["delta_pct"] is not None and diff["delta_pct"] > 1e25
# --------------------------------------------------------------------------
# 维度归因
# --------------------------------------------------------------------------
def test_attribution_pairs_keys_and_shares_add_up_to_one():
base, target = week("r1"), week("r2", NEXT_ROWS)
total = by_id(run_compare(base, target))["total"]
attribution = total["attribution"]
assert attribution["columns"] == ["region"] and attribution["measure"] == "gmv"
# 归因用的是哪个输入、两边各自哪份快照
assert (attribution["path"], attribution["base_artifact"], attribution["target_artifact"]) == (
"vars.kpi.amounts", base.queries[1], target.queries[1])
assert attribution["total_delta"] == 200.0
rows = attribution["rows"]
assert [(r["key"], r["base"], r["target"], r["delta"]) for r in rows] == [
(["东区"], 1200.5, 1500.5, 300.0), (["南区"], 800.0, 700.0, -100.0), (["西区"], 600.0, 600.0, 0.0)]
assert [r["share"] for r in rows] == [1.5, -0.5, 0.0]
assert abs(sum(r["share"] for r in rows) - 1) < 1e-12
def test_attribution_pairs_by_key_not_by_row_position():
"""这次多了一个区域、顺序也变了:按区域配对,只有一边有的那一边写空。"""
next_rows = [["北区", 90.0, 0.0], ["东区", 1500.5, 120.0], ["西区", 600.0, 10.0], ["南区", 700.0, 40.0]]
total = by_id(run_compare(week("r1"), week("r2", next_rows)))["total"]
rows = {tuple(r["key"]): r for r in total["attribution"]["rows"]}
assert rows[("北区",)]["base"] is None and rows[("北区",)]["target"] == 90.0 and rows[("北区",)]["delta"] == 90.0
assert rows[("东区",)]["delta"] == 300.0 and rows[("南区",)]["delta"] == -100.0
assert abs(sum(r["share"] for r in rows.values()) - 1) < 1e-12
def test_attribution_keeps_the_ten_largest_changes():
names = [f"{n}区" for n in "一二三四五六七八九十甲乙"]
base_rows = [[name, 100.0, 0.0] for name in names]
next_rows = [[name, 100.0 + (i + 1) * 10 * (-1) ** i, 0.0] for i, name in enumerate(names)]
total = by_id(run_compare(week("r1", base_rows), week("r2", next_rows)))["total"]
rows = total["attribution"]["rows"]
assert len(rows) == 10
assert [abs(r["delta"]) for r in rows] == [120.0, 110.0, 100.0, 90.0, 80.0, 70.0, 60.0, 50.0, 40.0, 30.0]
assert "12" in total["attribution"]["note"]
# 占比按全部维度值的总变化算:前 10 个之和不必为 1,全部之和为 1
assert total["attribution"]["total_delta"] == sum((i + 1) * 10 * (-1) ** i for i in range(12))
def test_no_share_when_the_measure_does_not_add_up_to_the_input():
"""东区金额只是快照里的一格:各区域之和不等于它,各键的变化照列,占比为空并说明。"""
east = by_id(run_compare(week("r1"), week("r2", NEXT_ROWS)))["east"]
attribution = east["attribution"]
assert attribution["measure"] == "gmv" and [r["key"] for r in attribution["rows"]] == [["东区"], ["南区"], ["西区"]]
assert all(r["share"] is None for r in attribution["rows"]) and attribution["note"]
def test_no_share_when_only_one_side_adds_up():
"""基准运行里东区就是全部(各区域之和等于它),这次南区也有了:只有一边对得上,不给占比。"""
base_rows = [["东区", 100.0, 0.0], ["南区", 0.0, 0.0], ["西区", 0.0, 0.0]]
next_rows = [["东区", 150.0, 0.0], ["南区", 20.0, 0.0], ["西区", 0.0, 0.0]]
east = by_id(run_compare(week("r1", base_rows), week("r2", next_rows)))["east"]
attribution = east["attribution"]
assert east["classification"] == "data_only" and len(attribution["rows"]) == 3
assert all(r["share"] is None for r in attribution["rows"]) and compare.NO_SHARE in attribution["note"]
def test_a_total_row_summed_in_floating_point_still_adds_up():
"""合计行是库里按浮点加出来的(0.1 + 0.2 + 0.3 = 0.6000000000000001):差在最后几位也算相等,照给占比。"""
base_rows = [["东区", 0.1, 0.0], ["南区", 0.2, 0.0], ["西区", 0.3, 0.0], ["合计", 0.1 + 0.2 + 0.3, 0.0]]
next_rows = [["东区", 0.4, 0.0], ["南区", 0.2, 0.0], ["西区", 0.3, 0.0], ["合计", 0.4 + 0.2 + 0.3, 0.0]]
def build(fetch, bot, rows, bot_rows):
return [metric("all", "cell(nodes.fetch, 3, 'gmv')", rows[3][1], [cell_in(3, "gmv", rows[3][1], fetch)],
decimals=None)]
found = by_id(run_compare(week("r1", base_rows, metrics=build), week("r2", next_rows, metrics=build)))["all"]
rows = found["attribution"]["rows"]
assert [r["key"] for r in rows] == [["东区"], ["南区"], ["西区"]]
assert abs(sum(r["share"] for r in rows) - 1) < 1e-9
def test_date_typed_and_constant_columns_are_not_dimensions():
"""日期类型的列(两边有相同的日子也不算)和两边都只有一个值的列(币种)不当维度。"""
columns = ["day", "region", "currency", "gmv"]
types = {"day": "date", "region": "text", "currency": "text", "gmv": "number"}
def make(run_id, rows):
run = Fake(run_id)
sql = "SELECT day, region, currency, SUM(amount) AS gmv FROM orders GROUP BY day, region, currency"
bot = run.agent("bot", sql, columns, rows, types=types)
run.card([metric("total", "sum(vars.kpi.amounts)", sum(r[3] for r in rows), [amounts_in(bot, rows)])])
return run
base_rows = [["2026-09-01", "东区", "CNY", 10.0], ["2026-09-02", "南区", "CNY", 20.0]]
next_rows = [["2026-09-02", "东区", "CNY", 15.0], ["2026-09-03", "南区", "CNY", 18.0]]
attribution = by_id(run_compare(make("r1", base_rows), make("r2", next_rows)))["total"]["attribution"]
assert attribution["columns"] == ["region"]
assert [(r["key"], r["delta"]) for r in attribution["rows"]] == [(["东区"], 5.0), (["南区"], -2.0)]
def test_a_total_row_is_left_out_of_its_own_breakdown():
"""合计行和各区域在同一份结果里:指标取合计那一格,按其余各行归因,占比之和为 1。"""
base_rows = [*BASE_ROWS, ["合计", 2600.5, 150.0]]
next_rows = [*NEXT_ROWS, ["合计", 2800.5, 170.0]]
def build(fetch, bot, rows, bot_rows):
return [metric("all", "cell(nodes.fetch, 3, 'gmv')", rows[3][1], [cell_in(3, "gmv", rows[3][1], fetch)])]
total = by_id(run_compare(week("r1", base_rows, metrics=build), week("r2", next_rows, metrics=build)))["all"]
rows = total["attribution"]["rows"]
assert ["合计"] not in [r["key"] for r in rows] and len(rows) == 3
assert abs(sum(r["share"] for r in rows) - 1) < 1e-12 and total["attribution"]["total_delta"] == 200.0
def test_a_truncated_snapshot_is_not_attributed():
base, target = week("r1"), Fake("r2")
sql = region_sql()
fetch = target.tool("fetch", sql, rows=NEXT_ROWS)
bot = target.agent("bot", sql, rows=NEXT_ROWS, truncated=True)
target.card(default_metrics(fetch, bot, NEXT_ROWS, NEXT_ROWS))
total = by_id(run_compare(base, target))["total"]
attribution = total["attribution"]
assert attribution["rows"] == [] and "截断" in attribution["note"]
assert result_queries(run_compare(base, target), "bot")["target_truncated"] is True
def test_a_truncated_base_snapshot_is_not_attributed_either():
base = Fake("r1")
fetch = base.tool("fetch", region_sql())
bot = base.agent("bot", region_sql(), truncated=True)
base.card(default_metrics(fetch, bot, BASE_ROWS, BASE_ROWS))
total = by_id(run_compare(base, week("r2", NEXT_ROWS)))["total"]
assert total["classification"] == "data_only"
assert total["attribution"]["rows"] == [] and "基准运行" in total["attribution"]["note"]
def result_queries(result, node_id):
return next(q for q in result["queries"] if q["node_id"] == node_id)
def test_a_period_column_that_never_matches_is_not_a_dimension():
"""每期都不同的列(这里是日期写成的文本)不拿来配对:按其余的维度列归因。"""
columns = ["day", "region", "gmv"]
types = {"day": "text", "region": "text", "gmv": "number"}
def make(run_id, days, values):
run = Fake(run_id)
rows = [[day, region, value] for day in days for region, value in zip(("东区", "南区"), values)]
sql = "SELECT day, region, SUM(amount) AS gmv FROM orders GROUP BY day, region"
bot = run.agent("bot", sql, columns, rows, types=types)
total = sum(r[2] for r in rows)
run.card([metric("total", "sum(vars.kpi.amounts)", total, [amounts_in(bot, rows)])])
return run
total = by_id(run_compare(make("r1", ("2026-09-05", "2026-09-06"), (10.0, 20.0)),
make("r2", ("2026-09-12", "2026-09-13"), (15.0, 18.0))))["total"]
attribution = total["attribution"]
assert attribution["columns"] == ["region"]
assert [(r["key"], r["delta"]) for r in attribution["rows"]] == [(["东区"], 10.0), (["南区"], -4.0)]
assert "day" in attribution["note"]
def test_odd_values_stay_json_safe():
"""维度列里有非数、度量加起来超出浮点范围:结果照样写得进 JSON(接口按 allow_nan=False 写,写不进就是 500)。"""
def make(run_id, rows):
run = Fake(run_id)
bot = run.agent("bot", region_sql(), ["region", "gmv"], rows, types={"region": "text", "gmv": "number"})
# 卡上的值随便取一个有限的数(这里不重算它),两次不同就行
run.card([metric("total", "sum(vars.kpi.amounts)", float(len(rows)), [amounts_in(bot, rows)])])
return run
nan = float("nan")
result = run_compare(make("r1", [["东区", 1.0], [nan, 2.0]]), make("r2", [["东区", 1e308], ["东区", 1e308], [nan, 1.0]]))
rows = {tuple(r["key"]): r for r in by_id(result)["total"]["attribution"]["rows"]}
assert rows[("东区",)]["target"] is None and rows[("nan",)]["delta"] == -1.0
# --------------------------------------------------------------------------
# 遮罩列
# --------------------------------------------------------------------------
def test_masked_columns_never_show_up():
"""数据源遮了 manager(经理名)和 refund:经理名不当维度、不出现在结果里;用到 refund 的指标输入不给值、不拆分。"""
columns = ["region", "manager", "gmv", "refund"]
types = {"region": "text", "manager": "text", "gmv": "number", "refund": "number"}
def make(run_id, rows, expression, version):
run = Fake(run_id)
sql = "SELECT region, manager, SUM(amount) AS gmv, SUM(refund) AS refund FROM orders GROUP BY region, manager"
fetch = run.tool("fetch", sql, columns, rows, types=types)
bot = run.agent("bot", sql, columns, rows, types=types)
run.masked = {fetch: frozenset({"manager", "refund"}), bot: frozenset({"manager", "refund"})}
inputs = [cell_in(0, "gmv", rows[0][2], fetch)]
value = rows[0][2]
if "refund" in expression:
inputs.append(cell_in(0, "refund", rows[0][3], fetch))
value = rows[0][2] - rows[0][3]
run.card([metric("net", expression, value, inputs),
metric("total", "sum(vars.kpi.amounts)", sum(r[2] for r in rows), [amounts_in(bot, rows)])],
version=version)
return run
base_rows = [["东区", "经理甲", 1200.5, 101.25], ["南区", "经理乙", 800.0, 52.75]]
next_rows = [["东区", "经理甲", 1500.5, 133.5], ["南区", "经理乙", 700.0, 47.25]]
result = run_compare(make("r1", base_rows, EAST, "v1"),
make("r2", next_rows, "cell(nodes.fetch, 0, 'gmv') - cell(nodes.fetch, 0, 'refund')", "v2"))
text = json.dumps(result, ensure_ascii=False)
for secret in ("经理甲", "经理乙", "101.25", "52.75", "133.5", "47.25"):
assert secret not in text, secret
metrics = by_id(result)
net = metrics["net"]
refund = next(i for i in net["inputs"] if "refund" in i["path"])
assert refund["masked"] is True and refund["base"] is None and refund["target"] is None
assert net["decomposition"] is None and "遮罩" in net["decomposition_note"]
# 维度只用没遮的 region;占比照给
attribution = metrics["total"]["attribution"]
assert attribution["columns"] == ["region"] and abs(sum(r["share"] for r in attribution["rows"]) - 1) < 1e-12
assert any("遮罩" in n for n in result["notes"])
def test_a_masked_measure_is_not_attributed():
def masked(run):
run.masked = {a: frozenset({"gmv"}) for a in run.queries}
return run
result = run_compare(masked(week("r1")), masked(week("r2", NEXT_ROWS)))
total = by_id(result)["total"]
assert total["attribution"]["rows"] == [] and "遮罩" in total["attribution"]["note"]
east = by_id(result)["east"]
assert east["inputs"][0]["base"] is None and east["inputs"][0]["masked"] is True
assert "1200.5" not in json.dumps(east["inputs"]) and "800.0" not in json.dumps(result)
# --------------------------------------------------------------------------
# 对不上具体查询的输入、运行输入、一边才有的指标
# --------------------------------------------------------------------------
def code_metrics(value):
def build(fetch, bot, rows, bot_rows):
return [metric("calc", "vars.adj * 2", value * 2, [scalar_in("vars.adj", value, "calc", "code")])]
return build
def test_an_input_from_a_code_node_follows_its_upstream():
def make(run_id, rows, value, sql=None):
run = Fake(run_id, graph=weekly_graph(sql, between=(node("calc", "code", "调整", code="print(1)",
assign_to="adj"),)))
sql = sql or region_sql()
run.tool("fetch", sql, rows=rows)
run.agent("bot", sql, rows=rows)
run.code("calc", value, "adj")
run.card(code_metrics(value)(None, None, rows, rows))
return run
# 上游什么都没变:变化来自数据
calc = by_id(run_compare(make("r1", BASE_ROWS, 10), make("r2", NEXT_ROWS, 12)))["calc"]
assert calc["classification"] == "data_only" and calc["delta"] == 4
assert calc["reasons"] == [compare.DATA_FROM_UPSTREAM]
# 上游的 SQL 结构变了:说不清它变没变,不能报成数据变化
calc = by_id(run_compare(make("r1", BASE_ROWS, 10),
make("r2", NEXT_ROWS, 12, sql=region_sql(extra=" AND status = 'paid'"))))["calc"]
assert calc["classification"] == "incomparable" and any("调整" in r for r in calc["reasons"])
# 上游查到的数据两次一模一样,它交的数却变了:变化不来自数据
calc = by_id(run_compare(make("r1", BASE_ROWS, 10), make("r2", BASE_ROWS, 12)))["calc"]
assert calc["classification"] == "incomparable" and any("调整" in r for r in calc["reasons"])
assert compare.DATA_FROM_UPSTREAM not in calc["reasons"]
def test_a_value_from_a_querying_node_that_moved_without_its_data_is_incomparable():
"""调用工具节点整份交出的结果(不是 cell() 取的格):它查到的行两次一样,交出的值(这里是查询耗时)却变了,
变化不来自数据;查到的行也变了的,照常是数据变化。"""
def make(run_id, elapsed, rows=BASE_ROWS):
run = Fake(run_id)
run.tool("fetch", region_sql(), rows=rows)
run.agent("bot", region_sql(), rows=rows)
run.card([metric("cost", "vars.res.elapsed_ms", elapsed,
[scalar_in("vars.res.elapsed_ms", elapsed, "fetch", "tool")], unit="毫秒", decimals=None)])
return run
cost = by_id(run_compare(make("r1", 3), make("r2", 5)))["cost"]
assert cost["classification"] == "incomparable" and any("区域取数" in r for r in cost["reasons"])
cost = by_id(run_compare(make("r1", 3), make("r2", 5, NEXT_ROWS)))["cost"]
assert cost["classification"] == "data_only"
def test_data_from_upstream_is_only_claimed_when_the_graph_is_the_same():
"""改了它下游的节点(成果字段):它的上游没变,变化照样来自上游数据,但不能说「模板没有变化」。"""
def make(run_id, rows, value, graph):
run = Fake(run_id, graph=graph)
run.tool("fetch", region_sql(), rows=rows)
run.agent("bot", region_sql(), rows=rows)
run.code("calc", value, "adj")
run.card(code_metrics(value)(None, None, rows, rows))
return run
base_graph = weekly_graph(between=(node("calc", "code", "调整", code="print(1)", assign_to="adj"),))
target_graph = copy.deepcopy(base_graph)
target_graph["nodes"][-1]["data"]["config"] = {"fields": [{"name": "周报", "value": "{{ nodes.write.text }}"}]}
calc = by_id(run_compare(make("r1", BASE_ROWS, 10, base_graph), make("r2", NEXT_ROWS, 12, target_graph)))["calc"]
assert calc["classification"] == "data_only"
assert calc["reasons"] == [compare.DATA_FROM_UPSTREAM_SCOPED]
def test_the_code_node_itself_changed_is_a_template_change():
def make(run_id, code, value):
run = Fake(run_id, graph=weekly_graph(between=(node("calc", "code", "调整", code=code, assign_to="adj"),)))
run.tool("fetch", region_sql())
run.agent("bot", region_sql())
run.code("calc", value, "adj")
run.card(code_metrics(value)(None, None, BASE_ROWS, BASE_ROWS))
return run
calc = by_id(run_compare(make("r1", "x = 1", 10), make("r2", "x = 2", 11)))["calc"]
assert calc["classification"] == "sql_changed" and any("调整" in r for r in calc["reasons"])
def test_rewired_edges_are_a_template_change():
"""调整节点改由取数节点接进来(各节点的配置都没动):它交的数变了算模板变化,不能说变化来自上游数据。"""
calc_node = node("calc", "code", "调整", code="result = 1", assign_to="adj")
base_graph = weekly_graph(between=(calc_node,))
target_graph = copy.deepcopy(base_graph)
target_graph["edges"] = [e for e in target_graph["edges"] if (e["source"], e["target"]) != ("bot", "calc")]
target_graph["edges"].append({"source": "fetch", "target": "calc"})
def make(run_id, graph, value):
run = Fake(run_id, graph=graph)
run.tool("fetch", region_sql())
run.agent("bot", region_sql())
run.code("calc", value, "adj")
run.card(code_metrics(value)(None, None, BASE_ROWS, BASE_ROWS))
return run
result = run_compare(make("r1", base_graph, 10), make("r2", target_graph, 12))
calc = by_id(result)["calc"]
assert calc["classification"] == "sql_changed" and any("连线" in r for r in calc["reasons"]), calc["reasons"]
assert compare.EDGES_CHANGED in result["notes"]
def uncited(run_id, share, *, graph=None, sqls=None):
"""Agent 没开按出处核对,口径卡用了它交的占比 vars.kpi.share;东区金额照旧取取数节点的那一格。"""
run = Fake(run_id, graph=graph)
fetch = run.tool("fetch", region_sql())
run.plain_agent("bot", sqls or [region_sql()], {"kpi": {"share": share}})
run.card([metric("east", EAST, BASE_ROWS[0][1], [cell_in(0, "gmv", BASE_ROWS[0][1], fetch)], name="东区金额"),
metric("share", "vars.kpi.share * 100", share * 100, [scalar_in("vars.kpi.share", share, "bot", "agent")],
unit="%", fmt="plain")])
return run
def test_a_number_the_agent_gave_without_citing_is_incomparable():
"""Agent 没按出处交的数:两次查到的数据一样,数却变了(模型本身的差异),不能说是数据变化。"""
result = run_compare(uncited("r1", 0.46), uncited("r2", 0.52))
share = by_id(result)["share"]
assert share["classification"] == "incomparable" and share["attribution"] is None
assert any("区域分析" in r and "查询快照" in r for r in share["reasons"]), share["reasons"]
assert by_id(result)["east"]["classification"] == "unchanged"
# 交的数一样:照常没变
assert by_id(run_compare(uncited("r1", 0.46), uncited("r2", 0.46)))["share"]["classification"] == "unchanged"
def test_an_extra_query_by_the_writer_is_a_sql_change():
extra = "SELECT COUNT(*) AS n FROM refunds"
share = by_id(run_compare(uncited("r1", 0.46), uncited("r2", 0.52, sqls=[region_sql(), extra])))["share"]
assert share["classification"] == "sql_changed" and any("多了一次查询" in r for r in share["reasons"])
def test_a_changed_default_model_is_a_template_change_for_agents_that_follow_it():
base_graph = weekly_graph()
base_graph["defaults"] = {"provider": "anthropic", "model": "model-a"}
target_graph = copy.deepcopy(base_graph)
target_graph["defaults"]["model"] = "model-b"
result = run_compare(uncited("r1", 0.46, graph=base_graph), uncited("r2", 0.52, graph=target_graph))
share = by_id(result)["share"]
assert share["classification"] == "sql_changed" and any("默认设置" in r for r in share["reasons"]), share["reasons"]
assert result["template"]["same_graph"] is False and compare.DEFAULTS_CHANGED in result["notes"]
# 取数节点不用模型:东区金额不受影响
assert by_id(result)["east"]["classification"] == "unchanged"
# Agent 自己指定了模型:默认设置变了它不跟着变
for graph in (base_graph, target_graph):
graph["nodes"][2]["data"]["config"]["model"] = "model-x"
share = by_id(run_compare(uncited("r1", 0.46, graph=base_graph), uncited("r2", 0.52, graph=target_graph)))["share"]
assert share["classification"] == "incomparable"
def cited(run_id, rows=BASE_ROWS, *, column="gmv", span=None):
"""Agent 按出处交回数组字段:引用快照的哪一列、哪一段行由调用方定。"""
run = Fake(run_id)
run.tool("fetch", region_sql(), rows=rows)
bot = run.agent("bot", region_sql(), rows=rows, column=column, span=span)
first, last = span or (0, len(rows) - 1)
values = [r[COLUMNS.index(column)] for r in rows[first:last + 1]]
run.card([metric("total", "sum(vars.kpi.amounts)", sum(values),
[amounts_in(bot, rows, column=column, span=span)])])
return run
def test_an_agent_citing_another_column_is_not_a_data_change():
"""SQL 一字不差、数据一模一样,Agent 这次引用的是退款列:取的不是同一个数。"""
total = by_id(run_compare(cited("r1"), cited("r2", column="refund")))["total"]
assert total["classification"] == "incomparable" and total["attribution"] is None
assert any("gmv" in r and "refund" in r for r in total["reasons"]), total["reasons"]
def test_an_agent_citing_other_rows_is_not_a_data_change():
"""Agent 这次只引用了后两行:东区的数还在快照里,不能把它算成「东区的数据没了」。"""
total = by_id(run_compare(cited("r1"), cited("r2", span=(1, 2))))["total"]
assert total["classification"] == "incomparable" and total["attribution"] is None
assert any("行" in r for r in total["reasons"]), total["reasons"]
# 两边引用的都是整份结果(这次多了一个区域):照常是数据变化
total = by_id(run_compare(cited("r1"), cited("r2", [*NEXT_ROWS, ["北区", 90.0, 0.0]])))["total"]
assert total["classification"] == "data_only" and total["attribution"]["rows"]
def test_an_unknown_writer_on_one_side_is_named_plainly():
"""一边记不出 vars.adj 是哪个节点写的:说清是哪一边没记录,不写「None」。"""
def make(run_id, writer, value):
run = Fake(run_id, graph=weekly_graph(between=(node("calc", "code", "调整", code="x", assign_to="adj"),)))
run.tool("fetch", region_sql())
run.agent("bot", region_sql())
run.code("calc", value, "adj")
item = scalar_in("vars.adj", value, writer, "code" if writer else "state")
run.card([metric("calc", "vars.adj * 2", value * 2, [item])])
return run
calc = by_id(run_compare(make("r1", None, 10), make("r2", "calc", 12)))["calc"]
assert calc["classification"] == "incomparable"
assert not any("None" in r for r in calc["reasons"]) and any("基准运行" in r for r in calc["reasons"])
def test_a_list_run_input_is_compared_by_its_content():
"""运行输入是列表:卡上只记了「[2 项]」,按入口节点的产出还原出完整值再比。"""
def make(run_id, targets):
run = Fake(run_id, inputs={"targets": targets})
run.add("node.started", "start", {"node_type": "input"})
run.add("node.finished", "start", {"artifact": run.put({"targets": targets})})
item = {"path": "input.targets", "value": f"[{len(targets)} 项]", "node_id": "start", "via": "input",
"field": "targets", "status": "ok"}
run.card([metric("goal", "sum(input.targets)", float(sum(targets)), [item])])
return run
goal = by_id(run_compare(make("r1", [100.0, 200.0]), make("r2", [100.0, 500.0])))["goal"]
assert goal["classification"] == "params_only" and any("targets" in r for r in goal["reasons"]), goal["reasons"]
goal = by_id(run_compare(make("r1", [100.0, 200.0]), make("r2", [100.0, 200.0])))["goal"]
assert goal["classification"] == "unchanged"
def test_a_run_input_change_is_a_parameter_change():
def make(run_id, goal):
run = Fake(run_id, inputs={"goal": goal})
run.add("node.started", "start", {"node_type": "input"})
run.add("node.finished", "start", {"artifact": run.put({"goal": goal})})
fetch = run.tool("fetch", region_sql())
run.agent("bot", region_sql())
run.card([metric("reach", f"round({EAST} / input.goal * 100, 1)", round(1200.5 / goal * 100, 1),
[cell_in(0, "gmv", 1200.5, fetch), scalar_in("input.goal", goal, "start", "input")],
unit="%", fmt="plain")])
return run
result = run_compare(make("r1", 1000), make("r2", 1500))
reach = by_id(result)["reach"]
assert reach["classification"] == "params_only" and any("input.goal" in r for r in reach["reasons"])
assert reach["delta_rendered"] == "-40.0 个百分点"
assert any("goal" in n for n in result["notes"])
def test_metrics_and_cards_on_one_side_only():
def extra(fetch, bot, rows, bot_rows):
return [*default_metrics(fetch, bot, rows, bot_rows),
metric("west", "cell(nodes.fetch, 2, 'gmv')", rows[2][1], [cell_in(2, "gmv", rows[2][1], fetch)])]
base = week("r1")
target = week("r2", metrics=extra)
target.card([metric("cnt", "len(vars.kpi.amounts)", 3, [amounts_in(target.queries[1], BASE_ROWS)], unit="个",
decimals=None)], node_id="card2", caliber="补充口径")
result = run_compare(base, target)
metrics = by_id(result)
assert metrics["west"]["classification"] == "added" and metrics["west"]["base"] is None
assert metrics["cnt"]["classification"] == "added" and metrics["cnt"]["card_id"] == "card2"
assert result["summary"]["added"] == 2
swapped = by_id(run_compare(target, base))
assert swapped["west"]["classification"] == "removed" and swapped["west"]["target"] is None
def test_an_unreadable_card_makes_its_metrics_incomparable():
base, target = week("r1"), week("r2", NEXT_ROWS)
base.tampered.add(base.cards[0][1])
base.cards = [] # 接口只交回复验过哈希的口径卡
result = run_compare(base, target)
assert {m["classification"] for m in result["metrics"]} == {"incomparable"}
assert any("哈希" in r for r in by_id(result)["east"]["reasons"])
def test_a_card_executed_several_times_uses_the_last_one():
base = week("r1")
target = Fake("r2")
fetch = target.tool("fetch", region_sql(), rows=NEXT_ROWS)
bot = target.agent("bot", region_sql(), rows=NEXT_ROWS)
target.card(default_metrics(fetch, bot, BASE_ROWS, BASE_ROWS))
target.card(default_metrics(fetch, bot, NEXT_ROWS, NEXT_ROWS), runs=2)
result = run_compare(base, target)
assert by_id(result)["east"]["target"] == 1500.5
assert any("执行了 3 次" in n for n in result["notes"])
def test_verify_queries_are_left_out():
base = week("r1")
target = Fake("r2")
fetch = target.tool("fetch", region_sql(), rows=NEXT_ROWS)
bot = target.agent("bot", region_sql(), rows=NEXT_ROWS, verify=["SELECT COUNT(*) AS n FROM orders"])
target.card(default_metrics(fetch, bot, NEXT_ROWS, NEXT_ROWS))
result = run_compare(base, target)
assert [(q["node_id"], q["structure"]) for q in result["queries"]] == [("fetch", "same"), ("bot", "same")]
# --------------------------------------------------------------------------
# 查询配对、模板对比
# --------------------------------------------------------------------------
def test_queries_fall_back_to_normalized_sql_pairing():
"""节点换了 ID:按「节点 + 第几次 + 工具」配不上,再按规范化 SQL 配(大小写、空白不同也认)。"""
base = week("r1")
target = Fake("r2", graph=weekly_graph())
target.tool("pull", region_sql(WEEK37).lower(), rows=NEXT_ROWS)
target.agent("bot", region_sql(WEEK37), rows=NEXT_ROWS)
result = run_compare(base, target)
pull = result_queries(result, "pull")
assert pull["structure"] == "params_only" and pull["base_sql"] == region_sql()
assert len(result["queries"]) == 2
def test_an_agent_asking_in_another_order_is_paired_by_sql():
"""Agent 这次换了查询的先后:同一节点里先按规范化 SQL 配,不按第几次硬配成两条「结构变了」。"""
by_region = region_sql()
by_day = "SELECT day, SUM(amount) AS gmv FROM orders WHERE day >= '2026-08-31' GROUP BY day"
def make(run_id, sqls):
run = Fake(run_id)
run.add("node.started", "bot", {"node_type": "agent"})
for i, sql in enumerate(sqls):
artifact = run.snapshot(sql, ["k", "gmv"], [["甲", 1.0]])
run.add("tool.start", "bot", {"tool": "db_query__shop", "args": {"sql": sql}, "call_id": f"c{i}"})
run.add("tool.end", "bot", {"tool": "db_query__shop", "call_id": f"c{i}", "query_artifact": artifact})
run.add("node.finished", "bot", {"duration_ms": 1, "attempt": 1})
return run
result = run_compare(make("r1", [by_region, by_day]),
make("r2", [by_day.replace("2026-08-31", "2026-09-07"), region_sql(WEEK37)]))
pairs = [(q["structure"], q["base_sql"][:20]) for q in result["queries"]]
assert pairs == [("params_only", by_day[:20]), ("params_only", by_region[:20])]
def test_swapped_queries_with_the_same_literals_pair_up_exactly():
"""Agent 两次都查了东区和南区,只是先后互换:先配字面量也相同的,两条都没变;下游的数照常是数据变化。"""
def region(name):
return f"SELECT SUM(amount) AS gmv FROM orders WHERE region = '{name}'"
def make(run_id, regions, value, gmv):
run = Fake(run_id, graph=weekly_graph(between=(node("calc", "code", "调整", code="x", assign_to="adj"),)))
run.plain_agent("bot", [region(r) for r in regions], {"text": "ok"}, ["gmv"], [[gmv]], types={"gmv": "number"})
run.code("calc", value, "adj")
run.card(code_metrics(value)(None, None, None, None))
return run
result = run_compare(make("r1", ["东区", "南区"], 10, 1.0), make("r2", ["南区", "东区"], 12, 2.0))
assert [(q["structure"], q["literals"]) for q in result["queries"]] == [("same", []), ("same", [])]
assert by_id(result)["calc"]["classification"] == "data_only"
def test_a_switched_data_source_is_a_structure_change():
"""SQL 一字不差、换了数据源:查的不是同一份数据。"""
result = run_compare(week("r1"), week("r2", NEXT_ROWS, source="shop_backup"))
fetch = result_queries(result, "fetch")
assert fetch["structure"] == "changed" and any("shop_backup" in d for d in fetch["differences"])
assert by_id(result)["east"]["classification"] == "sql_changed"
def test_a_literal_edited_in_the_workflow_is_a_template_change():
"""图里写死的筛选值改了('paid' 改成 'refunded'):执行的 SQL 只差字面量,但这是改模板,不是运行时代入的参数。"""
paid, refunded = region_sql(extra=" AND status = 'paid'"), region_sql(extra=" AND status = 'refunded'")
result = run_compare(week("r1", sql=paid, graph=weekly_graph(paid)),
week("r2", NEXT_ROWS, sql=refunded, graph=weekly_graph(refunded)))
east = by_id(result)["east"]
assert east["classification"] == "sql_changed", east["reasons"]
assert any("区域取数" in r and "'paid'" in r for r in east["reasons"])
assert east["decomposition"] is None and east["attribution"] is None
# 查询表照实写:执行的 SQL 确实只差这一处字面量
assert result_queries(result, "fetch")["structure"] == "params_only"
def test_reformatting_the_workflow_sql_is_not_a_change():
loose = region_sql().replace(" FROM ", "\n FROM ").lower()
result = run_compare(week("r1"), week("r2", NEXT_ROWS, sql=loose, graph=weekly_graph(loose)))
assert {m["classification"] for m in result["metrics"]} == {"data_only"}
def test_renaming_a_node_is_not_a_change():
graph = weekly_graph()
graph["nodes"][2]["data"]["label"] = "区域分析(新)"
result = run_compare(week("r1"), week("r2", NEXT_ROWS, graph=graph))
assert {m["classification"] for m in result["metrics"]} == {"data_only"}
[renamed] = result["template"]["nodes"]
assert (renamed["node_id"], renamed["keys"]) == ("bot", ["label"])
MERGE_SQL = "SELECT region, gmv FROM a ORDER BY region"
OUTER_SQL = "SELECT region, gmv FROM m ORDER BY region"
MERGED_TYPES = {"region": "text", "gmv": "number"}
def merged(run_id, sql, rows, *, template=None, nested=False, twice=False):
"""取数节点查一次,合并节点把它当输入 a 再执行一条合并 SQL(nested:再套一层合并;twice:这次多接一个输入 b),
口径卡取合并结果的一格。"""
nodes = [node("start", "input", "开始"), node("fetch", "tool", "区域取数", tool=TOOL, args={"sql": template or sql}),
node("join", "merge", "合并", sql=MERGE_SQL, inputs={"a": "fetch", **({"b": "fetch"} if twice else {})})]
if nested:
nodes.append(node("outer", "merge", "再合并", sql=OUTER_SQL, inputs={"m": "join"}))
nodes.append(node("card", "metrics", "口径卡", caliber="周报口径", caliber_version="v1"))
run = Fake(run_id, graph=chain(*nodes))
fetch = run.tool("fetch", sql, rows=rows)
table = [[r[0], r[1]] for r in rows]
inputs = [("a", "fetch", fetch)] + ([("b", "fetch", fetch)] if twice else [])
top, owner = run.merge("join", MERGE_SQL, inputs, ["region", "gmv"], table, types=MERGED_TYPES), "join"
if nested:
top, owner = run.merge("outer", OUTER_SQL, [("m", "join", top)], ["region", "gmv"], table,
types=MERGED_TYPES), "outer"
run.card([metric("east", f"cell(nodes.{owner}, 0, 'gmv')", table[0][1],
[cell_in(0, "gmv", table[0][1], top, node_id=owner)], name="东区金额")])
return run
def test_a_merged_cell_follows_the_queries_feeding_the_merge():
"""合并 SQL 一字没改,喂它的取数 SQL 加了筛选:读合并结果的指标是 SQL 变了,不拆、不归因(嵌套的合并也追到底)。"""
paid = region_sql(extra=" AND status = 'paid'")
for nested in (False, True):
east = by_id(run_compare(merged("r1", region_sql(), BASE_ROWS, nested=nested),
merged("r2", paid, NEXT_ROWS, nested=nested)))["east"]
assert east["classification"] == "sql_changed", (nested, east["reasons"])
assert east["decomposition"] is None and east["attribution"] is None
assert any("区域取数" in r and "SQL" in r for r in east["reasons"]), east["reasons"]
# 上游只是代进去的日期不同:参数变化
template = region_sql(("{{ input.start }}", "{{ input.end }}"))
east = by_id(run_compare(merged("r1", region_sql(), BASE_ROWS, template=template),
merged("r2", region_sql(WEEK37), NEXT_ROWS, template=template)))["east"]
assert east["classification"] == "params_only", east["reasons"]
# 合并 SQL 和上游都没变:数据变化
east = by_id(run_compare(merged("r1", region_sql(), BASE_ROWS), merged("r2", region_sql(), NEXT_ROWS)))["east"]
assert east["classification"] == "data_only", east["reasons"]
# 合并节点这次多接了一个输入
east = by_id(run_compare(merged("r1", region_sql(), BASE_ROWS), merged("r2", region_sql(), NEXT_ROWS, twice=True)))
assert east["east"]["classification"] == "sql_changed" and any("「b」" in r for r in east["east"]["reasons"])
def test_an_agent_reconfigured_with_the_same_sql_is_a_template_change():
"""Agent 改了提示词、SQL 一字不差:它按出处交来的字段可能换了取法,不能报成数据变化。"""
graph = weekly_graph()
graph["nodes"][2]["data"]["config"]["prompt"] = "按区域列出金额,从大到小"
result = run_compare(week("r1"), week("r2", NEXT_ROWS, graph=graph))
metrics = by_id(result)
assert metrics["total"]["classification"] == "sql_changed"
assert any("区域分析" in r for r in metrics["total"]["reasons"])
# 取数节点的格子不经过这个 Agent:照旧是数据变化
assert metrics["east"]["classification"] == "data_only"
def test_a_merge_query_is_compared_by_its_merge_sql():
"""合并查询只记在节点的台账里(没有 tool.end),SQL 在快照和 merge.end 里;快照文件不在了也比得了。"""
def make(run_id, sql, *, drop=False):
run = Fake(run_id)
run.tool("fetch", region_sql())
snap = {"columns": ["region", "gmv"], "rows": [["东区", 1.0]], "truncated": False, "sql": sql,
"source": MERGE_SOURCE, "sources": ["shop"]}
artifact = run.put(snap)
run.add("node.started", "join", {"node_type": "merge"})
run.add("merge.end", "join", {"sql": sql, "query_artifact": artifact, "rows": 1})
run.add("node.finished", "join", {"duration_ms": 1, "attempt": 1, "evidence": [
{"kind": "query", "node_id": "join", "exec": 1, "artifact": artifact, "source": MERGE_SOURCE,
"columns": ["region", "gmv"], "rows": 1, "truncated": False}]})
if drop:
del run.store[artifact]
return run
result = run_compare(make("r1", "SELECT region, gmv FROM a WHERE gmv > 10"),
make("r2", "SELECT region, gmv FROM a WHERE gmv > 20"))
join = result_queries(result, "join")
assert (join["ordinal"], join["structure"], join["literals"]) == (1, "params_only", [{"base": "10", "target": "20"}])
result = run_compare(make("r1", "SELECT region, gmv FROM a WHERE gmv > 10", drop=True),
make("r2", "SELECT region, gmv FROM a WHERE gmv > 10 AND region <> '西区'"))
join = result_queries(result, "join")
assert join["structure"] == "changed" and join["base_sql"] == "SELECT region, gmv FROM a WHERE gmv > 10"
assert "无法读取" in join["problem"]
def test_a_node_run_in_a_loop_is_paired_execution_by_execution():
def make(run_id, regions):
run = Fake(run_id)
for region in regions:
run.tool("fetch", f"SELECT SUM(amount) AS gmv FROM orders WHERE region = '{region}'", ["gmv"], [[1.0]],
types={"gmv": "number"})
return run
result = run_compare(make("r1", ["东区", "南区", "西区"]), make("r2", ["东区", "北区"]))
assert [(q["ordinal"], q["structure"]) for q in result["queries"]] == [(1, "same"), (2, "params_only"), (3, "removed")]
assert result["queries"][1]["literals"] == [{"base": "'南区'", "target": "'北区'"}]
def test_unpaired_queries_are_added_or_removed():
base = week("r1")
target = week("r2")
target.tool("extra", "SELECT COUNT(*) AS n FROM refunds", ["n"], [[7]], types={"n": "number"})
result = run_compare(base, target)
extra = result_queries(result, "extra")
assert extra["structure"] == "added" and extra["base_sql"] is None and extra["target_rows"] == 1
removed = result_queries(run_compare(target, base), "extra")
assert removed["structure"] == "removed" and removed["target_sql"] is None
def test_the_template_lists_added_removed_and_reconfigured_nodes():
base_graph = weekly_graph(between=(node("calc", "code", "调整", code="x = 1"),))
target_graph = weekly_graph(region_sql(WEEK37), between=(node("check", "dual_check", "双盲复算"),))
target_graph["nodes"][-2]["data"]["label"] = "写周报(新)"
target_graph["nodes"][-1]["data"]["config"] = {"fields": [{"name": "周报", "value": "{{ nodes.write.text }}"}]}
base = week("r1", graph=base_graph)
target = week("r2", graph=target_graph, version="v1")
base.version, target.version = 3, 4
template = run_compare(base, target)["template"]
assert (template["base_version"], template["target_version"], template["same_graph"]) == (3, 4, False)
changes = {n["node_id"]: (n["change"], n["keys"]) for n in template["nodes"]}
assert changes == {"fetch": ("config", ["args.sql"]), "check": ("added", []), "write": ("config", ["label"]),
"out": ("config", ["fields"]), "calc": ("removed", [])}
labels = {n["node_id"]: n["labels"] for n in template["nodes"]}
assert labels["write"] == ["节点名称"] and labels["out"] == ["成果字段"]
# 配置的值(这里是 SQL 和模板)一个字都不展示
assert "2026-09" not in json.dumps(template, ensure_ascii=False)
def test_a_missing_snapshot_file_is_reported_and_withheld():
"""基准运行的快照文件不在了:SQL 从 tool.start 的参数里取,结构照比;输入的值核对不了遮没遮,不给;不归因。"""
base, target = week("r1"), week("r2", NEXT_ROWS)
fetch = base.queries[0]
del base.store[fetch]
result = run_compare(base, target)
query = result_queries(result, "fetch")
assert query["structure"] == "same" and query["base_sql"] == region_sql() and query["base_rows"] is None
assert "无法读取" in query["problem"]
east = by_id(result)["east"]
assert east["classification"] == "data_only" and east["inputs"][0]["base"] is None
assert east["attribution"]["rows"] == [] and "无法读取" in east["attribution"]["note"]
def test_runs_without_metric_cards_still_compare_queries():
base, target = Fake("r1"), Fake("r2")
base.tool("fetch", region_sql())
target.tool("fetch", region_sql(WEEK37), rows=NEXT_ROWS)
result = run_compare(base, target)
assert result["metrics"] == [] and sum(result["summary"].values()) == 0
assert any("没有口径卡" in n for n in result["notes"])
assert [q["structure"] for q in result["queries"]] == ["params_only"]
def test_the_comparison_does_not_touch_the_runs():
base, target = week("r1"), week("r2", NEXT_ROWS)
before = (list(base.events), list(target.events), dict(base.store), dict(target.store))
run_compare(base, target)
assert (base.events, target.events, base.store, target.store) == before