"""评分卡差分审计与涨幅停表(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