code-to10 v7 mech1: scorecard rubric versioning, append-only rubric + snapshot tables

(cherry picked from commit 368475243367685bf9db9335ec929958de031ca6)
This commit is contained in:
host committed 2026-11-03 02:49:27 +00:00
1 parent 3978dbbbf2
commit be81d658b6
3 files changed
+684 -6

No files matched your search

+282
View File
@@ -0,0 +1,282 @@
"""评分卡标尺版本化:仅追加的持久存储(v7 机制第 1 项)。
问题:cloud/audit_scorecard.py 的 DEFAULT_THRESHOLDS 只存在于
代码里,标尺一改,旧标尺下的分数就再也算不出来、也对不上
——审计员每次重审都在新标尺下重算旧分,基线漂移就是这么来的。
解法(复用 audit_loop_driver 的键思路,不复用它的表):
- audit_loop_driver 以 (module_id, contract_version) 为键,
合同版本一变,计数与预算全部重置、旧版本状态原地不动。
这里同理以 rubric_version 为键:标尺改动 = 写一个新版本行,
旧版本行原地不动;同一证据在旧版本下重算,分数逐字相同。
- 不复用 audit_loop_state 表:那张表存的是循环状态
(pushback 轮次、失败计数),标尺不是循环状态,混表会让
两类数据的生命周期互相牵连。故新开两张表(见下)。
两张表(SQLite / Postgres 兼容写法,沿用 audit_loop_wiring 的
conn + ``?`` 占位符 + CREATE TABLE IF NOT EXISTS 模式;类型只用
TEXT / REAL,不用任何一方专属类型,不用自增列):
- scorecard_rubric_versions:标尺版本行,主键 rubric_version。
列:rubric_version、thresholds_snapshot(归一化阈值表的
canonical JSON)、baseline_head(该版本锚定的基线提交,
调用方显式传入,本模块不跑 git、不猜 HEAD)、
thresholds_source(user_approved 仅当全部条目都已拍板,
其余一律 test_default_unapproved)、created_at。
- scorecard_snapshots:已算出的卡片行,主键
(rubric_version, evidence_sha256)。列除主键外还有
baseline_head 与 thresholds_snapshot(从版本行反规范化而来,
让单行快照自带审计三要素,不必联表也能核对)、
scorecard_json(build_scorecard 的完整输出)、created_at。
选型理由:只存版本行 + 现算也能复现旧分(scorer 是纯函数),
但快照行让“旧行分数不变”成为可直接读回的事实而非“重算
碰巧一致”的推论;快照不存证据原文,只存证据的 SHA-256,
避免证据体在库里再留一份副本。
仅追加(append-only),这是本模块的核心承诺:
- 对外 API 只有 create / get / list / latest / score / record /
get_snapshot,没有任何改旧行、删旧行的方法;重复建同一
版本号直接报 ValueError(主键冲突转译,不做 upsert)。
- 同一 (rubric_version, evidence) 重复 record 时返回已存行的
卡片原样,不覆盖、不合并。
- 本模块防的是“走正常 API 的误写”(与 ledger 的触发器同款
诚实边界):能直接拿连接跑 SQL 的人当然能改库,库内机制
拦不住超级用户;可信锚点仍须落在库外(如导出的行哈希)。
事务边界:沿用 audit_loop_wiring 的做法——本模块的写函数
在写后 commit;调用方若想把多写包进同一事务,应直接用
底层的 INSERT 语句而非本模块的写函数。读函数不 commit。
诚实边界:
- 阈值本身仍不由本模块拍板:thresholds_source 的判定与
audit_scorecard 一致,测试默认值永远标
test_default_unapproved;user_approved 只能由调用方显式
传入全部已拍板条目。
- 未在真实 Postgres 上验证:SQL 只用两端通用的写法,
并以 SQLite(文件库 + :memory:)与假连接(只录语句)
测试;PG 接通后须以真实 PG 复测一次才能宣称两端可用。
- 本模块不改 #238 auto_apply(保持 False)、不改 #247
(纠正为 pass 本身不解封)、不改 v1.1 契约与 7 维定义。
"""
from __future__ import annotations
import hashlib
import json
import time
from cloud.audit_scorecard import build_scorecard, thresholds_source
_RUBRIC_TABLE_SQL = (
"CREATE TABLE IF NOT EXISTS scorecard_rubric_versions("
"rubric_version TEXT PRIMARY KEY, "
"thresholds_snapshot TEXT NOT NULL, "
"baseline_head TEXT NOT NULL, "
"thresholds_source TEXT NOT NULL, "
"created_at REAL NOT NULL)"
)
_SNAPSHOT_TABLE_SQL = (
"CREATE TABLE IF NOT EXISTS scorecard_snapshots("
"rubric_version TEXT NOT NULL, "
"baseline_head TEXT NOT NULL, "
"evidence_sha256 TEXT NOT NULL, "
"thresholds_snapshot TEXT NOT NULL, "
"scorecard_json TEXT NOT NULL, "
"created_at REAL NOT NULL, "
"PRIMARY KEY(rubric_version, evidence_sha256))"
)
def ensure_rubric_tables(conn):
"""建两张表(幂等)。写法与 audit_loop_wiring 保持一致。"""
conn.execute(_RUBRIC_TABLE_SQL)
conn.execute(_SNAPSHOT_TABLE_SQL)
try:
conn.commit()
except Exception:
pass
def _canonical_json(value):
return json.dumps(value, sort_keys=True, separators=(",", ":"))
def _evidence_sha256(evidence):
return hashlib.sha256(
_canonical_json(evidence).encode("utf-8")).hexdigest()
def _normalize_thresholds(thresholds):
"""阈值表归一化:每项 -> {"value": 数值, "source": 来源}。
纯数值条目按 audit_scorecard 的旧规则视作未拍板;
dict 条目缺 source 时同样视作未拍板,绝不默认已拍板。
"""
if not isinstance(thresholds, dict) or not thresholds:
raise ValueError("thresholds must be a non-empty dict")
normalized = {}
for key, entry in thresholds.items():
if isinstance(entry, dict):
if "value" not in entry:
raise ValueError(
"threshold %r must carry a value" % (key,))
value = entry["value"]
source = entry.get("source", "test_default_unapproved")
else:
value = entry
source = "test_default_unapproved"
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(
"threshold %r value must be a number" % (key,))
if not isinstance(source, str) or not source:
raise ValueError(
"threshold %r source must be a non-empty string" % (key,))
normalized[key] = {"value": value, "source": source}
return normalized
def _row_to_version(row):
return {
"rubric_version": row[0],
"thresholds_snapshot": row[1],
"thresholds": json.loads(row[1]),
"baseline_head": row[2],
"thresholds_source": row[3],
"created_at": row[4],
}
def create_rubric_version(conn, rubric_version, thresholds, baseline_head,
created_at=None):
"""写一个新标尺版本行。版本号已存在时报 ValueError,旧行不动。
thresholds:audit_scorecard 阈值表形态(dict 条目或纯数值)。
baseline_head:调用方显式传入的基线提交标识,不可为空。
"""
if not isinstance(rubric_version, str) or not rubric_version:
raise ValueError("rubric_version must be a non-empty string")
if not isinstance(baseline_head, str) or not baseline_head:
raise ValueError("baseline_head must be a non-empty string")
normalized = _normalize_thresholds(thresholds)
snapshot = _canonical_json(normalized)
source = thresholds_source(normalized)
stamp = time.time() if created_at is None else created_at
ensure_rubric_tables(conn)
try:
conn.execute(
"INSERT INTO scorecard_rubric_versions"
"(rubric_version, thresholds_snapshot, baseline_head,"
" thresholds_source, created_at) VALUES (?, ?, ?, ?, ?)",
(rubric_version, snapshot, baseline_head, source, stamp))
except Exception as exc:
# 只有主键冲突才转译为“版本已存在”;其它错误原样抛出,
# 绝不能把真实写失败伪装成版本冲突、更不能转成 upsert。
text = str(exc).lower()
if "unique" in text or "duplicate" in text:
raise ValueError(
"rubric_version %r already exists; create a new version"
" instead of modifying it" % (rubric_version,)) from exc
raise
conn.commit()
return {
"rubric_version": rubric_version,
"thresholds_snapshot": snapshot,
"thresholds": normalized,
"baseline_head": baseline_head,
"thresholds_source": source,
"created_at": stamp,
}
def get_rubric_version(conn, rubric_version):
"""读一个版本行;不存在返回 None(不抛错、不造默认值)。"""
ensure_rubric_tables(conn)
row = conn.execute(
"SELECT rubric_version, thresholds_snapshot, baseline_head,"
" thresholds_source, created_at FROM scorecard_rubric_versions"
" WHERE rubric_version = ?",
(rubric_version,)).fetchone()
if row is None:
return None
return _row_to_version(row)
def list_rubric_versions(conn):
"""全部版本行,按 (created_at, rubric_version) 升序(写序)。"""
ensure_rubric_tables(conn)
rows = conn.execute(
"SELECT rubric_version, thresholds_snapshot, baseline_head,"
" thresholds_source, created_at FROM scorecard_rubric_versions"
" ORDER BY created_at ASC, rubric_version ASC").fetchall()
return [_row_to_version(row) for row in rows]
def latest_rubric_version(conn):
"""最新版本行(created_at 最大,时间并列时版本号最大);无行返回 None。"""
ensure_rubric_tables(conn)
row = conn.execute(
"SELECT rubric_version, thresholds_snapshot, baseline_head,"
" thresholds_source, created_at FROM scorecard_rubric_versions"
" ORDER BY created_at DESC, rubric_version DESC LIMIT 1").fetchone()
if row is None:
return None
return _row_to_version(row)
def scorecard_for_rubric(conn, rubric_version, evidence):
"""用指定版本行的快照阈值现算一张卡(只读,不写快照表)。
未知版本号报 ValueError,不回落到默认值——回落会让旧分
在不知情下变成新标尺的分,正是版本化要杜绝的漂移。
"""
version = get_rubric_version(conn, rubric_version)
if version is None:
raise ValueError("unknown rubric_version %r" % (rubric_version,))
card = build_scorecard(evidence, thresholds=version["thresholds"],
rubric_version=rubric_version)
card["baseline_head"] = version["baseline_head"]
return card
def record_scorecard(conn, rubric_version, evidence, created_at=None):
"""算卡并写入快照行;同 (版本, 证据) 已有行时返回旧行原样。
返回值恒为卡片 dict(新算的或旧存的),调用方凭它核对。
"""
version = get_rubric_version(conn, rubric_version)
if version is None:
raise ValueError("unknown rubric_version %r" % (rubric_version,))
card = build_scorecard(evidence, thresholds=version["thresholds"],
rubric_version=rubric_version)
card["baseline_head"] = version["baseline_head"]
digest = _evidence_sha256(evidence)
ensure_rubric_tables(conn)
row = conn.execute(
"SELECT scorecard_json FROM scorecard_snapshots"
" WHERE rubric_version = ? AND evidence_sha256 = ?",
(rubric_version, digest)).fetchone()
if row is not None:
return json.loads(row[0])
conn.execute(
"INSERT INTO scorecard_snapshots"
"(rubric_version, baseline_head, evidence_sha256,"
" thresholds_snapshot, scorecard_json, created_at)"
" VALUES (?, ?, ?, ?, ?, ?)",
(rubric_version, version["baseline_head"], digest,
version["thresholds_snapshot"], _canonical_json(card),
time.time() if created_at is None else created_at))
conn.commit()
return card
def get_scorecard_snapshot(conn, rubric_version, evidence):
"""读一张已存快照卡;无行返回 None(不现算、不回落)。"""
digest = _evidence_sha256(evidence)
ensure_rubric_tables(conn)
row = conn.execute(
"SELECT scorecard_json FROM scorecard_snapshots"
" WHERE rubric_version = ? AND evidence_sha256 = ?",
(rubric_version, digest)).fetchone()
if row is None:
return None
return json.loads(row[0])