717 lines
28 KiB
Python
717 lines
28 KiB
Python
"""SQLite persistence for tuning sessions, trials, proposals and scalar data."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sqlite3
|
||
import threading
|
||
import uuid
|
||
from collections.abc import Iterator
|
||
from contextlib import contextmanager
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from .schema import FLAT_TASK, RewardConfigError, validate_configuration
|
||
|
||
SCHEMA_VERSION = 2
|
||
|
||
|
||
class StorageConflict(RuntimeError):
|
||
"""Optimistic-concurrency revision mismatch."""
|
||
|
||
|
||
def now_iso() -> str:
|
||
return datetime.now(UTC).isoformat()
|
||
|
||
|
||
def _json(value: Any) -> str:
|
||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
||
|
||
|
||
def _decode(value: str | None) -> Any:
|
||
return json.loads(value) if value else None
|
||
|
||
|
||
def _lttb(points: list[dict], threshold: int) -> list[dict]:
|
||
"""Largest-Triangle-Three-Buckets downsampling preserving peaks and endpoints."""
|
||
if threshold >= len(points) or threshold < 3:
|
||
return points[:threshold]
|
||
sampled = [points[0]]
|
||
bucket_width = (len(points) - 2) / (threshold - 2)
|
||
anchor_index = 0
|
||
for bucket in range(threshold - 2):
|
||
average_start = int((bucket + 1) * bucket_width) + 1
|
||
average_end = min(int((bucket + 2) * bucket_width) + 1, len(points))
|
||
average_bucket = points[average_start:average_end] or [points[-1]]
|
||
average_x = sum(point["step"] for point in average_bucket) / len(average_bucket)
|
||
average_y = sum(point["value"] for point in average_bucket) / len(average_bucket)
|
||
range_start = int(bucket * bucket_width) + 1
|
||
range_end = min(int((bucket + 1) * bucket_width) + 1, len(points) - 1)
|
||
anchor = points[anchor_index]
|
||
selected_index = range_start
|
||
maximum_area = -1.0
|
||
for index in range(range_start, max(range_start + 1, range_end)):
|
||
point = points[index]
|
||
area = abs(
|
||
(anchor["step"] - average_x) * (point["value"] - anchor["value"])
|
||
- (anchor["step"] - point["step"]) * (average_y - anchor["value"])
|
||
)
|
||
if area > maximum_area:
|
||
maximum_area = area
|
||
selected_index = index
|
||
sampled.append(points[selected_index])
|
||
anchor_index = selected_index
|
||
sampled.append(points[-1])
|
||
return sampled
|
||
|
||
|
||
class TuningStorage:
|
||
def __init__(self, path: Path):
|
||
self.path = path.expanduser().resolve()
|
||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||
self.local = threading.local()
|
||
self._migrate()
|
||
|
||
def connection(self) -> sqlite3.Connection:
|
||
connection = getattr(self.local, "connection", None)
|
||
if connection is None:
|
||
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
|
||
connection.row_factory = sqlite3.Row
|
||
connection.execute("PRAGMA foreign_keys=ON")
|
||
connection.execute("PRAGMA journal_mode=WAL")
|
||
connection.execute("PRAGMA busy_timeout=10000")
|
||
self.local.connection = connection
|
||
return connection
|
||
|
||
@contextmanager
|
||
def transaction(self) -> Iterator[sqlite3.Connection]:
|
||
connection = self.connection()
|
||
connection.execute("BEGIN IMMEDIATE")
|
||
try:
|
||
yield connection
|
||
connection.execute("COMMIT")
|
||
except Exception:
|
||
connection.execute("ROLLBACK")
|
||
raise
|
||
|
||
def _migrate(self) -> None:
|
||
connection = self.connection()
|
||
connection.executescript(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY);
|
||
CREATE TABLE IF NOT EXISTS sessions(
|
||
id TEXT PRIMARY KEY, state TEXT NOT NULL, mode TEXT NOT NULL,
|
||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||
config_json TEXT NOT NULL, objective_json TEXT NOT NULL,
|
||
message TEXT NOT NULL, current_trial_id TEXT, best_trial_id TEXT,
|
||
consecutive_no_improve INTEGER NOT NULL DEFAULT 0,
|
||
fallback_enabled INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE TABLE IF NOT EXISTS trials(
|
||
id TEXT PRIMARY KEY,
|
||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||
number INTEGER NOT NULL, state TEXT NOT NULL, rung INTEGER NOT NULL,
|
||
target_iterations INTEGER NOT NULL, reward_config_json TEXT NOT NULL,
|
||
proposal_id TEXT, run_dir TEXT NOT NULL, checkpoint_path TEXT,
|
||
policy_path TEXT, evaluation_json TEXT, score REAL, eligible INTEGER,
|
||
created_at TEXT NOT NULL, started_at TEXT, ended_at TEXT, message TEXT NOT NULL,
|
||
UNIQUE(session_id, number, rung)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS proposals(
|
||
id TEXT PRIMARY KEY,
|
||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||
base_trial_id TEXT, state TEXT NOT NULL, source TEXT NOT NULL,
|
||
patch_json TEXT NOT NULL, rationale TEXT NOT NULL,
|
||
expected_json TEXT, confidence REAL NOT NULL,
|
||
created_at TEXT NOT NULL, decided_at TEXT, feedback TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS metric_points(
|
||
trial_id TEXT NOT NULL REFERENCES trials(id) ON DELETE CASCADE,
|
||
tag TEXT NOT NULL, step INTEGER NOT NULL,
|
||
wall_time REAL NOT NULL, value REAL NOT NULL,
|
||
PRIMARY KEY(trial_id, tag, step)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS audit_events(
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||
event_type TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS presets(
|
||
id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, session_id TEXT NOT NULL,
|
||
trial_id TEXT NOT NULL, reward_config_json TEXT NOT NULL, created_at TEXT NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS session_controls(
|
||
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
|
||
run_policy TEXT NOT NULL DEFAULT 'continuous'
|
||
CHECK(run_policy IN ('continuous','step')),
|
||
dispatch_tokens INTEGER NOT NULL DEFAULT 0 CHECK(dispatch_tokens >= 0),
|
||
active_base_trial_id TEXT,
|
||
revision INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE TABLE IF NOT EXISTS session_constraints(
|
||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||
path TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('range','fixed')),
|
||
min_value REAL, max_value REAL, fixed_value REAL,
|
||
updated_at TEXT NOT NULL,
|
||
PRIMARY KEY(session_id, path)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_trials_session ON trials(session_id, number, rung);
|
||
CREATE INDEX IF NOT EXISTS idx_proposals_session ON proposals(session_id, created_at);
|
||
CREATE INDEX IF NOT EXISTS idx_metrics_trial_tag ON metric_points(trial_id, tag, step);
|
||
"""
|
||
)
|
||
connection.execute(
|
||
"INSERT OR IGNORE INTO session_controls(session_id) SELECT id FROM sessions"
|
||
)
|
||
connection.execute(
|
||
"INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)", (SCHEMA_VERSION,)
|
||
)
|
||
|
||
def recover_interrupted(self) -> None:
|
||
at = now_iso()
|
||
with self.transaction() as connection:
|
||
previous = connection.execute(
|
||
"SELECT id,state FROM sessions WHERE state IN "
|
||
"('queued','running','evaluating','paused','awaiting_approval')"
|
||
).fetchall()
|
||
for row in previous:
|
||
connection.execute(
|
||
"INSERT INTO audit_events(session_id,event_type,payload_json,created_at) "
|
||
"VALUES (?,?,?,?)",
|
||
(
|
||
row["id"],
|
||
"service_restart_interrupted",
|
||
_json(
|
||
{
|
||
"previousState": row["state"],
|
||
"reason": "service_restart",
|
||
"requiresExplicitResume": True,
|
||
}
|
||
),
|
||
at,
|
||
),
|
||
)
|
||
connection.execute(
|
||
"UPDATE trials SET state='interrupted', ended_at=?, "
|
||
"message='服务重启中断,等待显式恢复' "
|
||
"WHERE state IN ('queued','training','evaluating')",
|
||
(at,),
|
||
)
|
||
connection.execute(
|
||
"UPDATE sessions SET state='interrupted', updated_at=?, "
|
||
"message='服务重启中断,可从完整 checkpoint 恢复' "
|
||
"WHERE state IN ('queued','running','evaluating','paused','awaiting_approval')",
|
||
(at,),
|
||
)
|
||
|
||
def create_session(self, mode: str, config: dict, objective: dict, fallback: bool) -> dict:
|
||
session_id, at = uuid.uuid4().hex, now_iso()
|
||
with self.transaction() as connection:
|
||
connection.execute(
|
||
"INSERT INTO sessions("
|
||
"id,state,mode,created_at,updated_at,config_json,objective_json,"
|
||
"message,fallback_enabled) "
|
||
"VALUES (?, 'queued', ?, ?, ?, ?, ?, '等待基线训练', ?)",
|
||
(session_id, mode, at, at, _json(config), _json(objective), int(fallback)),
|
||
)
|
||
connection.execute("INSERT INTO session_controls(session_id) VALUES (?)", (session_id,))
|
||
connection.execute(
|
||
"INSERT INTO audit_events(session_id,event_type,payload_json,created_at) "
|
||
"VALUES (?,?,?,?)",
|
||
(session_id, "session_created", _json({"mode": mode}), at),
|
||
)
|
||
return self.get_session(session_id)
|
||
|
||
def _session(self, row: sqlite3.Row) -> dict:
|
||
return {
|
||
"id": row["id"],
|
||
"state": row["state"],
|
||
"mode": row["mode"],
|
||
"createdAt": row["created_at"],
|
||
"updatedAt": row["updated_at"],
|
||
"config": _decode(row["config_json"]),
|
||
"objectiveWeights": _decode(row["objective_json"]),
|
||
"message": row["message"],
|
||
"currentTrialId": row["current_trial_id"],
|
||
"bestTrialId": row["best_trial_id"],
|
||
"consecutiveNoImprove": row["consecutive_no_improve"],
|
||
"fallbackEnabled": bool(row["fallback_enabled"]),
|
||
}
|
||
|
||
def get_session(self, session_id: str) -> dict:
|
||
row = (
|
||
self.connection().execute("SELECT * FROM sessions WHERE id=?", (session_id,)).fetchone()
|
||
)
|
||
if row is None:
|
||
raise KeyError(session_id)
|
||
return self._session(row)
|
||
|
||
def list_sessions(self, limit: int = 50) -> list[dict]:
|
||
rows = (
|
||
self.connection()
|
||
.execute("SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?", (limit,))
|
||
.fetchall()
|
||
)
|
||
return [self._session(row) for row in rows]
|
||
|
||
def update_session(self, session_id: str, **changes: Any) -> bool:
|
||
columns = {
|
||
"state": "state",
|
||
"mode": "mode",
|
||
"message": "message",
|
||
"current_trial_id": "current_trial_id",
|
||
"best_trial_id": "best_trial_id",
|
||
"consecutive_no_improve": "consecutive_no_improve",
|
||
}
|
||
values, assignments = [], []
|
||
for key, value in changes.items():
|
||
if key not in columns:
|
||
raise ValueError(key)
|
||
assignments.append(f"{columns[key]}=?")
|
||
values.append(value)
|
||
assignments.append("updated_at=?")
|
||
values.extend((now_iso(), session_id))
|
||
cursor = self.connection().execute(
|
||
f"UPDATE sessions SET {', '.join(assignments)} WHERE id=?", values
|
||
)
|
||
return cursor.rowcount == 1
|
||
|
||
def get_control(self, session_id: str) -> dict:
|
||
row = (
|
||
self.connection()
|
||
.execute("SELECT * FROM session_controls WHERE session_id=?", (session_id,))
|
||
.fetchone()
|
||
)
|
||
if row is None:
|
||
raise KeyError(session_id)
|
||
constraint_rows = (
|
||
self.connection()
|
||
.execute(
|
||
"SELECT * FROM session_constraints WHERE session_id=? ORDER BY path", (session_id,)
|
||
)
|
||
.fetchall()
|
||
)
|
||
constraints = {}
|
||
for constraint in constraint_rows:
|
||
if constraint["kind"] == "fixed":
|
||
value = {"kind": "fixed", "value": constraint["fixed_value"]}
|
||
else:
|
||
value = {
|
||
"kind": "range",
|
||
"min": constraint["min_value"],
|
||
"max": constraint["max_value"],
|
||
}
|
||
constraints[constraint["path"]] = value
|
||
return {
|
||
"runPolicy": row["run_policy"],
|
||
"dispatchTokens": row["dispatch_tokens"],
|
||
"constraintsRevision": row["revision"],
|
||
"constraints": constraints,
|
||
"activeBaseTrialId": row["active_base_trial_id"],
|
||
}
|
||
|
||
def replace_constraints(
|
||
self, session_id: str, expected_revision: int, constraints: dict
|
||
) -> dict:
|
||
at = now_iso()
|
||
with self.transaction() as connection:
|
||
row = connection.execute(
|
||
"SELECT revision FROM session_controls WHERE session_id=?", (session_id,)
|
||
).fetchone()
|
||
if row is None:
|
||
raise KeyError(session_id)
|
||
if row["revision"] != expected_revision:
|
||
raise StorageConflict(
|
||
f"参数护栏 revision 已变化(当前 {row['revision']},请求 {expected_revision})"
|
||
)
|
||
connection.execute("DELETE FROM session_constraints WHERE session_id=?", (session_id,))
|
||
for path, constraint in constraints.items():
|
||
connection.execute(
|
||
"INSERT INTO session_constraints("
|
||
"session_id,path,kind,min_value,max_value,fixed_value,updated_at) "
|
||
"VALUES (?,?,?,?,?,?,?)",
|
||
(
|
||
session_id,
|
||
path,
|
||
constraint["kind"],
|
||
constraint.get("min"),
|
||
constraint.get("max"),
|
||
constraint.get("value"),
|
||
at,
|
||
),
|
||
)
|
||
connection.execute(
|
||
"UPDATE session_controls SET revision=revision+1 WHERE session_id=?",
|
||
(session_id,),
|
||
)
|
||
return self.get_control(session_id)
|
||
|
||
def grant_dispatch_token(self, session_id: str) -> dict:
|
||
"""Atomically grant the sole outstanding one-Trial token."""
|
||
with self.transaction() as connection:
|
||
cursor = connection.execute(
|
||
"UPDATE session_controls SET run_policy='step',dispatch_tokens=1 "
|
||
"WHERE session_id=? AND dispatch_tokens=0",
|
||
(session_id,),
|
||
)
|
||
if cursor.rowcount != 1:
|
||
exists = connection.execute(
|
||
"SELECT 1 FROM session_controls WHERE session_id=?", (session_id,)
|
||
).fetchone()
|
||
if exists is None:
|
||
raise KeyError(session_id)
|
||
raise StorageConflict("已有未消费的单步 Trial 令牌")
|
||
return self.get_control(session_id)
|
||
|
||
def use_dispatch_token(self, session_id: str) -> bool:
|
||
"""Atomically consume one step token; continuous mode never needs a token."""
|
||
with self.transaction() as connection:
|
||
row = connection.execute(
|
||
"SELECT run_policy,dispatch_tokens FROM session_controls WHERE session_id=?",
|
||
(session_id,),
|
||
).fetchone()
|
||
if row is None:
|
||
raise KeyError(session_id)
|
||
if row["run_policy"] == "continuous":
|
||
return True
|
||
if row["dispatch_tokens"] <= 0:
|
||
return False
|
||
connection.execute(
|
||
"UPDATE session_controls SET dispatch_tokens=dispatch_tokens-1 WHERE session_id=?",
|
||
(session_id,),
|
||
)
|
||
return True
|
||
|
||
def set_run_policy(self, session_id: str, policy: str) -> dict:
|
||
if policy not in {"continuous", "step"}:
|
||
raise ValueError(policy)
|
||
cursor = self.connection().execute(
|
||
"UPDATE session_controls SET run_policy=?,"
|
||
"dispatch_tokens=CASE WHEN ?='continuous' THEN 0 ELSE dispatch_tokens END "
|
||
"WHERE session_id=?",
|
||
(policy, policy, session_id),
|
||
)
|
||
if cursor.rowcount != 1:
|
||
raise KeyError(session_id)
|
||
return self.get_control(session_id)
|
||
|
||
def reset_step_gate(self, session_id: str) -> dict:
|
||
cursor = self.connection().execute(
|
||
"UPDATE session_controls SET run_policy='step',dispatch_tokens=0 WHERE session_id=?",
|
||
(session_id,),
|
||
)
|
||
if cursor.rowcount != 1:
|
||
raise KeyError(session_id)
|
||
return self.get_control(session_id)
|
||
|
||
def set_active_base(self, session_id: str, trial_id: str | None) -> dict:
|
||
cursor = self.connection().execute(
|
||
"UPDATE session_controls SET active_base_trial_id=? WHERE session_id=?",
|
||
(trial_id, session_id),
|
||
)
|
||
if cursor.rowcount != 1:
|
||
raise KeyError(session_id)
|
||
return self.get_control(session_id)
|
||
|
||
def create_trial(
|
||
self,
|
||
session_id: str,
|
||
number: int,
|
||
rung: int,
|
||
target: int,
|
||
reward_config: dict,
|
||
proposal_id: str | None,
|
||
run_dir: str,
|
||
) -> dict:
|
||
trial_id, at = uuid.uuid4().hex, now_iso()
|
||
with self.transaction() as connection:
|
||
connection.execute(
|
||
"INSERT INTO trials("
|
||
"id,session_id,number,state,rung,target_iterations,reward_config_json,"
|
||
"proposal_id,run_dir,created_at,message) "
|
||
"VALUES (?,?,?,'queued',?,?,?,?,?,?,'等待训练')",
|
||
(
|
||
trial_id,
|
||
session_id,
|
||
number,
|
||
rung,
|
||
target,
|
||
_json(reward_config),
|
||
proposal_id,
|
||
run_dir,
|
||
at,
|
||
),
|
||
)
|
||
connection.execute(
|
||
"UPDATE sessions SET current_trial_id=?,updated_at=? WHERE id=?",
|
||
(trial_id, at, session_id),
|
||
)
|
||
return self.get_trial(trial_id)
|
||
|
||
def _trial(self, row: sqlite3.Row) -> dict:
|
||
return {
|
||
"id": row["id"],
|
||
"sessionId": row["session_id"],
|
||
"number": row["number"],
|
||
"state": row["state"],
|
||
"rung": row["rung"],
|
||
"targetIterations": row["target_iterations"],
|
||
"rewardConfig": _decode(row["reward_config_json"]),
|
||
"proposalId": row["proposal_id"],
|
||
"runDir": row["run_dir"],
|
||
"checkpointPath": row["checkpoint_path"],
|
||
"policyPath": row["policy_path"],
|
||
"evaluation": _decode(row["evaluation_json"]),
|
||
"score": row["score"],
|
||
"eligible": None if row["eligible"] is None else bool(row["eligible"]),
|
||
"createdAt": row["created_at"],
|
||
"startedAt": row["started_at"],
|
||
"endedAt": row["ended_at"],
|
||
"message": row["message"],
|
||
}
|
||
|
||
def get_trial(self, trial_id: str) -> dict:
|
||
row = self.connection().execute("SELECT * FROM trials WHERE id=?", (trial_id,)).fetchone()
|
||
if row is None:
|
||
raise KeyError(trial_id)
|
||
return self._trial(row)
|
||
|
||
def list_trials(self, session_id: str) -> list[dict]:
|
||
rows = (
|
||
self.connection()
|
||
.execute("SELECT * FROM trials WHERE session_id=? ORDER BY number,rung", (session_id,))
|
||
.fetchall()
|
||
)
|
||
return [self._trial(row) for row in rows]
|
||
|
||
def delete_trial(self, trial_id: str) -> bool:
|
||
cursor = self.connection().execute(
|
||
"DELETE FROM trials WHERE id=? AND state='interrupted'", (trial_id,)
|
||
)
|
||
return cursor.rowcount == 1
|
||
|
||
def update_trial(self, trial_id: str, **changes: Any) -> bool:
|
||
columns = {
|
||
"state": "state",
|
||
"message": "message",
|
||
"checkpoint_path": "checkpoint_path",
|
||
"policy_path": "policy_path",
|
||
"score": "score",
|
||
"eligible": "eligible",
|
||
"started_at": "started_at",
|
||
"ended_at": "ended_at",
|
||
"evaluation": "evaluation_json",
|
||
}
|
||
values, assignments = [], []
|
||
for key, value in changes.items():
|
||
if key not in columns:
|
||
raise ValueError(key)
|
||
if key == "evaluation":
|
||
value = _json(value)
|
||
if key == "eligible":
|
||
value = int(value)
|
||
assignments.append(f"{columns[key]}=?")
|
||
values.append(value)
|
||
values.append(trial_id)
|
||
cursor = self.connection().execute(
|
||
f"UPDATE trials SET {', '.join(assignments)} WHERE id=?", values
|
||
)
|
||
return cursor.rowcount == 1
|
||
|
||
def create_proposal(
|
||
self,
|
||
session_id: str,
|
||
base_trial_id: str | None,
|
||
patch: dict,
|
||
rationale: str,
|
||
expected: Any,
|
||
confidence: float,
|
||
source: str = "agent",
|
||
) -> dict:
|
||
proposal_id, at = uuid.uuid4().hex, now_iso()
|
||
self.connection().execute(
|
||
"INSERT INTO proposals("
|
||
"id,session_id,base_trial_id,state,source,patch_json,rationale,"
|
||
"expected_json,confidence,created_at) "
|
||
"VALUES (?,?,?,'pending',?,?,?,?,?,?)",
|
||
(
|
||
proposal_id,
|
||
session_id,
|
||
base_trial_id,
|
||
source,
|
||
_json(patch),
|
||
rationale,
|
||
_json(expected),
|
||
confidence,
|
||
at,
|
||
),
|
||
)
|
||
return self.get_proposal(proposal_id)
|
||
|
||
def _proposal(self, row: sqlite3.Row) -> dict:
|
||
return {
|
||
"id": row["id"],
|
||
"sessionId": row["session_id"],
|
||
"baseTrialId": row["base_trial_id"],
|
||
"state": row["state"],
|
||
"source": row["source"],
|
||
"patch": _decode(row["patch_json"]),
|
||
"rationale": row["rationale"],
|
||
"expectedImpact": _decode(row["expected_json"]),
|
||
"confidence": row["confidence"],
|
||
"createdAt": row["created_at"],
|
||
"decidedAt": row["decided_at"],
|
||
"feedback": row["feedback"],
|
||
}
|
||
|
||
def get_proposal(self, proposal_id: str) -> dict:
|
||
row = (
|
||
self.connection()
|
||
.execute("SELECT * FROM proposals WHERE id=?", (proposal_id,))
|
||
.fetchone()
|
||
)
|
||
if row is None:
|
||
raise KeyError(proposal_id)
|
||
return self._proposal(row)
|
||
|
||
def list_proposals(self, session_id: str) -> list[dict]:
|
||
rows = (
|
||
self.connection()
|
||
.execute(
|
||
"SELECT * FROM proposals WHERE session_id=? ORDER BY created_at", (session_id,)
|
||
)
|
||
.fetchall()
|
||
)
|
||
return [self._proposal(row) for row in rows]
|
||
|
||
def decide_proposal(
|
||
self, proposal_id: str, state: str, feedback: str | None, patch: dict | None = None
|
||
) -> bool:
|
||
at = now_iso()
|
||
assignments, values = ["state=?", "feedback=?", "decided_at=?"], [state, feedback, at]
|
||
if patch is not None:
|
||
assignments.append("patch_json=?")
|
||
values.append(_json(patch))
|
||
values.extend((proposal_id,))
|
||
cursor = self.connection().execute(
|
||
f"UPDATE proposals SET {', '.join(assignments)} WHERE id=? AND state='pending'", values
|
||
)
|
||
return cursor.rowcount == 1
|
||
|
||
def insert_metrics(self, trial_id: str, points: list[tuple[str, int, float, float]]) -> None:
|
||
self.connection().executemany(
|
||
"INSERT INTO metric_points(trial_id,tag,step,wall_time,value) "
|
||
"VALUES (?,?,?,?,?) ON CONFLICT(trial_id,tag,step) DO UPDATE SET "
|
||
"wall_time=excluded.wall_time,value=excluded.value",
|
||
[(trial_id, *point) for point in points],
|
||
)
|
||
|
||
def metrics(
|
||
self,
|
||
trial_id: str,
|
||
tags: list[str] | None = None,
|
||
max_points: int = 1000,
|
||
after_step: int | None = None,
|
||
) -> list[dict]:
|
||
parameters: list[Any] = [trial_id]
|
||
clause = "trial_id=?"
|
||
if tags:
|
||
clause += f" AND tag IN ({','.join('?' for _ in tags)})"
|
||
parameters.extend(tags)
|
||
if after_step is not None:
|
||
clause += " AND step>?"
|
||
parameters.append(after_step)
|
||
rows = (
|
||
self.connection()
|
||
.execute(
|
||
f"SELECT tag,step,wall_time,value FROM metric_points "
|
||
f"WHERE {clause} ORDER BY tag,step",
|
||
parameters,
|
||
)
|
||
.fetchall()
|
||
)
|
||
grouped: dict[str, list[dict]] = {}
|
||
for row in rows:
|
||
grouped.setdefault(row["tag"], []).append(
|
||
{"step": row["step"], "wallTime": row["wall_time"], "value": row["value"]}
|
||
)
|
||
series = []
|
||
for tag, values in grouped.items():
|
||
if len(values) > max_points:
|
||
values = _lttb(values, max_points)
|
||
series.append({"tag": tag, "points": values})
|
||
return series
|
||
|
||
def audit(self, session_id: str, event_type: str, payload: Any) -> None:
|
||
self.connection().execute(
|
||
"INSERT INTO audit_events(session_id,event_type,payload_json,created_at) "
|
||
"VALUES (?,?,?,?)",
|
||
(session_id, event_type, _json(payload), now_iso()),
|
||
)
|
||
|
||
def audit_events(self, session_id: str) -> list[dict]:
|
||
rows = (
|
||
self.connection()
|
||
.execute("SELECT * FROM audit_events WHERE session_id=? ORDER BY id", (session_id,))
|
||
.fetchall()
|
||
)
|
||
return [
|
||
{
|
||
"id": row["id"],
|
||
"type": row["event_type"],
|
||
"payload": _decode(row["payload_json"]),
|
||
"createdAt": row["created_at"],
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
def _preset_task(self, session_id: str) -> str:
|
||
# Identity comes only from the persisted source session, never client/preset labels.
|
||
try:
|
||
config = self.get_session(session_id)["config"]
|
||
except (KeyError, ValueError, TypeError) as error:
|
||
raise RewardConfigError("奖励 preset 来源 session 丢失或损坏") from error
|
||
if not isinstance(config, dict):
|
||
raise RewardConfigError("奖励 preset 来源 config 必须是对象")
|
||
# Original Flat-only sessions did not require taskId. Validate their preset below.
|
||
return config.get("taskId", FLAT_TASK)
|
||
|
||
def _preset(self, row) -> dict:
|
||
task_id = self._preset_task(row["session_id"])
|
||
try:
|
||
reward_config = validate_configuration(_decode(row["reward_config_json"]), task_id)
|
||
except (ValueError, TypeError) as error:
|
||
raise RewardConfigError("奖励 preset 配置无效:" + str(error)) from error
|
||
return {
|
||
"id": row["id"],
|
||
"name": row["name"],
|
||
"sessionId": row["session_id"],
|
||
"trialId": row["trial_id"],
|
||
"taskId": task_id,
|
||
"rewardConfig": reward_config,
|
||
"createdAt": row["created_at"],
|
||
}
|
||
|
||
def save_preset(self, name: str, session_id: str, trial_id: str, reward_config: dict) -> dict:
|
||
reward_config = validate_configuration(reward_config, self._preset_task(session_id))
|
||
preset_id, at = uuid.uuid4().hex, now_iso()
|
||
self.connection().execute(
|
||
"INSERT INTO presets(id,name,session_id,trial_id,reward_config_json,created_at) "
|
||
"VALUES (?,?,?,?,?,?)",
|
||
(preset_id, name, session_id, trial_id, _json(reward_config), at),
|
||
)
|
||
return self.get_preset(preset_id)
|
||
|
||
def get_preset(self, preset_id: str) -> dict:
|
||
row = self.connection().execute("SELECT * FROM presets WHERE id=?", (preset_id,)).fetchone()
|
||
if row is None:
|
||
raise KeyError(preset_id)
|
||
return self._preset(row)
|
||
|
||
def list_presets(self) -> list[dict]:
|
||
rows = (
|
||
self.connection().execute("SELECT * FROM presets ORDER BY created_at DESC").fetchall()
|
||
)
|
||
return [self._preset(row) for row in rows]
|