v7 mech-5: scorecard baseline_head machine field, stale check, frozen store (append-only)

(cherry picked from commit e3e1f517e916eaac597b82d04bac5307a3f8b126)
This commit is contained in:
host committed 2026-11-03 02:49:27 +00:00
1 parent be81d658b6
commit 8fedd7fba0
3 files changed
+531 -1

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
"""评分卡冻结版的服务端存储(v7 机制第 5 项:基线漂移门禁)。
定位:只存“冻结的那一版”,不算分、不判 stale、不改旧分。
算分在 cloud/audit_scorecard.py 的 build_scorecard,
漂移判定在 check_baseline_stale,本模块只做冻结行的写入与读取。
键思路沿用仓库 DB 模式:ledger.py / audit_loop_driver.py 按
(module_id, contract_version) 计 slot,本表按
(rubric_version, baseline_head) 计冻结版——同一 rubric 版本在
同一 main 头上冻结的评分卡快照,是一版;main 头推进后冻结的,
是另一版,旧版仍在表里供追溯,不被覆盖。
行字段:
- rubric_version:评分口径版本(调用方传入的字符串,如 "v1";
本模块不规定口径内容、不替用户拍板阈值)。
- baseline_head:冻结时调用方传入的 main 头(字符串,非空)。
- frozen_at:冻结时间戳(秒,调用方不传时取当前时间)。
- scorecard_snapshot:build_scorecard 输出的完整 dict,以 JSON
文本存入,读回时还原为 dict。
只追加,不改删:
- 本模块只提供 ensure_schema(建表,幂等)、freeze_scorecard
(INSERT 新行)、get_frozen_scorecard / get_latest_frozen_scorecard /
list_frozen_scorecards(只读)。刻意不提供任何 UPDATE / DELETE
方法;SQLite 下另有触发器在表层挡住 UPDATE / DELETE
(写法沿用 ledger.py 的只追加触发器)。
- 不 commit:调用方在自己的事务边界提交(与 ledger.ensure_schema
同款约定);独立调用方须自己 commit,否则同连接外看不到新行。
- 真实 PG 未接通:PG 适配连接跳过 SQLite 触发器建(方言不同,
与 ledger.py 同款处理),PG 下的只追加护栏与真实 PG 验证均未做,
本项只用 SQLite / 内存库测试并明说。
- 本模块绝不在生产代码里探测版本控制状态、绝不读版本控制目录:
rubric_version / baseline_head / frozen_at 全部由调用方传入。
"""
from __future__ import annotations
import json
import time
_FROZEN_TABLE_SQL = (
"CREATE TABLE IF NOT EXISTS scorecard_baseline_freeze("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"rubric_version TEXT NOT NULL, "
"baseline_head TEXT NOT NULL, "
"frozen_at DOUBLE PRECISION NOT NULL, "
"scorecard_snapshot TEXT NOT NULL)"
)
_FROZEN_INDEX_SQL = (
"CREATE INDEX IF NOT EXISTS scorecard_baseline_freeze_key "
"ON scorecard_baseline_freeze(rubric_version, baseline_head)"
)
# SQLite 只追加触发器(PG 不走这段,见模块 docstring)。
_SQLITE_GUARD_SQL = (
"CREATE TRIGGER IF NOT EXISTS scorecard_baseline_freeze_no_update "
"BEFORE UPDATE ON scorecard_baseline_freeze BEGIN "
"SELECT RAISE(ABORT, 'scorecard_baseline_freeze is append-only'); END",
"CREATE TRIGGER IF NOT EXISTS scorecard_baseline_freeze_no_delete "
"BEFORE DELETE ON scorecard_baseline_freeze BEGIN "
"SELECT RAISE(ABORT, 'scorecard_baseline_freeze is append-only'); END",
)
def _is_pg_conn(conn):
"""PgConnection 适配连接(触发器方言不同,须跳过 SQLite 护栏)。"""
return type(conn).__name__ == "PgConnection"
def ensure_schema(conn):
"""建冻结表 + 索引 + SQLite 只追加触发器(幂等,不 commit)。"""
conn.execute(_FROZEN_TABLE_SQL)
conn.execute(_FROZEN_INDEX_SQL)
if not _is_pg_conn(conn):
for stmt in _SQLITE_GUARD_SQL:
conn.execute(stmt)
def _require_non_empty_str(value, name):
if not isinstance(value, str) or value == "":
raise ValueError("%s must be a non-empty string" % name)
return value
def freeze_scorecard(conn, rubric_version, baseline_head,
scorecard_snapshot, frozen_at=None):
"""写入一个新冻结行(INSERT only),返回读回形态的行 dict。
scorecard_snapshot 必须是 build_scorecard 输出的 dict;不是 dict
时拒绝写入,不做任何格式猜测。调用方负责 commit。
"""
_require_non_empty_str(rubric_version, "rubric_version")
_require_non_empty_str(baseline_head, "baseline_head")
if not isinstance(scorecard_snapshot, dict):
raise ValueError("scorecard_snapshot must be a dict")
stamp = float(frozen_at) if frozen_at is not None else time.time()
payload = json.dumps(scorecard_snapshot, sort_keys=True,
ensure_ascii=False)
cur = conn.execute(
"INSERT INTO scorecard_baseline_freeze"
"(rubric_version, baseline_head, frozen_at, scorecard_snapshot) "
"VALUES (?, ?, ?, ?)",
(rubric_version, baseline_head, stamp, payload))
return {
"id": cur.lastrowid,
"rubric_version": rubric_version,
"baseline_head": baseline_head,
"frozen_at": stamp,
"scorecard_snapshot": scorecard_snapshot,
}
def _row_to_dict(row):
row_id, rubric_version, baseline_head, frozen_at, snapshot_text = row
return {
"id": row_id,
"rubric_version": rubric_version,
"baseline_head": baseline_head,
"frozen_at": frozen_at,
"scorecard_snapshot": json.loads(snapshot_text),
}
def get_frozen_scorecard(conn, rubric_version, baseline_head):
"""读某 (rubric_version, baseline_head) 的最新冻结行,无则 None。
同一键有多行时(不同 frozen_at 多次冻结)返回 id 最大的最新行,
旧行仍在表里,不被覆盖、不被删除。
"""
_require_non_empty_str(rubric_version, "rubric_version")
_require_non_empty_str(baseline_head, "baseline_head")
row = conn.execute(
"SELECT id, rubric_version, baseline_head, frozen_at, "
"scorecard_snapshot FROM scorecard_baseline_freeze "
"WHERE rubric_version = ? AND baseline_head = ? "
"ORDER BY id DESC LIMIT 1",
(rubric_version, baseline_head)).fetchone()
return _row_to_dict(row) if row is not None else None
def get_latest_frozen_scorecard(conn, rubric_version=None):
"""读最新冻结行;传 rubric_version 时只在该口径内取最新,无则 None。"""
if rubric_version is None:
row = conn.execute(
"SELECT id, rubric_version, baseline_head, frozen_at, "
"scorecard_snapshot FROM scorecard_baseline_freeze "
"ORDER BY id DESC LIMIT 1").fetchone()
else:
_require_non_empty_str(rubric_version, "rubric_version")
row = conn.execute(
"SELECT id, rubric_version, baseline_head, frozen_at, "
"scorecard_snapshot FROM scorecard_baseline_freeze "
"WHERE rubric_version = ? ORDER BY id DESC LIMIT 1",
(rubric_version,)).fetchone()
return _row_to_dict(row) if row is not None else None
def list_frozen_scorecards(conn, rubric_version=None, baseline_head=None):
"""按 id 升序列出冻结行;两参数均可选作过滤条件,不过滤时全列。"""
sql = ("SELECT id, rubric_version, baseline_head, frozen_at, "
"scorecard_snapshot FROM scorecard_baseline_freeze")
clauses, params = [], []
if rubric_version is not None:
_require_non_empty_str(rubric_version, "rubric_version")
clauses.append("rubric_version = ?")
params.append(rubric_version)
if baseline_head is not None:
_require_non_empty_str(baseline_head, "baseline_head")
clauses.append("baseline_head = ?")
params.append(baseline_head)
if clauses:
sql += " WHERE " + " AND ".join(clauses)
sql += " ORDER BY id ASC"
return [_row_to_dict(row) for row in conn.execute(sql, params).fetchall()]