// Assessment engine — exam and capstone definitions, cohort assignment, and live sessions. // The timer and the drawn scenario set are BOTH server-authoritative: expires_at is computed // once at session start and never recomputed from a client-supplied value, and the drawn // scenario_ids live only in Postgres (assessment_session_scenarios) — a student can see them // (they need to know what to work on) but can't set or replace them. See // docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §19 for the full integrity story. const crypto = require('crypto'); // Tiny deterministic PRNG (mulberry32) — enough for a reproducible/audit-able draw without a // new dependency. Seeded from a string via a cheap hash; the seed itself is recorded on the // session for audit, not exposed to the student before the draw happens. function mulberry32(seed) { let a = seed >>> 0; return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function seedToInt(seed) { let h = 0; for (let i = 0; i < seed.length; i++) { h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0; } return h; } function shuffle(arr, rng) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } // Draws a scenario set from a pool_spec: every id in `required` is included, plus `count` // randomly-chosen ids from each `random` group's `from` list, all shuffled together into the // student's presentation order. function drawScenarios(poolSpec, seed) { const actualSeed = seed || crypto.randomBytes(8).toString('hex'); const rng = mulberry32(seedToInt(actualSeed)); const required = poolSpec.required || []; const randomPicks = (poolSpec.random || []).flatMap((group) => shuffle(group.from, rng).slice(0, group.count)); return { scenario_ids: shuffle([...required, ...randomPicks], rng), seed: actualSeed }; } async function getActiveSessionForStudent(pool, studentId) { const { rows } = await pool.query( `SELECT * FROM assessment_sessions WHERE student_id = $1 AND status IN ('not_started', 'in_progress') ORDER BY created_at DESC LIMIT 1`, [studentId] ); const session = rows[0]; if (!session) return null; return lazyExpire(pool, session); } // Lazy expiry — checked whenever a session is read or written, rather than a background cron // (no multi-host scheduler in this phase, see the Phase 3B non-goals list). A session past its // server-computed expires_at is transitioned to 'expired' on the next touch, whoever that is. async function lazyExpire(pool, session) { if (session.status === 'in_progress' && session.expires_at && new Date(session.expires_at) <= new Date()) { const { rows } = await pool.query( `UPDATE assessment_sessions SET status = 'expired', submitted_at = COALESCE(submitted_at, now()) WHERE assessment_session_id = $1 AND status = 'in_progress' RETURNING *`, [session.assessment_session_id] ); return rows[0] || session; } return session; } async function getSessionScenarios(pool, sessionId) { const { rows } = await pool.query( `SELECT * FROM assessment_session_scenarios WHERE assessment_session_id = $1 ORDER BY position`, [sessionId] ); return rows; } // Starts a new session for a student against a definition — refuses if they already have a // live (not_started/in_progress) session for ANY definition, exam or capstone (one assessment // at a time; keeps the "which Lab scenario am I working on for which purpose" question // unambiguous for both the student and the hint-policy/attempt-linking logic). async function startSession(pool, { definition, studentId, cohortId }) { const active = await getActiveSessionForStudent(pool, studentId); if (active) { const err = new Error('An assessment is already in progress.'); err.code = 'ALREADY_ACTIVE'; throw err; } const { scenario_ids, seed } = drawScenarios(definition.pool_spec, null); const startedAt = new Date(); const expiresAt = new Date(startedAt.getTime() + definition.duration_minutes * 60 * 1000); const client = await pool.connect(); try { await client.query('BEGIN'); const { rows } = await client.query( `INSERT INTO assessment_sessions (assessment_definition_id, definition_version, student_id, cohort_id, type, title, duration_minutes, hint_policy, status, started_at, expires_at, random_seed) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'in_progress',$9,$10,$11) RETURNING *`, [definition.assessment_definition_id, definition.version, studentId, cohortId || null, definition.type, definition.title, definition.duration_minutes, definition.hint_policy, startedAt, expiresAt, seed] ); const session = rows[0]; for (let i = 0; i < scenario_ids.length; i++) { await client.query( `INSERT INTO assessment_session_scenarios (assessment_session_id, scenario_id, position) VALUES ($1,$2,$3)`, [session.assessment_session_id, scenario_ids[i], i] ); } await client.query('COMMIT'); return session; } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } } // Finds the live session (if any) that has THIS scenario_id in its draw for THIS student — the // server-side signal that a validate/hint call is happening "inside" an assessment, derived // entirely from what the server itself stored at draw time. A client asserting "I'm in // assessment mode" is never trusted; this lookup is the only source of truth. async function findActiveAssessmentContextForScenario(pool, studentId, scenarioId) { const session = await getActiveSessionForStudent(pool, studentId); if (!session || session.status !== 'in_progress') return null; const { rows } = await pool.query( `SELECT * FROM assessment_session_scenarios WHERE assessment_session_id = $1 AND scenario_id = $2`, [session.assessment_session_id, scenarioId] ); if (!rows[0]) return null; return { session, sessionScenario: rows[0] }; } // Updates the per-scenario row after a scenario_attempts completion, and finalizes the whole // session once every drawn scenario is in a terminal state. Returns the finalized session row // if this call was the one that completed it, otherwise null — the caller uses that to decide // whether to record an achievement. async function recordScenarioOutcome(pool, sessionId, scenarioId, attemptId, passed) { await pool.query( `UPDATE assessment_session_scenarios SET status = $3, attempt_id = $4 WHERE assessment_session_id = $1 AND scenario_id = $2`, [sessionId, scenarioId, passed ? 'completed' : 'in_progress', attemptId] ); if (!passed) return null; // still workable — session stays open until this scenario passes or time runs out const scenarios = await getSessionScenarios(pool, sessionId); const allDone = scenarios.every((s) => s.status === 'completed'); if (!allDone) return null; const total = scenarios.length; const passedCount = scenarios.filter((s) => s.status === 'completed').length; const { rows } = await pool.query( `UPDATE assessment_sessions SET status = 'completed', submitted_at = now(), result = $2 WHERE assessment_session_id = $1 AND status = 'in_progress' RETURNING *`, [sessionId, JSON.stringify({ scenarios_total: total, scenarios_passed: passedCount, passed: passedCount === total })] ); return rows[0] || null; } async function submitSession(pool, sessionId) { const scenarios = await getSessionScenarios(pool, sessionId); const total = scenarios.length; const passedCount = scenarios.filter((s) => s.status === 'completed').length; const { rows } = await pool.query( `UPDATE assessment_sessions SET status = 'completed', submitted_at = now(), result = $2 WHERE assessment_session_id = $1 AND status = 'in_progress' RETURNING *`, [sessionId, JSON.stringify({ scenarios_total: total, scenarios_passed: passedCount, passed: passedCount === total })] ); return rows[0] || null; } module.exports = { drawScenarios, getActiveSessionForStudent, lazyExpire, getSessionScenarios, startSession, findActiveAssessmentContextForScenario, recordScenarioOutcome, submitSession, };