运行对比加固(第 2 轮):行段要把引用过的每个区域切在同一处——一边整个被引用、另一边只引用了一部分的,引用的行数没变少也说不清;只在一边有的区域那一边算整个被引用;引用的行不从第 1 行开始时还要是它同样的那几行;单格只看那一行是不是同一个区域

- 一个区域在一边整个被引用、另一边只引用了一部分(其余的行还在快照里):上一轮只在「有行在外面的那一边引用得少」时判说不清,南区这次多了一天、引用的行只盖住前两天(行数没变少,甚至更多)照样报成只数据变,归因和「真的只有这几天」的对照一字不差,反方向连正负号都反了;现在按切法比:两边都整个被引用,或者都只引用了它同样的那几行,才是同一组数
- 只在一边的快照里有的区域,没有它的那一边算整个被引用:北区这次新出现、三天只引用了两天,基准运行南区只引用了一天、这次南区没了,都说不清;新出现、没有了的区域整个被引用的照常是数据变化
- 引用的行不从第 1 行开始时,按「排在引用的行前面的行数、引用的行数」认引用的是它的哪几行:两边都只引用了东区一行,基准是它金额第二高的那天、这次是最高的那天,说不清,说明里写出两边排在前面的行数
- 单格(数组元素)只看引用的那一行两次是不是同一个区域,不套行段的切法:东区两天还是一天,第 1 行都是东区的第一天;第 1 行的东区真的没有了照常比
- 说明说引用的行外面有它的那一边,两边都有的说引用得少的那一边;几个区域切法都不同时说基准运行引用的行里先出现的那个
- 截断的快照仍按取回的行比(和引用整份结果一致,挤到截断点后面的行看不到,不做归因),写进文档
- 新测试:一边整个一边部分(这次多一天、这次引用得更多、反方向,各带真的只有这几天的对照,占比之和为 1)、切在同一处(两边都只引用南区第一天仍是数据变化)、行数不同两个方向的说明、几个区域都不同时说哪一个、排在前面的行数不同、只在一边有的区域(新出现、没有了)、两列维度一起认(含一边只有一个值的区域列)、单格只看那一行、挤出两行时的行号和行数(两个方向)、正好 3 行不写「等」、两个 Agent 都交得不同时点名画布上靠前的、单边遮罩时整体说明提到遮罩
This commit is contained in:
ThearchyHelios
2026-10-05 14:47:05 +08:00
parent 36848f0a1e
commit 44e4597037
2 changed files with 322 additions and 39 deletions
+64 -26
View File
@@ -23,10 +23,12 @@
结果时,再按别名一路追到喂它的每一份上游快照。只差字面量时还要看图里的 SQL:图里的 SQL 改过(改了写死的筛选值、
系数)是模板变化,图里一字未改的才是运行时代入的参数
- Agent 按出处交来的字段还要引用同一列、同一段行(两边都引用整份结果的不算换了;行段相同、一边是整份结果一边
不是的算换了;行号相同、都不是整份结果的,按维度值数两边各引用了几行,一边引用得少、那一边的快照里引用的行外面
还有它的行,也算换了——取前几名时名次变了、按区域排序时多了一个区域、区域 × 日期的结果里一个区域有一天被挤出
引用的行;单格引用的那一边快照读不出来、核对不了是不是同一个维度值的,和行段一样说不清):取哪一列、哪几行是
Agent 每次运行时定的,换了就不是同一个数
不是的算换了;行号相同、都不是整份结果的,按维度值数两边各引用了它几行、引用的行外面还有没有它的行:引用过的
每个维度值两边要被切在同一处——都整个被引用(一边的快照里根本没有它的,那一边算整个被引用),或者都只引用了
它同样的那几行(排在引用的行前面的行数、引用的行数都一样)——否则也算换了:取前几名时名次变了、按区域排序时
多了一个区域、区域 × 日期的结果里一个区域有一天被挤出引用的行、一个区域多了一天却只引用了前几天;单格只看引用
的那一行两次是不是同一个维度值;单格引用的那一边快照读不出来、核对不了是不是同一个维度值的,和行段一样说不清):
取哪一列、哪几行是 Agent 每次运行时定的,换了就不是同一个数
- 不落在快照上的:模型、人交出的值(Agent 没按出处交、模型调用、人工审批……)没有出处,值变了说不清是数据还是它
本身的差异;代码、数据整形这类节点看它自己查过的库,不查库的看它的配置和画布上的全部上游;记不出写入者的看
口径卡之前可能执行过的节点(口径卡自己、只在它下游的不算)。上游的查询结果两次一模一样、值却变了的同样说不清;
@@ -1075,8 +1077,9 @@ class _Compare:
def _citation(self, agent: Any, b: Any, t: Any, qb: _Query, qt: _Query) -> _Change | None:
"""Agent 按出处交来的字段两次引用的是不是同一处:同一列(对象数组是同一组列)、同一行或同一段行。两边引用的都是
整份结果的算同一处(这次多了几行也是);行段相同、一边是整份结果一边不是的不算——没引用的那几行还在这次的快照
里,按维度配对会把它们算成「数据没了」。行号相同、都不是整份结果的,还要引用同一批维度值:一个维度值在一边
引用的行更少、那一边引用的行外面还有它的行的不算(_left_out)。不是同一处、核对不了是不是同一处的,返回说明。"""
里,按维度配对会把它们算成「数据没了」。行号相同、都不是整份结果的,还要引用同一批维度值(_left_out):行段
要把引用过的每个维度值切在同一处(两边都整个被引用,或者都只引用了它同样的那几行),单格要引用同一个维度值。
不是同一处、核对不了是不是同一处的,返回说明。"""
name = f"Agent「{self.title(agent)}」" if agent else "Agent"
lb, lt = _locator(b), _locator(t)
cb, ct = lb.get("column") or lb.get("columns"), lt.get("column") or lt.get("columns")
@@ -1107,12 +1110,27 @@ class _Compare:
"这次引用了整份结果,取的不是同一组数")
def _left_out(self, name: str, what: str, b: Any, t: Any, qb: _Query, qt: _Query, column: Any) -> _Change | None:
"""行号相同、都不是整份结果的引用,引用的是不是同一批维度值(区域……)。按维度键分别数两边各引用了几行:某个键
在一边引用的行更少、那一边的快照里引用的行外面还有它的行,取的就不是同一组数——按区域排序的结果多了北区,西区
整个挪出了引用的行段;按金额取前 3 名,北区挤了进来;区域 × 日期的结果东区多了一天,南区的第二天被挤到引用的
行外面。按维度配对会把还在快照里的那几行算成「数据没了」,和真的少了一天分不出来。只看「引用的行里有没有这个
键」查不出最后一种:南区两边都被引用过,只是有一行挪了出去。那一边的快照里引用的行外面也没有的,是数据真的
少了,照常比。
"""行号相同、都不是整份结果的引用,引用的是不是同一批维度值(区域……)。按维度键分别数两边引用了它几行、引用
的行外面还有没有它的行:
- 行段(口径卡按引用的几行一起算):引用过的每个维度值,两边要被切在同一处——都整个被引用(引用的行外面没有
它的行;那一边的快照里根本没有它的,也算整个被引用,没什么可漏的),或者都只引用了它同样的那几行(按查询
结果的先后数:它排在引用的行前面的行数一样、引用的行数也一样,两次都是南区的第一天)。切法不同就不是同一组
数:按区域排序的结果多了北区,西区整个挪出了引用的行段;区域 × 日期的结果东区多了一天,南区的第二天被挤到
引用的行外面;南区这次多了一天、引用的行只盖住前两天(引用的行数没变少,南区却从整个被引用变成只引用了一
部分);北区这次新出现,三天里只引用了两天;引用的行不从第 1 行开始,东区两次都只引用了一行,基准运行是它
金额第二高的那天、这次是最高的那天。按维度配对会把还在快照里、只是没被引用的行算成数据变化,和真的少了、
真的多了分不出来,正负号都可能反。只比引用的行数查不出后三种,只看「引用的行里有没有这个键」连挤出一部分都
查不出。
- 单格(数组字段里的一个元素):只看引用的那一行两次是不是同一个维度值;不是的,一边引用的那个维度值在另一边的
快照里、只是不在这一行,取的不是同一个数。单格只有一行,没有切法可比:东区两天还是一天,第 1 行都是东区的
第一天。
- 一边引用了、另一边快照里没有的,是真的没有了(或者这次才有),照常比。快照被截断的按取回的行比,和引用整份
结果时一样(系统的行数上限当作查询的一部分,像 SQL 里的 LIMIT):挤到截断点后面的行看不到,和真的没有了
分不出来——截断的快照不做归因,_attribute 写明截断。
说明里说的是引用的行外面有它的那一边(两边都有的,是引用得少的那一边;行数一样、只是引用的不是它同样那几行
的,两边排在前面的行数都写出来);几个维度值切法都不同的,说基准运行引用的行里先出现的那个。
维度按两边整份结果认(和归因同一个规则,_dimensions):遮罩列不当维度,只用没遮的列认、说明里写的也只有没遮的
列的值和行号,遮住的值不进说明;分不出维度(没有不是数值的列、维度列都设了遮罩)、行的形状不对的,只能按行号
@@ -1144,20 +1162,40 @@ class _Compare:
return inside, outside
(in_b, out_b), (in_t, out_t) = split(rb, b), split(rt, t)
for key, count in in_b.items():
if in_t.get(key, 0) < count and key in out_t:
rows = out_t[key]
where = (f"这次有 {len(rows)} 行在查询结果{_row_numbers(rows)}" if key in in_t
else f"这次仍在查询结果{_row_numbers(rows)}")
return _Change("unknown", f"{name}两次都引用{_rows_text(t)},{_key_text(key)}{where},不在引用的行里,"
f"取的不是{what}")
for key, count in in_t.items():
if in_b.get(key, 0) < count and key in out_b:
rows = out_b[key]
where = (f"在基准运行中有 {len(rows)} 行位于查询结果{_row_numbers(rows)}" if key in in_b
else f"在基准运行中位于查询结果{_row_numbers(rows)}")
return _Change("unknown", f"{name}两次都引用{_rows_text(b)},{_key_text(key)}{where},不在引用的行里,"
f"取的不是{what}")
single = b.row is not None # 和 t.row 一样(_citation 只在两边行号相同时走到这里)
cited, first = _rows_text(t), _cited(t, len(rt))[0]
def cut(inside: int, outside: list[int]) -> tuple[int, int] | None:
"""行段切在这个维度值的哪儿:引用的行外面没有它的行记 None(整个被引用;快照里根本没有它的也是 None);
有的记(它排在引用的行前面的行数, 引用了它几行)——按查询结果的先后,引用的是它的第几行到第几行。"""
return (sum(1 for i in outside if i < first), inside) if outside else None
for key in dict.fromkeys([*in_b, *in_t]): # 只看引用过的:两边都没引用的不进这个数
nb, nt = in_b.get(key, 0), in_t.get(key, 0)
ob, ot = out_b.get(key, []), out_t.get(key, [])
if single:
# 两边那一行是同一个维度值,是同一处;不是的,看只在一边被引用的它在另一边的快照里还有没有——有(只是
# 不在这一行)是换了一个数,没有是真的没有了、这次才有
if nb == nt or not (ot if nb > nt else ob):
continue
else:
cb, ct = cut(nb, ob), cut(nt, ot)
if cb == ct:
continue
if cb is not None and ct is not None and nb == nt:
# 都只引用了它的一部分、行数也一样,可排在引用的行前面的行数不同:引用的不是它同样的那几行
return _Change("unknown", f"{name}两次都引用{cited},两次都引用了{_key_text(key)}的 {nb} 行,可不是"
f"它同样的那几行(它排在引用的行前面的,基准运行有 {cb[0]} 行,这次有 "
f"{ct[0]} 行),取的不是{what}")
# 说引用的行外面有它的那一边;两边都有的,说引用得少的那一边
if ot and (not ob or nt < nb):
where = (f"这次有 {len(ot)} 行在查询结果{_row_numbers(ot)}" if nt
else f"这次仍在查询结果{_row_numbers(ot)}")
else:
where = (f"在基准运行中有 {len(ob)} 行位于查询结果{_row_numbers(ob)}" if nb
else f"在基准运行中位于查询结果{_row_numbers(ob)}")
return _Change("unknown", f"{name}两次都引用{cited},{_key_text(key)}{where},不在引用的行里,"
f"取的不是{what}")
return None
def _hidden(self, b: Any, t: Any) -> frozenset[str]:
+258 -13
View File
@@ -10,11 +10,12 @@ SQL,Agent 节点 bot 自己再查一次、把各区域的金额作为数组字
- 遮罩列的值绝不出现在结果里(只有一边的快照遮了也算遮了);用到遮罩列的指标不拆分
- 模板对比:新增、删除的节点,配置有变化的节点和配置项名
- 输入的出处:合并查询追到它的上游查询;Agent 按出处交的字段还要引用同一列、同一段行(行段相同、一边是整份结果
一边不是的不算;都不是整份结果的,按区域数两边各引用了几行,一边引用得少、那一边快照里引用的行外面还有它的行的
也不算——区域整个挪出去、区域 × 日期的结果里有一天被挤出去都是;单格引用的那一边快照读不出来的说不清);没按
出处交的数、上游数据没变数却变了的、上游的 Agent 两次交出的结果不同的,说不清变化从哪来(incomparable),记不出
写入者的只看口径卡之前可能执行过的节点(循环回边连回来的算在它之前,严格拆分也照这个范围核对);图里写死的字面量
改了、连线变了、读到的默认设置变了都算模板变化
一边不是的不算;都不是整份结果的,引用过的每个区域两边要被切在同一处——都整个被引用(一边没有这个区域的算整个
被引用),或者都只引用了它同样的那几行(排在引用的行前面的行数、引用的行数都一样)——区域整个挪出去、区域 ×
日期的结果里有一天被挤出去、多了一天却只引用了前几天都不算;维度有几列的按几列一起认;单格只看那一行是不是同一个
区域,那一边快照读不出来的说不清);没按出处交的数、上游数据没变数却变了的、上游的 Agent 两次交出的结果不同的,
说不清变化从哪来(incomparable),记不出写入者的只看口径卡之前可能执行过的节点(循环回边连回来的算在它之前,严格
拆分也照这个范围核对);图里写死的字面量改了、连线变了、读到的默认设置变了都算模板变化
"""
from __future__ import annotations
@@ -40,6 +41,7 @@ from tests.fixtures.compare_runs import (
NEXT_ROWS,
REFUND,
TOOL,
TYPES,
WEEK37,
Fake,
amounts_in,
@@ -783,6 +785,11 @@ def test_an_unknown_writer_names_a_node_that_answered_differently_by_where_it_si
assert calc["classification"] == "incomparable"
assert calc["reasons"] == ["输入「vars.adj」不知道由哪个节点写入,节点「旁路分析」(Agent)两次交出的结果不同,"
"无法判断变化是否来自数据"]
# 两个都交得不同:点名画布上靠前的那个(口径卡上游的上游分析),不是后加在画布末尾的旁路分析
calc = by_id(run_compare(unknown_writer("r1", BASE_ROWS, 10),
unknown_writer("r2", NEXT_ROWS, 12, up="本周", aside="本周")))["calc"]
assert calc["reasons"] == ["输入「vars.adj」不知道由哪个节点写入,上游节点「上游分析」(Agent)两次交出的结果不同,"
"无法判断变化是否来自数据"]
def test_a_split_with_an_unknown_writer_leaves_out_the_card_and_what_follows_it():
@@ -1258,6 +1265,8 @@ def test_a_mask_on_one_side_only_still_hides_the_column():
assert not leaked, (label, where, leaked)
# 区域认不出,只能按行号认(同 test_the_same_rows_are_not_the_same_citation_when_a_cited_region_moved_out_of_them)
assert (total["classification"], total["reasons"]) == ("data_only", [compare.DATA_FROM_QUERIES]), label
# 整体说明照样提一句有列设了遮罩:归因少了区域这一列,看的人要知道是遮掉了,不是没有
assert compare.MASKED_NOTE in result["notes"], label
def test_an_input_cites_the_whole_result_only_when_it_covers_every_row():
@@ -1301,16 +1310,18 @@ def test_cited_rows_on_an_unreadable_snapshot_are_not_taken_as_the_same():
assert total["reasons"] == ["Agent「区域分析」引用的行由第 1 至 3 行改为第 2 至 3 行,取的不是同一组数"]
def element(run_id, rows, span):
"""口径卡取 Agent 数组字段 amounts 的第一个元素(vars.kpi.amounts[0]):落在快照上的是 Agent 交的行段的第一行。"""
def element(run_id, rows, span, *, sql=None, columns=COLUMNS, types=TYPES):
"""口径卡取 Agent 数组字段 amounts 的第一个元素(vars.kpi.amounts[0]):落在快照上的是 Agent 交的行段的第一行。
sql、columns、types 换查询的形状(默认是按区域汇总的那条)。"""
run = Fake(run_id)
run.tool("fetch", region_sql(), rows=rows)
bot = run.agent("bot", region_sql(), rows=rows, span=span)
row = span[0]
item = {"path": "vars.kpi.amounts[0]", "value": rows[row][1], "node_id": "bot", "via": "agent_field",
sql = sql or region_sql()
run.tool("fetch", sql, columns, rows, types=types)
bot = run.agent("bot", sql, columns, rows, types=types, span=span)
row, at = span[0], columns.index("gmv")
item = {"path": "vars.kpi.amounts[0]", "value": rows[row][at], "node_id": "bot", "via": "agent_field",
"field": "amounts[0]", "ref": f"Q1.r{row}.gmv", "artifact": bot,
"locator": {"row": row, "column": "gmv"}, "eid": f"ev:cell:{bot[:8]}:{row}:gmv", "status": "verified"}
run.card([metric("first", "vars.kpi.amounts[0]", rows[row][1], [item])])
run.card([metric("first", "vars.kpi.amounts[0]", rows[row][at], [item])])
return run
@@ -1400,8 +1411,20 @@ def test_a_region_partly_pushed_out_of_the_cited_rows_is_not_the_same_citation()
assert total["classification"] == "incomparable"
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 4 行,「南区」这次仍在查询结果第 5、6 行,不在引用的"
"行里,取的不是同一组数"]
# 挤出去的行多时只列前 3 个行号,后面写「等」(几十行全列出来说明就读不下去了)
# 东区多了一天、南区也多了一天:南区只引用了一天,两天挤在外面,挤出去的几行都写出来(写的是在外面的行数,不是
# 少引用的行数);反过来(基准运行里南区有两行在外面)也是
crowded = [["东区", "2026-09-07", 100.0], ["东区", "2026-09-08", 100.0], ["东区", "2026-09-09", 100.0],
["南区", "2026-09-07", 50.0], ["南区", "2026-09-08", 50.0], ["南区", "2026-09-09", 50.0],
["西区", "2026-09-08", 30.0]]
total = by_id(run_compare(by_day("r1", BY_DAY_BASE), by_day("r2", crowded)))["total"]
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 4 行,「南区」这次有 2 行在查询结果第 5、6 行,不在"
"引用的行里,取的不是同一组数"]
total = by_id(run_compare(by_day("r1", crowded), by_day("r2", BY_DAY_BASE)))["total"]
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 4 行,「南区」在基准运行中有 2 行位于查询结果第 5、6 "
"行,不在引用的行里,取的不是同一组数"]
# 挤出去的行多时只列前 3 个行号,后面写「等」(几十行全列出来说明就读不下去了);正好 3 行的全列出来,不写「等」
assert compare._row_numbers([4, 5, 6, 7]) == "第 5、6、7 行等"
assert compare._row_numbers([4, 5, 6]) == "第 5、6、7 行"
# 对照:南区这次真的只有一天(快照里引用的行外面没有南区):照常是数据变化,按区域归因,占比之和为 1
gone = [["东区", "2026-09-07", 100.0], ["东区", "2026-09-08", 100.0], ["东区", "2026-09-09", 100.0],
["南区", "2026-09-08", 50.0], ["西区", "2026-09-08", 30.0], ["西区", "2026-09-09", 30.0]]
@@ -1433,6 +1456,228 @@ def test_a_masked_column_beside_the_region_is_spoken_of_by_row_number():
assert not [name for name in ("经理甲", "经理乙", "经理丙", "经理丁", "经理戊", "经理庚") if name in text]
def test_a_region_cited_whole_on_one_side_and_partly_on_the_other_is_not_the_same_citation():
"""一个区域在一边整个被引用、在另一边只引用了一部分(其余的行还在快照里,在引用的行外面):引用的行数没变少、甚至
变多了,也不是同一组数。南区这次三天,引用的第 1 至 4 行只盖住了前两天,第三天(200)在第 5 行:按引用的行算南区是
100 → 20,快照里南区却是 220——归因会说南区少了 80,正负号都反了,还和「南区这次真的只有两天」的输出一字不差。东区
这次真的少了一天、南区引用了三天(比基准还多一天)、第四天在外面的也是;反方向(基准运行里南区有一天在引用的行
外面、这次整个被引用)也一样。对照:南区真的只有引用的那几天,照常是数据变化,按区域归因,占比之和为 1。"""
def attributed(base_rows, target_rows):
total = by_id(run_compare(by_day("r1", base_rows), by_day("r2", target_rows)))["total"]
assert (total["classification"], total["reasons"]) == ("data_only", [compare.DATA_FROM_QUERIES])
rows = total["attribution"]["rows"]
assert sum(exact(r["share"]) for r in rows) == 1
return [(r["key"], r["base"], r["target"], r["share"]) for r in rows]
three = [["东区", "2026-09-08", 100.0], ["东区", "2026-09-09", 100.0], ["南区", "2026-09-08", 10.0],
["南区", "2026-09-09", 10.0], ["南区", "2026-09-10", 200.0], ["西区", "2026-09-08", 30.0],
["西区", "2026-09-09", 30.0]]
total = by_id(run_compare(by_day("r1", BY_DAY_BASE), by_day("r2", three)))["total"]
assert total["classification"] == "incomparable" and total["attribution"] is None
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 4 行,「南区」这次有 1 行在查询结果第 5 行,不在引用"
"的行里,取的不是同一组数"]
# 对照:南区这次真的只有两天(10、10)
assert attributed(BY_DAY_BASE, three[:4] + three[5:]) == [
(["南区"], 100.0, 20.0, 1.0), (["东区"], 200.0, 200.0, 0.0)]
# 东区这次真的少了一天;南区四天,引用了三天(比基准还多一天),第四天在第 5 行
four = [["东区", "2026-09-08", 100.0], ["南区", "2026-09-07", 50.0], ["南区", "2026-09-08", 50.0],
["南区", "2026-09-09", 50.0], ["南区", "2026-09-10", 50.0], ["西区", "2026-09-08", 30.0]]
total = by_id(run_compare(by_day("r1", BY_DAY_BASE), by_day("r2", four)))["total"]
assert total["classification"] == "incomparable" and total["attribution"] is None
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 4 行,「南区」这次有 1 行在查询结果第 5 行,不在引用"
"的行里,取的不是同一组数"]
# 对照:南区这次真的只有三天
assert attributed(BY_DAY_BASE, four[:4] + four[5:]) == [
(["东区"], 200.0, 100.0, 2.0), (["南区"], 100.0, 150.0, -1.0)]
# 反方向:基准运行南区三天,第三天(500)在第 5 行;这次南区两天都被引用。按引用的行算是 100 → 120,基准的南区
# 实际是 600
base3 = [["东区", "2026-09-01", 100.0], ["东区", "2026-09-02", 100.0], ["南区", "2026-09-01", 50.0],
["南区", "2026-09-02", 50.0], ["南区", "2026-09-03", 500.0], ["西区", "2026-09-01", 30.0]]
now = [["东区", "2026-09-08", 100.0], ["东区", "2026-09-09", 100.0], ["南区", "2026-09-08", 60.0],
["南区", "2026-09-09", 60.0], ["西区", "2026-09-08", 30.0]]
total = by_id(run_compare(by_day("r1", base3), by_day("r2", now)))["total"]
assert total["classification"] == "incomparable" and total["attribution"] is None
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 4 行,「南区」在基准运行中有 1 行位于查询结果第 5 行,"
"不在引用的行里,取的不是同一组数"]
# 对照:基准运行南区真的只有两天
assert attributed(base3[:4] + base3[5:], now) == [
(["南区"], 100.0, 120.0, 1.0), (["东区"], 200.0, 200.0, 0.0)]
def test_a_region_cut_at_the_same_place_on_both_sides_is_the_same_citation():
"""两边都只引用了南区的一部分、引用的行数也一样:行段在同一处切开了南区(两次都是第 3 行被引用、第 4 行在外面),
取的是同一组数(南区的第一天),照常是数据变化,按区域归因,占比之和为 1——不能因为南区有行在引用的行外面就一概
说不清,每周结构一样的对比就都比不了了。引用的行数不一样的(一边引用了南区两天、另一边一天)切在了不同处,不是
同一组数,说引用得少、有行在外面的那一边。两个区域都切法不同时,说基准运行引用的行里先出现的那个。"""
before = [["东区", "2026-09-01", 100.0], ["东区", "2026-09-02", 100.0], ["南区", "2026-09-01", 50.0],
["南区", "2026-09-02", 50.0], ["西区", "2026-09-01", 30.0]]
after = [["东区", "2026-09-08", 120.0], ["东区", "2026-09-09", 110.0], ["南区", "2026-09-08", 40.0],
["南区", "2026-09-09", 50.0], ["西区", "2026-09-08", 30.0]]
total = by_id(run_compare(by_day("r1", before, span=(0, 2)), by_day("r2", after, span=(0, 2))))["total"]
assert (total["classification"], total["reasons"]) == ("data_only", [compare.DATA_FROM_QUERIES])
rows = total["attribution"]["rows"]
assert [(r["key"], r["base"], r["target"], r["share"]) for r in rows] == [
(["东区"], 200.0, 230.0, 1.5), (["南区"], 50.0, 40.0, -0.5)]
assert sum(exact(r["share"]) for r in rows) == 1
# 东区这次少了一天,南区引用了两天、第三天在第 4 行;基准运行只引用了南区一天、第二天在第 4 行:基准引用得少
more = [["东区", "2026-09-08", 100.0], ["南区", "2026-09-07", 50.0], ["南区", "2026-09-08", 50.0],
["南区", "2026-09-09", 50.0], ["西区", "2026-09-08", 30.0]]
total = by_id(run_compare(by_day("r1", before, span=(0, 2)), by_day("r2", more, span=(0, 2))))["total"]
assert total["classification"] == "incomparable" and total["attribution"] is None
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 3 行,「南区」在基准运行中有 1 行位于查询结果第 4 行,"
"不在引用的行里,取的不是同一组数"]
# 反过来,这次引用得少
total = by_id(run_compare(by_day("r1", more, span=(0, 2)), by_day("r2", before, span=(0, 2))))["total"]
assert total["classification"] == "incomparable" and total["attribution"] is None
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 3 行,「南区」这次有 1 行在查询结果第 4 行,不在引用"
"的行里,取的不是同一组数"]
# 按金额取前 3 行:基准运行东区两天都在前 3、南区一天;这次南区两天都在前 3、东区一天。东区、南区都切法不同,
# 说基准运行引用的行里先出现的东区,不是这次先出现的南区
top = BY_DAY_SQL.replace("ORDER BY region, day", "ORDER BY gmv DESC")
ranked_before = [["东区", "2026-09-01", 300.0], ["南区", "2026-09-01", 250.0], ["东区", "2026-09-02", 200.0],
["南区", "2026-09-02", 100.0], ["西区", "2026-09-01", 50.0]]
ranked_after = [["南区", "2026-09-08", 320.0], ["东区", "2026-09-08", 300.0], ["南区", "2026-09-09", 260.0],
["东区", "2026-09-09", 150.0], ["西区", "2026-09-08", 60.0]]
total = by_id(run_compare(by_day("r1", ranked_before, span=(0, 2), sql=top),
by_day("r2", ranked_after, span=(0, 2), sql=top)))["total"]
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 3 行,「东区」这次有 1 行在查询结果第 4 行,不在引用"
"的行里,取的不是同一组数"]
def test_a_region_cut_on_other_rows_of_it_is_not_the_same_citation():
"""引用的行不从第 1 行开始(按金额排的第 2 至 4 行):两边都只引用了东区的一行,行数一样,可基准运行引用的是东区
金额第二高的那天(最高的那天排在引用的行前面),这次是最高的那天(第二高的排在后面)——引用的不是它同样的那几行,
东区按引用的行算是 250 → 300,东区两天的合计却是 550 → 400。要按查询结果的先后数清引用的是它的第几行:排在引用的
行前面的行数一样、引用的行数也一样,才是同样那几行。对照:两次都是东区第二高的那天被引用,照常是数据变化。"""
top = BY_DAY_SQL.replace("ORDER BY region, day", "ORDER BY gmv DESC")
before = [["东区", "2026-09-01", 300.0], ["东区", "2026-09-02", 250.0], ["南区", "2026-09-01", 200.0],
["南区", "2026-09-02", 150.0], ["西区", "2026-09-01", 50.0]]
after = [["西区", "2026-09-08", 400.0], ["南区", "2026-09-08", 350.0], ["南区", "2026-09-09", 320.0],
["东区", "2026-09-08", 300.0], ["东区", "2026-09-09", 100.0]]
total = by_id(run_compare(by_day("r1", before, span=(1, 3), sql=top),
by_day("r2", after, span=(1, 3), sql=top)))["total"]
assert total["classification"] == "incomparable" and total["attribution"] is None
assert total["reasons"] == ["Agent「区域分析」两次都引用第 2 至 4 行,两次都引用了「东区」的 1 行,可不是它同样的"
"那几行(它排在引用的行前面的,基准运行有 1 行,这次有 0 行),取的不是同一组数"]
# 对照:两次都是东区第二高的那天被引用(最高的那天都排在前面)
same = [["东区", "2026-09-08", 320.0], ["东区", "2026-09-09", 260.0], ["南区", "2026-09-08", 210.0],
["南区", "2026-09-09", 140.0], ["西区", "2026-09-08", 60.0]]
total = by_id(run_compare(by_day("r1", before, span=(1, 3), sql=top),
by_day("r2", same, span=(1, 3), sql=top)))["total"]
assert (total["classification"], total["reasons"]) == ("data_only", [compare.DATA_FROM_QUERIES])
rows = total["attribution"]["rows"]
assert [(r["key"], r["base"], r["target"], r["share"]) for r in rows] == [
(["东区"], 250.0, 260.0, 1.0), (["南区"], 350.0, 350.0, 0.0)]
assert sum(exact(r["share"]) for r in rows) == 1
def test_a_region_on_one_side_only_counts_as_cited_whole_where_it_is_missing():
"""只在一边的快照里有的区域(这次新出现,或者这次没有了):没有它的那一边算整个被引用——没什么可漏的。另一边它只
被引用了一部分、还有行在引用的行外面的,切法不同,也不是同一组数:北区这次新出现、三天里只引用了两天,按引用的行
算北区是 20,快照里是 30,和「北区这次真的只有两天」的输出一字不差;基准运行里南区只引用了一天、另一天在外面,这次
南区没有了,归因会说南区少了 50,基准的南区实际是 100。对照:新出现、没有了的区域整个被引用的,照常是数据变化,
按区域归因,占比之和为 1。"""
# 南区这次没有了;北区新出现三天,引用的第 1 至 4 行盖住了前两天,第三天在第 5 行
north = [["东区", "2026-09-08", 100.0], ["东区", "2026-09-09", 100.0], ["北区", "2026-09-07", 10.0],
["北区", "2026-09-08", 10.0], ["北区", "2026-09-09", 10.0], ["西区", "2026-09-08", 30.0],
["西区", "2026-09-09", 30.0]]
total = by_id(run_compare(by_day("r1", BY_DAY_BASE), by_day("r2", north)))["total"]
assert total["classification"] == "incomparable" and total["attribution"] is None
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 4 行,「北区」这次有 1 行在查询结果第 5 行,不在引用"
"的行里,取的不是同一组数"]
# 对照:北区这次真的只有两天
total = by_id(run_compare(by_day("r1", BY_DAY_BASE), by_day("r2", north[:4] + north[5:])))["total"]
assert (total["classification"], total["reasons"]) == ("data_only", [compare.DATA_FROM_QUERIES])
rows = total["attribution"]["rows"]
assert [(r["key"], r["base"], r["target"], r["share"]) for r in rows] == [
(["南区"], 100.0, None, 1.25), (["北区"], None, 20.0, -0.25), (["东区"], 200.0, 200.0, 0.0)]
assert sum(exact(r["share"]) for r in rows) == 1
# 基准运行引用第 1 至 3 行,南区只引用了一天、第二天在第 4 行;这次南区没有了,新出现的北区排在东区后面
before = [["东区", "2026-09-01", 100.0], ["东区", "2026-09-02", 100.0], ["南区", "2026-09-01", 50.0],
["南区", "2026-09-02", 50.0], ["西区", "2026-09-01", 30.0]]
after = [["东区", "2026-09-08", 120.0], ["东区", "2026-09-09", 110.0], ["北区", "2026-09-08", 30.0],
["西区", "2026-09-08", 30.0]]
total = by_id(run_compare(by_day("r1", before, span=(0, 2)), by_day("r2", after, span=(0, 2))))["total"]
assert total["classification"] == "incomparable" and total["attribution"] is None
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 3 行,「南区」在基准运行中有 1 行位于查询结果第 4 行,"
"不在引用的行里,取的不是同一组数"]
# 对照:基准运行南区真的只有一天(整个被引用)
total = by_id(run_compare(by_day("r1", before[:3] + before[4:], span=(0, 2)),
by_day("r2", after, span=(0, 2))))["total"]
assert (total["classification"], total["reasons"]) == ("data_only", [compare.DATA_FROM_QUERIES])
rows = total["attribution"]["rows"]
assert [(r["key"], r["base"], r["target"], r["share"]) for r in rows] == [
(["南区"], 50.0, None, -5.0), (["东区"], 200.0, 230.0, 3.0), (["北区"], None, 30.0, 3.0)]
assert sum(exact(r["share"]) for r in rows) == 1
#: 区域 × 渠道的结果按金额从大到小排:两列都是维度
BY_CHANNEL = {"sql": "SELECT region, channel, SUM(amount) AS gmv FROM orders GROUP BY region, channel "
"ORDER BY gmv DESC",
"columns": ["region", "channel", "gmv"], "types": {"region": "text", "channel": "text", "gmv": "number"}}
def test_dimension_values_are_told_apart_by_every_dimension_column():
"""维度有两列(区域、渠道):按两列一起认是哪个维度值。按金额取前 3 行,南区 / 线上这次落到第 4 行(还在快照里),
南区 / 线下排了进来。只按区域认,南区两次都是一行被引用、一行在外面,看着切法一样,会报成数据变化(南区 / 线下
占 700%、南区 / 线上占 -600%);说明里两列的值都写出来。对照:南区 / 线上这次真的没有了,新出现的西区 / 线上排进了
前 3,照常是数据变化,按两列归因,占比之和为 1。
区域列在一边只有一个值也拿来认(基准运行的结果只有东区):两边各自都只有一个值的才分不出维度。这次北区排到第一,
东区有两天挤出了引用的前 2 行,不拿区域认就成了按行号认、报成数据变化。"""
before = [["东区", "线上", 500.0], ["东区", "线下", 400.0], ["南区", "线上", 300.0], ["南区", "线下", 100.0]]
after = [["东区", "线上", 500.0], ["东区", "线下", 400.0], ["南区", "线下", 350.0], ["南区", "线上", 200.0]]
total = by_id(run_compare(by_day("r1", before, span=(0, 2), **BY_CHANNEL),
by_day("r2", after, span=(0, 2), **BY_CHANNEL)))["total"]
assert total["classification"] == "incomparable" and total["attribution"] is None
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 3 行,「南区 / 线上」这次仍在查询结果第 4 行,不在引用"
"的行里,取的不是同一组数"]
gone = [["东区", "线上", 500.0], ["东区", "线下", 400.0], ["西区", "线上", 350.0], ["南区", "线下", 100.0]]
total = by_id(run_compare(by_day("r1", before, span=(0, 2), **BY_CHANNEL),
by_day("r2", gone, span=(0, 2), **BY_CHANNEL)))["total"]
assert (total["classification"], total["reasons"]) == ("data_only", [compare.DATA_FROM_QUERIES])
attribution = total["attribution"]
assert attribution["columns"] == ["region", "channel"]
assert [(r["key"], r["base"], r["target"], r["share"]) for r in attribution["rows"]] == [
(["西区", "线上"], None, 350.0, 7.0), (["南区", "线上"], 300.0, None, -6.0),
(["东区", "线上"], 500.0, 500.0, 0.0), (["东区", "线下"], 400.0, 400.0, 0.0)]
assert sum(exact(r["share"]) for r in attribution["rows"]) == 1
top = BY_DAY_SQL.replace("ORDER BY region, day", "ORDER BY gmv DESC")
only_east = [["东区", "2026-09-01", 100.0], ["东区", "2026-09-02", 90.0], ["东区", "2026-09-03", 80.0]]
north_first = [["北区", "2026-09-08", 200.0], ["东区", "2026-09-08", 100.0], ["东区", "2026-09-09", 95.0],
["东区", "2026-09-10", 85.0]]
total = by_id(run_compare(by_day("r1", only_east, span=(0, 1), sql=top),
by_day("r2", north_first, span=(0, 1), sql=top)))["total"]
assert total["classification"] == "incomparable" and total["attribution"] is None
assert total["reasons"] == ["Agent「区域分析」两次都引用第 1 至 2 行,「东区」这次有 2 行在查询结果第 3、4 行,不在"
"引用的行里,取的不是同一组数"]
def test_an_element_only_needs_the_same_region_on_its_row():
"""口径卡取 Agent 数组字段的第一个元素(单格):只看引用的那一行两次是不是同一个区域。区域 × 日期的结果里,基准
运行东区两天(第二天在引用的行外面)、这次只有一天:第 1 行两次都是东区的第一天,是同一个数,照常是数据变化。行段
要看每个区域被切成什么样(整个、还是只有一部分),因为口径卡按引用的几行一起算;单格只有一行,东区还有没有别的行
不改变它取的是哪个数。第 1 行的东区这次真的没有了(换成了北区,东区不在这次的快照里)也照常比;东区还在快照里、
只是不在第 1 行的说不清(test_an_element_of_a_cited_array_follows_its_row)。"""
day = {"sql": BY_DAY_SQL, "columns": BY_DAY_COLUMNS, "types": BY_DAY_TYPES}
before = [["东区", "2026-09-01", 100.0], ["东区", "2026-09-02", 90.0], ["南区", "2026-09-01", 50.0],
["西区", "2026-09-01", 30.0]]
after = [["东区", "2026-09-08", 110.0], ["南区", "2026-09-08", 60.0], ["西区", "2026-09-08", 30.0]]
first = by_id(run_compare(element("r1", before, (0, 2), **day), element("r2", after, (0, 2), **day)))["first"]
assert (first["classification"], first["reasons"]) == ("data_only", [compare.DATA_FROM_QUERIES])
first = by_id(run_compare(element("r1", after, (0, 2), **day), element("r2", before, (0, 2), **day)))["first"]
assert (first["classification"], first["reasons"]) == ("data_only", [compare.DATA_FROM_QUERIES])
gone = [["北区", "2026-09-08", 80.0], ["南区", "2026-09-08", 60.0], ["西区", "2026-09-08", 30.0]]
first = by_id(run_compare(element("r1", before, (0, 2), **day), element("r2", gone, (0, 2), **day)))["first"]
assert (first["classification"], first["reasons"]) == ("data_only", [compare.DATA_FROM_QUERIES])
def test_no_split_when_an_old_leaf_now_cites_another_column():
"""口径变了(v1 → v2),旧口径用到的 Agent 字段这次引用的是退款列:说不清它只是数据变了,不拆,说明为什么。"""
def make(run_id, column, expression, factor, version):