457 lines
19 KiB
Python
457 lines
19 KiB
Python
"""评分卡差分审计与涨幅停表(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
|
||
|
||
from cloud.db import connect as _db_connect
|
||
|
||
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:
|
||
# 层规则:服务层不得直连驱动,须走 cloud.db.connect
|
||
# (其 SQLite 路径已设 WAL + busy_timeout + Row)。
|
||
self._conn = _db_connect(str(db_path))
|
||
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
|