feat(learning): add scenario attempt telemetry and curriculum completion rules

Evolves scenario_attempts (Phase 2B: one row per validation click) into a real attempt
lifecycle — one row per continuous working session, with live counters (validation
attempts/passes/fails, hint reveals, duration) updated in place, not re-inserted. Backward
compatible: every new column is nullable/defaulted, existing rows backfilled under the new
semantics rather than dropped. hint_count (Phase 2B, always NULL — 'not measurable') finally
gets a real DEFAULT 0 and increments, since this is the phase that builds real hint tracking.

curriculum-rules.js computes rule-driven stage lock/complete/percent/next-scenario state from
scenarios/rootnode-path.json's new per-stage completion_rule (all_required/threshold/
prerequisites) against a student's progress. The capstone stage is no longer a hardcoded
locked placeholder — it carries an unlock_rule and a pointer at the assessment definition that
actually defines its scenario draw (see the assessment-engine commit).
This commit is contained in:
2026-08-18 14:35:11 -05:00
parent e793d9476b
commit 01a862e007
4 changed files with 231 additions and 1 deletions
@@ -0,0 +1,55 @@
-- Evolves scenario_attempts (Phase 2B) from "one row per validation click" into a real attempt
-- lifecycle: one row per continuous working session on a scenario, with aggregated telemetry
-- counters, updated in place as the student works — not a new table (Phase 3B explicitly asks
-- to extend/refine rather than duplicate). See platform/api/src/attempts.js.
--
-- Backward compatible: every new column is nullable or has a DEFAULT, so Phase 2B's existing
-- rows (real historical data from testing) stay valid without being dropped or rewritten in
-- shape — the backfill UPDATE below just gives them sane values under the new semantics.
-- Phase 2B required `result` on every row (it was always written at completion time, atomically).
-- Under the new attempt lifecycle a row can exist mid-attempt (status='in_progress') with no
-- result yet — result is only ever set once the attempt reaches a terminal state.
ALTER TABLE scenario_attempts ALTER COLUMN result DROP NOT NULL;
-- hint_count (migration 005) was deliberately left NULL / undefaulted — Phase 2B's note was
-- "not measurable today." It's measurable now (this is the phase that builds real hint
-- tracking), so it needs a DEFAULT 0 for new rows to actually increment from, and existing NULL
-- rows backfilled to 0 (same "not measured" meaning either way, just now a real zero).
ALTER TABLE scenario_attempts ALTER COLUMN hint_count SET DEFAULT 0;
UPDATE scenario_attempts SET hint_count = 0 WHERE hint_count IS NULL;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS cohort_id TEXT;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS curriculum_stage TEXT;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'completed';
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS validation_attempt_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS validation_pass_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS validation_fail_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS highest_hint_level INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS assessment_session_id UUID;
DO $$ BEGIN
ALTER TABLE scenario_attempts ADD CONSTRAINT scenario_attempts_status_check
CHECK (status IN ('in_progress', 'completed', 'abandoned'));
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Backfill: Phase 2B rows were written atomically at completion time (one INSERT per validate
-- call, already carrying completed_at + result) — under the new semantics that's a completed
-- attempt with exactly one validation performed.
UPDATE scenario_attempts SET
status = 'completed',
validation_attempt_count = 1,
validation_pass_count = CASE WHEN result = 'passed' THEN 1 ELSE 0 END,
validation_fail_count = CASE WHEN result = 'failed' THEN 1 ELSE 0 END
WHERE validation_attempt_count = 0 AND completed_at IS NOT NULL;
-- "Find the open attempt for this student+scenario+environment" is the hot lookup path (every
-- hint reveal and validate call needs it) — environment-scoped so a Lab recreate doesn't
-- silently resume an attempt tied to a destroyed environment.
CREATE INDEX IF NOT EXISTS idx_attempts_open_lookup
ON scenario_attempts(student_id, scenario_id, environment_id, status);
CREATE INDEX IF NOT EXISTS idx_attempts_cohort ON scenario_attempts(cohort_id);
CREATE INDEX IF NOT EXISTS idx_attempts_assessment_session ON scenario_attempts(assessment_session_id);