分析算子给结论里以比率存的列和数记换算:契约加 ResultTable.formats、AnalysisResult.value_formats(换算同 [[m:]] 的后缀,另加 pp 表示百分点),各算子逐个填全——可加、乘积拆解的 pct、share;比率拆解的 w_*、r_* 记 pct,mix、rate、total 是比率之差记 pp;波动基线按变化率比较时的 change,趋势的相对斜率;差异检验比率模式的 a_value、b_value 记 pct,diff、ci_lo、ci_hi 记 pp;values 里的 pct_total、top_share、top_k_share、mix_share、rate_share 等。都为空时整项不写,结论和以前一字不差;run 与 recompute 逐字段相同、同输入两次 run 逐字节相同照旧成立(tests/test_analysis_run 的形状检查认这两项可选)

This commit is contained in:
ThearchyHelios
2026-10-21 15:54:46 +00:00
parent 1c117d78aa
commit 3ecc6223df
5 changed files with 86 additions and 17 deletions
+24 -6
View File
@@ -64,13 +64,18 @@ def today_param(env: Env) -> str:
def build(env: Env, params: dict[str, Any], used: Sequence[str], *, status: str = "ok", def build(env: Env, params: dict[str, Any], used: Sequence[str], *, status: str = "ok",
reason_code: str | None = None, reason: str | None = None, table: Mapping[str, Any] | None = None, reason_code: str | None = None, reason: str | None = None, table: Mapping[str, Any] | None = None,
values: Mapping[str, Any] | None = None, verdict: Mapping[str, Any] | None = None, criterion: str, values: Mapping[str, Any] | None = None, verdict: Mapping[str, Any] | None = None, criterion: str,
phrase: str, caveats: Sequence[Mapping[str, str]] = ()) -> AnalysisResult: phrase: str, caveats: Sequence[Mapping[str, str]] = (),
"""组装一份结论:形状严格按 contract.AnalysisResult,params 补上查过的目录事实。""" formats: Mapping[str, str] | None = None, named: Mapping[str, str] | None = None) -> AnalysisResult:
"""组装一份结论:形状严格按 contract.AnalysisResult,params 补上查过的目录事实。formats 是结果表各列的换算(占比、
变化率的列记 pct,比率的变化记 pp),named 是具名的数的换算(value_formats):都只留记了的,为空时整项不写。"""
record = env.facts.record() record = env.facts.record()
out_params = {k: v for k, v in params.items() if v is not None} out_params = {k: v for k, v in params.items() if v is not None}
if record: if record:
out_params["catalog"] = record out_params["catalog"] = record
return { out_table = dict(table) if table is not None else None
if out_table is not None and (table_formats := column_formats(formats or {})):
out_table["formats"] = table_formats
out: AnalysisResult = {
"op": env.op, "op": env.op,
"mode": env.mode, "mode": env.mode,
"method": METHODS[(env.op, env.mode)], "method": METHODS[(env.op, env.mode)],
@@ -81,7 +86,7 @@ def build(env: Env, params: dict[str, Any], used: Sequence[str], *, status: str
"status": status, # type: ignore[typeddict-item] "status": status, # type: ignore[typeddict-item]
"reason_code": reason_code, "reason_code": reason_code,
"reason": reason, "reason": reason,
"table": dict(table) if table is not None else None, # type: ignore[typeddict-item] "table": out_table, # type: ignore[typeddict-item]
"values": dict(values or {}), "values": dict(values or {}),
"verdict": dict(verdict) if verdict is not None else None, # type: ignore[typeddict-item] "verdict": dict(verdict) if verdict is not None else None, # type: ignore[typeddict-item]
"criterion": criterion, "criterion": criterion,
@@ -89,6 +94,9 @@ def build(env: Env, params: dict[str, Any], used: Sequence[str], *, status: str
"caveats": [dict(c) for c in caveats], # type: ignore[misc] "caveats": [dict(c) for c in caveats], # type: ignore[misc]
"hypothesis": env.hypothesis, "hypothesis": env.hypothesis,
} }
if named_formats := value_formats(named or {}):
out["value_formats"] = named_formats
return out
def refuse(env: Env, params: dict[str, Any], used: Sequence[str], code: str, reason: str, *, def refuse(env: Env, params: dict[str, Any], used: Sequence[str], code: str, reason: str, *,
@@ -155,9 +163,19 @@ def column_types(columns: Sequence[str], *, dims: Mapping[str, str | None] | Non
return out return out
def column_formats(formats: Mapping[str, str]) -> dict[str, str]:
"""结果表的 formats(ResultTable.formats):列 id → 换算。只留记了换算的列,都为空时整项不写(结论和以前一字不差)。"""
return {str(k): v for k, v in formats.items() if v}
def value_formats(values: Mapping[str, Any]) -> dict[str, str]:
"""结论的 value_formats(AnalysisResult.value_formats):键 → 换算。只留记了换算的数,都为空时整项不写。"""
return {str(k): v for k, v in values.items() if v}
def sign_word(value: Fraction | float | int) -> str | None: def sign_word(value: Fraction | float | int) -> str | None:
return "up" if value > 0 else "down" if value < 0 else None return "up" if value > 0 else "down" if value < 0 else None
__all__ = ["DERIVED_PARAMS", "Env", "build", "closes", "column_label", "column_types", "label_for", "need", "refuse", __all__ = ["DERIVED_PARAMS", "Env", "build", "closes", "column_formats", "column_label", "column_types", "label_for",
"reserved_check", "sign_word", "today_param", "written_places"] "need", "refuse", "reserved_check", "sign_word", "today_param", "value_formats", "written_places"]
+21 -2
View File
@@ -277,6 +277,10 @@ class ResultTable(TypedDict):
# 列 id → 类型(写法同查询快照的 column_types;第 1 波 W1-a 加,可选):维度列照输入快照里那一列查询时记下的类型 # 列 id → 类型(写法同查询快照的 column_types;第 1 波 W1-a 加,可选):维度列照输入快照里那一列查询时记下的类型
# (文本型的码值 "1203" 才不会被渲染成「1,203」;输入没记类型的不写),数值列 number,说明、叫法这类文字列 text # (文本型的码值 "1203" 才不会被渲染成「1,203」;输入没记类型的不写),数值列 number,说明、叫法这类文字列 text
column_types: NotRequired[dict[str, str]] column_types: NotRequired[dict[str, str]]
# 列 id → 换算(第 2 波加,可选):占比、变化率、权重这类以比率存的列,渲染时按它换算,换算的写法同 [[m:]] 的后缀
# (pct 显示成百分数、pp 显示成「+1.2 个百分点」)。引用这一列的一格、展开整表时没写换算的按它渲染,写作者自己写了换算的
# 照他的(引用上记下实际用的换算,导出、可验证报告照它渲染)
formats: NotRequired[dict[str, str]]
class AnalysisRequest(TypedDict, total=False): class AnalysisRequest(TypedDict, total=False):
@@ -302,6 +306,9 @@ class AnalysisResult(TypedDict, total=False):
reason: str | None reason: str | None
table: ResultTable | None table: ResultTable | None
values: dict[str, Any] # 具名标量:[[a:A3.top_share]];数一律十进制字符串 values: dict[str, Any] # 具名标量:[[a:A3.top_share]];数一律十进制字符串
# 键 → 换算(第 2 波加,可选):占比、变化率这类以比率存的数,渲染时按它换算(写法同 ResultTable.formats)。
# [[a:A3.top_share]] 没写换算的按它渲染,写作者自己写了换算的照他的
value_formats: dict[str, str]
verdict: Verdict | None verdict: Verdict | None
criterion: str # 判定标准,一句,照实际阈值写 criterion: str # 判定标准,一句,照实际阈值写
phrase: str # 判定语(app/analysis/phrases 渲染),[[a:A3]] 渲染成它 phrase: str # 判定语(app/analysis/phrases 渲染),[[a:A3]] 渲染成它
@@ -417,6 +424,16 @@ class ScopeBlock(TypedDict, total=False):
#: 复算核对时不比的字段:hypothesis 是 agent 写的话,不是算出来的 #: 复算核对时不比的字段:hypothesis 是 agent 写的话,不是算出来的
NOT_RECOMPUTED = frozenset({"hypothesis"}) NOT_RECOMPUTED = frozenset({"hypothesis"})
#: 结论里记格式的两处:结果表的 formats(列 id → 换算)、values 的 value_formats(键 → 换算)。换算取值同 [[m:]] 的后缀,
#: 另加 pp(比率的变化按百分点写:「+1.2 个百分点」)
TABLE_FORMATS = "formats"
VALUE_FORMATS = "value_formats"
#: 比率的换算:pct 按百分数写(0.62 显示成 62.0%)
CONV_PCT = "pct"
#: 比率的变化按百分点写(0.012 显示成「+1.2 个百分点」,下降时带负号):[[m:]] 的后缀里原本没有这一种
CONV_POINTS = "pp"
FORMAT_CONVS: tuple[str, ...] = (CONV_PCT, CONV_POINTS)
def same(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool: def same(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool:
"""两份结论(封存的、复算的)是否逐字段相同(hypothesis 除外)。""" """两份结论(封存的、复算的)是否逐字段相同(hypothesis 除外)。"""
@@ -425,8 +442,10 @@ def same(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool:
__all__ = [ __all__ = [
"ARTIFACT_KIND", "CATALOG_PREFIX", "CAVEATS", "DEFAULTS", "LABELS", "LEDGER_KIND", "MARKER_KIND", "METHODS", "MODES", "ARTIFACT_KIND", "CATALOG_PREFIX", "CAVEATS", "CONV_PCT", "CONV_POINTS", "DEFAULTS", "FORMAT_CONVS", "LABELS",
"MODE_LABELS", "NODE_TYPE", "NOT_RECOMPUTED", "OPS", "OP_LABELS", "RANGES", "REASONS", "SETTINGS_KEY", "TOOL_NAMES", "LEDGER_KIND", "MARKER_KIND", "METHODS", "MODES",
"MODE_LABELS", "NODE_TYPE", "NOT_RECOMPUTED", "OPS", "OP_LABELS", "RANGES", "REASONS", "SETTINGS_KEY",
"TABLE_FORMATS", "TOOL_NAMES", "VALUE_FORMATS",
"VERDICTS", "AnalysisInput", "AnalysisRequest", "AnalysisResult", "CallError", "Caveat", "Context", "InputSnapshot", "VERDICTS", "AnalysisInput", "AnalysisRequest", "AnalysisResult", "CallError", "Caveat", "Context", "InputSnapshot",
"MetricComparability", "Probe", "Prober", "ResultTable", "ScopeBlock", "ScopeItem", "Verdict", "catalog_result", "MetricComparability", "Probe", "Prober", "ResultTable", "ScopeBlock", "ScopeItem", "Verdict", "catalog_result",
"effective_settings", "is_result", "ledger_entry", "method_label", "same", "settings_problem", "effective_settings", "is_result", "ledger_entry", "method_label", "same", "settings_problem",
+24 -4
View File
@@ -29,7 +29,7 @@ from app.analysis import comparable as cmp
from app.analysis import phrases as ph from app.analysis import phrases as ph
from app.analysis.common import (Env, build, closes, column_label, column_types, label_for, need, refuse, from app.analysis.common import (Env, build, closes, column_label, column_types, label_for, need, refuse,
reserved_check, sign_word, today_param) reserved_check, sign_word, today_param)
from app.analysis.contract import CallError from app.analysis.contract import CONV_PCT, CONV_POINTS, CallError
from app.analysis.inputs import Snap, as_list, hashable, same_value from app.analysis.inputs import Snap, as_list, hashable, same_value
from app.analysis.numbers import canon, to_fraction from app.analysis.numbers import canon, to_fraction
@@ -227,6 +227,17 @@ def _parent(raw: Mapping[str, Any], env: Env, mode: str, dims: int) -> tuple[dic
return {"of": name, "key": key}, dict(zip(columns, hit)), depth return {"of": name, "key": key}, dict(zip(columns, hit)), depth
#: 结果表里以比率存的列(占比、变化率、权重、比率本身):引用、展开时按百分数显示(ResultTable.formats 记 pct)
_RATE_COLUMNS = ("pct", "share")
def _rate_formats(columns: Sequence[str], rate: Sequence[str] = _RATE_COLUMNS,
points: Sequence[str] = ()) -> dict[str, str]:
"""结果表各列记的换算:rate 里的列(占比、变化率、权重、比率本身)记 pct,points 里的列(比率的变化)记 pp。"""
return {**{c: CONV_PCT for c in rate if c in columns},
**{c: CONV_POINTS for c in points if c in columns}}
_FIXED_ADDITIVE = ("base", "target", "delta", "pct", "share", "flag") _FIXED_ADDITIVE = ("base", "target", "delta", "pct", "share", "flag")
_FIXED_RATIO = ("n_base", "n_target", "d_base", "d_target", "w_base", "w_target", "r_base", "r_target", "mix", "rate", _FIXED_RATIO = ("n_base", "n_target", "d_base", "d_target", "w_base", "w_target", "r_base", "r_target", "mix", "rate",
"total", "flag") "total", "flag")
@@ -470,7 +481,9 @@ def _additive_result(env: Env, params: dict[str, Any], used: list[str], segs: li
skip = (("offset",) if code == "flat" else ()) + (("comparability",) if outcome is None else ()) skip = (("offset",) if code == "flat" else ()) + (("comparability",) if outcome is None else ())
phrase = ph.with_caveats(main, caveats, skip=skip) phrase = ph.with_caveats(main, caveats, skip=skip)
return build(env, params, used, table=table, values=values, verdict=verdict, return build(env, params, used, table=table, values=values, verdict=verdict,
criterion=ph.criterion_additive(s, top_k_max=k_max, summed=summed), phrase=phrase, caveats=caveats) criterion=ph.criterion_additive(s, top_k_max=k_max, summed=summed), phrase=phrase, caveats=caveats,
formats=_rate_formats(columns),
named={k: CONV_PCT for k in ("pct_total", "top_share", "top_k_share")})
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -681,7 +694,13 @@ def _ratio_result(env: Env, params: dict[str, Any], used: list[str], segs: list[
"direction": sign_word(delta) if code in ("mix_driven", "rate_driven", "both") else None} "direction": sign_word(delta) if code in ("mix_driven", "rate_driven", "both") else None}
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion,
phrase=ph.with_caveats(main, caveats, skip=("comparability",) if outcome is None else ()), phrase=ph.with_caveats(main, caveats, skip=("comparability",) if outcome is None else ()),
caveats=caveats) caveats=caveats,
# 权重、各段的比率按百分数显示;结构效应、自身效应、合计影响是比率的变化,按百分点
formats=_rate_formats(columns, rate=("w_base", "w_target", "r_base", "r_target"),
points=("mix", "rate", "total")),
named={k: CONV_PCT for k in ("r_base_total", "r_target_total")}
| {k: CONV_POINTS for k in ("delta", "mix_total", "rate_total")}
| {"mix_share": CONV_PCT, "rate_share": CONV_PCT})
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -811,7 +830,8 @@ def factor(raw: Mapping[str, Any], env: Env) -> Any:
verdict = {"code": code, "subjects": subjects, "direction": sign_word(delta) if code != "flat" else None} verdict = {"code": code, "subjects": subjects, "direction": sign_word(delta) if code != "flat" else None}
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion,
phrase=ph.with_caveats(main, caveats, skip=("comparability",) if outcome is None else ()), phrase=ph.with_caveats(main, caveats, skip=("comparability",) if outcome is None else ()),
caveats=caveats) caveats=caveats, formats=_rate_formats(columns),
named={k: CONV_PCT for k in ("pct_total", "top_share")})
__all__ = ["HEADLINE_SHAPE", "PARENT_SHAPE", "SAME_QUERY", "SIMPSON_SHARE", "USED", "additive", "factor", "lmdi", __all__ = ["HEADLINE_SHAPE", "PARENT_SHAPE", "SAME_QUERY", "SIMPSON_SHARE", "USED", "additive", "factor", "lmdi",
+7 -2
View File
@@ -33,7 +33,7 @@ from typing import Any
from app.analysis import phrases as ph from app.analysis import phrases as ph
from app.analysis import stats from app.analysis import stats
from app.analysis.common import Env, build, column_label, column_types, label_for, need, refuse, sign_word from app.analysis.common import Env, build, column_label, column_types, label_for, need, refuse, sign_word
from app.analysis.contract import CallError from app.analysis.contract import CONV_PCT, CONV_POINTS, CallError
from app.analysis.inputs import Snap, as_list, hashable, same_value from app.analysis.inputs import Snap, as_list, hashable, same_value
from app.analysis.numbers import canon, to_fraction from app.analysis.numbers import canon, to_fraction
@@ -389,8 +389,13 @@ def _decide(env: Env, params: dict[str, Any], used: list[str], pairs: list[Pair]
"test": shown.test, "pairs": str(len(pairs)), "differs": str(len(hits))} "test": shown.test, "pairs": str(len(pairs)), "differs": str(len(hits))}
verdict = {"code": code, "subjects": subjects, "direction": direction} verdict = {"code": code, "subjects": subjects, "direction": direction}
phrase = ph.with_caveats(main, caveats, skip=("multiple_comparisons",) if len(tested) > 1 else ()) phrase = ph.with_caveats(main, caveats, skip=("multiple_comparisons",) if len(tested) > 1 else ())
# 比率模式的组的比率按百分数显示,两组的差、差的区间是比率之差,按百分点(均值模式是量,照原样)
rate = ("a_value", "b_value") if mode == "rate" else ()
points = ("diff", "ci_lo", "ci_hi") if mode == "rate" else ()
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, phrase=phrase, return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, phrase=phrase,
caveats=caveats) caveats=caveats, formats={**{c: CONV_PCT for c in rate}, **{c: CONV_POINTS for c in points}},
named={**{k: CONV_PCT for k in ("a", "b")}, **{k: CONV_POINTS for k in ("diff", "ci_lo", "ci_hi")}}
if mode == "rate" else {})
def _sensitivity(pairs: list[Pair], mode: str, alpha: Fraction, min_expected: Fraction, def _sensitivity(pairs: list[Pair], mode: str, alpha: Fraction, min_expected: Fraction,
+10 -3
View File
@@ -33,7 +33,7 @@ from app.analysis import phrases as ph
from app.analysis import stats from app.analysis import stats
from app.analysis.common import (Env, build, column_label, column_types, label_for, need, refuse, reserved_check, from app.analysis.common import (Env, build, column_label, column_types, label_for, need, refuse, reserved_check,
sign_word, today_param) sign_word, today_param)
from app.analysis.contract import CallError from app.analysis.contract import CONV_PCT, CallError
from app.analysis.inputs import Snap, same_value from app.analysis.inputs import Snap, same_value
from app.analysis.numbers import canon, to_fraction from app.analysis.numbers import canon, to_fraction
@@ -258,8 +258,13 @@ def baseline(raw: Mapping[str, Any], env: Env) -> Any:
"z": canon(z) if z is not None else None, "rank": str(stats.rank_desc(x, hv)), "n_history": str(n), "z": canon(z) if z is not None else None, "rank": str(stats.rank_desc(x, hv)), "n_history": str(n),
"hist_min": canon(min(hv)), "hist_max": canon(max(hv))} "hist_min": canon(min(hv)), "hist_max": canon(max(hv))}
verdict = {"code": code, "subjects": [], "direction": sign_word(x - m)} verdict = {"code": code, "subjects": [], "direction": sign_word(x - m)}
# 按变化率比时,本期的变化率、中位数、常规波动范围、历史最小最大都是变化率,按百分数显示;按值本身比时照原样
change = ("change",) if basis == "change" else ()
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion,
phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats) phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats,
formats={c: CONV_PCT for c in change},
named={k: CONV_PCT for k in ("x", "median", "band_lo", "band_hi", "hist_min", "hist_max")
if basis == "change"})
def _compare_gap(series: Series, ordinal: int, lag: int, unit: str) -> str: def _compare_gap(series: Series, ordinal: int, lag: int, unit: str) -> str:
@@ -360,7 +365,9 @@ def trend(raw: Mapping[str, Any], env: Env) -> Any:
verdict = {"code": code, "subjects": [], "direction": "up" if code == "rising" else "down" if code == "falling" verdict = {"code": code, "subjects": [], "direction": "up" if code == "rising" else "down" if code == "falling"
else None} else None}
return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion, return build(env, params, used, table=table, values=values, verdict=verdict, criterion=criterion,
phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats) phrase=ph.with_caveats(main, caveats, skip=("comparability",)), caveats=caveats,
# 相对斜率是比率(每期变百分之几),按百分数显示;斜率本身、p 值、各期之比照原样
named={"rel_slope": CONV_PCT})
def _tail_gap(series: Series, ordinal: int) -> str: def _tail_gap(series: Series, ordinal: int) -> str: