All files / src/lib/assessment adaptive-assessment.ts

98.41% Statements 62/63
90.9% Branches 30/33
100% Functions 17/17
98.18% Lines 54/55

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313                                                                                                                                        9x     9x                                 57x   17x   16x   24x                 29x   18x   9x   1x   1x                         130x                                                                         260x 52x       260x     54x 270x   216x 216x 138x         52x 54x   54x 3x     51x 57x   1905x           57x     24x 8x 8x   8x       104x 49x   49x                                                                                     54x   54x 270x 216x       54x 54x   54x 12x     42x 17x   25x     25x     25x         54x               54x 54x   54x                                 59x 116x    
/**
 * Adaptive exercise familiarity assessment engine.
 *
 * Pure TypeScript — no DB or Supabase imports. All state is passed in and
 * returned explicitly; no global mutable state.
 *
 * The assessment presents exercises one at a time, escalating difficulty within
 * each domain until a boundary is found. It ends when all 5 domain boundaries
 * are found or 12 exercises have been assessed (whichever comes first).
 */
 
// ---------------------------------------------------------------------------
// Types (defined inline — no dependency on generated Supabase types so this
// file never needs type regeneration)
// ---------------------------------------------------------------------------
 
export type ExperienceLevel = 'beginner' | 'intermediate' | 'advanced';
export type ExerciseReadiness = 'confident' | 'familiar' | 'unknown';
export type ExerciseDomain =
	| 'barbell_compounds'
	| 'dumbbell'
	| 'cable_machine'
	| 'bodyweight_functional'
	| 'floor_mobility';
export type DifficultyTier = 'foundational' | 'intermediate' | 'advanced' | 'specialist';
export type EscalationStatus = 'pending' | 'boundary_found';
 
export interface DomainState {
	domain: ExerciseDomain;
	current_tier: DifficultyTier;
	escalation_status: EscalationStatus;
	/** Number of exercises assessed in this domain. Used for round-robin domain selection. */
	exercises_assessed_count: number;
}
 
export interface AssessmentExercise {
	id: string;
	name: string;
	domain: ExerciseDomain;
	difficulty_tier: DifficultyTier;
	is_custom: boolean;
}
 
/**
 * Result from selectNextExercise.
 *
 * 'exercise' — an exercise was found; present it to the athlete.
 *   autoCompletedDomains contains any domains that were auto-completed
 *   because they had no candidates at their current tier.
 *
 * 'complete' — assessment is done (all domains have a boundary or the pool
 *   of candidates is exhausted). autoCompletedDomains lists any domains
 *   auto-completed during this call.
 */
export type SelectionResult =
	| {
			kind: 'exercise';
			exercise: AssessmentExercise;
			domain: ExerciseDomain;
			autoCompletedDomains: ExerciseDomain[];
	  }
	| { kind: 'complete'; autoCompletedDomains: ExerciseDomain[] };
 
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
 
/** Maximum number of exercises that may be presented in a single assessment session. */
export const MAX_ASSESSMENT_EXERCISES = 12;
 
/** The 5 exercise domains in a stable, fixed order used for tie-breaking. */
export const ALL_DOMAINS: ExerciseDomain[] = [
	'barbell_compounds',
	'dumbbell',
	'cable_machine',
	'bodyweight_functional',
	'floor_mobility'
];
 
// ---------------------------------------------------------------------------
// Tier helpers
// ---------------------------------------------------------------------------
 
/**
 * Returns the highest difficulty tier an athlete at the given experience level
 * is allowed to reach during an assessment.
 */
export function tierCeilingFor(level: ExperienceLevel): DifficultyTier {
	switch (level) {
		case 'beginner':
			return 'foundational';
		case 'intermediate':
			return 'intermediate';
		case 'advanced':
			return 'advanced';
	}
}
 
/**
 * Returns the next difficulty tier after the given one, or null if there is no
 * higher tier (specialist is the maximum).
 */
export function nextTier(tier: DifficultyTier): DifficultyTier | null {
	switch (tier) {
		case 'foundational':
			return 'intermediate';
		case 'intermediate':
			return 'advanced';
		case 'advanced':
			return 'specialist';
		case 'specialist':
			return null;
	}
}
 
// ---------------------------------------------------------------------------
// State initialisation
// ---------------------------------------------------------------------------
 
/**
 * Returns the initial DomainState array for a fresh assessment.
 * All 5 domains start at foundational tier with pending escalation status.
 */
export function initializeDomainStates(): DomainState[] {
	return ALL_DOMAINS.map((domain) => ({
		domain,
		current_tier: 'foundational',
		escalation_status: 'pending',
		exercises_assessed_count: 0
	}));
}
 
// ---------------------------------------------------------------------------
// Exercise selection
// ---------------------------------------------------------------------------
 
/**
 * Selects the next exercise to present to the athlete.
 *
 * Domain selection heuristic: "fewest assessed exercises among pending domains".
 * Ties are broken by the fixed domain order in ALL_DOMAINS (naturally
 * round-robins across domains without requiring extra state).
 *
 * Exercise selection within a domain+tier: alphabetical by name, excluding
 * already-assessed exercises and custom exercises. Fully deterministic.
 *
 * IMPORTANT: When a domain has no candidates at its current tier, it is
 * auto-completed (boundary_found). This can cascade across multiple domains
 * in a single call. All auto-completed domains are reported in
 * SelectionResult.autoCompletedDomains so the caller can update persistent
 * state (DB or local). This function does NOT mutate its inputs.
 */
export function selectNextExercise(
	domainStates: DomainState[],
	assessedExerciseIds: Set<string>,
	exercises: AssessmentExercise[],
	// eslint-disable-next-line @typescript-eslint/no-unused-vars
	experienceLevel: ExperienceLevel
): SelectionResult {
	// Build a mutable working copy of the pending domains so we can track
	// auto-completions without mutating the caller's array.
	const workingStates = domainStates.map((s) => ({ ...s }));
	const autoCompletedDomains: ExerciseDomain[] = [];
 
	// Sort pending domains by exercises_assessed_count ascending, then by
	// fixed domain order for ties.
	const domainOrder = new Map(ALL_DOMAINS.map((d, i) => [d, i]));
 
	function sortedPendingDomains(): DomainState[] {
		return workingStates
			.filter((s) => s.escalation_status === 'pending')
			.sort((a, b) => {
				const countDiff = a.exercises_assessed_count - b.exercises_assessed_count;
				if (countDiff !== 0) return countDiff;
				return (domainOrder.get(a.domain) ?? 0) - (domainOrder.get(b.domain) ?? 0);
			});
	}
 
	// Keep iterating while there are pending domains, so we can cascade auto-completions.
	while (true) {
		const pending = sortedPendingDomains();
 
		if (pending.length === 0) {
			return { kind: 'complete', autoCompletedDomains };
		}
 
		for (const domainState of pending) {
			const candidates = exercises.filter(
				(e) =>
					e.domain === domainState.domain &&
					e.difficulty_tier === domainState.current_tier &&
					!e.is_custom &&
					!assessedExerciseIds.has(e.id)
			);
 
			if (candidates.length === 0) {
				// No exercises available for this domain at the current tier.
				// Auto-complete the domain and record it.
				const ws = workingStates.find((s) => s.domain === domainState.domain)!;
				ws.escalation_status = 'boundary_found';
				autoCompletedDomains.push(domainState.domain);
				// Continue to next domain in the sorted list.
				continue;
			}
 
			// Alphabetical by name — pick the first.
			candidates.sort((a, b) => a.name.localeCompare(b.name));
			const exercise = candidates[0];
 
			return {
				kind: 'exercise',
				exercise,
				domain: domainState.domain,
				autoCompletedDomains
			};
		}
 
		// All pending domains had empty tiers in this pass; they were all auto-completed.
		// Loop will now see no pending domains and return complete.
	}
}
 
// ---------------------------------------------------------------------------
// Response processing
// ---------------------------------------------------------------------------
 
/**
 * Processes an athlete's readiness response for a single exercise.
 *
 * Rules:
 * - unknown or familiar → domain boundary found (don't escalate)
 * - confident at tier ceiling for the athlete's level → boundary found
 * - confident below ceiling → escalate domain to next tier
 *
 * Returns a new state (immutable — the input arrays are never modified).
 *
 * NOTE: In the live code path, state is persisted via the
 * `record_assessment_response` DB RPC which returns the updated domain_states
 * directly. This function is used in unit tests and can also be used to
 * simulate assessment flows.
 */
export function processResponse(
	domainStates: DomainState[],
	exerciseDomain: ExerciseDomain,
	readiness: ExerciseReadiness,
	experienceLevel: ExperienceLevel,
	exercisesAssessed: number
): {
	updatedStates: DomainState[];
	exercisesAssessed: number;
	isComplete: boolean;
} {
	const ceiling = tierCeilingFor(experienceLevel);
 
	const updatedStates: DomainState[] = domainStates.map((state) => {
		if (state.domain !== exerciseDomain) {
			return { ...state };
		}
 
		// Determine new escalation status and tier for this domain.
		let newStatus: EscalationStatus = state.escalation_status;
		let newTier: DifficultyTier = state.current_tier;
 
		if (readiness === 'unknown' || readiness === 'familiar') {
			newStatus = 'boundary_found';
		} else {
			// confident
			if (state.current_tier === ceiling) {
				newStatus = 'boundary_found';
			} else {
				const next = nextTier(state.current_tier);
				// nextTier only returns null for 'specialist', which is above all ceilings.
				// If somehow we're already at specialist, treat as boundary found.
				Iif (next === null) {
					newStatus = 'boundary_found';
				} else {
					newTier = next;
				}
			}
		}
 
		return {
			...state,
			current_tier: newTier,
			escalation_status: newStatus,
			exercises_assessed_count: state.exercises_assessed_count + 1
		};
	});
 
	const newExercisesAssessed = exercisesAssessed + 1;
	const complete = isAssessmentComplete(updatedStates, newExercisesAssessed);
 
	return {
		updatedStates,
		exercisesAssessed: newExercisesAssessed,
		isComplete: complete
	};
}
 
// ---------------------------------------------------------------------------
// Completion check
// ---------------------------------------------------------------------------
 
/**
 * Returns true when the assessment should end:
 * - All 5 domains have a boundary found, OR
 * - The maximum number of exercises has been reached.
 */
export function isAssessmentComplete(domainStates: DomainState[], exercisesAssessed: number): boolean {
	if (exercisesAssessed >= MAX_ASSESSMENT_EXERCISES) return true;
	return domainStates.every((s) => s.escalation_status === 'boundary_found');
}