feat(v2): T06-a 方案维度解析(去重、刻度冲突、呈现判定)

T06 是大卡,按纪律拆子卡。本批为第一块:维度契约与解析。

新增 contracts:ScaleLevel、DimensionSpec。刻度层级必须升序且取值唯一
(乱序会让展示语义倒置);同 ID 必须标签/量纲/刻度层级完全一致。

新增 services/review_plan.py:
- resolve_dimensions():按 dimension_id 合并;**同 ID 不同定义直接报冲突并拒绝**,
  不让程序或模型临时挑一个(BR-28 / E-20);相同定义只留一份并合并组件引用,
  避免重复计数
- presentation():AC-44 的呈现判定——0 维 empty / 2 维 list / 3–8 同刻度 radar /
  9 维 grouped / 异刻度 faceted;硬阻断**单列**且永不进雷达面积
- assert_nothing_dropped():三组并集必须等于全部维度,"不默默丢失关键项" 有守卫
  且守卫自身有测试证明会报错

刻度改版(同 scale_id 不同 version)视为不同定义——语义可能已变,不得共用雷达。

未完成:T06-b 方案装配与依赖冻结(AC-28)、T06-c 维度在结果中的落位属后续子卡。

测试:新增 test_review_plan.py 18 项(去重 8 + 呈现 10)。全量 333 项 OK(315 → 333);
verify.py 10/10 PASS。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 20:16:39 +08:00
co-authored by Claude Code
parent d18c147076
commit 019d5081df
3 changed files with 304 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
"""T06-a 方案维度解析:去重、刻度冲突与呈现判定。
依据:docs/v2/14-experience-loop.md §5、03-detailed-design.md §9.2;
BR-28 / E-20 / AC-29 / AC-44。
三条不可妥协的规则:
1. **同 ID 必须语义一致**:标签、量纲(scale_id/version)、刻度层级任一不同即
**拒绝合并**并报冲突,绝不让模型临时猜一个(BR-28)。相同定义只保留一份。
2. **异刻度不强行归一化**:不同刻度的维度不得共用雷达,改为分面展示(14 §5)。
3. **不默默丢失关键项**:呈现判定返回的 radar / listed / hard_block 三组并集
必须等于全部维度;任何维度都要出现在某一组里(AC-44)。
"""
from __future__ import annotations
from contracts.prescreen import DimensionSpec
RADAR_MIN, RADAR_MAX = 3, 8
EMPTY_REASON = "当前方案尚未配置核查维度"
FACETED_REASON = "维度刻度不一致,分面展示;不强行归一化,避免误导读者比较不可比的量"
RADAR_REASON = "同一刻度且 3–8 维,可用雷达并附等价文字列表"
FEW_REASON = "少于 3 维,用状态条/列表比雷达更清楚"
MANY_REASON = "多于 8 维,雷达会不可读,改为分组/列表且全部维度仍可查看"
def _definition(spec: dict) -> tuple:
"""判据:标签 + 刻度标识/版本 + 层级内容。任一不同即视为不同定义。"""
return (
str(spec.get("label", "")),
str(spec.get("scale_id", "")),
str(spec.get("scale_version", "")),
tuple((level.get("value"), level.get("label"), level.get("criterion"))
for level in spec.get("levels", [])),
)
def _validate(spec: dict) -> dict:
try:
return DimensionSpec(**spec).model_dump(mode="json")
except Exception as exc:
raise ValueError(f"维度不合法:{exc}") from exc
def resolve_dimensions(specs) -> list[dict]:
"""按 dimension_id 合并;同 ID 不同定义 → 冲突拒绝(AC-29 / E-20)。
返回按 order 稳定排序后的维度列表,**同一定义只出现一次**。
"""
merged: dict[str, dict] = {}
for raw in specs or ():
spec = _validate(raw)
existing = merged.get(spec["id"])
if existing is None:
merged[spec["id"]] = spec
continue
if _definition(existing) != _definition(spec):
raise ValueError(
f"维度 {spec['id']} 存在不一致定义(标签/刻度/层级不同),请统一后保存方案;"
"不得由程序或模型临时选择其一"
)
# 完全相同的定义:合并引用,避免重复计数(14 §5)
references = {ref["id"]: ref for ref in existing["component_refs"]}
for ref in spec["component_refs"]:
references.setdefault(ref["id"], ref)
existing["component_refs"] = list(references.values())
existing["required"] = existing["required"] or spec["required"]
return sorted(merged.values(), key=lambda item: (item["order"], item["id"]))
def is_single_scale(dimensions) -> bool:
"""全部维度是否共用同一可解释刻度(可比性前提)。"""
return len({(d["scale_id"], d["scale_version"]) for d in dimensions}) <= 1
def presentation(dimensions, hard_block_ids=()) -> dict:
"""AC-44 的呈现判定:雷达 / 列表 / 分组 / 分面 / 空。
硬阻断维度**单列**,不参与雷达面积(14 §5:不被平均数或面积覆盖)。
"""
ids = [item["id"] for item in dimensions]
if not dimensions:
return {"layout": "empty", "reason": EMPTY_REASON,
"radar_ids": [], "listed_ids": [], "hard_block_ids": []}
blocks = [item_id for item_id in ids if item_id in set(hard_block_ids)]
remaining = [item_id for item_id in ids if item_id not in set(blocks)]
if not is_single_scale(dimensions):
return {"layout": "faceted", "reason": FACETED_REASON,
"radar_ids": [], "listed_ids": remaining, "hard_block_ids": blocks}
count = len(remaining)
if count < RADAR_MIN:
layout, reason, radar = "list", FEW_REASON, []
elif count > RADAR_MAX:
layout, reason, radar = "grouped", MANY_REASON, []
else:
layout, reason, radar = "radar", RADAR_REASON, remaining
listed = [item_id for item_id in remaining if item_id not in set(radar)]
return {"layout": layout, "reason": reason,
"radar_ids": radar, "listed_ids": listed, "hard_block_ids": blocks}
def assert_nothing_dropped(result: dict, dimensions) -> None:
"""呈现判定不得丢失任何维度(AC-44:不默默丢失关键项)。"""
shown = set(result["radar_ids"]) | set(result["listed_ids"]) | set(result["hard_block_ids"])
expected = {item["id"] for item in dimensions}
missing = expected - shown
if missing:
raise ValueError(f"呈现判定丢失了维度:{sorted(missing)}")
if shown - expected:
raise ValueError(f"呈现判定出现了未知维度:{sorted(shown - expected)}")