Files
codex-app-server/cloud/scorecard_rubric.py
host be81d658b6 code-to10 v7 mech1: scorecard rubric versioning, append-only rubric + snapshot tables
(cherry picked from commit 368475243367685bf9db9335ec929958de031ca6)
2026-11-03 02:49:27 +00:00

283 lines
12 KiB
Python
Raw Permalink 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 机制第 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])