feat(milestone): merge phase/01 mastery-core → milestone/v0.3-mastery-scoring

Phase 1 complete. Mastery scoring + competency rubrics + VC issuer shipped.
9 slices, 5 waves, 238 tests passing, 13/13 REQ-IDs covered.
4/4 grill MUST conditions satisfied. VERIFY: APPROVE_WITH_NOTES.

---ci---
project: praxis
phase: 1
milestone: v0.3
status: complete
requirements:
  covered: [REQ-MAST-01, REQ-MAST-02, REQ-MAST-03, REQ-SCEN-02, REQ-SCEN-03, REQ-SCEN-04, REQ-PATH-02, REQ-NFR-MAST-01, REQ-NFR-MAST-02, REQ-NFR-VC-01, REQ-NFR-VC-02, REQ-NFR-IRT-01]
  partial: []
---/ci---
This commit is contained in:
Praxis CI
2026-08-04 00:03:13 +00:00
parent 926322960e
commit 4d39596a7d
51 changed files with 6962 additions and 215 deletions
+77
View File
@@ -0,0 +1,77 @@
-- Migration 0003 — mastery tables (SLICE-04, TASK-04-02).
-- Adds learner_ability (IRT theta persistence) + mastery_progress (path state).
CREATE TABLE IF NOT EXISTS learner_ability (
learner_id TEXT NOT NULL,
path TEXT NOT NULL,
theta REAL NOT NULL DEFAULT 0.0,
sigma_sq REAL NOT NULL DEFAULT 1.0,
observations INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (learner_id, path)
);
CREATE TABLE IF NOT EXISTS mastery_progress (
learner_id TEXT NOT NULL,
path TEXT NOT NULL,
current_week INTEGER NOT NULL DEFAULT 1,
scenarios_passed_json TEXT NOT NULL DEFAULT '[]',
mastery_score REAL NOT NULL DEFAULT 0.0,
gate_open INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (learner_id, path)
);
-- Mastery gate event audit log (SLICE-07 TASK-07-02, REQ-NFR-MAST-02).
-- One row per mastery-flow run that produced a score (scoring_inconclusive
-- runs do NOT record a gate event — they surface a retry instead).
CREATE TABLE IF NOT EXISTS mastery_gate_events (
id TEXT PRIMARY KEY,
learner_id TEXT NOT NULL,
path TEXT NOT NULL,
week INTEGER NOT NULL,
scenarios_passed_json TEXT NOT NULL DEFAULT '[]',
rubric_scores_json TEXT NOT NULL DEFAULT '[]',
mastery_score REAL NOT NULL DEFAULT 0.0,
gate_open INTEGER NOT NULL DEFAULT 0,
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_mastery_gate_events_learner
ON mastery_gate_events (learner_id, path);
-- SLICE-09 TASK-09-01 — VC issuer tables (SQLite-backed, D-042, D-043).
-- issuer_keys: Ed25519 keypairs, private key encrypted at rest (app-layer
-- SecretBox with PRAXIS_VC_ISSUER_KEY root key). status active|superseded.
CREATE TABLE IF NOT EXISTS issuer_keys (
id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
private_key_enc BLOB NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_issuer_keys_status
ON issuer_keys (status);
-- issued_credentials: one row per issued VC. status active|revoked.
CREATE TABLE IF NOT EXISTS issued_credentials (
id TEXT PRIMARY KEY,
learner_id TEXT NOT NULL,
vc_payload_json TEXT NOT NULL,
signature_b64 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
issued_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_issued_credentials_learner
ON issued_credentials (learner_id);
-- status_lists: Bitstring Status List (W3C Bitstring Status List v1.0).
-- One bitstring per list; bit i = revoked status for credential slot i.
CREATE TABLE IF NOT EXISTS status_lists (
id TEXT PRIMARY KEY,
bitstring BLOB NOT NULL,
size INTEGER NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+232
View File
@@ -181,6 +181,238 @@ class PraxisStore:
row = await cur.fetchone()
return dict(row) if row else None
async def get_ability(self, learner_id: str, path: str) -> dict | None:
"""Return the learner_ability row for (learner_id, path) or None."""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT learner_id, path, theta, sigma_sq, observations, updated_at "
"FROM learner_ability WHERE learner_id = ? AND path = ?",
(learner_id, path),
)
row = await cur.fetchone()
return dict(row) if row else None
async def upsert_ability(
self,
learner_id: str,
path: str,
theta: float,
sigma_sq: float,
observations: int,
) -> None:
"""Insert or update the learner_ability row for (learner_id, path)."""
async with self._connect() as db:
await db.execute(
"INSERT INTO learner_ability (learner_id, path, theta, sigma_sq, observations, updated_at) "
"VALUES (?, ?, ?, ?, ?, datetime('now')) "
"ON CONFLICT(learner_id, path) DO UPDATE SET "
"theta = excluded.theta, sigma_sq = excluded.sigma_sq, "
"observations = excluded.observations, updated_at = datetime('now')",
(learner_id, path, theta, sigma_sq, observations),
)
await db.commit()
async def get_progress(self, learner_id: str, path: str) -> dict | None:
"""Return the mastery_progress row for (learner_id, path) or None."""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT learner_id, path, current_week, scenarios_passed_json, "
"mastery_score, gate_open, updated_at "
"FROM mastery_progress WHERE learner_id = ? AND path = ?",
(learner_id, path),
)
row = await cur.fetchone()
return dict(row) if row else None
async def upsert_progress(
self,
learner_id: str,
path: str,
current_week: int,
scenarios_passed: list[str],
mastery_score: float,
gate_open: bool,
) -> None:
"""Insert or update the mastery_progress row for (learner_id, path)."""
gate_int = 1 if gate_open else 0
async with self._connect() as db:
await db.execute(
"INSERT INTO mastery_progress "
"(learner_id, path, current_week, scenarios_passed_json, mastery_score, gate_open, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, datetime('now')) "
"ON CONFLICT(learner_id, path) DO UPDATE SET "
"current_week = excluded.current_week, "
"scenarios_passed_json = excluded.scenarios_passed_json, "
"mastery_score = excluded.mastery_score, gate_open = excluded.gate_open, "
"updated_at = datetime('now')",
(
learner_id,
path,
current_week,
json.dumps(scenarios_passed),
mastery_score,
gate_int,
),
)
await db.commit()
async def record_gate_event(
self,
learner_id: str,
path: str,
week: int,
scenarios_passed: list[str],
rubric_scores: list[dict],
mastery_score: float,
gate_open: bool,
) -> str:
"""Append a row to the mastery_gate_events audit log; return the event id."""
event_id = f"gate-{uuid.uuid4().hex[:12]}"
gate_int = 1 if gate_open else 0
async with self._connect() as db:
await db.execute(
"INSERT INTO mastery_gate_events "
"(id, learner_id, path, week, scenarios_passed_json, rubric_scores_json, "
"mastery_score, gate_open, recorded_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))",
(
event_id,
learner_id,
path,
week,
json.dumps(scenarios_passed),
json.dumps(rubric_scores),
mastery_score,
gate_int,
),
)
await db.commit()
return event_id
async def list_gate_events(
self, learner_id: str, path: str | None = None
) -> list[dict]:
"""Query mastery_gate_events by learner (optionally by path), oldest first."""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
if path is None:
cur = await db.execute(
"SELECT * FROM mastery_gate_events WHERE learner_id = ? "
"ORDER BY recorded_at, id",
(learner_id,),
)
else:
cur = await db.execute(
"SELECT * FROM mastery_gate_events WHERE learner_id = ? AND path = ? "
"ORDER BY recorded_at, id",
(learner_id, path),
)
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def init_issuer_key(
self, key_id: str, public_key: str, private_key_enc: bytes
) -> None:
async with self._connect() as db:
await db.execute(
"INSERT INTO issuer_keys (id, public_key, private_key_enc, status) "
"VALUES (?, ?, ?, 'active')",
(key_id, public_key, private_key_enc),
)
await db.commit()
async def get_active_signing_key_row(self) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, public_key, private_key_enc, status, created_at "
"FROM issuer_keys WHERE status = 'active' ORDER BY created_at DESC LIMIT 1"
)
row = await cur.fetchone()
return dict(row) if row else None
async def get_public_key_row(self, key_id: str) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, public_key, status, created_at "
"FROM issuer_keys WHERE id = ?",
(key_id,),
)
row = await cur.fetchone()
return dict(row) if row else None
async def set_issuer_key_superseded(self, key_id: str) -> None:
async with self._connect() as db:
await db.execute(
"UPDATE issuer_keys SET status = 'superseded' WHERE id = ?",
(key_id,),
)
await db.commit()
async def insert_credential(
self,
cred_id: str,
learner_id: str,
payload_json: str,
signature_b64: str,
) -> None:
async with self._connect() as db:
await db.execute(
"INSERT INTO issued_credentials "
"(id, learner_id, vc_payload_json, signature_b64, status) "
"VALUES (?, ?, ?, ?, 'active')",
(cred_id, learner_id, payload_json, signature_b64),
)
await db.commit()
async def get_credential(self, cred_id: str) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, learner_id, vc_payload_json, signature_b64, status, issued_at "
"FROM issued_credentials WHERE id = ?",
(cred_id,),
)
row = await cur.fetchone()
return dict(row) if row else None
async def set_credential_status(self, cred_id: str, status: str) -> None:
async with self._connect() as db:
await db.execute(
"UPDATE issued_credentials SET status = ? WHERE id = ?",
(status, cred_id),
)
await db.commit()
async def get_status_list(self, list_id: str) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, bitstring, size, updated_at "
"FROM status_lists WHERE id = ?",
(list_id,),
)
row = await cur.fetchone()
return dict(row) if row else None
async def upsert_status_list(
self, list_id: str, bitstring: bytes, size: int
) -> None:
async with self._connect() as db:
await db.execute(
"INSERT INTO status_lists (id, bitstring, size, updated_at) "
"VALUES (?, ?, ?, datetime('now')) "
"ON CONFLICT(id) DO UPDATE SET "
"bitstring = excluded.bitstring, size = excluded.size, "
"updated_at = datetime('now')",
(list_id, bitstring, size),
)
await db.commit()
__all__ = [
"PraxisStore",