Files
codex-app-server/cloud/scorecard_diff.py
T
host ae012a8e7c 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)
2026-11-03 02:49:27 +00:00

454 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""评分卡差分审计与涨幅停表(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