chane35's picture
PERMANENCE: reversibility-aware RL environment for training LLM agents
796da7c verified
Raw
History Blame
12.6 kB
"""
permanence.world.db β€” mock SQL database with transactional reversibility.
This module simulates the operational semantics of DDL and DML operations
that matter for reversibility prediction. It is not a SQL engine; it models:
* Tables, rows, primary keys
* Transactions with BEGIN / COMMIT / ROLLBACK
* A write-ahead log for committed changes
* Named snapshots (backups)
All state is in-memory Python. No subprocess, no network, no file I/O.
Reversibility classes encoded:
R1 ``SELECT`` β€” read-only
R2 ``INSERT``/``UPDATE``/``DELETE`` inside txn β€” rolled back trivially
R3 ``COMMIT`` of a DML txn β€” reversible via WAL replay
and/or a prior backup
R4 ``DROP TABLE`` when a backup exists β€” reversible from backup
R5 ``DROP TABLE`` with no backup, or
``TRUNCATE`` + ``COMMIT`` with no backup β€” unrecoverable
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import copy
# ─────────────────────────────────────────────────────────────────────────────
# Data model
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class Table:
name: str
primary_key: str
rows: Dict[Any, Dict[str, Any]] = field(default_factory=dict) # pk β†’ row
def n_rows(self) -> int:
return len(self.rows)
@dataclass
class TxnOp:
op: str # "insert" | "update" | "delete" | "drop" | "truncate"
table: str
before: Optional[Any]
after: Optional[Any]
@dataclass
class DBResult:
ok: bool
message: str
r_level: int
rows_affected: int = 0
# ─────────────────────────────────────────────────────────────────────────────
# The mock database
# ─────────────────────────────────────────────────────────────────────────────
class MockDatabase:
"""In-memory relational store with transactions and backups.
State layers:
* ``tables`` β€” the committed state
* ``txn_ops`` β€” operations staged in the current transaction
* ``txn_active`` β€” whether BEGIN has been issued
* ``wal`` β€” ordered list of committed TxnOp batches
* ``backups`` β€” named snapshots of the full ``tables`` map
Reversibility is derived from whether a prior state is still reachable
β€” via ROLLBACK (uncommitted), via WAL replay (committed), or via a
backup (DROP/TRUNCATE recovery).
"""
def __init__(self) -> None:
self.tables: Dict[str, Table] = {}
self.txn_ops: List[TxnOp] = []
self.txn_active: bool = False
self.wal: List[List[TxnOp]] = []
self.backups: Dict[str, Dict[str, Table]] = {}
# ─── Helpers ──────────────────────────────────────────────────────────
def _require_table(self, name: str) -> Optional[Table]:
return self.tables.get(name)
def _record_op(self, op: TxnOp) -> None:
if self.txn_active:
self.txn_ops.append(op)
else:
# Implicit autocommit: single op goes straight to WAL
self.wal.append([op])
def _backup_contains_table(self, name: str) -> bool:
return any(name in snap for snap in self.backups.values())
# ─── DDL ──────────────────────────────────────────────────────────────
def create_table(self, name: str, primary_key: str) -> DBResult:
if name in self.tables:
return DBResult(False, f"table exists: {name}", r_level=1)
self.tables[name] = Table(name=name, primary_key=primary_key)
return DBResult(True, f"created table {name}", r_level=2)
def drop_table(self, name: str) -> DBResult:
t = self._require_table(name)
if t is None:
return DBResult(False, f"no such table: {name}", r_level=1)
before = copy.deepcopy(t)
del self.tables[name]
op = TxnOp(op="drop", table=name, before=before, after=None)
self._record_op(op)
# DROP cannot be rolled back inside most SQL engines, but we model
# recovery via backup: R4 if a snapshot contains it, R5 otherwise.
r = 4 if self._backup_contains_table(name) else 5
return DBResult(
True,
f"dropped {name} ({t.n_rows()} rows, {'recoverable from backup' if r == 4 else 'UNRECOVERABLE'})",
r_level=r,
rows_affected=t.n_rows(),
)
def truncate(self, name: str) -> DBResult:
t = self._require_table(name)
if t is None:
return DBResult(False, f"no such table: {name}", r_level=1)
n = t.n_rows()
before = copy.deepcopy(t.rows)
t.rows = {}
op = TxnOp(op="truncate", table=name, before=before, after=None)
if self.txn_active:
self.txn_ops.append(op)
return DBResult(True, f"truncated {name} (uncommitted)", r_level=2, rows_affected=n)
# Auto-committed truncate: recovery depends on backup
self.wal.append([op])
r = 4 if self._backup_contains_table(name) else 5
return DBResult(
True,
f"truncated {name} ({'backed up' if r == 4 else 'UNRECOVERABLE'})",
r_level=r,
rows_affected=n,
)
# ─── DML ──────────────────────────────────────────────────────────────
def insert(self, table: str, row: Dict[str, Any]) -> DBResult:
t = self._require_table(table)
if t is None:
return DBResult(False, f"no such table: {table}", r_level=1)
pk = row.get(t.primary_key)
if pk is None:
return DBResult(False, f"missing primary key {t.primary_key}", r_level=1)
if pk in t.rows:
return DBResult(False, f"duplicate pk: {pk}", r_level=1)
t.rows[pk] = dict(row)
self._record_op(TxnOp(op="insert", table=table, before=None, after=pk))
# Inside a txn this is R2; autocommitted it becomes R3 (reversible
# via WAL replay to a snapshot, but not trivially).
r = 2 if self.txn_active else 3
return DBResult(True, f"inserted 1 into {table}", r_level=r, rows_affected=1)
def update(self, table: str, pk: Any, updates: Dict[str, Any]) -> DBResult:
t = self._require_table(table)
if t is None:
return DBResult(False, f"no such table: {table}", r_level=1)
if pk not in t.rows:
return DBResult(False, f"no row with pk={pk}", r_level=1)
before = copy.deepcopy(t.rows[pk])
t.rows[pk].update(updates)
self._record_op(TxnOp(op="update", table=table, before=before, after=pk))
r = 2 if self.txn_active else 3
return DBResult(True, f"updated pk={pk} in {table}", r_level=r, rows_affected=1)
def delete(self, table: str, pk: Any) -> DBResult:
t = self._require_table(table)
if t is None:
return DBResult(False, f"no such table: {table}", r_level=1)
if pk not in t.rows:
return DBResult(False, f"no row with pk={pk}", r_level=1)
before = t.rows.pop(pk)
self._record_op(TxnOp(op="delete", table=table, before=before, after=None))
r = 2 if self.txn_active else 3
return DBResult(True, f"deleted pk={pk} from {table}", r_level=r, rows_affected=1)
def select(self, table: str, pk: Optional[Any] = None) -> DBResult:
t = self._require_table(table)
if t is None:
return DBResult(False, f"no such table: {table}", r_level=1)
if pk is not None:
if pk not in t.rows:
return DBResult(False, f"no row with pk={pk}", r_level=1)
return DBResult(True, str(t.rows[pk]), r_level=1, rows_affected=1)
return DBResult(True, f"{t.n_rows()} rows", r_level=1, rows_affected=t.n_rows())
# ─── Transactions ─────────────────────────────────────────────────────
def begin(self) -> DBResult:
if self.txn_active:
return DBResult(False, "transaction already active", r_level=1)
self.txn_active = True
self.txn_ops = []
return DBResult(True, "BEGIN", r_level=1)
def commit(self) -> DBResult:
if not self.txn_active:
return DBResult(False, "no active transaction", r_level=1)
ops = self.txn_ops
self.txn_ops = []
self.txn_active = False
if ops:
self.wal.append(ops)
# Commit of DML is R3 by default (WAL replay possible but non-trivial);
# commit of a DROP/TRUNCATE escalates based on backup presence.
highest_r = 3
for op in ops:
if op.op in ("drop", "truncate"):
if not self._backup_contains_table(op.table):
highest_r = max(highest_r, 5)
else:
highest_r = max(highest_r, 4)
return DBResult(True, f"COMMIT ({len(ops)} ops)", r_level=highest_r)
def rollback(self) -> DBResult:
if not self.txn_active:
return DBResult(False, "no active transaction", r_level=1)
# Replay txn_ops in reverse to undo them on ``self.tables``.
for op in reversed(self.txn_ops):
t = self.tables.get(op.table)
if op.op == "insert" and t is not None and op.after in t.rows:
del t.rows[op.after]
elif op.op == "update" and t is not None and op.before is not None:
t.rows[op.after] = op.before
elif op.op == "delete" and t is not None and op.before is not None:
t.rows[op.before[t.primary_key]] = op.before
elif op.op == "drop" and op.before is not None:
self.tables[op.table] = op.before
elif op.op == "truncate" and op.before is not None and t is not None:
t.rows = dict(op.before)
self.txn_ops = []
self.txn_active = False
return DBResult(True, "ROLLBACK", r_level=2)
# ─── Backups ──────────────────────────────────────────────────────────
def snapshot(self, snap_id: str) -> DBResult:
self.backups[snap_id] = {
n: Table(name=n, primary_key=t.primary_key, rows=copy.deepcopy(t.rows))
for n, t in self.tables.items()
}
return DBResult(True, f"snapshot {snap_id} ({len(self.tables)} tables)", r_level=2)
def restore(self, snap_id: str) -> DBResult:
if snap_id not in self.backups:
return DBResult(False, f"no such snapshot: {snap_id}", r_level=1)
self.tables = {
n: Table(name=t.name, primary_key=t.primary_key, rows=dict(t.rows))
for n, t in self.backups[snap_id].items()
}
return DBResult(True, f"restored from {snap_id}", r_level=2)
# ─── Introspection ────────────────────────────────────────────────────
def summary(self) -> Dict[str, int]:
return {
"tables": len(self.tables),
"rows": sum(t.n_rows() for t in self.tables.values()),
"wal_entries": len(self.wal),
"backups": len(self.backups),
"txn_active": int(self.txn_active),
}