v7 mech-3: scorecard diff scope, gain ledger, stagnation stop table

- cloud/scorecard_diff.py: audit_scope (changed files + claimed_fixed,
  unchanged/baseline-out files out of package), ScorecardGainLedger
  (memory) + SqliteScorecardGainLedger (SQLite only, PG_DDL text-only),
  three tables code/production/product kept separately, average
  reference-only; check_stagnation: two consecutive code gains <0.5
  (test_default_unapproved) escalate, no auto pushback; rubric_version
  change starts a new sequence.
- cloud/tests/test_scorecard_mech_diff.py: 24 tests, all passing.
- No change to audit_loop_driver.py semantics, #238 auto_apply,
  #247 correction-pass semantics, or v1.1 contract.

(cherry picked from commit 57d44becaf559a074c995249e95bd36924dae13f)
This commit is contained in:
host committed 2026-11-03 02:49:27 +00:00
1 parent b7e2f29868
commit ae012a8e7c
2 files changed
+745

No files matched your search

+453
View File
@@ -0,0 +1,453 @@
"""评分卡差分审计与涨幅停表(v7 机制第 3 项,纯函数 + 标准库 only)。
只做这一项,不碰其它 v7 机制项。本模块不改动
``cloud/audit_loop_driver.py`` 的语义,也不改动
``cloud/audit_scorecard.py``:它是评分卡侧的停表机制,不是
审计循环本身。审计循环的 pushback 上限 3、重建预算 1、
版本变更重置思路只作参考,不在本模块里复写或放宽。
三张表分开记分,绝不只记平均分
--------------------------------
每轮 ledger 条目同时记 ``code`` / ``production`` / ``product``
三张表各自的分数(任一表未知记 ``None``,不估算)。
``average_for_reference_only`` 只作参考信息,停表判定只看
代码表(``code``),绝不用平均分判定——与 audit_scorecard
“平均分绝不能作为达标依据”的规则一致。
阈值未拍板
----------
``STAGNATION_THRESHOLD`` 的值 0.5 标注
``source="test_default_unapproved"``(测试默认值、未拍板),
与 audit_scorecard 的 ``DEFAULT_THRESHOLDS`` 口径一致。
生产阈值须用户拍板后由调用方显式传入 ``threshold`` 才能替换;
本模块绝不把测试初值写成生产值,也不替用户决定 B/C 与阈值。
与已定语义的关系(本模块不改它们)
----------------------------------
- #238 ``evaluate_feedback`` 的 ``auto_apply`` 保持 False;
本模块没有 auto_apply 路径。
- #247 纠正为 pass 本身不解封;本模块不做解封、不做
re-audit、不碰门禁状态,只返回停表判定。
- v1.1 契约不变;本模块不增维、不改 7 维定义。
- 停表触发时返回 ``escalate`` 语义,不再自动推回:
结果里 ``auto_pushback`` 恒为 False,且
``escalation_grants_user_acceptance`` 恒为 False
(escalate 从不 tick 用户验收,与 audit_loop_driver 一致)。
(a) 差分范围输入:``audit_scope(baseline_snapshot, current_snapshot,
claimed_fixed)``
----------------------------------------------------------------
快照形式(二选一,两种都接受):
- 文件路径集合:``set`` / ``list`` / ``tuple``,元素为路径字符串。
此时变更 = 对称差(新增 + 删除);交集 = 未变更。
- 快照 dict:键为路径,值为内容指纹(hash/内容字符串),或
``{"files": <路径集合或路径->指纹 dict>}`` 形式。
此时变更 = 新增 + 删除 + 同路径指纹不同(修改);指纹相同
的路径 = 未变更。
审计范围 = 变更文件 + 调用方声称修好的项(``claimed_fixed``)。
未变更文件进 ``out_of_scope``,不进审计包;基线外(即不在
变更集内、也不在声称修好集内)的文件一律不进包。
``claimed_fixed`` 是项 id(检查名/维 id 等字符串),不是文件
路径;它与文件集分开返回,调用方按项复核,不与文件混算。
(b) 涨幅 ledger:``ScorecardGainLedger``(内存)与
``SqliteScorecardGainLedger``(SQLite)
------------------------------------------------
- 内存 ledger:append-only 列表,``append()`` 存规范化后的
条目副本,``rounds()`` 返回副本(调用方改返回结果不影响
已存条目)。模块级函数 ``append_round`` / ``read_rounds``
接受 ledger 对象或普通 list,方便测试与调用方复用。
- SQLite ledger:同一列名、同一追加语义的 SQLite 表
``scorecard_gain_rounds``(见 ``SQLITE_DDL``),用标准库
``sqlite3``、``?`` 占位符。``PG_DDL`` 是与之列对应的
Postgres 建表语句文本,仅作仓库 DB 模式兼容写法:
真实 PG 在本项中不接通、不执行、不验证,只用
SQLite(内存或文件)测试并明说。
- 条目校验:``rubric_version`` 必须为非空字符串,否则
``ValueError``;三张表分数各为 ``None`` 或 0-10 的数,
超范围或非数值 likewise ``ValueError``;bool 不算数值。
(c) 停表判定:``check_stagnation(history)``
--------------------------------------------
- 只看 trailing 同 rubric_version 序列:从最新一轮往回取
与最新轮 ``rubric_version`` 相同的连续轮次;改标尺即新
序列,旧标尺的轮次不连算。
- 该序列内相邻两轮代码表分数之差为一轮涨幅;分数为 1 位
小数(与 audit_scorecard 的 _round1 一致),涨幅按
1 位小数舍入后再与阈值比较,避免 8.8-8.3 这类二进制
浮点残差把恰好 0.5 误判为 <0.5。
- 涨幅 < 阈值(含负涨幅,即代码表下滑)记为小涨幅;
涨幅 >= 阈值,或涨幅未知(任一端分数为 None),打断
连续计数。连续两轮小涨幅(即该序列内最近两次涨幅
均为小涨幅,至少需要 3 轮)即触发停表。
- 触发返回 ``{"decision": "escalate", "reason": "stagnation", ...}``;
未触发返回 ``{"decision": "continue", ...}``。两种结果都
不含 pushback 动作;触发时 ``auto_pushback`` 为 False。
"""
from __future__ import annotations
import sqlite3
TABLES = ("code", "production", "product")
# 停表阈值:测试默认值、未拍板。生产阈值须用户拍板后显式传入。
STAGNATION_THRESHOLD = {"value": 0.5, "source": "test_default_unapproved"}
# 触发所需连续小涨幅轮数(2 次小涨幅 = 序列内至少 3 轮)。
STAGNATION_CONSECUTIVE_GAINS = 2
SQLITE_DDL = (
"CREATE TABLE IF NOT EXISTS scorecard_gain_rounds ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"rubric_version TEXT NOT NULL, "
"code REAL, "
"production REAL, "
"product REAL, "
"average_for_reference_only REAL"
")"
)
# 与 SQLITE_DDL 列对应的 Postgres 建表文本。仅作模式兼容写法,
# 本项不接通真实 PG、不执行、不验证;测试只覆盖 SQLite。
PG_DDL = (
"CREATE TABLE IF NOT EXISTS scorecard_gain_rounds ("
"id SERIAL PRIMARY KEY, "
"rubric_version TEXT NOT NULL, "
"code DOUBLE PRECISION, "
"production DOUBLE PRECISION, "
"product DOUBLE PRECISION, "
"average_for_reference_only DOUBLE PRECISION"
")"
)
# ------------------------------------------------------------------
# (a) 差分范围输入
# ------------------------------------------------------------------
def _fingerprint(value):
"""快照 dict 值的规范指纹:hash/sha256/content 字段优先,否则原值。"""
if isinstance(value, dict):
for key in ("sha256", "hash", "content"):
if value.get(key) is not None:
return value[key]
return repr(sorted(value.items()))
return value
def _snapshot_mapping(snapshot):
"""把快照归一化为 {路径: 指纹或 None}。
路径集合形式:指纹为 None(只比存在性)。
快照 dict 形式:键为路径,值为指纹;{"files": ...} 形式先解包。
None 快照视为空快照。
"""
if snapshot is None:
return {}
if isinstance(snapshot, dict):
inner = snapshot.get("files", snapshot) if "files" in snapshot else snapshot
if isinstance(inner, dict):
return {str(path): _fingerprint(value) for path, value in inner.items()}
if isinstance(inner, (set, list, tuple)):
return {str(path): None for path in inner}
raise TypeError("snapshot files must be a mapping or a path collection")
if isinstance(snapshot, (set, list, tuple)):
return {str(path): None for path in snapshot}
raise TypeError("snapshot must be a path collection, a mapping, or None")
def _normalize_claimed(claimed_fixed):
"""声称修好的项:None -> [];单个字符串 -> [它];否则去重排序。"""
if claimed_fixed is None:
return []
if isinstance(claimed_fixed, str):
return [claimed_fixed]
return sorted({str(item) for item in claimed_fixed})
def audit_scope(baseline_snapshot, current_snapshot, claimed_fixed=None):
"""算出差分审计范围。
返回 {"in_scope": {"files": [...], "claimed_fixed": [...]},
"out_of_scope": {"files": [...]}}。
in_scope.files = 变更文件(新增 + 删除 + 修改);
out_of_scope.files = 两快照中都出现且指纹相同(路径集合
形式下即交集)的未变更文件;claimed_fixed 原样进包,
与文件集分开,不因文件未变更而被排除。
"""
baseline = _snapshot_mapping(baseline_snapshot)
current = _snapshot_mapping(current_snapshot)
# 以存在性为准:只在一侧出现的路径必为变更;路径集合形式下
# 指纹均为 None,不能只比指纹(缺失侧 get 缺省也是 None,
# 会把新增/删除误判为未变更),故先判存在性再比指纹。
changed = sorted(
path
for path in set(baseline) | set(current)
if path not in baseline or path not in current or baseline[path] != current[path]
)
unchanged = sorted(
path
for path in set(baseline) & set(current)
if baseline[path] == current[path]
)
return {
"in_scope": {
"files": changed,
"claimed_fixed": _normalize_claimed(claimed_fixed),
},
"out_of_scope": {"files": unchanged},
}
# ------------------------------------------------------------------
# (b) 涨幅 ledger:条目规范化
# ------------------------------------------------------------------
def _is_number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool)
def normalize_round(rubric_version, code=None, production=None, product=None, entry=None):
"""规范化一轮 ledger 条目(返回新 dict,不修改入参)。
接受两种调用:显式三表分数,或 entry dict(含 flat 的
code/production/product,或嵌套的 scores dict)。
平均分只在三表全已知时算出,且只作参考,永不用于停表。
"""
if entry is not None:
if not isinstance(entry, dict):
raise TypeError("entry must be a dict")
scores = entry.get("scores") if isinstance(entry.get("scores"), dict) else entry
rubric_version = entry.get("rubric_version", rubric_version)
code = scores.get("code", code) if code is None else code
production = scores.get("production", production) if production is None else production
product = scores.get("product", product) if product is None else product
if not isinstance(rubric_version, str) or not rubric_version:
raise ValueError("rubric_version must be a non-empty string")
values = {}
for name, value in (("code", code), ("production", production), ("product", product)):
if value is None:
values[name] = None
elif _is_number(value) and 0 <= float(value) <= 10:
values[name] = round(float(value) + 1e-9, 1)
else:
raise ValueError("%s must be None or a number in 0..10, got %r" % (name, value))
known = [values[name] for name in TABLES]
average = round(sum(known) / len(known) + 1e-9, 1) if all(v is not None for v in known) else None
return {
"rubric_version": rubric_version,
"code": values["code"],
"production": values["production"],
"product": values["product"],
"average_for_reference_only": average,
"average_is_not_pass_criterion": True,
}
class ScorecardGainLedger:
"""内存涨幅 ledger:append-only,存条目副本,读出副本。"""
def __init__(self):
self._rounds = []
def append(self, rubric_version=None, code=None, production=None, product=None, entry=None):
normalized = normalize_round(rubric_version, code, production, product, entry)
self._rounds.append(normalized)
return dict(normalized)
def rounds(self):
return [dict(entry) for entry in self._rounds]
# history() 是 rounds() 的别名,供 check_stagnation 直接接受 ledger。
def history(self):
return self.rounds()
def append_round(ledger, rubric_version=None, code=None, production=None, product=None, entry=None):
"""向 ledger 追加一轮:ledger 对象(有 .append)或普通 list 均可。"""
normalized = normalize_round(rubric_version, code, production, product, entry)
if hasattr(ledger, "append") and not isinstance(ledger, list):
# ledger 对象自身的 append 已做规范化;为避免双重规范化
# 带来的舍入差异,这里直接传规范化后的 entry。
ledger.append(entry=normalized)
return dict(normalized)
ledger.append(normalized)
return dict(normalized)
def read_rounds(ledger):
"""读出 ledger 全部轮次副本:ledger 对象(有 .rounds)或 list 均可。"""
if hasattr(ledger, "rounds"):
return [dict(entry) for entry in ledger.rounds()]
return [dict(entry) for entry in ledger]
class SqliteScorecardGainLedger:
"""SQLite 涨幅 ledger:与内存 ledger 同列、同追加语义。
db_path 为 ":memory:"(默认)或文件路径;传入 sqlite3
连接亦可(此时 close() 不关闭调用方连接)。真实 PG
不接通:PG_DDL 只是模式兼容文本,不在本类中执行。
"""
def __init__(self, db_path=":memory:", connection=None):
if connection is not None:
self._conn = connection
self._owns_conn = False
else:
self._conn = sqlite3.connect(str(db_path))
self._conn.row_factory = sqlite3.Row
self._owns_conn = True
self._conn.execute(SQLITE_DDL)
self._conn.commit()
def append(self, rubric_version=None, code=None, production=None, product=None, entry=None):
normalized = normalize_round(rubric_version, code, production, product, entry)
self._conn.execute(
"INSERT INTO scorecard_gain_rounds"
" (rubric_version, code, production, product, average_for_reference_only)"
" VALUES (?, ?, ?, ?, ?)",
(normalized["rubric_version"], normalized["code"], normalized["production"],
normalized["product"], normalized["average_for_reference_only"]),
)
self._conn.commit()
return dict(normalized)
def rounds(self):
rows = self._conn.execute(
"SELECT rubric_version, code, production, product,"
" average_for_reference_only FROM scorecard_gain_rounds ORDER BY id"
).fetchall()
out = []
for row in rows:
mapping = dict(row) if isinstance(row, sqlite3.Row) else dict(row)
entry = {
"rubric_version": mapping["rubric_version"],
"code": mapping["code"],
"production": mapping["production"],
"product": mapping["product"],
"average_for_reference_only": mapping["average_for_reference_only"],
"average_is_not_pass_criterion": True,
}
out.append(entry)
return out
def history(self):
return self.rounds()
def close(self):
if self._owns_conn:
self._conn.close()
# ------------------------------------------------------------------
# (c) 停表判定
# ------------------------------------------------------------------
def _history_entries(history):
"""接受 list/tuple 或 ledger 对象(有 .rounds/.history),返回条目 list。"""
if history is None:
return []
if hasattr(history, "rounds"):
return list(history.rounds())
if hasattr(history, "history"):
return list(history.history())
return list(history)
def _code_score(entry):
"""取一轮的代码表分数:flat 的 code,或嵌套 scores.code;缺失为 None。"""
if not isinstance(entry, dict):
return None
if entry.get("code") is not None:
return entry["code"]
scores = entry.get("scores")
if isinstance(scores, dict):
return scores.get("code")
return None
def _trailing_run(entries):
"""最新 rubric_version 的 trailing 连续序列(改标尺即新序列)。"""
if not entries:
return []
latest_version = entries[-1].get("rubric_version") if isinstance(entries[-1], dict) else None
run = []
for entry in reversed(entries):
version = entry.get("rubric_version") if isinstance(entry, dict) else None
if version != latest_version:
break
run.append(entry)
run.reverse()
return run
def code_gains(history):
"""trailing 同标尺序列内相邻两轮的代码表涨幅 list。
每项为 1 位小数舍入后的涨幅,或 None(任一端分数未知)。
全序列的涨幅不返回:旧标尺轮次不连算。
"""
run = _trailing_run(_history_entries(history))
scores = [_code_score(entry) for entry in run]
gains = []
for prev_score, cur_score in zip(scores, scores[1:]):
if prev_score is None or cur_score is None:
gains.append(None)
elif not _is_number(prev_score) or not _is_number(cur_score):
gains.append(None)
else:
gains.append(round(float(cur_score) - float(prev_score) + 1e-9, 1))
return gains
def _resolve_threshold(threshold):
"""阈值解析:None -> 测试默认值;数字 -> 该值(来源仍标测试默认,
除非调用方显式传 {"value":..., "source": "user_approved"});
dict -> 取 value/source。"""
if threshold is None:
return STAGNATION_THRESHOLD["value"], STAGNATION_THRESHOLD["source"]
if isinstance(threshold, dict):
return threshold.get("value", STAGNATION_THRESHOLD["value"]), threshold.get(
"source", STAGNATION_THRESHOLD["source"])
return threshold, STAGNATION_THRESHOLD["source"]
def check_stagnation(history, threshold=None):
"""停表判定:trailing 同标尺序列内连续两轮代码表涨幅 < 阈值即停。
触发返回 decision="escalate"、reason="stagnation",且
auto_pushback=False(不再自动推回);
未触发返回 decision="continue"。两种结果的
escalation_grants_user_acceptance 均为 False。
"""
threshold_value, threshold_source = _resolve_threshold(threshold)
entries = _history_entries(history)
run = _trailing_run(entries)
gains = code_gains(entries)
consecutive = 0
for gain in reversed(gains):
if gain is not None and gain < threshold_value:
consecutive += 1
else:
break
latest_version = run[-1].get("rubric_version") if run else None
base = {
"rubric_version": latest_version,
"run_rounds": len(run),
"code_gains": gains,
"consecutive_small_gains": consecutive,
"threshold": threshold_value,
"threshold_source": threshold_source,
"auto_pushback": False,
"escalation_grants_user_acceptance": False,
}
if consecutive >= STAGNATION_CONSECUTIVE_GAINS:
result = {"decision": "escalate", "reason": "stagnation"}
result.update(base)
return result
result = {"decision": "continue", "reason": "not_stagnant"}
result.update(base)
return result