# IRT engine + test types

## The math (3PL model)

For a learner with ability θ and an item with parameters (a, b, c):

```
P(correct | θ) = c + (1 - c) / (1 + exp(-a · (θ - b)))
```

- `a` (discrimination, 0.5–2.0): how sharply the item separates strong from weak learners. Higher = sharper.
- `b` (difficulty, typically -3 to +3): the θ at which the learner has 50% chance of answering correctly (after subtracting guessing).
- `c` (pseudo-guessing): the floor probability from blind guessing. 0.25 for 4-option MCQ.

Implementation in `app/Services/Irt/ThreeParameterLogistic.php`.

## Ability estimation — EAP

Each session estimates θ via **Expected A Posteriori** over a 61-point Gauss-quadrature integration of a N(0,1) prior. Preferred over MLE for adaptive tests because EAP stays finite for all-correct / all-incorrect response patterns (where MLE diverges to ±∞).

Implementation in `app/Services/Irt/AbilityEstimator.php`.

## Item selection — Max Fisher Information

Each turn, pick the question (from the eligible pool) with the highest Fisher information at the current θ. Pool is filtered by:

1. `is_active = true`
2. `is_calibrated = true`
3. `status_id = Public` (drafts and archived are admin-only)
4. `show_singlish` config gate (when off, words with `is_singlish=true` are excluded)
5. `difficulty BETWEEN θ ± 1.5 logits` (band selection — falls back to wider pool if empty)
6. Not in the user's already-administered set for this session
7. Top 5 by information, picked at random for exposure control

Implementation in `app/Services/Irt/ItemSelector.php`.

## Scoring — Vocabile scale (0–1500)

Linear mapping from θ:

```
vocabile_score = round(650 + 200 · θ), clamped to [0, 1500]
```

The score band → grade level is a DB lookup against `vocabile_levels`:

| Code | Label | score_min | score_max |
|---|---|---|---|
| K | Kindergarten | 0 | 100 |
| G1 | Grade 1 | 100 | 200 |
| G2 | Grade 2 | 200 | 300 |
| … | … | … | … |
| G12 | Grade 12 | 1200 | 1300 |
| BEYOND | Beyond Grade 12 | 1300 | (null = open-ended) |

Admins can re-band by editing rows in `/admin/grade-levels`. No code change required — score-to-level resolution is `VocabileLevel::forScore($score)`.

Implementation in `app/Services/Irt/VocabileScore.php`.

## Three test types

Mirroring AGS Math (`mathapi11v2`), via the **Strategy pattern**. See `app/Services/Irt/Strategies/`.

### 1. Vocab Diagnostic (`test_type_id = 1`)
**Purpose:** measure a learner's true Vocabile score with an unbiased adaptive walk.

- **Selection:** standard Max Information from the full pool
- **Stop rule:** SE ≤ 0.30 after at least 15 items, hard max at 40 items
- **Lives:** **NOT deducted** (a placement test must not punish wrong answers, or learners avoid stretching themselves)
- **Kudos:** **0 awarded** (same reason — kudos in a diagnostic would bias the IRT walk)
- **Updates canonical score:** yes — writes `ability_estimates` and updates `users.vocabile_score`
- **Eligibility:** 30-day cooldown for non-premium users. `is_unlimited_lives=true` proxies as premium and bypasses the cooldown.

### 2. Skill Practice (`test_type_id = 2`)
**Purpose:** targeted drill on a scope the learner chooses.

- **Scope (mandatory):** one of `skill` / `pos` / `level` / `genre` — drives a JOIN that limits the pool
- **Selection priority within scope:**
  1. Untested questions (this user has never answered)
  2. Previously-wrong questions (this user got wrong before)
  3. Any in-scope question, less-exposed first
- **Stop rule:** exactly 10 items
- **Lives:** deducted on wrong answers
- **Kudos:** awarded on correct answers using the standard formula
- **Updates canonical score:** **no** — practice doesn't drag down headline score
- **Eligibility:** scope must be supplied, scope's question pool must be non-empty

### 3. Vocab Path (`test_type_id = 3`)
**Purpose:** continuous adaptive practice with no fixed end.

- **Selection:** Max Information with a **freshness penalty** — words answered in the last 14 days are deprioritised (multiplier 0.6 on their info score)
- **Stop rule:** 10 items per "round"; user can start the next round
- **Lives:** deducted on wrong answers
- **Kudos:** awarded on correct answers
- **Updates canonical score:** yes — θ drifts over time as the learner improves
- **Eligibility:** **premium only** (`is_unlimited_lives=true` for now)

## Strategy dispatch

`TestSessionService` reads `session.test_type_id`, asks `TestStrategyResolver::for($id)`, and dispatches lifecycle calls (`pickNext`, `shouldStop`, `checkEligibility`, `shouldUpdateCanonicalScore`) to the right strategy:

```php
match ($testTypeId) {
    TestType::VOCAB_DIAGNOSTIC => $this->diagnostic,
    TestType::SKILL_PRACTICE   => $this->skillPractice,
    TestType::VOCAB_PATH       => $this->vocabPath,
    default                    => $this->diagnostic,
};
```

Adding a new test type = add a row to `test_types`, write a new strategy class, add a branch in the resolver. The Flutter HomeScreen renders cards automatically from `/api/test-types` — no client change needed.

## Kudos & lives interplay

- **Kudos formula** = `(word.difficulty.rank ?? 0) + 1` for correct answers (1–7 kudos), `0` for wrong, **`0` always in Diagnostic**. Mirrors AGS Math's single-axis approach.
- **Lives**: -1 per wrong answer, regen 1 per 20 minutes, capped at `max_lives`. Premium = unlimited. **Suppressed in Diagnostic.**

Both write to immutable ledgers (`kudo_events`, `life_events`) for audit + cross-product unification. See [architecture.md](architecture.md).
