# Mastery and Regression

> **⚠ Status (2026-05-23)**: The mastery state machine described here
> is still correct. The *location* moved: it now lives in
> `MaxileCascade::updateSkillUser` (called from
> `Question::processProgressFor` and from `AnswerGradingService` on
> `/api/answers`). Thresholds still come from `Config::passThreshold()`
> / `Config::failThreshold()`. Phase 0 fixes also live: draft-track
> filter, composite-PK field_user write, atomic total counters.
> Otherwise this doc is current. See [SYSTEM.md](SYSTEM.md) for the
> full updated picture.

"Mastery" in this codebase means a boolean confirmation that a user has
demonstrated competence at a given scope (skill / track / field). It's
distinct from maxile — maxile is a numeric score, mastery is a flag.

Mastery is **confirmed bottom-up** (skill is the unit of mastery,
everything above is derived) and **regressed bottom-up** (a skill can
lose mastery, which cascades). The user's overall progress is the
combination of mastery booleans and maxile numbers — they can diverge
in interesting ways.

| Scope | Mastery flag | Mastery condition | Regression possible? |
|---|---|---|---|
| Skill | `skill_user.skill_passed` | `difficulty_passed >= Difficulty::tierCount()` | Yes — `difficulty_passed` can drop |
| Track | `track_user.track_passed` | All skills in the track have `skill_passed = 1` | Yes — any skill regression breaks it |
| Field | (implicit, no flag) | All tracks in the field have `track_passed = 1`, or `field_maxile == top level end_maxile` | In principle yes; in practice `field_maxile` is monotonic so the implied flag is sticky |

---

## Skill mastery

### Confirmation

`Question::processProgressFor` (`app/Models/Question.php:323-342`):

```text
on each graded answer:
    noOfTries++
    if correct:
        correct_streak++; wrong_streak = 0
    else:
        wrong_streak++;    correct_streak = 0

    # tier upgrade
    if correct
       and question.difficulty > difficulty_passed
       and correct_streak >= Config::passThreshold():       # configs.no_rights_to_pass, default 2
        difficulty_passed = question.difficulty
        correct_streak    = 1     # counts the streak-completing answer toward next tier

    # tier downgrade  ← REGRESSION
    elif not correct
         and question.difficulty <= difficulty_passed
         and wrong_streak >= Config::failThreshold():        # configs.no_wrongs_to_fail, default 2
        difficulty_passed = max(0, difficulty_passed - 1)
        wrong_streak       = 1

    skill_passed = (difficulty_passed >= Difficulty::tierCount())
```

So skill mastery confirms when the user has worked their way up to the
top difficulty tier and the upgrade rule triggers on a top-tier question.

The default thresholds are both **2** — two consecutive corrects at a
harder tier promote you; two consecutive wrongs at or below your tier
demote you. Tunable via the `configs` row (see [[CONFIGURATION.md]]).

### Regression

The downgrade branch above is the regression mechanism. Key properties:

- **Requires `difficulty <= difficulty_passed`.** If the user answers a
  question harder than their current tier wrong, it doesn't count against
  them — they weren't claiming that tier yet. Regression only happens
  when they fail at their own level or below.
- **Drops one tier at a time.** Even with a long fail streak, the user
  loses one tier per threshold-crossing event.
- **Bottoms out at 0.** `max(0, difficulty_passed - 1)` — once at tier 0,
  no further regression.
- **`wrong_streak` resets to 1** after a downgrade (just like
  `correct_streak` after an upgrade), so it takes another threshold-long
  failure run to drop again. This prevents a tantrum from collapsing the
  user to 0 in one stretch.

### Effect on skill_maxile

`skill_maxile` is computed from `difficulty_passed` (see [[MAXILE.md]])
but **enforced monotonic** at `Question.php:353`:

```text
skill_maxile = max(existing_skill_maxile, computed_value)
```

So if regression drops `difficulty_passed` from 3 → 2, the freshly
computed maxile drops too — but the stored value stays at its previous
peak. The user keeps their score even though their demonstrated
mastery has slipped. Whether this is the right behavior is a product
question, not a technical one; flag it before changing.

---

## Track mastery

### Confirmation

`Question::processProgressFor` (`app/Models/Question.php:372-377`):

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

track_passed = (passedSkills === totalSkills) and (totalSkills > 0)
```

Every skill in the track must individually have `skill_passed = 1`.
No partial credit, no threshold below 100%.

### Regression

Track regression is **automatic and synchronous**: every time
`Question::processProgressFor` runs (i.e., every answer), it re-counts
`passedSkills`. If a skill regressed earlier in this same transaction,
the count drops and `track_passed` flips back to `0`.

Track maxile follows the count too:

```text
if not track_passed:
    track_maxile = track.level.start + (passedSkills / totalSkills) * level_range
```

So track maxile is **not monotonic** — it tracks the live count of
passed skills. If a user passed 4/5 skills and then regressed on one,
their track_maxile drops from interpolated-4/5 back to interpolated-3/5.

There is no separate "track regression event" to handle — it falls out
of the recompute. The downside is there's no audit trail of when a
track was first passed; `track_test_date` updates on every answer, not
just on mastery events.

---

## Field mastery

### Confirmation

There is **no `field_passed` column**. The codebase treats field mastery
as implicit:

- **Strict interpretation**: All tracks in the field have
  `track_passed = 1`. No code path checks this today, but it would be a
  natural definition.
- **Operational interpretation**: `field_user.field_maxile` has reached
  the top level's `end_maxile_level` for the field. This is what the
  diagnostic flow effectively checks (`MaxileService::getMaxLevelForField`).

If a UI needs to display "Field X mastered," compute it from the tracks:

```sql
SELECT COUNT(*) = SUM(track_passed)
FROM track_user tu
JOIN tracks t ON t.id = tu.track_id
WHERE tu.user_id = ?
  AND t.field_id = ?
  AND t.status_id = 3;
```

### Regression

`field_user.field_maxile` is **monotonic** by design
(`Question.php:406-415`):

```text
avgTrackMaxile = avg(track_maxile > 0 for tracks in this field)

if avgTrackMaxile > existing_field_maxile:
    write field_maxile = avgTrackMaxile
# else: leave it alone
```

So field maxile cannot regress. Once a user hits a peak, it stays. This
means:

- If a user passes a track then regresses on a skill (so `track_maxile`
  drops), `avgTrackMaxile` drops too, but `field_maxile` doesn't move.
- A field's stored maxile is the **peak average track maxile ever
  achieved in that field, across months**.

The `field_user` row is keyed on `(user_id, field_id, month_achieved)`,
so each month gets its own row. The user's "current" field maxile is
`MAX(field_maxile) GROUP BY field_id` across all their months. The
monotonic guard above is per (user, field, month) — across months,
the MAX picks up the historical peak even if a later month is lower.

---

## User-level "mastery"

There is no overall mastery flag. `users.maxile_level` is the only
signal, computed as the unweighted average of `MAX(field_maxile)` across
fields where the user has any positive row (`Question.php:416-418`,
`MaxileService::calculateUserMaxile`).

Because field maxile is monotonic, `users.maxile_level` should also be
near-monotonic in practice. The only way it can drop is if a previously
positive field reverts to zero, which doesn't happen through normal
play — only through admin reset.

---

## Designing for regression — what to keep in mind

If you're adding a feature that triggers, depends on, or surfaces
regression, the following constraints are load-bearing today:

1. **Regression is a tier-step, not a wipe.** Code expecting `0` after
   regression is wrong — the user drops one tier. Use `difficulty_passed`
   reads, not `skill_passed` flips, to detect regression-in-progress.

2. **Streak counters reset to 1 after a threshold cross, not 0.** Both
   sides. So `correct_streak = 1` immediately after an upgrade can be
   misread as "freshly started" if you weren't aware. Cross-reference
   [[STREAKS.md]].

3. **`skill_maxile` is sticky; `track_maxile` is live.** Track maxile
   reflects the user's current mastery exactly; skill maxile reflects
   their historical peak. UI that conflates the two will look
   inconsistent during regression.

4. **There is no event log.** Regression is computed inline and writes
   the new state. If you need an audit trail (e.g., "user regressed on
   skill X on date Y"), add it explicitly — `skill_test_date` updates
   on every answer, not just on regression events. The `attempt_ledger`
   table (Phase 1B) captures raw attempts but doesn't surface
   regression events specifically.

5. **The new per-answer endpoint doesn't run the cascade.** As of
   Phase 1B, `/api/answers` returns a stale maxile snapshot. Mastery
   updates happen via the canonical batch path
   (`Question::processProgressFor` from
   `AnswerProcessingService::processAnswers`, called by
   `API\TrackController::postAnswers` and `KiasuController::postAnswers`).
   If a feature relies on both fresh mastery and the new endpoint,
   that gap will bite —
   see [[MAXILE.md#phase-1b-api-answers-does-not-update-maxile]] and
   [[ANSWER_GRADING.md]].

6. **Track and field "mastery" don't have their own pass thresholds.**
   They're derived. If product wants "field considered mastered at
   80% of tracks passed", that's a new column or a recompute rule —
   the current code is binary.

7. **The `MaxileService` (System B) path doesn't update
   `difficulty_passed`.** It writes to `user_skill_levels.current_level`
   (a maxile number), bypassing the mastery flag. If grading flips to
   System B exclusively, the mastery booleans will go stale unless the
   legacy update is preserved or replaced. See [[MAXILE.md#the-two-systems]].

---

## Quick reference — what changes when a single answer is graded

For a track-test answer graded via `AnswerController` →
`Question::processProgressFor`:

| Correct? | Difficulty vs tier | What changes |
|---|---|---|
| ✅ correct | difficulty > difficulty_passed and streak hits threshold | `difficulty_passed` ↑, `correct_streak = 1`, possibly `skill_passed = 1` |
| ✅ correct | difficulty ≤ difficulty_passed (or threshold not hit) | streak ↑, maxile unchanged |
| ❌ wrong | difficulty ≤ difficulty_passed and streak hits threshold | `difficulty_passed` ↓, `wrong_streak = 1`, possibly `skill_passed = 0` |
| ❌ wrong | difficulty > difficulty_passed (or threshold not hit) | `wrong_streak` ↑, no demotion |

After the skill mutation, the track and field recompute happens
unconditionally — even on a no-op skill update, `track_user` and
`field_user` are touched because `passedSkills` is recounted.

See [[STREAKS.md]] for the streak counter semantics in detail and
[[MAXILE.md]] for the maxile formulas at each scope.
