mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:21:59 +00:00
数字推演复核修正(第 3 轮):决定取哪一格的容器不再给可勾的行段,包着它的引用给了新值时不再说不准
- cell(nodes.q, len(vars.res.rows) - 5, …) 里的 vars.res.rows 这种决定取哪一格的数组字段、整份结果,第 2 轮起排除它的行 只回「说不准」,可 row_span 照给:界面让人勾 10 行,勾了推演不出来,「不支持推演的输入」里又说它不能用。现在这种容器 不给行段;决定取哪一格的单格照给(勾掉它确定是排除后无法计算)。前端 row_span 的类型说明跟着补上这一条 - 第 2 轮加的「说不准」不看包着它的引用有没有给新值:那一格改成 100、再排除别的行,以前能算出 100,第 2 轮起回说不准。 _refs 现在记下每个嵌在里面的引用归哪个最外层引用(嵌几层都记最外层);最外层引用都给了新值时,只嵌在里面的输入无关 紧要(_Plan.moot):它那一行排除了不再报排除后无法计算,对不上快照的也不再说不准。它自己也是最外层引用的照常拿值, 只是取哪一格不再说不准 - 补测试:选取用的容器不给行段、只有那一格那一行勾了有确定结果(单元和真跑);那一格给了新值后排除任何行都照常算; 既数行数又选取的容器;嵌在下标里的单格、对不上快照的下标(含嵌两层)、两个引用共用一个下标要都给新值;碰到了却没变 时 maybe_changed 为假、下游口径卡不标(单元和真跑);嵌在下标里的遮罩格让整个指标推演不了(BLOCKED 加一例);读不出来 的快照上嵌在里面的那一格不给行段(原先守它的测试被这次的选取判断盖住了) - 逐个做过变异,全部变红:选取容器给行段、moot 不过滤、说不准不看 settled、_doubt 不看 moot、只记直接上一层、settled 用 any、moot 含最外层引用,以及复核点名的 maybe_changed 只看碰没碰到、遮罩只看最外层。第 2 轮的两份变异单重放后也都 变红,只有「row_span 只看快照读不读得出」成了等价变异:完整值找不回来的容器在最外层时整个指标推演不了,嵌在里面时 又是选取用的,两头都已不给行段
This commit is contained in:
@@ -25,7 +25,10 @@ round(和 metrics.run_metrics 一样只 round 浮点),渲染走 evidence.r
|
||||
别的原样交回。排除行时有两种例外,都只能说不知道(unsupported),不能报「无变化」:
|
||||
- 指标的某个输入对不上任何快照(完整值找不回来的容器、加工过的结果、按位置从结果里取的一格、由查询结果算出来的数),
|
||||
它可能就是从被排除了行的那份结果来的,重算不了。写它的节点在那份快照交回之前就跑完了的,影响不到它,照常不算
|
||||
- 决定取哪一格的容器(cell(nodes.q, len(vars.res.rows) - 1, …) 里的 vars.res.rows)少了几行:取到的是哪一格说不准
|
||||
- 决定取哪一格的容器(cell(nodes.q, len(vars.res.rows) - 1, …) 里的 vars.res.rows)少了几行:取到的是哪一格说不准。
|
||||
这种容器也就不给可勾的行段(勾了只能说不知道);决定取哪一格的单格照给,勾掉它确定是排除后无法计算
|
||||
给了新值的以新值为准:只嵌在给了新值的引用里的输入(cell(…) 那一格给了新值,它行号里的 vars.res.rows)取到哪一格
|
||||
已无关紧要,它那一行排除了、它说不准受不受影响都不碍事(_Plan.moot)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -166,6 +169,18 @@ class _Plan:
|
||||
tops: list[str] = field(default_factory=list)
|
||||
inputs: dict[str, _Input] = field(default_factory=dict)
|
||||
problem: str | None = None
|
||||
#: 嵌在别的引用里的输入 → 包着它的最外层引用(_refs)
|
||||
within: dict[str, set[str]] = field(default_factory=dict)
|
||||
|
||||
def settled(self, path: str, changes: Changes) -> bool:
|
||||
"""包着这个输入的最外层引用都给了新值:它决定取哪一格已无关紧要,那几个引用以新值为准。"""
|
||||
outer = self.within.get(path)
|
||||
return bool(outer) and all(top in changes.inputs for top in outer)
|
||||
|
||||
def moot(self, path: str, changes: Changes) -> bool:
|
||||
"""这个输入这次用不上:只嵌在别的引用里,包着它的又都给了新值。它那一行排除了、它说不准受不受影响,都碍不着
|
||||
重算。它自己也是最外层引用的不算:那一处照常拿它的值。"""
|
||||
return path not in self.tops and self.settled(path, changes)
|
||||
|
||||
@property
|
||||
def blocked(self) -> tuple[str, str] | None:
|
||||
@@ -234,50 +249,54 @@ def _plan(metric: dict[str, Any], store: _Store) -> _Plan:
|
||||
tree, _, _ = parse_expression(expr)
|
||||
except ExpressionError as e:
|
||||
return _Plan(metric, problem=f"表达式有误({e})")
|
||||
tops, nested = _refs(tree)
|
||||
remaining = _remaining(metric.get("substituted"), [*tops, *nested])
|
||||
tops, within = _refs(tree)
|
||||
remaining = _remaining(metric.get("substituted"), [*tops, *within])
|
||||
inputs: dict[str, _Input] = {}
|
||||
for item in items:
|
||||
if isinstance(item, dict) and isinstance(item.get("path"), str) and item["path"] not in inputs:
|
||||
inputs[item["path"]] = _restore(item, remaining, nested, store)
|
||||
plan = _Plan(metric, tree, tops, inputs)
|
||||
inputs[item["path"]] = _restore(item, remaining, set(within), store)
|
||||
plan = _Plan(metric, tree, tops, inputs, within=within)
|
||||
if lost := [p for p in tops if p not in inputs]:
|
||||
plan.problem = f"口径卡缺少输入 {'、'.join(lost[:3])} 的记录,不能推演"
|
||||
return plan
|
||||
|
||||
|
||||
def _refs(tree: ast.AST) -> tuple[list[str], set[str]]:
|
||||
"""表达式引用的值:(最外层的引用, 嵌在别的引用里面的)。和 leaf_refs 按同一套规则认引用,只多分一下里外:
|
||||
vars.rows[vars.i] 里的 vars.i、cell(nodes.q, vars.r, 'gmv') 里的 vars.r 是嵌在里面的,它们决定取哪一格。"""
|
||||
def _refs(tree: ast.AST) -> tuple[list[str], dict[str, set[str]]]:
|
||||
"""表达式引用的值:(最外层的引用, 嵌在别的引用里面的 → 包着它的最外层引用)。和 leaf_refs 按同一套规则认引用,
|
||||
只多分一下里外:vars.rows[vars.i] 里的 vars.i、cell(nodes.q, vars.r, 'gmv') 里的 vars.r 是嵌在里面的,它们决定
|
||||
取哪一格。嵌了几层的也记最外层那个:重算时只有最外层的引用拿值(_evaluate),给了新值的就是它们。"""
|
||||
top: list[str] = []
|
||||
inner: set[str] = set()
|
||||
within: dict[str, set[str]] = {}
|
||||
|
||||
def add(node: ast.AST, nested: bool) -> None:
|
||||
def add(node: ast.AST, outer: str | None) -> str:
|
||||
"""记下这个引用,交回它里面的引用归谁:已经嵌在里面的归原来那个最外层引用,最外层的归它自己。"""
|
||||
path = ast.unparse(node)
|
||||
if nested:
|
||||
inner.add(path)
|
||||
elif path not in top:
|
||||
if outer is not None:
|
||||
within.setdefault(path, set()).add(outer)
|
||||
return outer
|
||||
if path not in top:
|
||||
top.append(path)
|
||||
return path
|
||||
|
||||
def visit(node: ast.AST, nested: bool) -> None:
|
||||
def visit(node: ast.AST, outer: str | None) -> None:
|
||||
if _cell_call(node):
|
||||
add(node, nested)
|
||||
owner = add(node, outer)
|
||||
for arg in node.args[1:]:
|
||||
visit(arg, True)
|
||||
visit(arg, owner)
|
||||
return
|
||||
if isinstance(node, (ast.Attribute, ast.Subscript, ast.Name)) and _chain_root(node):
|
||||
add(node, nested)
|
||||
owner = add(node, outer)
|
||||
cur: ast.AST = node
|
||||
while isinstance(cur, (ast.Attribute, ast.Subscript)):
|
||||
if isinstance(cur, ast.Subscript):
|
||||
visit(cur.slice, True)
|
||||
visit(cur.slice, owner)
|
||||
cur = cur.value
|
||||
return
|
||||
for child in ast.iter_child_nodes(node):
|
||||
visit(child, nested)
|
||||
visit(child, outer)
|
||||
|
||||
visit(tree, False)
|
||||
return top, inner
|
||||
visit(tree, None)
|
||||
return top, within
|
||||
|
||||
|
||||
def _remaining(substituted: Any, paths: list[str]) -> set[str]:
|
||||
@@ -556,13 +575,13 @@ def _doubt(plan: _Plan, changes: Changes, sources: Sources) -> str | None:
|
||||
数组字段的完整值找不回来时也知道它用到哪几行,排除了那几行就算碰到了它(指标因它还原不了而推演不了)。
|
||||
别的输入——完整值找不回来的容器、对不上任何快照的容器(加工过的结果)、不是直接取自快照的数(按位置从整份结果里
|
||||
取的一格、transform 由查询结果算出来的数)——都可能来自被排除了行的那份结果,又重算不了,只能说不知道,不能拿
|
||||
原值算出一个「无变化」或者一个不对应任何一份数据的数。给了新值的以新值为准;写它的节点在那份快照交回之前就跑完了
|
||||
的,影响不到它(_after)。
|
||||
原值算出一个「无变化」或者一个不对应任何一份数据的数。给了新值的以新值为准,只嵌在给了新值的引用里的也就无关紧要
|
||||
(_Plan.moot);写它的节点在那份快照交回之前就跑完了的,影响不到它(_after)。
|
||||
"""
|
||||
if not changes.exclude:
|
||||
return None
|
||||
for path, inp in plan.inputs.items():
|
||||
if path in changes.inputs or inp.snapshot is not None:
|
||||
if path in changes.inputs or inp.snapshot is not None or plan.moot(path, changes):
|
||||
continue
|
||||
if any(_after(snapshot, inp, sources) for snapshot in changes.exclude):
|
||||
why = inp.problem or (DOUBT_UNMATCHED if inp.shape == "container" else DOUBT_DERIVED)
|
||||
@@ -582,14 +601,16 @@ def _after(snapshot: str, inp: _Input, sources: Sources) -> bool:
|
||||
def _compute(plan: _Plan, values: dict[str, Any], notes: dict[str, list[int]], changes: Changes,
|
||||
doubt: str | None) -> tuple[str, Any, str | None]:
|
||||
"""(status, 新值, 原因)。整个推演不了的(_Plan.blocked)到不了这里。排除后缺了的、说不准受不受影响的先说,
|
||||
都没有才求值。"""
|
||||
hit = [(path, inp) for path, inp in plan.inputs.items() if path in notes and path not in changes.inputs]
|
||||
都没有才求值。只嵌在给了新值的引用里的输入(_Plan.moot)不论排除了什么都不碍事:那几个引用以新值为准。"""
|
||||
hit = [(path, inp) for path, inp in plan.inputs.items()
|
||||
if path in notes and path not in changes.inputs and not plan.moot(path, changes)]
|
||||
for path, inp in hit:
|
||||
if inp.row is not None:
|
||||
return "excluded", None, f"{EXCLUDED}:{path} 所在的第 {inp.row + 1} 行已排除"
|
||||
for path, inp in hit:
|
||||
if inp.selector:
|
||||
# 嵌在下标、cell() 行列里的容器少了几行:外面那个引用取到的是哪一格跟着变,卡上记的值不再作数
|
||||
if inp.selector and not plan.settled(path, changes):
|
||||
# 嵌在下标、cell() 行列里的容器少了几行:外面那个引用取到的是哪一格跟着变,卡上记的值不再作数。
|
||||
# 包着它的引用都给了新值时,它只剩自己作为最外层引用的那一处(排除了哪几行是确定的),照常算
|
||||
return "unsupported", None, DOUBT.format(path=path, why=DOUBT_SELECTOR)
|
||||
if doubt:
|
||||
return "unsupported", None, doubt
|
||||
@@ -741,11 +762,14 @@ def _editable(inp: _Input, *, blocked: bool) -> bool:
|
||||
def _row_span(inp: _Input) -> tuple[int, int] | None:
|
||||
"""这个输入实际用到的快照行(含两端):单格一行、数组字段一段、整份结果全部。排除别的行影响不到它,界面据此只让
|
||||
这几行可勾。遮罩的、还原不了的(完整值找不回来、快照读不出来,都记在 problem 里)不给:排除它们的行什么也推演
|
||||
不出来。"""
|
||||
不出来。决定取哪一格的数组字段、整份结果也不给:少了几行,外面那个引用取到哪一格说不准(_compute),勾了只能
|
||||
说不知道,「不支持推演的输入」里又说它不能用。决定取哪一格的单格照给:勾掉它,确定是排除后无法计算。"""
|
||||
if inp.snapshot is None or inp.masked or inp.problem is not None:
|
||||
return None
|
||||
if inp.row is not None:
|
||||
return inp.row, inp.row
|
||||
if inp.selector:
|
||||
return None
|
||||
if inp.rows is not None:
|
||||
return inp.rows
|
||||
rows = inp.value.get("rows") if isinstance(inp.value, dict) else inp.value
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- 容器输入的还原:agent 数组字段(按 locator.rows 去掉被排除的行)、nodes.* 从节点产出、vars.* 只认 value_artifact
|
||||
- 遮罩列取来的输入既不给值、也不能拿来推演
|
||||
- 整个指标推演不了的(遮罩列、还原不了的输入、读不出来的快照、没记输入),不改动也照实说,输入一个都不让动
|
||||
- 排除了行却说不准受不受影响的指标,不报「无变化」
|
||||
- 排除了行却说不准受不受影响的指标,不报「无变化」,也不给勾了只能说不准的行段;给了新值的以新值为准
|
||||
- 不合法的请求抛 WhatIfError(接口回 422),说清哪里不对
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -484,15 +484,118 @@ def test_only_inputs_written_after_the_snapshot_came_back_are_doubted(finished,
|
||||
assert (mixed["status"], mixed["changed"]) == (("unsupported", None) if doubted else ("unchanged", False))
|
||||
|
||||
|
||||
#: cell() 的行号由整份结果算出:取最后一行
|
||||
LAST_CELL = "cell(nodes.pull, len(vars.res.rows) - 1, 'amount')"
|
||||
LAST = lone("last", LAST_CELL, "40.0", 40.0, [
|
||||
{**cell("pull", 3, "amount", 40.0), "path": LAST_CELL},
|
||||
{**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}], decimals=1)
|
||||
|
||||
|
||||
def test_a_container_that_picks_the_cell_is_doubted_when_its_rows_are_excluded():
|
||||
"""cell() 的行号由整份结果算出(取最后一行):排除了别的行,行号跟着变,取到的是哪一格说不准,不能报「无变化」。"""
|
||||
path = "cell(nodes.pull, len(vars.res.rows) - 1, 'amount')"
|
||||
card = lone("last", path, "40.0", 40.0, [
|
||||
{**cell("pull", 3, "amount", 40.0), "path": path},
|
||||
{**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}], decimals=1)
|
||||
assert recompute(card["metrics"][0], Changes(), sources())["new"] == 40.0
|
||||
[last] = whatif(card, Changes(exclude={S1: frozenset({1})}), sources())["metrics"]
|
||||
assert last["status"] == "unsupported" and last["changed"] is None and "vars.res.rows" in last["reason"]
|
||||
"""排除了别的行,行号跟着变,取到的是哪一格说不准,不能报「无变化」。整份结果因此不给行段:勾了也推演不出来,
|
||||
「不支持推演的输入」里又说它不能用。那一格自己那一行照给:勾掉它,确定是排除后无法计算。"""
|
||||
assert recompute(LAST["metrics"][0], Changes(), sources())["new"] == 40.0
|
||||
baseline = whatif(LAST, Changes(), sources())
|
||||
assert [(i["path"], i.get("row_span")) for i in baseline["metrics"][0]["inputs"]] == [
|
||||
(LAST_CELL, [3, 3]), ("vars.res.rows", None)]
|
||||
assert [u["input"] for u in baseline["unsupported"]] == ["vars.res.rows"]
|
||||
assert "取哪" in baseline["unsupported"][0]["reason"]
|
||||
for row in (0, 1, 2):
|
||||
[last] = whatif(LAST, Changes(exclude={S1: frozenset({row})}), sources())["metrics"]
|
||||
assert last["status"] == "unsupported" and last["changed"] is None and "vars.res.rows" in last["reason"]
|
||||
[last] = whatif(LAST, Changes(exclude={S1: frozenset({3})}), sources())["metrics"]
|
||||
assert last["status"] == "excluded" and "第 4 行" in last["reason"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rows", [{1}, {3}, {0, 1, 2, 3}])
|
||||
def test_an_explicit_value_on_the_picked_cell_settles_the_container_that_picks_it(rows):
|
||||
"""那一格给了新值:取到的是哪一格已无关紧要,以新值为准。排除整份结果里的行(哪怕连它自己那一行)照常算。"""
|
||||
[last] = whatif(LAST, Changes(inputs={LAST_CELL: 100}, exclude={S1: frozenset(rows)}), sources())["metrics"]
|
||||
assert (last["status"], last["new"], last["changed"]) == ("ok", 100, True), last
|
||||
|
||||
|
||||
def test_a_container_that_is_also_counted_keeps_only_the_count_once_the_cell_has_a_new_value():
|
||||
"""整份结果既拿来数行数、又决定取哪一格:那一格给了新值,它只剩数行数的那一处,排除了哪几行是确定的,照常算。"""
|
||||
card = lone("both", f"len(vars.res.rows) + {LAST_CELL}", "len(vars.res.rows) + 40.0", 44.0, [
|
||||
{**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS},
|
||||
{**cell("pull", 3, "amount", 40.0), "path": LAST_CELL}], decimals=1)
|
||||
assert recompute(card["metrics"][0], Changes(), sources())["new"] == 44.0
|
||||
[both] = whatif(card, Changes(exclude={S1: frozenset({1})}), sources())["metrics"]
|
||||
assert both["status"] == "unsupported" and "vars.res.rows" in both["reason"]
|
||||
[both] = whatif(card, Changes(inputs={LAST_CELL: 100}, exclude={S1: frozenset({1})}), sources())["metrics"]
|
||||
assert (both["status"], both["new"]) == ("ok", 3 + 100), both
|
||||
|
||||
|
||||
#: 按第 2 行的区域取费率:费率写在查询之前(fetch),区域那一格决定取哪一项
|
||||
FEE = "vars.kpi.rates[cell(nodes.pull, 1, 'region')]"
|
||||
REGION = "cell(nodes.pull, 1, 'region')"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression, substituted, value, after", [
|
||||
# 区域那一格只用来取费率:费率给了新值,它就无关紧要
|
||||
(f"{FEE} * 100", "0.05 * 100", 5.0, ("ok", 8.0)),
|
||||
# 区域那一格另外还拿来数字数:那一处照常拿它的值,它那一行排除了照样算不出来
|
||||
(f"len({REGION}) + {FEE} * 100", "len('华南') + 0.05 * 100", 7.0, ("excluded", None)),
|
||||
])
|
||||
def test_a_cell_that_picks_the_item_is_moot_once_the_item_has_a_new_value(expression, substituted, value, after):
|
||||
"""区域那一格所在的行排除了:取哪一项不知道,排除后无法计算。费率给了新值:取哪一项已无关紧要,照常算——
|
||||
那一格另外还直接参与计算的除外。"""
|
||||
card = lone("fee", expression, substituted, value, [scalar(FEE, 0.05), cell("pull", 1, "region", "华南")],
|
||||
decimals=1)
|
||||
timed = sources(returned={S1: 10}, finished={"fetch": 5})
|
||||
assert recompute(card["metrics"][0], Changes(), timed)["new"] == value
|
||||
[fee] = whatif(card, Changes(exclude={S1: frozenset({1})}), timed)["metrics"]
|
||||
assert fee["status"] == "excluded" and "第 2 行" in fee["reason"]
|
||||
[fee] = whatif(card, Changes(inputs={FEE: 0.08}, exclude={S1: frozenset({1})}), timed)["metrics"]
|
||||
assert (fee["status"], fee["new"]) == after, fee
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path, middle", [
|
||||
("vars.kpi.rates[vars.kpi.region]", []),
|
||||
# 嵌了两层:区域名先换成区域代码再取费率,包着它的最外层引用照样是费率
|
||||
("vars.kpi.rates[vars.kpi.codes[vars.kpi.region]]", [scalar("vars.kpi.codes[vars.kpi.region]", "S")]),
|
||||
])
|
||||
def test_an_index_that_would_be_doubted_is_moot_once_the_item_has_a_new_value(path, middle):
|
||||
"""取哪一项由区域名决定,区域名是查询交回之后才算出来的(对不上快照):排除行后取到的是哪一项说不准。费率给了
|
||||
新值:区域名受不受影响已无关紧要,按剩下的行照常算。"""
|
||||
card = lone("fee2", f"round({path} * sum(vars.kpi.amounts), 1)", "round(0.05 * sum(vars.kpi.amounts), 1)", 17.0,
|
||||
[scalar(path, 0.05), *middle, scalar("vars.kpi.region", "华南", node="label"), AMOUNTS], decimals=1)
|
||||
timed = sources(returned={S1: 10}, finished={"fetch": 5, "label": 12})
|
||||
assert recompute(card["metrics"][0], Changes(), timed)["new"] == 17.0
|
||||
[fee] = whatif(card, Changes(exclude={S1: frozenset({1})}), timed)["metrics"]
|
||||
assert fee["status"] == "unsupported" and "排除行对 vars.kpi.region 的影响无法确定" in fee["reason"]
|
||||
[fee] = whatif(card, Changes(inputs={path: 0.1}, exclude={S1: frozenset({1})}), timed)["metrics"]
|
||||
assert (fee["status"], fee["new"]) == ("ok", round(0.1 * (340.0 - 80.0), 1)) == ("ok", 26.0), fee
|
||||
|
||||
|
||||
def test_an_index_shared_by_two_items_is_moot_only_when_both_have_new_values():
|
||||
"""费率和固定费都按区域名取:只给费率新值,固定费取到哪一项照样说不准;两样都给了才无关紧要。"""
|
||||
rate, fixed = "vars.kpi.rates[vars.kpi.region]", "vars.kpi.fixed[vars.kpi.region]"
|
||||
card = lone("fee4", f"round({rate} * sum(vars.kpi.amounts) + {fixed}, 1)",
|
||||
"round(0.05 * sum(vars.kpi.amounts) + 3, 1)", 20.0,
|
||||
[scalar(rate, 0.05), scalar("vars.kpi.region", "华南", node="label"), AMOUNTS, scalar(fixed, 3)],
|
||||
decimals=1)
|
||||
timed = sources(returned={S1: 10}, finished={"fetch": 5, "label": 12})
|
||||
assert recompute(card["metrics"][0], Changes(), timed)["new"] == 20.0
|
||||
[fee] = whatif(card, Changes(inputs={rate: 0.1}, exclude={S1: frozenset({1})}), timed)["metrics"]
|
||||
assert fee["status"] == "unsupported" and "vars.kpi.region" in fee["reason"]
|
||||
[fee] = whatif(card, Changes(inputs={rate: 0.1, fixed: 5}, exclude={S1: frozenset({1})}), timed)["metrics"]
|
||||
assert (fee["status"], fee["new"]) == ("ok", 31.0), fee
|
||||
|
||||
|
||||
def test_a_change_that_leaves_the_value_as_it_was_moves_nothing_downstream():
|
||||
"""改动碰到了指标,算出来却没变:4 行里排除 1 行,min(行数, 3) 还是 3;填回原值也一样。照实说无变化,下游也不会
|
||||
受影响(maybe_changed 为假)。排除到只剩 2 行才变。"""
|
||||
card = lone("cap", "min(len(vars.res.rows), 3)", "min(len(vars.res.rows), 3)", 3,
|
||||
[{**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}])
|
||||
result = whatif(card, Changes(exclude={S1: frozenset({3})}), sources())
|
||||
[cap] = result["metrics"]
|
||||
assert (cap["status"], cap["new"], cap["changed"], cap["inputs"][0]["excluded_rows"]) == ("ok", 3, False, [3])
|
||||
assert result["maybe_changed"] is False
|
||||
same = whatif(CARD, Changes(inputs={"vars.kpi.gmv": 45678.5}), sources(), metric="wow")
|
||||
assert (same["metrics"][0]["status"], same["metrics"][0]["changed"], same["maybe_changed"]) == ("ok", False, False)
|
||||
moved = whatif(card, Changes(exclude={S1: frozenset({0, 3})}), sources())
|
||||
assert moved["metrics"][0]["new"] == 2 and moved["maybe_changed"] is True
|
||||
|
||||
|
||||
def test_a_metric_without_lineage_is_unsupported_whatever_changes():
|
||||
@@ -503,6 +606,8 @@ def test_a_metric_without_lineage_is_unsupported_whatever_changes():
|
||||
assert old["status"] == "unsupported" and old["changed"] is None and "没有记录输入" in old["reason"]
|
||||
|
||||
|
||||
#: 按遮罩列那一格的长度取第几项
|
||||
PHONE_PICK = "vars.kpi.list[len(cell(nodes.crm, 0, 'phone')) - 10]"
|
||||
#: 整个指标推演不了的几种:(卡, Sources 的改动, 改点什么, 状态, 原因里该有的字)。每种都另有一个本来能改的输入
|
||||
BLOCKED = {
|
||||
# 老运行:容器型 vars 输入没存完整值
|
||||
@@ -513,6 +618,11 @@ BLOCKED = {
|
||||
"masked": (lone("mix", "len(cell(nodes.crm, 0, 'phone')) + vars.kpi.k", "len('13800000000') + 2", 13,
|
||||
[cell("crm", 0, "phone", "13800000000", artifact=S2), scalar("vars.kpi.k", 2)]),
|
||||
{}, Changes(inputs={"vars.kpi.k": 5}), "masked", ["遮罩"]),
|
||||
# 遮罩列的那一格只嵌在下标里(决定取哪一项):改了别的输入,取到哪一项照样露出它的长度
|
||||
"masked_index": (lone("pick_m", f"{PHONE_PICK} + vars.kpi.k", "20 + 2", 22,
|
||||
[scalar(PHONE_PICK, 20), cell("crm", 0, "phone", "13800000000", artifact=S2),
|
||||
scalar("vars.kpi.k", 2)]),
|
||||
{}, Changes(inputs={"vars.kpi.k": 5}), "masked", ["遮罩"]),
|
||||
# 那一格所在的查询快照读不出来:遮没遮核对不了
|
||||
"unreadable": (lone("mix2", "cell(nodes.pull, 0, 'amount') + vars.kpi.k", "120.5 + 2", 122.5,
|
||||
[cell("pull", 0, "amount", 120.5), scalar("vars.kpi.k", 2)], decimals=1),
|
||||
@@ -638,6 +748,8 @@ def test_a_withheld_cell_inside_another_reference_stays_out_of_the_substituted_e
|
||||
[m] = whatif(card, Changes(), sources(load=lambda a: None if a == S1 else store.get(a)))["metrics"]
|
||||
assert m["status"] == "unchanged" and m["substituted"] is None
|
||||
assert [i.get("withheld") for i in m["inputs"]] == [None, True]
|
||||
# 那一格所在的快照读不出来:它那一行也不给勾(排除读不出来的快照里的行,接口回 422)
|
||||
assert "row_span" not in m["inputs"][1]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -811,6 +811,29 @@ async def test_inputs_carry_the_rows_they_use(client, monkeypatch):
|
||||
assert cell["row_span"] == [5, 5] and rows["row_span"] == [0, 9]
|
||||
|
||||
|
||||
async def test_a_container_that_picks_the_cell_offers_no_rows(client, monkeypatch):
|
||||
"""cell() 的行号由整份结果算出:整份结果不给行段(勾了别的行,取到哪一格说不准,什么也推演不出来),那一格
|
||||
自己那一行照常可勾、勾了确定是排除后无法计算。那一格给了新值,取到哪一格已无关紧要,排除别的行照常算。"""
|
||||
pick = "cell(nodes.fetch, len(vars.res.rows) - 5, 'amount')"
|
||||
row = await run_graph(monkeypatch, query_graph(metrics=[
|
||||
{"id": "pick", "name": "倒数第五单金额", "unit": "元", "decimals": 1, "format": "thousands", "expression": pick}]),
|
||||
text="倒数第五单金额 [[m:pick]]。")
|
||||
snapshot = await snapshot_of(row.id)
|
||||
body = await whatif(client, row.id, {"card": "card"})
|
||||
[m] = body["metrics"]
|
||||
assert m["status"] == "unchanged" and m["old"] == 600.5
|
||||
assert [(i["path"], i.get("row_span"), i["editable"]) for i in m["inputs"]] == [
|
||||
(pick, [5, 5], True), ("vars.res.rows", None, False)]
|
||||
assert [(u["input"], "取哪" in u["reason"]) for u in body["unsupported"]] == [("vars.res.rows", True)]
|
||||
[m] = (await whatif(client, row.id, {"card": "card", "overrides": {"exclude_rows": {snapshot: [5]}}}))["metrics"]
|
||||
assert m["status"] == "excluded" and "第 6 行" in m["reason"]
|
||||
body = await whatif(client, row.id, {"card": "card", "report": "write",
|
||||
"overrides": {"inputs": {pick: 1000}, "exclude_rows": {snapshot: [0]}}})
|
||||
[m] = body["metrics"]
|
||||
assert (m["status"], m["new"], m["rendered_new"]) == ("ok", 1000, "1,000.0元"), m
|
||||
assert [(s["rendered_old"], s["rendered_new"]) for s in body["segments"]] == [("600.5元", "1,000.0元")]
|
||||
|
||||
|
||||
async def test_rows_of_a_sealed_snapshot_can_be_paged_with_masks_applied(client, monkeypatch, shop):
|
||||
"""推演区里给整份结果勾行:按页取封存范围内快照的行,遮罩列写「已遮罩」。只读。"""
|
||||
row = await run_graph(monkeypatch, query_graph())
|
||||
@@ -863,6 +886,24 @@ async def test_downstream_cards_through_an_intermediate_node_are_flagged(client,
|
||||
assert (flagged["node_id"], flagged["metrics"], flagged["direct"]) == ("yearly", ["year"], False)
|
||||
|
||||
|
||||
async def test_a_change_that_moves_no_metric_flags_no_downstream_card(client, monkeypatch):
|
||||
"""改动碰到了指标、算出来却没变(10 单里排除 2 单,min(单数, 3) 还是 3):下游不会受影响,不标。排除到只剩 2 单
|
||||
才变,这时才标。界面不会提交和原值一样的输入,排除行却常有这种情况(又如 max(…) 勾掉的不是最大值那一行)。"""
|
||||
row = await run_graph(monkeypatch, query_graph(
|
||||
metrics=[{"id": "cap", "name": "封顶单数", "unit": "单", "format": "thousands",
|
||||
"expression": "min(len(vars.res.rows), 3)"}],
|
||||
between=[node("yearly", "metrics", caliber="年口径", metrics=[
|
||||
{"id": "year", "name": "年化", "expression": "nodes.card.metrics[0].value * 52"}])]),
|
||||
text="封顶单数 [[m:cap]]。")
|
||||
snapshot = await snapshot_of(row.id)
|
||||
body = await whatif(client, row.id, {"card": "card", "overrides": {"exclude_rows": {snapshot: [2, 7]}}})
|
||||
[cap] = body["metrics"]
|
||||
assert (cap["status"], cap["new"], cap["changed"], cap["inputs"][0]["excluded_rows"]) == ("ok", 3, False, [2, 7])
|
||||
assert body["affected_cards"] == []
|
||||
body = await whatif(client, row.id, {"card": "card", "overrides": {"exclude_rows": {snapshot: list(range(8))}}})
|
||||
assert body["metrics"][0]["new"] == 2 and [c["node_id"] for c in body["affected_cards"]] == ["yearly"]
|
||||
|
||||
|
||||
async def test_downstream_cards_are_flagged_when_the_change_cannot_be_followed(client, monkeypatch):
|
||||
"""改动碰到了指标、它却推演不了(还原不了、遮罩):新值不知道,下游同样可能受影响。"""
|
||||
from app.engine.nodes import metrics as metrics_node
|
||||
|
||||
@@ -3062,7 +3062,8 @@ export interface WhatIfInput {
|
||||
excluded_rows?: number[]
|
||||
/**
|
||||
* 这个输入实际用到的快照行(含两端,从 0 数):单格一行、数组字段一段、整份查询结果全部。排除别的行影响不到它,
|
||||
* 界面只让这几行可勾。遮罩的、还原不了的(完整值找不回来、快照读不出来)没有,整个指标推演不了时一律没有
|
||||
* 界面只让这几行可勾。遮罩的、还原不了的(完整值找不回来、快照读不出来)没有,决定取哪一格的数组字段、整份结果
|
||||
* 也没有(少了几行,取到哪一格说不准),整个指标推演不了时一律没有
|
||||
*/
|
||||
row_span?: [number, number]
|
||||
masked?: boolean
|
||||
|
||||
Reference in New Issue
Block a user