# Maxile in the AllGifted Math API

> **⚠ Status (2026-05-23)**: The "two competing systems" framing below
> is **outdated**. As of Phase 1, the cascade is consolidated into
> a single service: `app/Services/Maxile/MaxileCascade.php`. The
> legacy System A path (`Question::processProgressFor`) now delegates
> to it in one line, and System B (`MaxileService`) is dead code
> (no callers — safe to delete). The Phase 1B `/api/answers` endpoint
> ALSO runs the cascade now (was always returning `delta = 0`).
>
> The Phase 0 bug fixes are also live: draft-track filter, composite-PK
> `field_user` write, `total_correct_attempts` atomic increment. See
> [SYSTEM.md](SYSTEM.md) for current truth; this doc's storage layout +
> per-level interpolation math is still accurate.

Maxile is the numeric ability score that powers everything from question
selection to the dashboard banner. It exists at four nested scopes (skill →
track → field → user) and is stored on four pivot/lookup tables.

This doc explains the cascade, the storage layout, and how a single answer
propagates upward.

| Scope | Canonical storage | Updated by |
|---|---|---|
| Skill | `skill_user.skill_maxile` (legacy, monotonic) and `user_skill_levels.current_level` (new, IRT-ish) | `Question::processProgressFor` / `MaxileService::calculateSkillMaxile` |
| Track | `track_user.track_maxile` (legacy) and `user_track_levels.current_level` (new) | `Question::processProgressFor` / `MaxileService::calculateTrackMaxile` |
| Field | `field_user.field_maxile` (canonical) and `user_field_levels.current_level` (deprecated) | `Question::processProgressFor` / `MaxileService::calculateFieldMaxile` |
| User | `users.maxile_level` | `Question::processProgressFor` / `MaxileService::calculateUserMaxile` |

> **`user_field_levels` is deprecated.** Multiple files explicitly note it
> was contaminated with mixed `level_id` and maxile values. Read field
> maxile from `field_user.field_maxile` only.

---

## The two systems

### System A — legacy, mastery-driven (`Question::processProgressFor`)

Lives in `app/Models/Question.php:280-427`. Called from
`AnswerProcessingService::processAnswers`
(`app/Services/AnswerProcessingService.php:112`) for every graded
answer in a track-practice or Kiasu Path batch.

> **Verified 2026-05-21**: earlier versions of this doc named
> `AnswerController::processSingleAnswer` as the caller. That method
> exists in the unrouted legacy `app/Http/Controllers/AnswerController.php`
> and is **dead code** on every live endpoint. The live caller is
> `AnswerProcessingService`, used by both `API\TrackController` (track
> practice) and `KiasuController` (Kiasu Path).

- Maxile per skill is **interpolated from `difficulty_passed`**, the
  highest difficulty tier the user has demonstrably mastered for that
  skill. The user "owns" their mastery; the score follows from it.
- Skill maxile and field maxile are **monotonic**: never decrease, even if
  the user's recent performance gets worse. Track maxile is **not
  monotonic** — it's recomputed each call from the count of passed skills.
- Writes propagate up the chain in one transaction: skill_user →
  track_user → field_user → users.maxile_level.

### System B — new, recent-performance IRT (`MaxileService`)

Lives in `app/Services/MaxileService.php`. Called from grading paths that
use the Phase 1B per-answer endpoint and from the diagnostic flow.

- Maxile per skill is the **average maxile-equivalent of the user's last
  N attempts** at that skill (N = `configs.maxile_lookback_window`,
  default 10). Each attempt contributes a point in the level range based
  on the question's difficulty, weighted by whether the user got it right.
- **Not monotonic** at any level. A bad day genuinely lowers the score.
- The cascade is the same shape (skill → track → field → user) but the
  per-level formulas are different averages, not weighted interpolation.

Both systems write to `users.maxile_level`. The most recently graded
answer's path wins, since both paths overwrite. There's no merge logic.

---

## Schema reference

Verified against the live `api` database on 2026-05-21.

### `skill_user` pivot (System A storage)

| Column | Type | Notes |
|---|---|---|
| `skill_id`, `user_id` | int, PK composite | |
| `skill_maxile` | decimal(6,2) | Monotonic; max of existing and recomputed |
| `difficulty_passed` | int | Highest tier mastered (1..N where N = `Difficulty::tierCount()`) |
| `skill_passed` | int (0/1) | `1` iff `difficulty_passed >= tierCount()` |
| `correct_streak`, `wrong_streak` | int | Drive tier upgrade/downgrade — see [[STREAKS.md]] |
| `noOfTries`, `total_correct_attempts`, `total_incorrect_attempts` | int | Lifetime counters |
| `skill_test_date` | datetime | Last graded answer at this skill |

### `track_user` pivot (System A storage)

| Column | Type | Notes |
|---|---|---|
| `track_id`, `user_id` | int, PK composite | |
| `track_maxile` | decimal(8,2) | NOT monotonic — recomputed from passed-skills count |
| `track_passed` | int (0/1) | `1` iff every skill in the track has `skill_passed = 1` |
| `doneNess` | decimal | Completion % (= passed-skills / total-skills) |
| `track_test_date` | datetime | |

### `field_user` pivot (canonical field storage)

| Column | Type | Notes |
|---|---|---|
| `field_id`, `user_id`, `month_achieved` | composite PK | One row per (user, field, month) — historical retention |
| `field_maxile` | decimal(8,2) | Monotonic: only updated if new value > existing |
| `field_test_date` | datetime | |
| `source_session_id` | bigint | Diagnostic session that set this row (nullable) |

### `users.maxile_level`

`decimal(8,2)`. Average of MAX-per-field across all months in `field_user`.
This is what the FE displays as "overall maxile".

### `user_skill_levels` / `user_track_levels` / `user_field_levels` (System B storage)

```
user_id   PK
{skill|track|field}_id   PK
current_level  int       -- maxile value (NOT a level row's id, despite the column name)
level_source   varchar   -- 'initial' | 'practice' | 'diagnostic' | 'system'
level_determined_at      timestamp
```

`current_level` stores a **maxile number**, not a `levels.id`. The
column was named ambiguously and `user_field_levels` ended up
contaminated — see the deprecation note at the top.

### `levels` (lookup)

```
id, level (int), description, age, start_maxile_level, end_maxile_level
```

Defines the maxile ranges that each track sits inside. A track has
exactly one `level_id`; questions inherit their level via their skill's
track. `status_id = 3` means published/public — anywhere outside
admin code, filter for it.

### `difficulties` (lookup)

```
id, difficulty (int), short_description, description
```

`Difficulty::tierCount()` returns
`DB::table('difficulties')->where('status_id', 3)->max('difficulty')`.
Today that's 3 (Easy / Medium / Hard). The number drives the skill
upgrade/downgrade thresholds — see [[MASTERY.md]].

---

## System A formulas

### Skill maxile (interpolated from difficulty_passed)

From `Question::processProgressFor` lines 333-353:

```text
level_range = track.level.end_maxile_level - track.level.start_maxile_level
max_diff    = Difficulty::tierCount()

if difficulty_passed == 0:
    skill_maxile = track.level.start_maxile_level
elif skill_passed:
    skill_maxile = track.level.end_maxile_level
else:
    skill_maxile = track.level.start_maxile_level
                 + (difficulty_passed * level_range / max_diff)

skill_maxile = max(existing_skill_maxile, skill_maxile)     # monotonic
```

So skill maxile is a discrete staircase across the level's range:
0 → start, 1 → start + range/3, 2 → start + 2·range/3, 3 → end.

### Track maxile

From `Question::processProgressFor` lines 372-381:

```text
passedSkills = count(skills in this track where skill_passed = 1 for this user)
totalSkills  = total skills in this track

if passedSkills == totalSkills and totalSkills > 0:
    track_passed = true
    track_maxile = track.level.end_maxile_level
else:
    track_passed = false
    track_maxile = track.level.start_maxile_level
                 + (passedSkills / totalSkills) * level_range
```

**Not monotonic** — if a skill regresses to `skill_passed = 0` (see
[[MASTERY.md]]), the count drops and so does `track_maxile`.

### Field maxile

From `Question::processProgressFor` lines 392-415:

```text
avgTrackMaxile = avg(track_maxile across this user's tracks in this field
                     where track_maxile > 0)

if avgTrackMaxile > existing_field_maxile:
    field_user.field_maxile = avgTrackMaxile      # monotonic — only goes up
    (otherwise leave existing field_maxile untouched)
```

Note: the row keyed on `(user_id, field_id, month_achieved=YYYYMM)` — so
each month gets its own row. The "current" field maxile for a user is
`MAX(field_maxile) GROUP BY field_id`.

### User maxile

From `Question::processProgressFor` line 416:

```text
users.maxile_level = avg(field_maxile across user's positive field_user rows)
```

This is an unweighted average — every field counts equally.

---

## System B formulas

### Skill maxile — `MaxileService::calculateSkillMaxile`

`app/Services/MaxileService.php:23-70`

```text
attempts = last N answers in this skill at this level, N = configs.maxile_lookback_window
range    = level.end_maxile_level - level.start_maxile_level
max_diff = max(difficulties.difficulty) where status_id = 3

for each attempt:
    if attempt.correct:
        contribution = level.start_maxile_level + (attempt.difficulty / max_diff) * range
    else:
        contribution = level.start_maxile_level   # floor

skill_maxile = average(contributions)
             clamped to [level.start_maxile_level, level.end_maxile_level]
```

Below-3-attempts threshold: if the user has fewer than 3 attempts in this
skill at this level, return `level.start_maxile_level`. This avoids
over-confident estimates from a single hot answer.

### Track maxile — `MaxileService::calculateTrackMaxile`

`MaxileService.php:72-97` — average of `user_skill_levels.current_level`
for skills in this track (filtered to `skills.status_id = 3`), clamped to
the track's level range.

### Field maxile — `MaxileService::calculateFieldMaxile`

`MaxileService.php:99-114` — average of `user_track_levels.current_level`
for tracks in this field (filtered to `tracks.status_id = 3` and
`current_level > 0`). NOT monotonic.

### User maxile — `MaxileService::calculateUserMaxile`

`MaxileService.php:116-133` — `avg(MAX(field_maxile) per field)` read
from `field_user`. **Note: reads from `field_user`, not from
`user_field_levels`**. So even though System B owns its own per-field
table, the user-level rollup goes through System A's canonical source.

This is the only place the two systems explicitly meet.

---

## The cascade in one picture

A single answer at `POST /api/tracks/{id}/answers` (track practice) or
`POST /api/kiasu-path/submit` triggers, in order, inside one DB
transaction owned by `AnswerProcessingService::processAnswers`:

```text
                      AnswerProcessingService::processAnswers
                                   │
                                   ▼
              AnswerValidationService::checkAnswer
              ── grade the answer (MCQ direct compare,
                 FIB trim+lower per slot)
              ── deduct life if wrong & $deductLives
                                   │
                                   ▼
                  Question::processProgressFor  (SYSTEM A)
                  (wrapped in try/catch — cascade
                   failure does not roll back save)
                                   │
        ┌──────────────────────────┼──────────────────────────┐
        ▼                          ▼                          ▼
  skill_user                  track_user                 field_user
  ── correct_streak,          ── passedSkills            ── field_maxile
     wrong_streak                 counted across all         (only goes up
  ── difficulty_passed           skills in this track       if new > old)
     up/down                  ── track_maxile recomputed
  ── skill_passed             ── track_passed = all skills passed
  ── skill_maxile (max-      
     guarded, never drops)                                    │
                                                              ▼
                                                       users.maxile_level
                                                       ── avg of positive
                                                          field_maxiles
```

For the Phase 1B `/api/answers` endpoint (`AnswerGradingService::grade`),
maxile mutations are **deferred** — the response includes a
`maxile.delta = 0` placeholder. The cascade is run later, separately, via
`MaxileService::updateMaxilesFromQuestions` (see
`AnswerGradingService.php:36-38` comments). Today, that means the new
per-answer endpoint reports stale maxile until a background or
follow-up call triggers the recompute.

---

## Initial maxile — how a brand-new user gets one

When a user first answers a question for a track, `track_user`,
`skill_user`, `field_user`, and `user_track_levels` rows are inserted by
`QuestionAssignmentService::assignQuestionsToUser`
(`app/Services/QuestionAssignmentService.php:232-275`).

- `skill_user.skill_maxile = 0`, `difficulty_passed = 0`.
- `track_user.track_maxile = track.level.start_maxile_level`.
- `field_user.field_maxile = track.level.start_maxile_level`.
- `user_skill_levels.current_level = track.level_id` (note: this is a
  level **id**, not a maxile — the column name is misleading; subsequent
  System B writes overwrite it with an actual maxile).

If the user hasn't answered anything yet, `users.maxile_level = 0` (the
column default).

For users who haven't started a track but have completed a diagnostic,
the diagnostic flow seeds `field_user.field_maxile` directly — see
[[QUESTION_ASSIGNMENT.md#diagnostic]] and `DiagnosticService`.

---

## How maxile reaches the Flutter front end

Three places surface user-facing maxile:

| Endpoint | Field | Source |
|---|---|---|
| `GET /api/user/subscription-status` | `overall_maxile` (rounded) | `users.maxile_level` |
| `GET /api/user/profile` | `user.maxile_level` (float) | `users.maxile_level` |
| `POST /api/answers` (Phase 1B) | `maxile.user_total_after`, `maxile.field_total_after`, `maxile.delta` | `users.maxile_level` and `field_user.field_maxile`; **delta is currently always 0** |

There is no FE endpoint that returns per-skill or per-track maxile today.
Admin Blade views read them directly from the pivots.

---

## Default / fallback maxile

`AdaptiveLevelService` (`app/Services/AdaptiveLevelService.php`) provides
defaults used by the diagnostic and grade-onboarding flows:

- `getDefaultMaxileLevel()` — midpoint of `(MIN(start_maxile_level),
  MAX(end_maxile_level))` across public levels. Used as the initial
  estimate before a diagnostic.
- `maxileLevelFromAge($age)` / `maxileLevelFromGrade($grade)` — map a
  declared age or grade to the midpoint of the matching level row.
  Both filter to `status_id = 3` (public levels only) — without that
  clamp, a 60-year-old test user gets a 1250 maxile from a secondary
  level row and the diagnostic immediately ceilings them. See the
  inline comments at `AdaptiveLevelService.php:248-282`.

---

## Gotchas

1. **`users.maxile_level` is decimal but the FE rounds it.**
   `subscription-status` rounds to int (`round($user->maxile_level ?? 0)`
   at `HomeController.php:64`), `profile` returns a float
   (`(float) $user->maxile_level`). Don't break that contract — older
   Flutter builds may parse one and not the other.

2. **`user_field_levels` is contaminated and deprecated.** Every read in
   `MaxileService` and `AdaptiveLevelService` notes this. Some columns
   contain level IDs (1, 2, 3) and some contain maxile values (100, 300,
   700). Don't read from it for new code; use `field_user.field_maxile`.

3. **Two systems write to `users.maxile_level`.** They use different
   formulas. Whichever code path graded the most recent answer wins.
   There is no reconciliation. If you change one, decide what should
   happen when the other path runs next.

4. **Field maxile is monotonic but track maxile isn't.** A user who
   regresses on a single skill will see their `track_maxile` drop on the
   admin page, but `field_maxile` and (consequently) `users.maxile_level`
   stay at their previous peak. See [[MASTERY.md]] for the regression
   logic.

5. **Phase 1B `/api/answers` does NOT update maxile.** It returns a
   stale snapshot with `delta = 0`. The cascade happens separately
   (or not at all — Phase 2 work). Until that's wired, do not rely on
   `maxile.user_total_after` reflecting the just-graded answer.

6. **Skill maxile interpolation uses `track.level`, not the skill's
   own level.** A skill can sit in multiple tracks (many-to-many via
   `skill_track`). The maxile is computed in the context of whichever
   track-test the user is currently in. The same skill, same
   difficulty_passed, can yield different maxiles across tracks.

7. **`tierCount()` is cached for the request.** `MaxileService` and
   `Question::processProgressFor` both call it. If you change
   `difficulties.status_id` during a request, you'll get inconsistent
   computations within that request.

See [[STREAKS.md]] for the streak system, [[MASTERY.md]] for how
mastery is confirmed and how regression works, [[QUESTION_ASSIGNMENT.md]]
for how questions are chosen, [[ANSWER_GRADING.md]] for how a single
answer is graded, and [[CONFIGURATION.md]] for the configs table.
