Files
AgentLab/backend/tests/test_caliber_closure.py
T
ThearchyHelios 2f90f0eb3a 口径台账:固定执行、验证审阅发布与增量重导入
- QueryCore 固定执行(K09):按已发布的不可变口径跑一次,交回类型化标量、发布/manifest 引用与逐查询证据
- 依赖闭包(K08):按选中的指标算出要拷的取数节点、要的入口字段,接进 derive / compose / metrics
- SQL 模板参数完整性(K01):试跑、组合问数、派生图、运行时取数共用一套语义,缺参数在渲染前就拒
- 受限字段变更服务(K03)与口径领域边界(K01):改动打在整图深拷贝上,身份与证据归属不可改
- 版本化受控模板绑定(K04):业务口径按显式绑定落到 SQL,对不上就报 unsupported / needs_update
- 验证记录(K05)与最小审阅:样例匹配不等于正式批准,审批人只从服务端身份取
- 发布依据清单(K07):发布时按不可变图物化内容寻址清单,正式用途按它判
- 增量重导入(K11)与 B/L/R 三方合并、受限自然语言修改提案(K12):只产候选,落图仍走既有写路径
- 接口与前端:口径草稿页接上字段变更、绑定状态、验证与重导入,新增 caliber-drafts / studio 检查脚本

验证:前端 tsc -b 通过。
2026-11-10 15:05:18 +08:00

560 lines
32 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.
"""K08 的依赖闭包解析器(app/ledger/closure.py):S1 的解析、S2 的选择位置、S4 的摘要。
这一份**只按纯函数用**:不建库、不起引擎、不发模型请求(接线在 ledger/derive 与 ledger/compose——按选择裁要执行的取数
与入口字段;运行时按口径卡上的 caliber_from.metrics 读 effective_metrics)。合成 A/B 两张卡的形状(两指标两张表的口径、
指标类型)都在这里现搭,复用 tests/binding_samples 的受控模板与绑定写法。
覆盖任务书要的四类验收(§5 K08:A/B 独立、共享查询、全局约束、未知依赖),外加这里必须锁住的身份与确定性:
- 选 A 时 B 独有且无依赖的查询与参数不进这次范围;共享的那条 SQL 保留,并显式声明它还在算未选中的 B
(computes_unselected),不做投影;
- 共享参数、卡 on_missing、图 defaults 照样带上;
- 未知指标 / 式子读不出来 / 引用不存在的节点 / 参数没声明 / 绑定对不上 → unresolved,require() 抛错,绝不静默退回整卡;
- 缺省整卡、显式整卡、显式空选择(不当整卡)、显式与整卡冲突(不放宽);
- 确定性、metric_ids 重排不影响结果;
- 两条 SQL 内容一样的节点不合并(不拿内容哈希当身份);
- query_id 跨版本稳定、改名会变、显式 query_id 改名也稳定;别名不是源身份(source 记真实节点与版本);
- 绑定变化只动 closure_hash:selection_hash 不变(绑定指纹不进它)。
"""
from __future__ import annotations
import copy
import pytest
from app.data.caliber_identity import card_hash_of
from app.ledger import bindings, closure
from tests import binding_samples as S
#: 入口字段:start / end 是两张表都要的共享参数,store 只有 B 那张表要
FIELDS = [{"name": "start", "type": "string", "required": True, "description": "开始日期(含)"},
{"name": "end", "type": "string", "required": True, "description": "结束日期(不含)"},
{"name": "store", "type": "string", "required": False, "description": "门店(B 那条查询要)"}]
#: B 那张表比 A 多一个门店过滤(params 槽位:store 只有它用)
TEMPLATE_B = S.TEMPLATE + " AND o.store = '{{ input.store }}'"
def fetch(node_id: str, template: str = S.TEMPLATE, values: dict[str, str] | None = None, *,
query_id: str | None = None, binding: bool = True) -> dict:
"""一个取数节点:受控模板按当前业务值展开成 args.sql,并写一份对得上的绑定。"""
values = dict(values or S.OLD)
sql = bindings.render(template, S.SNIPPETS, values)
config: dict = {"tool": S.tool(), "args": {"sql": sql}}
if binding:
config[bindings.KEY] = S.binding(values, template=template, sql=sql)
if query_id:
config["query_id"] = query_id
return S.node(node_id, "tool", **config)
def metric(metric_id: str, node_id: str, *, column: str = "orders", time_role: str | None = S.OLD["time_role"],
refund_rule: str | None = S.OLD["refund_rule"], **extra) -> dict:
made: dict = {"id": metric_id, "name": metric_id, "unit": "单",
"expression": f"cell(nodes.{node_id}, 0, '{column}')"}
if time_role:
made["time_role"] = time_role
if refund_rule:
made["refund_rule"] = refund_rule
made.update(extra)
return made
def graph(*, metrics: list[dict], fetches: list[dict], fields: list[dict] | None = None,
card_config: dict | None = None, defaults: dict | None = None) -> dict:
"""入口 → 各条取数 → 口径卡 → 出口;defaults 给了就是图级 defaults。"""
made = S.chain(S.node("start", "input", fields=copy.deepcopy(fields if fields is not None else FIELDS)),
*copy.deepcopy(fetches),
S.node("card", "metrics", caliber="订单口径", caliber_version="v1",
metrics=copy.deepcopy(metrics), **copy.deepcopy(card_config or {})),
S.node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.card.text }}"}]))
if defaults:
made["defaults"] = copy.deepcopy(defaults)
return made
def _metrics_for(values: dict[str, str], pairs: list[tuple[str, str]]) -> list[dict]:
"""按当前业务值给指标声明(定义和那份 SQL 自洽):换个 values 就是自洽的新一版。"""
return [metric(metric_id, node_id, time_role=values.get("time_role"),
refund_rule=values.get("refund_rule")) for metric_id, node_id in pairs]
def graph_ab(*, values: dict[str, str] | None = None, card_config: dict | None = None,
defaults: dict | None = None) -> dict:
"""A 读 q_a、B 读 q_b:两条独立查询;store 只有 B 用。默认两张表都和定义同步。"""
values = dict(values or S.OLD)
return graph(metrics=_metrics_for(values, [("a", "q_a"), ("b", "q_b")]),
fetches=[fetch("q_a", S.TEMPLATE, values), fetch("q_b", TEMPLATE_B, values)],
card_config=card_config, defaults=defaults)
def graph_shared(*, values: dict[str, str] | None = None, card_config: dict | None = None,
defaults: dict | None = None) -> dict:
"""A 和 B 读同一条取数(共享 SQL):一条 SQL 上两个指标。"""
values = dict(values or S.OLD)
return graph(metrics=_metrics_for(values, [("a", "q_shared"), ("b", "q_shared")]),
fetches=[fetch("q_shared", S.TEMPLATE, values)],
card_config=card_config, defaults=defaults)
def codes(problems) -> list[str]:
return [p["code"] for p in problems]
# ==========================================================================
# A/B 独立:选 A 不带 B 的查询和参数
# ==========================================================================
def test_independent_selection_drops_the_other_metrics_query_and_params():
"""A/B 独立:选 A 只要 q_a,q_b 排除;store 只有 B 用,不算这次要的参数。"""
made = graph_ab()
whole = closure.build(made, "wf-ab", 1, "card").require()
assert whole.mode == closure.WHOLE_CARD and whole.whole_card
assert whole.card_metric_ids == ("a", "b") and whole.metric_ids == ("a", "b")
assert whole.query_ids == ("wf-ab:q_a", "wf-ab:q_b") and whole.excluded_query_ids == ()
assert [f["name"] for f in whole.required_entry_fields] == ["start", "end", "store"]
a = closure.build(made, "wf-ab", 1, "card", metric_ids=["a"]).require()
assert a.mode == closure.EXPLICIT and not a.whole_card
assert a.card_metric_ids == ("a", "b") and a.metric_ids == ("a",)
assert a.query_ids == ("wf-ab:q_a",) and a.excluded_query_ids == ("wf-ab:q_b",)
assert [f["name"] for f in a.required_entry_fields] == ["start", "end"] # store 被排除
assert a.entry_fields and [f["name"] for f in a.entry_fields] == ["start", "end", "store"]
assert a.metric_query_ids("a") == ["wf-ab:q_a"] and a.metric_query_ids("b") == []
assert [q.computes_unselected for q in a.queries] == [()] # q_a 只算 A
assert a.binding_fingerprints and set(a.binding_fingerprints) == {"wf-ab:q_a"}
b = closure.build(made, "wf-ab", 1, "card", metric_ids=["b"]).require()
assert b.query_ids == ("wf-ab:q_b",) and b.excluded_query_ids == ("wf-ab:q_a",)
assert [f["name"] for f in b.required_entry_fields] == ["start", "end", "store"]
def test_selection_is_normalized_by_source_card_order():
"""显式选择按源卡顺序归一:调用方传的顺序和重复项都不影响结果。"""
made = graph_ab()
forward = closure.build(made, "wf-ab", 1, "card", metric_ids=["a", "b"]).require()
reverse = closure.build(made, "wf-ab", 1, "card", metric_ids=["b", "a", "b"]).require()
assert forward.metric_ids == reverse.metric_ids == ("a", "b")
assert forward.query_ids == reverse.query_ids
assert forward.selection_hash == reverse.selection_hash
assert forward.closure_hash == reverse.closure_hash
assert forward.as_dict() == reverse.as_dict()
# ==========================================================================
# 共享查询:保留了就要声明它还在算谁
# ==========================================================================
def test_a_shared_query_is_kept_and_declares_what_it_still_computes():
"""共享 SQL 选 A 也保留,并显式声明它还在算未选中的 B;共享参数一并保留(不做投影)。"""
made = graph_shared()
a = closure.build(made, "wf-shared", 1, "card", metric_ids=["a"]).require()
assert a.metric_ids == ("a",) and a.query_ids == ("wf-shared:q_shared",) and a.excluded_query_ids == ()
shared = a.queries[0]
assert shared.metric_ids == ("a",) and shared.computes_unselected == ("b",)
# 共享参数照样要(这条 SQL 用 start/end 渲染):共享约束不能被裁掉
assert [f["name"] for f in a.required_entry_fields] == ["start", "end"]
assert a.binding_fingerprints # 共享节点上的绑定指纹还在
whole = closure.build(made, "wf-shared", 1, "card").require()
assert [q.computes_unselected for q in whole.queries] == [()] # 整卡模式下没有「未选中」
def test_two_nodes_with_the_same_sql_stay_two_queries():
"""不拿内容哈希当身份:两条 SQL 一字不差,也是两条查询(不合并、各有各的 query_id)。"""
made = graph(metrics=[metric("a", "q_a"), metric("b", "q_b")],
fetches=[fetch("q_a", S.TEMPLATE), fetch("q_b", S.TEMPLATE)])
assert made["nodes"][1]["data"]["config"]["args"]["sql"] == made["nodes"][2]["data"]["config"]["args"]["sql"]
got = closure.build(made, "wf-same", 1, "card").require()
assert got.query_ids == ("wf-same:q_a", "wf-same:q_b")
assert [q.sql_hash for q in got.queries][0] == [q.sql_hash for q in got.queries][1]
assert [q.node_id for q in got.queries] == ["q_a", "q_b"]
# ==========================================================================
# 全局约束:图 defaults、卡 on_missing
# ==========================================================================
def test_global_constraints_travel_with_the_selection():
"""选一小部分指标也要带上图 defaults 与卡的「缺输入时」——那是全局必要约束,不被裁剪。"""
made = graph_ab(card_config={"on_missing": "null"}, defaults={"tool": S.tool(), "args": {"limit": 500}})
a = closure.build(made, "wf-cfg", 1, "card", metric_ids=["a"]).require()
assert a.on_missing == "null"
assert a.defaults == {"tool": S.tool(), "args": {"limit": 500}}
assert a.as_dict()["on_missing"] == "null" and a.as_dict()["defaults"]["args"]["limit"] == 500
# ==========================================================================
# 零查询:external 指标没有取数,但不是未决项
# ==========================================================================
def test_an_external_metric_needs_no_query_and_is_not_unresolved():
"""external 型指标本来就不查库:进 metric_ids,query_ids 为空,unresolved 为空。"""
made = graph(metrics=[metric("a", "q_a"),
{"id": "e", "name": "e", "kind": "external",
"external": {"value": 1.5, "source": "监管口径", "as_of": "2026-10-01"}}],
fetches=[fetch("q_a", S.TEMPLATE)])
whole = closure.build(made, "wf-ext", 1, "card").require()
assert whole.metric_ids == ("a", "e")
assert whole.query_ids == ("wf-ext:q_a",)
assert whole.queries[0].metric_ids == ("a",)
assert [q.computes_unselected for q in whole.queries] == [()]
assert whole.unresolved == ()
only_external = graph(metrics=[{"id": "e", "name": "e", "kind": "external",
"external": {"value": 1, "source": "监管口径", "as_of": "2026-10-01"}}],
fetches=[fetch("q_a", S.TEMPLATE)])
empty = closure.build(only_external, "wf-ext", 1, "card", metric_ids=["e"]).require()
assert empty.metric_ids == ("e",) and empty.query_ids == () and empty.excluded_query_ids == ()
# ==========================================================================
# 未知依赖:进 unresolved,require() 抛错,绝不静默整卡
# ==========================================================================
def test_an_unknown_metric_is_unresolved_and_never_falls_back_to_the_whole_card():
made = graph_ab()
got = closure.build(made, "wf-unknown", 1, "card", metric_ids=["a", "没有这个"])
assert [p["code"] for p in got.unresolved] == [closure.METRIC_UNKNOWN]
assert got.unresolved[0]["metric_id"] == "没有这个"
assert got.metric_ids == ("a",) # 只按点名的那一个走,不放大成整卡
assert got.whole_card is False
with pytest.raises(closure.UnresolvedClosure) as raised:
got.require()
assert raised.value.problems == got.unresolved
def test_an_unknown_node_reference_is_unresolved():
made = graph(metrics=[metric("a", "ghost")], fetches=[fetch("q_a", S.TEMPLATE)])
got = closure.build(made, "wf-ghost", 1, "card")
assert codes(got.unresolved) == [closure.UNKNOWN_NODE]
assert got.unresolved[0]["node_id"] == "ghost"
assert got.query_ids == ()
with pytest.raises(closure.UnresolvedClosure):
got.require()
def test_a_metric_with_an_unreadable_definition_is_unresolved():
"""kind 不认识(或该类型下式子是空的):解析不了依赖,直接未决,不猜。"""
made = graph(metrics=[metric("a", "q_a", kind="nope")], fetches=[fetch("q_a", S.TEMPLATE)])
got = closure.build(made, "wf-bad", 1, "card")
assert codes(got.unresolved) == [closure.EXPRESSION_UNREADABLE]
assert got.unresolved[0]["metric_id"] == "a" and got.query_ids == ()
def test_an_undeclared_parameter_is_unresolved():
"""SQL 引用了入口没声明的参数:未决,不按空串发出去(K01 的同一套判定)。"""
made = graph(metrics=[metric("a", "q_a")],
fetches=[fetch("q_a", S.TEMPLATE + " AND o.region = '{{ input.region }}'")])
got = closure.build(made, "wf-param", 1, "card")
assert closure.UNDECLARED_PARAMETER in codes(got.unresolved)
assert [f["name"] for f in got.required_entry_fields] == ["start", "end"]
def test_an_unaligned_binding_is_unresolved_but_only_for_the_selected_metrics():
"""K04 的定义 vs 绑定对不上:整卡未决;只选 A 时,B 那条对不上的查询不牵连 A。"""
made = graph(metrics=[metric("a", "q_a"),
# B 的定义已经改成支付时间,q_b 那条 SQL 还停在按下单时间:对不上
metric("b", "q_b", time_role="payment_time", refund_rule="exclude_full_refund")],
fetches=[fetch("q_a", S.TEMPLATE), fetch("q_b", TEMPLATE_B)])
whole = closure.build(made, "wf-align", 1, "card")
assert codes(whole.unresolved) == [closure.BINDING_UNRESOLVED]
assert whole.unresolved[0]["node_id"] == "q_b"
with pytest.raises(closure.UnresolvedClosure):
whole.require()
a = closure.build(made, "wf-align", 1, "card", metric_ids=["a"]).require()
assert a.unresolved == () and a.query_ids == ("wf-align:q_a",) and a.excluded_query_ids == ("wf-align:q_b",)
def test_a_conflict_on_a_shared_query_is_only_reported_when_the_other_metric_is_selected():
"""同一条共享 SQL 上两个指标说法打架:整卡未决;只选 A(且 A 的说法就是 SQL 现在的说法)时不牵连 A。
这是有意的分寸:共享 SQL 只是「还算了 B」——如实写进 computes_unselected,B 的数不交回,A 的结论不受 B 的
说法牵连(反例「B 独有错误导致 A 失败」)。"""
made = graph(metrics=[metric("a", "q_shared"),
metric("b", "q_shared", time_role="payment_time",
refund_rule="exclude_full_refund")],
fetches=[fetch("q_shared", S.TEMPLATE)])
whole = closure.build(made, "wf-conflict", 1, "card")
assert closure.BINDING_UNRESOLVED in codes(whole.unresolved)
a = closure.build(made, "wf-conflict", 1, "card", metric_ids=["a"]).require()
assert a.queries[0].computes_unselected == ("b",)
assert a.binding_fingerprints and set(a.binding_fingerprints) == {"wf-conflict:q_shared"}
def test_a_chained_card_cannot_be_resolved_without_the_pinned_definitions():
"""转抄别处的卡(caliber_from)本图里没有定义:照实说未决,不假装解析出依赖。"""
made = graph_ab()
card = next(n for n in made["nodes"] if n["id"] == "card")
card["data"]["config"].pop("metrics")
card["data"]["config"]["caliber_from"] = {"workflow_id": "up", "workflow_version": 2, "node_id": "card"}
got = closure.build(made, "wf-chain", 1, "card")
assert codes(got.unresolved) == [closure.CARD_CHAINED]
assert got.card_metric_ids == () and got.query_ids == ()
# ==========================================================================
# 整卡与空选择
# ==========================================================================
def test_explicit_whole_card_and_an_empty_selection_are_different_things():
made = graph_ab()
whole = closure.build(made, "wf-mode", 1, "card", whole_card=True).require()
assert whole.mode == closure.WHOLE_CARD and whole.whole_card and whole.metric_ids == ("a", "b")
assert whole.excluded_query_ids == ()
empty = closure.build(made, "wf-mode", 1, "card", metric_ids=[])
assert codes(empty.unresolved) == [closure.EMPTY_SELECTION]
assert empty.metric_ids == () and empty.whole_card is False # 空选择不当整卡
with pytest.raises(closure.UnresolvedClosure):
empty.require()
def test_asking_for_both_is_reported_and_does_not_widen_anything():
made = graph_ab()
both = closure.build(made, "wf-both", 1, "card", metric_ids=["a"], whole_card=True)
assert closure.CONFLICTING_SELECTION in codes(both.unresolved)
assert both.metric_ids == ("a",) and both.whole_card is False # 冲突时按更窄的一边
assert both.excluded_query_ids == ("wf-both:q_b",)
with pytest.raises(closure.UnresolvedClosure):
both.require()
def test_an_empty_card_in_whole_card_mode_is_unresolved():
made = graph(metrics=[], fetches=[fetch("q_a", S.TEMPLATE)])
got = closure.build(made, "wf-empty", 1, "card")
assert codes(got.unresolved) == [closure.CARD_EMPTY] and got.query_ids == ()
# ==========================================================================
# 身份:query_id、source、整卡指纹
# ==========================================================================
def test_query_id_is_stable_across_versions_and_changes_on_rename():
"""query_id 是逻辑身份:不随版本变;节点改名会变;显式 query_id 改名也稳定;别名不是源身份。"""
made = graph_ab()
v1 = closure.build(made, "wf-id", 1, "card").require()
v9 = closure.build(made, "wf-id", 9, "card").require()
assert v1.query_ids == v9.query_ids == ("wf-id:q_a", "wf-id:q_b")
assert v1.source == {"workflow_id": "wf-id", "version": 1, "card_id": "card"}
assert v9.source["version"] == 9 and v9.selection_hash != v1.selection_hash
renamed = graph_ab()
next(n for n in renamed["nodes"] if n["id"] == "q_a")["id"] = "q_a_renamed"
next(n for n in renamed["nodes"] if n["id"] == "card")["data"]["config"]["metrics"][0]["expression"] = \
"cell(nodes.q_a_renamed, 0, 'orders')"
after = closure.build(renamed, "wf-id", 1, "card").require()
assert after.query_ids == ("wf-id:q_a_renamed", "wf-id:q_b")
# 节点上显式声明 query_id(别名):改名之后逻辑身份不变,但 source 记的是真实节点
aliased = graph_ab()
next(n for n in aliased["nodes"] if n["id"] == "q_a")["data"]["config"]["query_id"] = "orders_total"
one = closure.build(aliased, "wf-id", 3, "card", metric_ids=["a"]).require()
assert one.query_ids == ("orders_total",)
assert one.queries[0].source == {"workflow_id": "wf-id", "version": 3, "node_id": "q_a"}
assert one.queries[0].node_id == "q_a" and one.queries[0].query_id == "orders_total"
assert closure.query_id_of("wf-id", "q_a", {"query_id": "orders_total"}) == "orders_total"
assert closure.query_id_of("wf-id", "q_a", {}) == "wf-id:q_a"
def test_two_queries_with_the_same_logical_identity_are_unresolved():
made = graph(metrics=[metric("a", "q_a")],
fetches=[fetch("q_a", S.TEMPLATE, query_id="orders"),
fetch("q_b", TEMPLATE_B, query_id="orders")])
got = closure.build(made, "wf-dup", 1, "card", metric_ids=["a"])
assert closure.DUPLICATE_QUERY_ID not in codes(got.unresolved) # q_b 没被选中,不进这次范围
both = graph(metrics=[metric("a", "q_a"), metric("b", "q_b")],
fetches=[fetch("q_a", S.TEMPLATE, query_id="orders"),
fetch("q_b", TEMPLATE_B, query_id="orders")])
dup = closure.build(both, "wf-dup", 1, "card")
assert codes(dup.unresolved) == [closure.DUPLICATE_QUERY_ID]
assert dup.unresolved[0]["query_id"] == "orders"
def test_the_source_card_hash_covers_the_whole_card_not_the_selection():
"""整卡身份(source_card_hash)和本次选择身份(selection_hash)都可追踪:选一半,整卡指纹不变。"""
made = graph_ab()
whole = closure.build(made, "wf-hash", 1, "card").require()
part = closure.build(made, "wf-hash", 1, "card", metric_ids=["a"]).require()
card = next(n for n in made["nodes"] if n["id"] == "card")["data"]["config"]
expected = card_hash_of({"metrics": card["metrics"], "caliber": card["caliber"],
"caliber_version": card["caliber_version"]})
assert whole.source_card_hash == part.source_card_hash == expected
assert whole.selection_hash != part.selection_hash and whole.closure_hash != part.closure_hash
# ==========================================================================
# 跨指标契约:照实列出来,选一半时不悄悄丢掉
# ==========================================================================
def test_cross_metric_contracts_are_listed():
"""选中指标声明的比例契约、图上出具契约点名的指标,都在 contract_dependencies 里如实列出。"""
made = graph_ab(card_config={"on_missing": "null"})
card = next(n for n in made["nodes"] if n["id"] == "card")
card["data"]["config"]["metrics"][0]["contract"] = {
"shape": "scalar", "ratio": {"numerator": "orders", "denominator": "total"}}
out = next(n for n in made["nodes"] if n["id"] == "out")
out["data"]["config"]["contract"] = {"metrics_from": "card", "required": ["a", "b"]}
a = closure.build(made, "wf-contract", 1, "card", metric_ids=["a"]).require()
assert a.contract_dependencies == (
{"kind": "ratio", "metric_id": "a", "numerator": "orders", "denominator": "total"},
{"kind": "report_contract", "node_id": "out", "metric_ids": ["a", "b"], "unselected": ["b"]},
)
whole = closure.build(made, "wf-contract", 1, "card").require()
assert whole.contract_dependencies[1]["unselected"] == [] # 整卡模式下没有「没选中的」
# ==========================================================================
# 确定性与绑定变化
# ==========================================================================
def test_the_same_input_always_gives_the_same_closure():
made = graph_ab(card_config={"on_missing": "null"}, defaults={"x": 1})
first = closure.build(made, "wf-det", 2, "card", metric_ids=["a", "b"])
second = closure.build(copy.deepcopy(made), "wf-det", 2, "card", metric_ids=["b", "a"])
assert first.as_dict() == second.as_dict()
assert first.selection_hash == second.selection_hash and first.closure_hash == second.closure_hash
def test_a_binding_change_moves_closure_hash_but_not_selection_hash():
"""同一份选择、同一批查询,绑定(业务值)从旧定义同步到新定义:selection_hash 不变,closure_hash 变。"""
old = closure.build(graph_shared(values=S.OLD), "wf-bind", 2, "card", metric_ids=["a"]).require()
new = closure.build(graph_shared(values=S.NEW), "wf-bind", 2, "card", metric_ids=["a"]).require()
assert old.metric_ids == new.metric_ids == ("a",)
assert old.query_ids == new.query_ids == ("wf-bind:q_shared",)
assert old.binding_fingerprints != new.binding_fingerprints
assert old.selection_hash == new.selection_hash # 绑定指纹不进 selection_hash
assert old.closure_hash != new.closure_hash # 但它进 closure_hash
assert old.source_card_hash != new.source_card_hash
# ==========================================================================
# 输入本身不可用:抛 ClosureRefused(不是 unresolved)
# ==========================================================================
@pytest.mark.parametrize("call", [
lambda: closure.build(None, "wf", 1, "card"),
lambda: closure.build(graph_ab(), "wf", 1, "没有这张卡"),
lambda: closure.build(graph_ab(), "wf", "不是版本", "card"),
lambda: closure.build(graph_ab(), "wf", 1, "out"), # 不是口径卡
lambda: closure.build(graph_ab(), "wf", 1, "card", metric_ids="a"),
])
def test_bad_inputs_are_refused(call):
with pytest.raises(closure.ClosureRefused):
call()
def test_a_duplicated_metric_id_is_unresolved_and_never_resolves_to_one_of_them():
"""同一张卡上两个指标用同一个 id、各读一条查询:整卡与点名都未决(duplicate_metric_id)、require 失败。
不能出现「闭包取最后那一份、运行时取第一份」:重复的 id 说不清算的是哪一个,所以哪种模式都照实报(K08 复核)。"""
made = graph(metrics=[metric("a", "q_a"), metric("a", "q_b")],
fetches=[fetch("q_a", S.TEMPLATE), fetch("q_b", TEMPLATE_B)])
whole = closure.build(made, "wf-dup-metric", 1, "card")
assert codes(whole.unresolved) == [closure.DUPLICATE_METRIC_ID]
assert whole.unresolved[0]["metric_id"] == "a" and "重复" in whole.unresolved[0]["message"]
with pytest.raises(closure.UnresolvedClosure):
whole.require()
# 点名这个 id 也说不清算的是哪一份:照样未决(不静默挑一个)
one = closure.build(made, "wf-dup-metric", 1, "card", metric_ids=["a"])
assert codes(one.unresolved) == [closure.DUPLICATE_METRIC_ID]
with pytest.raises(closure.UnresolvedClosure):
one.require()
# 卡上另一个(不重的)id 也照实拒:这是卡的身份问题,不是某个指标的问题
other = graph(metrics=[metric("a", "q_a"), metric("a", "q_b"), metric("b", "q_b")],
fetches=[fetch("q_a", S.TEMPLATE), fetch("q_b", TEMPLATE_B)])
assert codes(closure.build(other, "wf-dup-metric", 1, "card", metric_ids=["b"]).unresolved) \
== [closure.DUPLICATE_METRIC_ID]
# 判定只有这一处:空 id 不算重复,重复的按第一次出现的先后只报一次
assert closure.duplicate_metric_ids([metric("a", "q_a"), metric("a", "q_b"), {"name": "没有 id"}, metric("a", "q_c"),
metric("b", "q_c")]) == ["a"]
# ==========================================================================
# 这次算哪些指标:选择在源卡里的位置(运行时统一的那一份)
# ==========================================================================
def test_effective_metrics_keeps_the_source_card_order_and_reports_unknowns():
"""按卡上记的选择筛定义:保持源卡相对顺序,调用方传的顺序与重复项都不影响;认不出的 id 照实交回。"""
definitions = [metric("a", "q_a"), metric("b", "q_b"), metric("c", "q_c")]
index = closure.selection_index(definitions, ["c", "a", "c", "没有这个"])
assert [d["id"] for d in index.definitions] == ["a", "c"]
assert index.positions == (0, 2) and index.metric_ids == ("a", "c")
assert index.unknown == ("没有这个",) and index.whole_card is False
assert index.as_dict() == {"metric_ids": ["a", "c"], "positions": [0, 2], "whole_card": False,
"unknown": ["没有这个"]}
assert [d["id"] for d in closure.effective_metrics(definitions, ["c", "a"])] == ["a", "c"]
assert closure.effective_metrics(definitions, ["c", "a"]) == closure.effective_metrics(definitions, ["a", "c"])
# 交回的是源卡里的那几条定义本身(不是拷贝):运行时拿它算,别的键一个不动
assert closure.effective_metrics(definitions, ["c", "a"]) == [definitions[0], definitions[2]]
def test_no_selection_is_the_whole_card_and_an_empty_one_selects_nothing():
"""不给选择就是整卡(顺序与条目一个不动);显式空选择是一个也不选,不静默当整卡。"""
definitions = [metric("a", "q_a"), {"name": "没有 id 的一条"}]
whole = closure.selection_index(definitions)
assert whole.whole_card and whole.positions == (0, 1) and whole.unknown == ()
assert closure.effective_metrics(definitions) == definitions
assert closure.effective_metrics(definitions, ["a"]) == [definitions[0]]
empty = closure.selection_index(definitions, [])
assert empty.definitions == () and empty.positions == () and empty.whole_card is False
assert empty.unknown == () and closure.effective_metrics(definitions, []) == []
def test_a_repeated_metric_id_in_the_card_is_taken_once():
"""同一张卡上重复的 id 只算第一次那个(口径卡的 id 该唯一;这一层不替它去重之后算两遍)。"""
first, second = metric("a", "q_a"), metric("a", "q_b")
index = closure.selection_index([first, second, metric("b", "q_c")], ["a", "b"])
assert [d["id"] for d in index.definitions] == ["a", "b"] and index.positions == (0, 2)
assert index.definitions[0] is first
# ==========================================================================
# 闭包摘要(S4):身份、查询、字段、指纹各一份,但没有 SQL
# ==========================================================================
def test_the_summary_carries_the_identity_without_any_sql():
"""闭包摘要(K08 S4)给目录、选用选项与口径描述读:identity 与依赖全在,**没有 SQL 正文、没有参数值**。"""
import json
got = closure.build(graph_ab(), "wf-summary", 3, "card", metric_ids=["a"]).require()
summary = got.summary()
assert summary["mode"] == closure.EXPLICIT and summary["whole_card"] is False
assert summary["metric_ids"] == ["a"] and summary["card_metric_ids"] == ["a", "b"]
assert summary["query_ids"] == ["wf-summary:q_a"] and summary["excluded_query_ids"] == ["wf-summary:q_b"]
assert summary["required_entry_fields"] == ["start", "end"]
[query] = summary["queries"]
assert query["query_id"] == "wf-summary:q_a" and query["node_id"] == "q_a"
assert query["metric_ids"] == ["a"] and query["computes_unselected"] == []
assert query["required_entry_fields"] == ["start", "end"] and query["sql_hash"] == got.queries[0].sql_hash
assert summary["binding_fingerprints"] == dict(got.binding_fingerprints) and got.binding_fingerprints
assert summary["selection_hash"] == got.selection_hash and summary["closure_hash"] == got.closure_hash
assert summary["source_card_hash"] == got.source_card_hash and summary["unresolved"] == []
assert summary["resolver_version"] == closure.RESOLVER_VERSION
# 整卡那一份也照样给(不点名:本次默认那份)
whole = closure.build(graph_ab(), "wf-summary", 3, "card").require().summary()
assert whole["whole_card"] is True and whole["query_ids"] == ["wf-summary:q_a", "wf-summary:q_b"]
# 摘要里没有 SQL 正文与参数值:条目是白的(从模板里挑几个一定只在 SQL 里出现的字)
blob = json.dumps([summary, whole], ensure_ascii=False)
bad = [n for n in ("SUM(", "FROM", "input.start", "{{", '"sql"', "refund_state") if n in blob]
assert bad == [], blob
# 交回的是新的一份,不把闭包内部的对象漏出去
assert summary["queries"][0] is not got.queries[0]
assert summary["queries"][0] is not whole["queries"][0]
assert got.queries[0].sql_hash and "SUM(" in graph_ab()["nodes"][1]["data"]["config"]["args"]["sql"]