Files
codex-app-server/cloud/scorecard_baseline_store.py
T
host 8fedd7fba0 v7 mech-5: scorecard baseline_head machine field, stale check, frozen store (append-only)
(cherry picked from commit e3e1f517e916eaac597b82d04bac5307a3f8b126)
2026-11-03 02:49:27 +00:00

176 lines
7.5 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 机制第 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()]