Implements the three approved Phase 5 architecture decisions: 1. Zone embedding boundary: add explicit documentation in StateDimensions and StateEncoder._encode_zones making the 6144-dim zero-padded zone contract visible to future maintainers. 2. SQLite WAL online learning store: new OnlineLearningStore class backed by WAL-mode SQLite for concurrent read/write on single-machine baseline. New /api/policy/decide endpoint collects decisions during inference. Stats endpoint at /api/policy/decide/stats. 3. Forge-only default weights: change ppo_weight default from 0.5 to 0.0 across scope.py, DistillationConfig, DatasetConfig, and CLI defaults. Add MIXED_MODE_PRESETS dict with forge_only, forge_90_10, forge_80_20 presets. Distill/start endpoint now accepts a "preset" parameter. 27 new tests covering all three decisions (all passing). Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
211 lines
7.3 KiB
Python
211 lines
7.3 KiB
Python
"""
|
|
Commander AI Lab — Online Learning Store (SQLite WAL)
|
|
═════════════════════════════════════════════════════
|
|
|
|
Single-machine backing store for online learning data collected during
|
|
policy inference (``/api/ml/predict`` and ``/api/policy/decide``).
|
|
|
|
Each prediction request can optionally log the state snapshot and the
|
|
chosen action so that future training runs can incorporate online
|
|
decision data alongside Forge and PPO batches.
|
|
|
|
Design decisions (Phase 5 architecture follow-up):
|
|
- SQLite in WAL mode for concurrent readers + single writer, which is
|
|
the right fit for a single-machine baseline (one API server process).
|
|
- Separate DB file (``online_learning.db``) to avoid contention with
|
|
the collection database.
|
|
- Append-only writes; reads are batched by the dataset builder.
|
|
- Thread-safe via the Python sqlite3 module's ``check_same_thread=False``
|
|
and WAL's built-in reader/writer concurrency.
|
|
|
|
Usage::
|
|
|
|
store = OnlineLearningStore() # uses default path
|
|
store.init_db()
|
|
|
|
# At prediction time:
|
|
store.record_decision(snapshot_json, action_index, confidence, playstyle)
|
|
|
|
# At training time:
|
|
decisions = store.fetch_decisions(since_rowid=last_seen, limit=10000)
|
|
store.mark_exported(max_rowid)
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
logger = logging.getLogger("ml.serving.online_store")
|
|
|
|
_DEFAULT_DB_PATH = str(
|
|
Path(__file__).parent.parent.parent / "online_learning.db"
|
|
)
|
|
|
|
|
|
class OnlineLearningStore:
|
|
"""SQLite WAL-backed store for online policy decision collection."""
|
|
|
|
def __init__(self, db_path: str = None):
|
|
self.db_path = db_path or _DEFAULT_DB_PATH
|
|
self._local = threading.local()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Connection management
|
|
# ------------------------------------------------------------------
|
|
|
|
def _get_conn(self) -> sqlite3.Connection:
|
|
"""Return a thread-local connection with WAL mode enabled."""
|
|
if not hasattr(self._local, "conn") or self._local.conn is None:
|
|
conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
|
conn.row_factory = sqlite3.Row
|
|
# Enable WAL for concurrent read/write on single machine.
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
self._local.conn = conn
|
|
return self._local.conn
|
|
|
|
def close(self) -> None:
|
|
"""Close the thread-local connection."""
|
|
conn = getattr(self._local, "conn", None)
|
|
if conn is not None:
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
self._local.conn = None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Schema
|
|
# ------------------------------------------------------------------
|
|
|
|
def init_db(self) -> None:
|
|
"""Create the decisions table if it doesn't exist."""
|
|
conn = self._get_conn()
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS online_decisions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
snapshot TEXT NOT NULL,
|
|
action_idx INTEGER NOT NULL,
|
|
confidence REAL NOT NULL DEFAULT 0.0,
|
|
playstyle TEXT NOT NULL DEFAULT 'midrange',
|
|
temperature REAL NOT NULL DEFAULT 1.0,
|
|
greedy INTEGER NOT NULL DEFAULT 0,
|
|
exported INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_od_exported
|
|
ON online_decisions(exported);
|
|
CREATE INDEX IF NOT EXISTS idx_od_created
|
|
ON online_decisions(created_at);
|
|
""")
|
|
conn.commit()
|
|
logger.info("Online learning store ready: %s (WAL mode)", self.db_path)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Write path (called during prediction)
|
|
# ------------------------------------------------------------------
|
|
|
|
def record_decision(
|
|
self,
|
|
snapshot: Dict,
|
|
action_idx: int,
|
|
confidence: float,
|
|
playstyle: str = "midrange",
|
|
temperature: float = 1.0,
|
|
greedy: bool = False,
|
|
) -> int:
|
|
"""Append a policy decision for future training.
|
|
|
|
Returns the row id of the inserted record.
|
|
"""
|
|
conn = self._get_conn()
|
|
cur = conn.execute(
|
|
"""
|
|
INSERT INTO online_decisions
|
|
(snapshot, action_idx, confidence, playstyle, temperature, greedy)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
json.dumps(snapshot, separators=(",", ":")),
|
|
action_idx,
|
|
confidence,
|
|
playstyle,
|
|
temperature,
|
|
1 if greedy else 0,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return cur.lastrowid
|
|
|
|
# ------------------------------------------------------------------
|
|
# Read path (called by dataset builder / training pipeline)
|
|
# ------------------------------------------------------------------
|
|
|
|
def fetch_decisions(
|
|
self,
|
|
since_rowid: int = 0,
|
|
limit: int = 50000,
|
|
only_unexported: bool = True,
|
|
) -> List[Dict]:
|
|
"""Fetch collected decisions for training.
|
|
|
|
Args:
|
|
since_rowid: Only return rows with id > this value.
|
|
limit: Max rows to return.
|
|
only_unexported: If True, skip rows already marked exported.
|
|
|
|
Returns:
|
|
List of dicts with keys: id, snapshot (parsed), action_idx,
|
|
confidence, playstyle, temperature, greedy, created_at.
|
|
"""
|
|
conn = self._get_conn()
|
|
clauses = ["id > ?"]
|
|
params: list = [since_rowid]
|
|
if only_unexported:
|
|
clauses.append("exported = 0")
|
|
where = " AND ".join(clauses)
|
|
rows = conn.execute(
|
|
f"SELECT * FROM online_decisions WHERE {where} ORDER BY id LIMIT ?",
|
|
(*params, limit),
|
|
).fetchall()
|
|
results = []
|
|
for row in rows:
|
|
d = dict(row)
|
|
try:
|
|
d["snapshot"] = json.loads(d["snapshot"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
results.append(d)
|
|
return results
|
|
|
|
def mark_exported(self, up_to_rowid: int) -> int:
|
|
"""Mark decisions as exported so they aren't fetched again.
|
|
|
|
Returns the number of rows updated.
|
|
"""
|
|
conn = self._get_conn()
|
|
cur = conn.execute(
|
|
"UPDATE online_decisions SET exported = 1 WHERE id <= ? AND exported = 0",
|
|
(up_to_rowid,),
|
|
)
|
|
conn.commit()
|
|
return cur.rowcount
|
|
|
|
def count(self, only_unexported: bool = False) -> int:
|
|
"""Return the total number of stored decisions."""
|
|
conn = self._get_conn()
|
|
if only_unexported:
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) FROM online_decisions WHERE exported = 0"
|
|
).fetchone()
|
|
else:
|
|
row = conn.execute(
|
|
"SELECT COUNT(*) FROM online_decisions"
|
|
).fetchone()
|
|
return row[0]
|