mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 16:52:00 +00:00
561 lines
38 KiB
Python
561 lines
38 KiB
Python
"""变化拆解(app/analysis/decompose.py,细案 01 §2):三种恒等式逐位成立、辛普森、抵消、不闭合、下钻闭合与超深、新出现 /
|
||
消失的段、比率当可加的调用错误……每个用例都顺带核对复算和运行逐字段相同(tests/analysis_samples.analyze)。"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import random
|
||
from decimal import Decimal
|
||
from fractions import Fraction
|
||
|
||
import pytest
|
||
|
||
from app.analysis import DATA_ERRORS
|
||
from app.analysis.decompose import lmdi, ratio_effects
|
||
from tests.analysis_samples import analyze, call_error, fixed_prober, notes, snap
|
||
|
||
SQL = "SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week"
|
||
TYPES = {"region": "text", "week": "text", "gmv": "number"}
|
||
WEEKLY = [["华东", "2026-W41", 820], ["华东", "2026-W42", 700], ["华北", "2026-W41", 600], ["华北", "2026-W42", 560],
|
||
["华南", "2026-W41", 700], ["华南", "2026-W42", 666], ["西南", "2026-W41", 100], ["西南", "2026-W42", 100]]
|
||
|
||
|
||
def weekly(rows=WEEKLY, **kw):
|
||
return snap("Q2", SQL, ["region", "week", "gmv"], rows, types=TYPES, **kw)
|
||
|
||
|
||
async def additive(rows=WEEKLY, *, snaps=None, **params):
|
||
params = {"query": "Q2", "by": ["region"], "period": "week", "base": "2026-W41", "target": "2026-W42",
|
||
"measure": "gmv", **params}
|
||
return await analyze("decompose", "additive", snaps=snaps or [weekly(rows)], **params)
|
||
|
||
|
||
def _rows(base: dict, target: dict) -> list[list]:
|
||
return [[k, "2026-W41", v] for k, v in base.items()] + [[k, "2026-W42", v] for k, v in target.items()]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# additive
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_a_driver_is_named_with_its_share_and_the_whole_shape_is_filled():
|
||
result = await additive(label="销售额", hypothesis="华东拖累")
|
||
assert result["method"] == "decompose.additive@1" and result["status"] == "ok"
|
||
assert result["verdict"] == {"code": "driver", "subjects": [["华东"]], "direction": "down"}
|
||
assert result["phrase"] == "销售额下降 194(-8.7%),主要来自华东(-120,占 61.9%)"
|
||
assert result["values"]["delta_total"] == "-194" and result["values"]["top_key"] == "华东"
|
||
assert result["values"]["top_share"] == "0.618556701031" and result["values"]["segments"] == "4"
|
||
table = result["table"]
|
||
assert table["columns"] == ["region", "base", "target", "delta", "pct", "share", "flag"]
|
||
assert table["labels"]["region"] == "地区" and table["labels"]["share"] == "占变化的比重"
|
||
assert table["column_types"] == {"region": "text", "base": "number", "target": "number", "delta": "number",
|
||
"pct": "number", "share": "number", "flag": "text"}
|
||
assert [r[0] for r in table["rows"]] == ["华东", "华北", "华南", "西南"] # 按变化的绝对值从大到小
|
||
assert result["settings"] == {"main_share": 0.5, "concentration": 0.8, "offset_ratio": 0.25, "flat_pct": 0.005,
|
||
"table_top_k": 10}
|
||
assert result["inputs"] == [{"ref": "Q2", "artifact": weekly()["artifact"], "role": "data",
|
||
"columns": ["region", "week", "gmv"], "rows": "all"}]
|
||
assert result["hypothesis"] == "华东拖累" and result["probes"] == []
|
||
assert result["criterion"].endswith("以各段之和为总数")
|
||
# 期次是现成的一列「2026-W41」,认不出是按哪种周号算的:本期齐不齐核对不了,只附在证据面板(不进判定语)
|
||
assert {c["code"] for c in result["caveats"]} == {"headline_basis", "comparability"}
|
||
assert result["params"]["catalog"] == {"labels": {"region": "地区"}, "measures": {"gmv": "flow"}}
|
||
assert result["params"]["today"] == "2026-10-21"
|
||
|
||
|
||
async def test_the_segments_add_up_to_the_total_digit_for_digit():
|
||
rng = random.Random(7)
|
||
regions = ["华东", "华南", "华北", "西南", "东北", "西北"]
|
||
for _ in range(20):
|
||
base = {r: round(rng.uniform(0, 5000), rng.choice([0, 2, 3])) for r in regions}
|
||
target = {r: round(rng.uniform(0, 5000), rng.choice([0, 2, 3])) for r in regions}
|
||
result = await additive(_rows(base, target))
|
||
rows = result["table"]["rows"]
|
||
assert sum(Decimal(r[3]) for r in rows) == Decimal(result["values"]["delta_total"])
|
||
assert sum(Decimal(r[1]) for r in rows) == Decimal(result["values"]["base_total"])
|
||
assert sum(Decimal(r[2]) for r in rows) == Decimal(result["values"]["target_total"])
|
||
assert Decimal(result["values"]["target_total"]) - Decimal(result["values"]["base_total"]) == \
|
||
Decimal(result["values"]["delta_total"])
|
||
assert result["values"]["gross_up"] and result["values"]["gross_down"]
|
||
|
||
|
||
async def test_concentrated_and_dispersed():
|
||
base = {f"店{i}": 1000 for i in range(10)}
|
||
target = {**base, "店0": 1450, "店1": 1400, "店2": 1050, "店3": 1050, "店4": 1050}
|
||
result = await additive(_rows(base, target), by="region")
|
||
assert result["verdict"]["code"] == "concentrated"
|
||
assert result["verdict"]["subjects"] == [["店0"], ["店1"]]
|
||
assert result["values"]["top_k"] == "2" and result["values"]["top_k_share"] == "0.85"
|
||
assert "集中在店0、店1(合计占 85.0%)" in result["phrase"]
|
||
spread = await additive(_rows({r: 1000 for r in "甲乙丙丁戊"}, {r: 1100 for r in "甲乙丙丁戊"}))
|
||
assert spread["verdict"] == {"code": "dispersed", "subjects": [], "direction": "up"}
|
||
assert "分散在各地区,没有哪一段占到一半" in spread["phrase"]
|
||
|
||
|
||
async def test_offsetting_segments_are_said_and_a_share_can_pass_one_hundred_percent():
|
||
result = await additive(_rows({"华东": 1000, "华北": 500, "华南": 500}, {"华东": 850, "华北": 540, "华南": 490}))
|
||
assert result["values"]["delta_total"] == "-120"
|
||
assert result["verdict"]["code"] == "driver" and result["values"]["top_share"] == "1.25"
|
||
offset = next(c for c in result["caveats"] if c["code"] == "offset")
|
||
assert offset["text"] == "下降的段合计 -160,上升的段合计 +40,互相抵消了一部分"
|
||
assert result["phrase"] == "销售额下降 120(-6.0%),主要来自华东(-150,占 125.0%);下降的段合计 -160,上升的段合计 +40,互相抵消了一部分"
|
||
|
||
|
||
async def test_flat_with_offsets_says_what_moved_inside():
|
||
result = await additive(_rows({"华东": 1000, "华北": 1000, "华南": 220}, {"华东": 1120, "华北": 882, "华南": 220}))
|
||
assert result["verdict"] == {"code": "flat", "subjects": [], "direction": None}
|
||
assert result["phrase"] == "销售额基本持平(+0.1%),内部此消彼长:华东 +120、华北 -118"
|
||
|
||
|
||
async def test_a_headline_that_does_not_close_stops_the_concentration_verdict():
|
||
head = snap("Q1", "SELECT week, SUM(amount) AS gmv FROM orders GROUP BY week", ["week", "gmv"],
|
||
[["2026-W42", 2050], ["2026-W41", 2220]], types={"week": "text", "gmv": "number"})
|
||
result = await additive(snaps=[weekly(), head], headline={"base": "Q1.r1.gmv", "target": "Q1.r0.gmv"})
|
||
assert result["verdict"] == {"code": "unclosed", "subjects": [], "direction": None}
|
||
assert result["values"]["gap"] == "24" and result["values"]["top_share"] is None
|
||
assert all(r[5] is None for r in result["table"]["rows"]) # 不给占比
|
||
assert result["phrase"] == "按地区拆开后各段之和和总数对不上(差 24),不判断变化来自哪里"
|
||
assert [i["role"] for i in result["inputs"]] == ["headline_base", "headline_target", "data"]
|
||
assert result["inputs"][0] == {"ref": "Q1.r1.gmv", "artifact": head["artifact"], "role": "headline_base",
|
||
"row": 1, "column": "gmv", "value": "2220"}
|
||
assert "各段之和要和总数对得上" in result["criterion"]
|
||
|
||
|
||
async def test_a_rounded_headline_closes_within_its_written_precision():
|
||
rows = [["华东", "2026-W41", "820.25"], ["华东", "2026-W42", "700.12"], ["华北", "2026-W41", "1399.9"],
|
||
["华北", "2026-W42", "1325.84"]]
|
||
for written, closes in (("2220.2", True), ("2220", True), ("2221", False)):
|
||
head = snap("Q1", "SELECT SUM(amount) AS gmv FROM orders", ["gmv_base", "gmv"], [[written, "2025.96"]])
|
||
result = await additive(rows, snaps=[weekly(rows), head], headline=["Q1.r0.gmv_base", "Q1.r0.gmv"])
|
||
assert (result["verdict"]["code"] != "unclosed") is closes, written
|
||
|
||
|
||
async def test_a_headline_rounded_by_the_database_to_a_whole_number_closes_within_half_a_unit():
|
||
# SQLite 的 ROUND() 交回浮点 2120.0:取到了个位,最短写法里的「.0」不是一位小数的书写精度
|
||
rows = [["华东", "2026-W41", "1120.2"], ["华东", "2026-W42", "700"], ["华北", "2026-W41", "1000.2"],
|
||
["华北", "2026-W42", "600"]]
|
||
for written, closes in ((2120.0, True), (2121.0, False), (2120.5, False)):
|
||
head = snap("Q1", "SELECT ROUND(SUM(amount)) AS gmv FROM orders", ["gmv_base", "gmv"], [[written, 1300.0]])
|
||
result = await additive(rows, snaps=[weekly(rows), head], headline=["Q1.r0.gmv_base", "Q1.r0.gmv"])
|
||
assert (result["verdict"]["code"] != "unclosed") is closes, written
|
||
|
||
|
||
async def test_a_drill_closes_against_the_exact_value_of_the_parent_segment():
|
||
top = await additive() # 华东 820 → 700
|
||
near = snap("Q4", "SELECT province, week, SUM(amount) AS gmv FROM orders WHERE region = '华东' GROUP BY 1, 2",
|
||
["province", "week", "gmv"],
|
||
[["沪", "2026-W41", "500.4"], ["沪", "2026-W42", "410"], ["苏", "2026-W41", 320],
|
||
["苏", "2026-W42", "290.3"]])
|
||
params = {"query": "Q4", "by": ["province"], "period": "week", "base": "2026-W41", "target": "2026-W42",
|
||
"measure": "gmv", "parent": {"of": "A3", "key": ["华东"]}}
|
||
off = await analyze("decompose", "additive", snaps=[near], parents={"A3": top}, **params)
|
||
# 上一层那一段是算出来的精确值,不是取整过的总数:820.4 对 820、700.3 对 700 都不闭合
|
||
assert off["verdict"]["code"] == "unclosed" and off["values"]["gap"] == "-0.4"
|
||
|
||
|
||
async def test_drilling_down_checks_closure_against_the_parent_segment():
|
||
top = await additive()
|
||
province = snap("Q4", "SELECT province, week, SUM(amount) AS gmv FROM orders WHERE region = '华东' GROUP BY 1, 2",
|
||
["province", "week", "gmv"],
|
||
[["沪", "2026-W41", 500], ["沪", "2026-W42", 410], ["苏", "2026-W41", 320], ["苏", "2026-W42", 290]])
|
||
params = {"query": "Q4", "by": ["province"], "period": "week", "base": "2026-W41", "target": "2026-W42",
|
||
"measure": "gmv", "parent": {"of": "A3", "key": ["华东"]}}
|
||
child = await analyze("decompose", "additive", snaps=[province], parents={"A3": top}, **params)
|
||
assert child["verdict"]["code"] == "driver" and child["verdict"]["subjects"] == [["沪"]]
|
||
assert child["params"]["depth"] == 1 and child["params"]["parent"] == {"of": "A3", "key": ["华东"]}
|
||
assert child["inputs"][0]["role"] == "parent" and child["inputs"][0]["ref"] == "A3"
|
||
assert "max_drill_depth" in child["settings"]
|
||
assert {c["code"] for c in child["caveats"]} == {"comparability"} # 有 parent 就不是「以各段之和为总数」
|
||
short = snap("Q4", province["sql"], ["province", "week", "gmv"],
|
||
[["沪", "2026-W41", 500], ["沪", "2026-W42", 410], ["苏", "2026-W41", 300], ["苏", "2026-W42", 290]])
|
||
unclosed = await analyze("decompose", "additive", snaps=[short], parents={"A3": top}, **params)
|
||
assert unclosed["verdict"]["code"] == "unclosed" and unclosed["values"]["gap"] == "20"
|
||
# 超深:上一层已经是第 1 层,最多 1 层
|
||
error = await call_error("decompose", "additive", snaps=[province], parents={"A3": child},
|
||
settings={"max_drill_depth": 1}, **{**params, "parent": {"of": "A3", "key": ["沪"]}})
|
||
assert error.code == "too_deep" and "最多 1 层" in error.message
|
||
missing = await call_error("decompose", "additive", snaps=[province], parents={"A3": top},
|
||
**{**params, "parent": {"of": "A3", "key": ["东北"]}})
|
||
assert missing.code == "parent_key_missing"
|
||
absent = await call_error("decompose", "additive", snaps=[province], **params)
|
||
assert absent.code == "parent_missing"
|
||
|
||
|
||
async def test_new_and_vanished_segments_and_empty_cells_are_flagged():
|
||
rows = [["华东", "2026-W41", 800], ["华东", "2026-W42", 700], ["西南", "2026-W42", 90],
|
||
["东北", "2026-W41", 40], ["华北", "2026-W41", None], ["华北", "2026-W42", 30]]
|
||
result = await additive(rows)
|
||
flags = {r[0]: r[6] for r in result["table"]["rows"]}
|
||
assert flags == {"华东": None, "西南": "新出现", "东北": "消失", "华北": "为空"}
|
||
null = next(c for c in result["caveats"] if c["code"] == "null_values")
|
||
assert null["text"] == "华北的销售额为空,按 0 算"
|
||
assert next(r for r in result["table"]["rows"] if r[0] == "西南")[4] is None # 基期为 0 不给变化率
|
||
|
||
|
||
async def test_two_dimension_keys():
|
||
sql = "SELECT region, channel, week, SUM(amount) AS gmv FROM orders GROUP BY 1, 2, 3"
|
||
rows = [["华东", "线上", "W1", 500], ["华东", "线上", "W2", 300], ["华东", "线下", "W1", 300], ["华东", "线下", "W2", 310],
|
||
["华北", "线上", "W1", 200], ["华北", "线上", "W2", 205]]
|
||
s = snap("Q2", sql, ["region", "channel", "week", "gmv"], rows)
|
||
result = await analyze("decompose", "additive", snaps=[s], query="Q2", by=["region", "channel"], period="week",
|
||
base="W1", target="W2", measure="gmv")
|
||
assert result["verdict"]["subjects"] == [["华东", "线上"]]
|
||
assert "主要来自华东·线上" in result["phrase"]
|
||
|
||
|
||
async def test_ratio_and_non_additive_measures_are_call_errors():
|
||
avg = snap("Q2", "SELECT region, week, AVG(amount) AS aov FROM orders GROUP BY 1, 2", ["region", "week", "aov"], WEEKLY)
|
||
error = await call_error("decompose", "additive", snaps=[avg], query="Q2", by="region", period="week",
|
||
base="2026-W41", target="2026-W42", measure="aov")
|
||
assert error.code == "ratio_as_additive" and "改用比率拆解" in error.message
|
||
rate_sql = "SELECT region, week, SUM(refund_rate) AS r FROM orders GROUP BY 1, 2"
|
||
labeled = {"shop": {"orders": notes(columns={"refund_rate": {"measure": "ratio"}})}}
|
||
error = await call_error("decompose", "additive", snaps=[snap("Q2", rate_sql, ["region", "week", "r"], WEEKLY)],
|
||
catalogs=labeled, query="Q2", by="region", period="week", base="2026-W41",
|
||
target="2026-W42", measure="r")
|
||
assert error.code == "ratio_as_additive"
|
||
# 只是推断(没人确认)的照算,写附带说明
|
||
guessed = {"shop": {"orders": notes(columns={"refund_rate": {"measure": {"value": "ratio", "source": "llm",
|
||
"status": "proposed"}}})}}
|
||
result = await analyze("decompose", "additive", snaps=[snap("Q2", rate_sql, ["region", "week", "r"], WEEKLY)],
|
||
catalogs=guessed, query="Q2", by="region", period="week", base="2026-W41",
|
||
target="2026-W42", measure="r")
|
||
note = next(c for c in result["caveats"] if c["code"] == "measure_unlabeled")
|
||
assert note["text"] == "目录里「r」是比率只是推断,没人确认,按可以按段相加处理"
|
||
unlabeled = await additive(snaps=[weekly()], catalogs={})
|
||
assert any(c["code"] == "measure_unlabeled" and "目录没标「gmv」的度量类型" in c["text"] for c in unlabeled["caveats"])
|
||
|
||
|
||
async def test_call_errors_tell_the_agent_how_to_fix_the_call():
|
||
dup = WEEKLY + [["华东", "2026-W42", 5]]
|
||
error = await call_error("decompose", "additive", snaps=[weekly(dup)], query="Q2", by="region", period="week",
|
||
base="2026-W41", target="2026-W42", measure="gmv")
|
||
assert error.code == "duplicate_rows" and "在 SQL 里按 地区、week 汇总" in error.message
|
||
masked = await call_error("decompose", "additive", snaps=[weekly(masked=["Region"])], query="Q2", by="region",
|
||
period="week", base="2026-W41", target="2026-W42", measure="gmv")
|
||
assert masked.code == "masked_column"
|
||
missing = await call_error("decompose", "additive", snaps=[weekly()], query="Q2", by="region", period="week",
|
||
base="2026-W40", target="2026-W42", measure="gmv")
|
||
assert missing.code == "period_not_found" and "2026-W41" in missing.message
|
||
column = await call_error("decompose", "additive", snaps=[weekly()], query="Q2", by="area", period="week",
|
||
base="2026-W41", target="2026-W42", measure="gmv")
|
||
assert column.code == "unknown_column" and "region、week、gmv" in column.message
|
||
text = await call_error("decompose", "additive", snaps=[weekly()], query="Q2", by="gmv", period="week",
|
||
base="2026-W41", target="2026-W42", measure="region")
|
||
assert text.code == "not_numeric"
|
||
unknown = await call_error("decompose", "additive", snaps=[weekly()], query="Q9", by="region", period="week",
|
||
base="2026-W41", target="2026-W42", measure="gmv")
|
||
assert unknown.code == "unknown_query"
|
||
same_column = await call_error("decompose", "additive", snaps=[weekly()], query="Q2", by="week", period="week",
|
||
base="2026-W41", target="2026-W42", measure="gmv")
|
||
assert same_column.code == "bad_by"
|
||
|
||
|
||
async def test_truncated_snapshots_and_single_segments_are_refused():
|
||
result = await additive(snaps=[weekly(truncated=True)])
|
||
assert result["status"] == "refused" and result["reason_code"] == "truncated"
|
||
assert result["reason"] == "Q2 只取回了前 8 行,各段之和不是完整的和"
|
||
assert result["phrase"] == "变化拆解没有做成:Q2 只取回了前 8 行,各段之和不是完整的和"
|
||
assert result["verdict"] is None and result["table"] is None and result["values"] == {}
|
||
one = await additive([["华东", "2026-W41", 1], ["华东", "2026-W42", 2]])
|
||
assert one["reason_code"] == "too_few_segments"
|
||
|
||
|
||
async def test_two_snapshots_check_comparability_first():
|
||
base = snap("Q1", "SELECT region, SUM(amount) AS gmv FROM orders WHERE order_date >= '2026-10-05' AND "
|
||
"order_date < '2026-10-12' GROUP BY region", ["region", "gmv"],
|
||
[["华东", 820], ["华北", 600], ["华南", 700]])
|
||
target = snap("Q2", "SELECT region, SUM(amount) AS gmv FROM orders WHERE order_date >= '2026-10-12' AND "
|
||
"order_date < '2026-10-19' GROUP BY region", ["region", "gmv"],
|
||
[["华东", 700], ["华北", 560], ["华南", 666]])
|
||
probe = fixed_prober({("orders", "order_date"): ("2025-01-01", "2026-10-20")})
|
||
result = await analyze("decompose", "additive", snaps=[base, target], probe=probe, base_query="Q1",
|
||
target_query="Q2", by="region", measure="gmv")
|
||
assert result["verdict"]["code"] == "driver" and result["params"]["today"] == "2026-10-21"
|
||
assert not any(c["code"] == "comparability" for c in result["caveats"])
|
||
assert [i["role"] for i in result["inputs"]] == ["base", "target"]
|
||
assert result["probes"] == [{"source": "shop", "table": "orders", "column": "order_date", "min": "2025-01-01",
|
||
"max": "2026-10-20", "basis": "live", "at": "2026-10-21T09:00:00"}]
|
||
# 本期没过完:照样拆,判定后附上可比性结论
|
||
partial = snap("Q2", target["sql"].replace("'2026-10-19'", "'2026-10-25'"), ["region", "gmv"], target["rows"])
|
||
result = await analyze("decompose", "additive", snaps=[base, partial], probe=probe, base_query="Q1",
|
||
target_query="Q2", by="region", measure="gmv")
|
||
note = next(c for c in result["caveats"] if c["code"] == "comparability")
|
||
assert note["text"].startswith("两期不可比:") and "本期数据只到 2026-10-20,还差 4 天" in note["text"]
|
||
assert result["verdict"]["code"] == "driver" and note["text"] in result["phrase"]
|
||
# 口径结构不同:不拆
|
||
other = snap("Q2", "SELECT region, SUM(amount) AS gmv FROM orders JOIN refunds USING (order_id) WHERE order_date "
|
||
">= '2026-10-12' GROUP BY region", ["region", "gmv"], target["rows"])
|
||
refused = await analyze("decompose", "additive", snaps=[base, other], probe=probe, base_query="Q1",
|
||
target_query="Q2", by="region", measure="gmv")
|
||
assert refused["reason_code"] == "incomparable_sides" and refused["reason"].startswith("两份查询的口径结构不同")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# ratio
|
||
# --------------------------------------------------------------------------
|
||
|
||
RATIO_SQL = "SELECT channel, week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY 1, 2"
|
||
|
||
|
||
def _ratio_snap(rows):
|
||
return snap("Q1", RATIO_SQL, ["channel", "week", "refunds", "orders"], rows)
|
||
|
||
|
||
async def ratio(rows, **params):
|
||
params = {"query": "Q1", "by": "channel", "period": "week", "base": "W41", "target": "W42",
|
||
"numerator": "refunds", "denominator": "orders", **params}
|
||
return await analyze("decompose", "ratio", snaps=[_ratio_snap(rows)], **params)
|
||
|
||
|
||
def test_mix_plus_rate_effects_equal_the_ratio_change_exactly():
|
||
rng = random.Random(11)
|
||
for _ in range(200):
|
||
segs = []
|
||
for _ in range(rng.randint(2, 7)):
|
||
d_b, d_t = rng.choice([0, rng.randint(1, 5000)]), rng.randint(1, 5000)
|
||
segs.append((Fraction(rng.randint(0, d_b)) if d_b else Fraction(0), Fraction(d_b),
|
||
Fraction(rng.randint(0, d_t)), Fraction(d_t)))
|
||
if sum(s[1] for s in segs) == 0:
|
||
continue
|
||
R, R2, effects = ratio_effects(segs)
|
||
assert sum((e[4] for e in effects), Fraction(0)) + sum((e[5] for e in effects), Fraction(0)) == R2 - R
|
||
assert sum((e[0] for e in effects), Fraction(0)) == 1 == sum((e[1] for e in effects), Fraction(0))
|
||
|
||
|
||
async def test_simpson_each_channel_improves_but_the_mix_makes_the_whole_worse():
|
||
rows = [["线上", "W41", 50, 500], ["线上", "W42", 90, 1000], ["线下", "W41", 10, 1000], ["线下", "W42", 4, 500]]
|
||
result = await ratio(rows, label="退款率")
|
||
assert result["verdict"] == {"code": "mix_driven", "subjects": [["线上"]], "direction": "up"}
|
||
# 结构变化只说一遍:正文说了结构变化、点了线上,辛普森那一句只说各段和整体反着变
|
||
assert result["phrase"] == ("退款率上升 2.3 个百分点(4.0% 到 6.3%),主要来自结构变化:线上占比从 33.3% 到 66.7%;"
|
||
"各段的退款率大多在下降,整体却上升")
|
||
simpson = next(c for c in result["caveats"] if c["code"] == "simpson")
|
||
assert simpson["text"] == "各段的退款率大多在下降,整体却上升"
|
||
assert result["phrase"].count("结构") == 1
|
||
values = result["values"]
|
||
assert Fraction(values["mix_total"]) + Fraction(values["rate_total"]) == pytest.approx(Fraction(values["delta"]))
|
||
assert result["table"]["columns"][:5] == ["channel", "n_base", "n_target", "d_base", "d_target"]
|
||
|
||
|
||
async def test_rate_driven_both_and_small_denominators():
|
||
rows = [["线上", "W41", 50, 1000], ["线上", "W42", 80, 1000], ["线下", "W41", 20, 1000], ["线下", "W42", 26, 1000]]
|
||
result = await ratio(rows, label="退款率")
|
||
assert result["verdict"]["code"] == "rate_driven" and result["verdict"]["subjects"] == [["线上"]]
|
||
assert result["phrase"] == "退款率上升 1.8 个百分点(3.5% 到 5.3%),主要来自各段自身的变化,线上影响最大"
|
||
tiny = [["线上", "W41", 2, 20], ["线上", "W42", 3, 25], ["线下", "W41", 20, 1000], ["线下", "W42", 30, 1000]]
|
||
small = await ratio(tiny, label="退款率")
|
||
note = next(c for c in small["caveats"] if c["code"] == "small_denominator")
|
||
assert note["text"] == "线上的 orders 不到 30,比率波动大,不参与多数判断"
|
||
assert "分母小" in next(r for r in small["table"]["rows"] if r[0] == "线上")[-1]
|
||
|
||
|
||
async def test_ratio_refusals():
|
||
zero = [["线上", "W41", 0, 0], ["线上", "W42", 3, 100], ["线下", "W41", 0, 0], ["线下", "W42", 2, 100]]
|
||
result = await ratio(zero)
|
||
assert result["reason_code"] == "zero_base" and result["reason"] == "基期的 orders 合计为 0,算不了比率"
|
||
orphan = [["线上", "W41", 5, 0], ["线上", "W42", 3, 100], ["线下", "W41", 1, 100], ["线下", "W42", 2, 100]]
|
||
assert (await ratio(orphan))["reason_code"] == "zero_base"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# factor
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _factor_snap(rows):
|
||
return snap("Q1", "SELECT week, SUM(amount) AS gmv, COUNT(*) AS orders, ROUND(AVG(amount), 2) AS aov FROM orders "
|
||
"GROUP BY week", ["week", "gmv", "orders", "aov"], rows)
|
||
|
||
|
||
async def factor(rows, **params):
|
||
params = {"query": "Q1", "period": "week", "base": "W41", "target": "W42", "product": "gmv",
|
||
"factors": ["orders", "aov"], "label": "销售额", **params}
|
||
return await analyze("decompose", "factor", snaps=[_factor_snap(rows)], **params)
|
||
|
||
|
||
def test_lmdi_contributions_add_up_to_the_change_within_float_error():
|
||
rng = random.Random(3)
|
||
for _ in range(200):
|
||
k = rng.randint(2, 4)
|
||
base = [rng.uniform(0.5, 500) for _ in range(k)]
|
||
target = [b * rng.uniform(0.5, 1.8) for b in base]
|
||
m_b = math.prod(base) * (1 + rng.uniform(-1e-6, 1e-6)) # 取整过的因子:乘积差一点点
|
||
m_t = math.prod(target) * (1 + rng.uniform(-1e-6, 1e-6))
|
||
contrib = lmdi(base, target, m_b, m_t)
|
||
assert math.fsum(contrib) == pytest.approx(m_t - m_b, rel=1e-9, abs=1e-9 * max(m_b, m_t))
|
||
same = lmdi([2.0, 3.0], [2.0, 3.0], 6.0, 6.0)
|
||
assert same == [0.0, 0.0]
|
||
|
||
|
||
async def test_factor_driver_with_rounded_factors():
|
||
rows = [["W41", 1000, 137, "7.30"], ["W42", 1320, 160, "8.25"]]
|
||
result = await factor(rows)
|
||
assert result["verdict"]["code"] in ("driver", "both")
|
||
contrib = [Fraction(r[4]) for r in result["table"]["rows"]]
|
||
assert float(sum(contrib)) == pytest.approx(320, rel=1e-9)
|
||
clean = await factor([["W41", "1000", 100, "10"], ["W42", "1320", 120, "11"]])
|
||
assert clean["verdict"] == {"code": "driver", "subjects": [["orders"]], "direction": "up"}
|
||
assert clean["phrase"].startswith("销售额上升 320(+32.0%),主要来自 orders 的变化(贡献 +")
|
||
assert clean["table"]["columns"] == ["factor", "base", "target", "pct", "contrib", "share"]
|
||
|
||
|
||
async def test_factor_call_errors_and_refusals():
|
||
# 客单价写到两位小数:容差是它的半个末位(加上订单数的),差 1.5% 不是取整造成的
|
||
error = await call_error("decompose", "factor", snaps=[_factor_snap([["W41", 1000, 100, "10.00"],
|
||
["W42", 1300, 120, "11.00"]])],
|
||
query="Q1", period="week", base="W41", target="W42", product="gmv",
|
||
factors=["orders", "aov"])
|
||
assert error.code == "not_product" and "本期差" in error.message
|
||
negative = await factor([["W41", 0, 0, 10], ["W42", 1320, 120, 11]])
|
||
assert negative["reason_code"] == "non_positive"
|
||
flat = await factor([["W41", 1000, 100, 10], ["W42", 1000, 125, 8]])
|
||
assert flat["verdict"]["code"] == "flat"
|
||
few = await call_error("decompose", "factor", snaps=[_factor_snap([["W41", 1, 1, 1], ["W42", 1, 1, 1]])],
|
||
query="Q1", period="week", base="W41", target="W42", product="gmv", factors=["orders"])
|
||
assert few.code == "bad_factors"
|
||
|
||
|
||
async def test_ratio_headline_closure_and_drilling_into_a_channel():
|
||
rows = [["线上", "W41", 50, 1000], ["线上", "W42", 80, 1000], ["线下", "W41", 20, 1000], ["线下", "W42", 26, 1000]]
|
||
head = snap("Q9", "SELECT week, SUM(refund_flag) * 1.0 / COUNT(*) AS rate FROM orders GROUP BY week",
|
||
["week", "rate"], [["W41", "0.035"], ["W42", "0.053"]])
|
||
params = {"query": "Q1", "by": "channel", "period": "week", "base": "W41", "target": "W42",
|
||
"numerator": "refunds", "denominator": "orders", "label": "退款率"}
|
||
closed = await analyze("decompose", "ratio", snaps=[_ratio_snap(rows), head],
|
||
headline={"base": "Q9.r0.rate", "target": "Q9.r1.rate"}, **params)
|
||
assert closed["verdict"]["code"] == "rate_driven"
|
||
off = snap("Q9", head["sql"], ["week", "rate"], [["W41", "0.035"], ["W42", "0.06"]])
|
||
unclosed = await analyze("decompose", "ratio", snaps=[_ratio_snap(rows), off],
|
||
headline={"base": "Q9.r0.rate", "target": "Q9.r1.rate"}, **params)
|
||
assert unclosed["verdict"]["code"] == "unclosed"
|
||
assert unclosed["phrase"].startswith("按渠道拆开后各段合起来的比率和总数对不上")
|
||
# 线上按地区下钻:各地区的分子、分母之和要等于上一层线上那一段的
|
||
region = snap("Q2", "SELECT region, week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders "
|
||
"WHERE channel = '线上' GROUP BY 1, 2", ["region", "week", "refunds", "orders"],
|
||
[["华东", "W41", 30, 600], ["华东", "W42", 55, 600], ["华北", "W41", 20, 400], ["华北", "W42", 25, 400]])
|
||
child = await analyze("decompose", "ratio", snaps=[region], parents={"A1": closed},
|
||
**{**params, "query": "Q2", "by": "region", "parent": {"of": "A1", "key": ["线上"]}})
|
||
assert child["verdict"]["code"] != "unclosed" and child["params"]["depth"] == 1
|
||
wrong = await call_error("decompose", "additive", snaps=[weekly()], parents={"A1": closed}, query="Q2",
|
||
by="region", period="week", base="2026-W41", target="2026-W42", measure="gmv",
|
||
parent={"of": "A1", "key": ["线上"]})
|
||
assert wrong.code == "parent_not_decompose"
|
||
|
||
|
||
async def test_the_same_query_cannot_be_both_periods():
|
||
error = await call_error("decompose", "additive", snaps=[weekly()], base_query="Q2", target_query="Q2",
|
||
by="region", measure="gmv")
|
||
assert error.code == "same_query"
|
||
error = await call_error("comparable", snaps=[weekly()], base_query="Q2", target_query="Q2")
|
||
assert error.code == "same_query"
|
||
|
||
|
||
async def test_dimension_columns_keep_the_type_recorded_at_query_time():
|
||
"""文本型的码值(门店编码 "1203")在结果表里仍是 text,渲染时不会写成「1,203」;查询时没记类型的维度列不写。"""
|
||
sql = "SELECT store_code, week, SUM(amount) AS gmv FROM orders GROUP BY 1, 2"
|
||
rows = [["1203", "W1", 500], ["1203", "W2", 300], ["0871", "W1", 200], ["0871", "W2", 260]]
|
||
typed = snap("Q2", sql, ["store_code", "week", "gmv"], rows,
|
||
types={"store_code": "text", "week": "text", "gmv": "number"})
|
||
result = await analyze("decompose", "additive", snaps=[typed], query="Q2", by="store_code", period="week",
|
||
base="W1", target="W2", measure="gmv")
|
||
assert result["table"]["column_types"]["store_code"] == "text"
|
||
assert [r[0] for r in result["table"]["rows"]] == ["1203", "0871"]
|
||
untyped = snap("Q2", sql, ["store_code", "week", "gmv"], rows)
|
||
result = await analyze("decompose", "additive", snaps=[untyped], query="Q2", by="store_code", period="week",
|
||
base="W1", target="W2", measure="gmv")
|
||
assert "store_code" not in result["table"]["column_types"]
|
||
assert result["table"]["column_types"]["delta"] == "number"
|
||
listed = snap("Q2", sql, ["store_code", "week", "gmv"], rows, types=None, column_types=["text", "text", "number"])
|
||
result = await analyze("decompose", "additive", snaps=[listed], query="Q2", by="store_code", period="week",
|
||
base="W1", target="W2", measure="gmv")
|
||
assert result["table"]["column_types"]["store_code"] == "text" # 按列序的列表也认
|
||
ratio_result = await ratio([["线上", "W41", 50, 1000], ["线上", "W42", 80, 1000], ["线下", "W41", 20, 1000],
|
||
["线下", "W42", 26, 1000]])
|
||
ratio_types = ratio_result["table"]["column_types"]
|
||
assert ratio_types["flag"] == "text" and ratio_types["mix"] == "number" and "channel" not in ratio_types
|
||
factor_result = await factor([["W41", "1000", 100, "10"], ["W42", "1320", 120, "11"]])
|
||
assert factor_result["table"]["column_types"] == {"factor": "text", "base": "number", "target": "number",
|
||
"pct": "number", "contrib": "number", "share": "number"}
|
||
|
||
|
||
|
||
async def test_a_label_cannot_carry_a_judgment_into_the_phrase():
|
||
cut = await additive(label="销售额,主要来自华南")
|
||
assert cut["params"]["label"] == "销售额" and cut["phrase"].startswith("销售额下降 194")
|
||
assert "华南" not in cut["phrase"]
|
||
judged = await additive(label="主要来自华南的销售额")
|
||
assert "label" not in judged["params"] # 消成空了,退回目录里的叫法
|
||
assert judged["phrase"].startswith("销售额下降 194") and "华南" not in judged["phrase"]
|
||
# 消过一遍会变的叫法:复算拿记下的叫法再消,判定语逐字相同(analyze 核对复算逐字段相同)
|
||
marked = await additive(label="销售额[ [v:Q1.r0.gmv] ]")
|
||
assert marked["params"]["label"] == "销售额" and marked["phrase"].startswith("销售额下降 194")
|
||
|
||
|
||
async def test_a_dimension_named_like_a_result_column_is_a_call_error():
|
||
rows = [["线上", "2026-W41", 820], ["线上", "2026-W42", 700], ["线下", "2026-W41", 600], ["线下", "2026-W42", 560]]
|
||
flagged = snap("Q2", "SELECT channel AS flag, week, SUM(amount) AS gmv FROM orders GROUP BY 1, 2",
|
||
["flag", "week", "gmv"], rows)
|
||
error = await call_error("decompose", "additive", snaps=[flagged], query="Q2", by=["flag"], period="week",
|
||
base="2026-W41", target="2026-W42", measure="gmv")
|
||
assert error.code == "reserved_column" and "在 SQL 里给它换个别名" in error.message and "AS flag_name" in error.message
|
||
assert error.code not in DATA_ERRORS # 结构性的:分析卡里照旧让卡失败
|
||
ratio_rows = [["线上", "W41", 50, 1000], ["线上", "W42", 80, 1000], ["线下", "W41", 20, 1000], ["线下", "W42", 26, 1000]]
|
||
mixed = snap("Q1", "SELECT channel AS Mix, week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders "
|
||
"GROUP BY 1, 2", ["Mix", "week", "refunds", "orders"], ratio_rows)
|
||
error = await call_error("decompose", "ratio", snaps=[mixed], query="Q1", by="Mix", period="week", base="W41",
|
||
target="W42", numerator="refunds", denominator="orders")
|
||
assert error.code == "reserved_column" and "「Mix」" in error.message
|
||
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 一份快照按期次分组:本期齐不齐(同波动基线那一套)
|
||
# --------------------------------------------------------------------------
|
||
|
||
WEEK_START = ("SELECT region, date(order_date, '-6 days', 'weekday 1') AS week, SUM(amount) AS gmv FROM orders "
|
||
"GROUP BY 1, 2")
|
||
DAY_SQL = "SELECT region, order_date AS day, SUM(amount) AS gmv FROM orders GROUP BY 1, 2"
|
||
|
||
|
||
def _two(base, target, *, sql=WEEK_START, col="week"):
|
||
rows = [["华东", base, 820], ["华东", target, 700], ["华北", base, 600], ["华北", target, 560]]
|
||
return snap("Q2", sql, ["region", col, "gmv"], rows, types={"region": "text", col: "text", "gmv": "number"})
|
||
|
||
|
||
async def test_one_snapshot_by_period_checks_the_target_is_whole():
|
||
probe = fixed_prober({("orders", "order_date"): ("2025-01-01", "2026-10-20")})
|
||
ask = {"query": "Q2", "by": ["region"], "period": "week", "measure": "gmv", "label": "销售额"}
|
||
# 运行当天 10-21:10-19 那一周没过完
|
||
partial = await analyze("decompose", "additive", snaps=[_two("2026-10-12", "2026-10-19")], probe=probe,
|
||
base="2026-10-12", target="2026-10-19", **ask)
|
||
assert partial["reason_code"] == "partial_target"
|
||
assert partial["reason"] == "本期数据只到 2026-10-20,还差 5 天,和完整的周比没有意义"
|
||
assert partial["params"]["today"] == "2026-10-21" and partial["probes"][0]["column"] == "order_date"
|
||
whole = await analyze("decompose", "additive", snaps=[_two("2026-10-05", "2026-10-12")], probe=probe,
|
||
base="2026-10-05", target="2026-10-12", **ask)
|
||
assert whole["status"] == "ok" and not [c for c in whole["caveats"] if c["code"] == "comparability"]
|
||
# 查不到表的范围:只附一句核对不了,不进判定语
|
||
unknown = await analyze("decompose", "additive", snaps=[_two("2026-10-05", "2026-10-12")],
|
||
base="2026-10-05", target="2026-10-12", **ask)
|
||
note = next(c["text"] for c in unknown["caveats"] if c["code"] == "comparability")
|
||
assert note == "没能核对本期的数据是否到齐(查不到表里这一列的日期范围)" and note not in unknown["phrase"]
|
||
ratio_rows = [["线上", "2026-10-12", 50, 1000], ["线上", "2026-10-19", 80, 1000], ["线下", "2026-10-12", 20, 1000],
|
||
["线下", "2026-10-19", 26, 1000]]
|
||
ratio_snap = snap("Q1", "SELECT channel, date(order_date, '-6 days', 'weekday 1') AS week, SUM(refund_flag) AS "
|
||
"refunds, COUNT(*) AS orders FROM orders GROUP BY 1, 2",
|
||
["channel", "week", "refunds", "orders"], ratio_rows)
|
||
refused = await analyze("decompose", "ratio", snaps=[ratio_snap], probe=probe, query="Q1", by="channel",
|
||
period="week", base="2026-10-12", target="2026-10-19", numerator="refunds",
|
||
denominator="orders")
|
||
assert refused["reason_code"] == "partial_target"
|
||
|
||
|
||
async def test_two_days_a_week_apart_from_a_plain_date_column_are_two_days():
|
||
# 期次列直接取的日期:昨天(10-20)对上周同一天,不是两周;10-20 当天的数据已经到了
|
||
probe = fixed_prober({("orders", "order_date"): ("2025-01-01", "2026-10-20")})
|
||
result = await analyze("decompose", "additive", snaps=[_two("2026-10-13", "2026-10-20", sql=DAY_SQL, col="day")],
|
||
probe=probe, query="Q2", by=["region"], period="day", base="2026-10-13",
|
||
target="2026-10-20", measure="gmv")
|
||
assert result["status"] == "ok" and not [c for c in result["caveats"] if c["code"] == "comparability"]
|