# Answer Grading

> **⚠ Status (2026-05-23)**: Major refactor since this doc was written.
> The grading layer is now consolidated:
>
> - **`KudosCalculator`** is the single source for kudos (one formula,
>   mode-aware; diagnostic = 0).
> - **`MaxileCascade`** is the single cascade (skill → track → field →
>   user); the "two systems" framing is gone. `MaxileService` is dead.
> - **`SessionAdvancer`** (Step 4) runs after grading on `/api/answers` —
>   decides completion + next batch + diagnostic boundary walk + writes
>   `summary_url`.
> - **Phase 1B `/api/answers`** now runs the full cascade (was always
>   returning `delta = 0`). Diagnostic mode skips the cascade by design
>   (boundary walk handles its field state).
> - The "two FIB normalizations" gotcha is gone — one FIB path
>   (normalized) is live; the looser `Question::correctness` was deleted.
> - The "two kudos formulas" gotcha is gone — `KudosService` deleted.
> - The legacy `AnswerController` + `Question::answered` are deleted.
>
> See [SYSTEM.md](SYSTEM.md) for current end-to-end flow. The mode
> behaviors + response shapes below are still accurate.

This doc covers what happens when a user submits an answer: which endpoint
they hit, how the answer is graded, what side effects fire, and what the
response looks like. There are **four answer-submission paths** in
production, each grading the same questions but with slightly different
rules, transaction shapes, and response payloads.

| Path | Endpoint | Controller | Grader | Per-tap or batch | Response shape |
|---|---|---|---|---|---|
| **Track practice** | `POST /api/tracks/{track}/answers` | `API\TrackController::postAnswers` | `AnswerProcessingService::processAnswers` → `AnswerValidationService::checkAnswer`; cascade via `Question::processProgressFor` | Batch | Track completion / partial |
| **Kiasu Path** | `POST /api/kiasu-path/submit` | `KiasuController::postAnswers` | Same `AnswerProcessingService::processAnswers` chain, with `deductLives = false` | Batch | Per-mode shape |
| **Phase 1B per-tap** | `POST /api/answers` | `API\AnswerController::store` | `AnswerGradingService` (orchestrator) → `AnswerValidationService::checkAnswer` | Per-tap (1 question) | `GradeResult` shape |
| **Diagnostic** | `POST /api/diagnostic/submit` | `DiagnosticController::submitAnswers` | `AnswerValidationService::checkAnswers` (batch) + inline IRT walk | Batch | Next-batch / completion |
| **Legacy (dead)** | `POST /test/answers` | `AnswerController::answer` (root namespace) | `Question::correctness` + `KudosService::calculateKudos` | Batch | Legacy shape |

The legacy `/test/answers` route is inside the `/* … */` comment block
in `routes/api.php:123-189`. It is **not active in production**, and
**no other route reaches `Question::correctness` or `KudosService`**.
The route's controller still exists (`app/Http/Controllers/AnswerController.php`)
but every code path it uses (`Question::correctness`,
`KudosService::calculateKudos`, `KudosService::getStreakCount`) is
dead code on every live endpoint.

> **`DiagnosticService` is also dead code.** The "hit ceiling twice OR
> floor once" algorithm at `app/Services/DiagnosticService.php:109-168`
> is referenced only by itself; no controller calls it. The active
> diagnostic uses a different rule — see Path 4 below.

---

## Question types

The `questions.type_id` column drives the grading logic:

| `type_id` | Type | Storage | Compare |
|---|---|---|---|
| 1 | MCQ (multiple choice) | `correct_answer` = int option index (0-3); `answer0..answer3` = display text | `correct_answer === selected_option` |
| 2 | FIB (fill-in-blank) | `answer0..answer3` = expected string per slot (nullable) | Slot-by-slot, normalized |

There is no type 3+ in use today.

---

## Path 1 — Track practice (`API\TrackController::postAnswers`)

This is the **canonical track-test grading path**. Used by the Flutter
app when a user completes a batch of track-practice questions.

### Request

```http
POST /api/tracks/{trackId}/answers
{
  "test": 123,
  "question_id": [456, 457, 458, ...],
  "answer": {
    "456": 2,                          # MCQ — option index
    "457": ["3", "x", null, null],     # FIB — 4-slot array
    ...
  }
}
```

### Controller — `API\TrackController::postAnswers`

`app/Http/Controllers/API/TrackController.php:176-269`

1. Validate `test`, `question_id`, `answer`.
2. Look up the test; 404 unless `test.user_id === user.id`.
3. Create or reuse an `assessment_sessions` row via the
   `getOrCreateAttemptSession` trait
   (`source = 'tracktest'`, `mode = 'learning'`).
4. Compute `$isFreeUser = !$user->unlimited`.
5. Hand off to **`AnswerProcessingService::processAnswers($test, $user,
   $questionIds, $answers, deductLives: $isFreeUser, assessmentType: 2,
   sessionId)`** (`TrackController.php:212-220`).
6. Write `attempt_ledger` rows via the `writeAttemptsToLedger` trait.
7. If free user is now out of lives, return 200-with-205-code "Out of
   lives" body via `completeTest($test, $user)`.
8. Otherwise return the per-batch response (includes test progress
   and lives info).

### Grading and side effects — `AnswerProcessingService::processAnswers`

`app/Services/AnswerProcessingService.php:25-187` — one DB transaction
across the whole batch.

For each `(question_id, answer)` pair:

1. **Grade + life deduction in one call**:
   `AnswerValidationService::checkAnswer($questionId, $userAnswer, $user,
   $deductLives)` (`AnswerProcessingService.php:43-48`). When
   `$deductLives = true` (free users), a wrong answer deducts a life
   inside this call (`AnswerValidationService.php:71-81`).
2. **Compute kudos**: returned from the validator as
   `(difficulty_id ?? 0) + 1` when correct, else `1`
   (`AnswerValidationService.php:64-66`). The kudos is **not awarded
   per-answer here** — it's accumulated into `$totalKudosEarned`.
3. **Upsert `question_user`**:
   `question_answered = 1, correct, answered_date, attempts++,
   test_type_id = $assessmentType` (= **2** for track practice),
   `kudos += $kudos` (`AnswerProcessingService.php:90-106`).
4. **Cascade via `Question::processProgressFor`** wrapped in `try/catch`
   (`AnswerProcessingService.php:111-121`). A cascade failure is logged
   as a warning and **does NOT roll back the outer transaction** — the
   answer save is preserved even if the cascade explodes. This is
   where streaks, mastery, and the skill/track/field/user maxile chain
   update — see [[MAXILE.md#system-a]] and [[MASTERY.md]].

After the loop (`AnswerProcessingService.php:131-151`):

- Recount `question_user` rows for this test; update
  `tests.questions_answered = $currentAnswered`.
- **Bulk award kudos**:
  `$user->increment('kudos', $totalKudosEarned)` and
  `$test->increment('kudos_earned', $totalKudosEarned)` — but **skipped
  entirely if `$test->diagnostic`** (`AnswerProcessingService.php:142`).
- Return `success = true` plus per-question results, weak skills, and
  the new `questions_answered` count.

### FIB grading on this path

`AnswerValidationService::checkFillInBlankAnswer`
(`AnswerValidationService.php:131-161`) — slot-by-slot, with `trim` +
`strtolower` normalization:

```php
$normalizedCorrect = trim(strtolower($correctAnswer));
$normalizedUser    = trim(strtolower($userAns ?? ''));
if ($normalizedCorrect !== $normalizedUser) $allCorrect = false;
```

> The legacy `Question::correctness` (`Question.php:109-122`) uses a
> different, looser comparison without normalization. **That method is
> dead code** — only the unrouted root-namespace `AnswerController`
> calls it. Every live path goes through
> `AnswerValidationService::checkAnswer`, so today there is **one** FIB
> behavior in production (normalized).

### Response

Built per partial / completion logic in `TrackController::postAnswers`
(continuation past line 269). Includes test progress, lives info via
`LiveService::getLivesInfo`, and (on completion) a level-based
encouragement string.

### Lives & free-vs-premium

`TrackController::postAnswers:209` reads `$isFreeUser = !$user->unlimited`
and passes that as `deductLives` into the processing service. Premium
users (`unlimited = true`) skip life deduction entirely; lives info
is still returned to the client.

---

## Path 2 — Kiasu Path (`KiasuController::postAnswers`)

`app/Http/Controllers/KiasuController.php:166-280+`. Same grading +
cascade shape as track practice — calls
**`AnswerProcessingService::processAnswers($test, $user, $questionIds,
$answers, deductLives: false)`** (`KiasuController.php:208-214`).
Differences vs Path 1:

- **Premium gate** at `KiasuController.php:177-183`: requires
  `user.access_type === 'premium'`; 403 with `requires_premium: true`
  otherwise.
- **`deductLives` is hardcoded `false`** since Kiasu is premium-only.
- **Rolling test model**: see [[QUESTION_ASSIGNMENT.md#2-kiasu-path]].
  After a batch, the next batch is added to the same test row, not a
  new one. Test completes only when `questions_per_test` answers are
  all settled.
- **Assessment-type field**: `AnswerProcessingService` writes
  `question_user.test_type_id = $assessmentType` parameter, which the
  Kiasu controller does not override (it falls through to the default
  `1`). So Kiasu Path answers end up with `question_user.test_type_id = 1`
  — coincidentally the actual `test_types.id` for "Kiasu Path", but
  by accident rather than by design.
- **Response includes** the partner info, lives info, and (if not
  completed) the next batch via `KiasuPathService::firstOrCreateKiasuPath`.

Same MCQ / FIB grading via `AnswerValidationService`. Same
streak/mastery/maxile cascade via `processProgressFor` (wrapped in
try/catch).

---

## Path 3 — Phase 1B per-tap (`POST /api/answers`)

The new per-tap grading endpoint introduced in Phase 1B. The Flutter app
calls it after every tap (one question per request), getting authoritative
grading + lives + kudos snapshots back. Long-term, this replaces the
batch endpoints.

### Request

```http
POST /api/answers
X-Client-Version: 1.4.0+2
Idempotency-Key: <client-generated UUID>
{
  "session_id": 42,
  "question_id": 456,
  "mode": "diagnostic" | "kiasu" | "track",
  "answer": {
    "type": "mcq",
    "selected_option": 2
  }
  // OR for FIB:
  // "answer": { "type": "fib", "fields": ["3", "x", null, null] }
}
```

Validated by `StoreAnswerRequest`
(`app/Http/Requests/StoreAnswerRequest.php:40-59`):

- `mode` is enforced via `'in:diagnostic,kiasu,track'` (line 45).
  `"learning"` is **not** an accepted value, despite legacy code
  elsewhere using that string.
- `answer.selected_option` (MCQ) is `between:0,4` (line 50) — allows
  five values (0..4 inclusive), even though `questions` has only
  `answer0..answer3`. Option 4 maps to nothing today.
- `answer.fields` (FIB) accepts up to 4 nullable strings.

The route has `idempotent` middleware so retries with the same
`Idempotency-Key` return the cached prior response — important since
this endpoint is called per tap and a tap may be retried on flaky
networks. See `app/Http/Middleware/Idempotent.php` and the BE2 ledger.

### Grading — `AnswerGradingService::grade`

`app/Services/AnswerGradingService.php:42-179`

One `DB::transaction`. Key steps:

1. **Lock the user row** (`User::lockForUpdate()`).
2. **Run lives regeneration** for any restorable lives that have come
   due during the lock.
3. **Lock attempt rows** for this `(session_id, question_id)` with
   `lockForUpdate` and count them. If count + 1 > 2, return
   `max_attempts_exceeded` (each question allows up to 2 attempts on
   the Phase 1B endpoint — attempt 1 wrong is "free", attempt 2 is
   final).
4. **Translate the canonical `{mcq|fib}` payload** to the legacy
   validator shape, then call `AnswerValidationService::checkAnswer`
   with `deductLivesOnWrong: false` (this service owns lives).
5. **Insert into `attempt_ledger`** the raw attempt record with
   skill_id, track_id, field_id, attempt_number, is_correct,
   answer_given (JSON).
6. **Lives**: deduct only if `!correct && isFinal && !unlimited`.
   Attempt-1-wrong is free.
7. **Kudos**: if correct, `(difficulty_id ?? 0) + 1` (matches the
   `AnswerValidationService::checkAnswer` formula). Awarded inline,
   not via `KudosService`. See `CHANGES.md` for the rationale.
8. **Pivot + items_recorded**: only on **final** attempt. The
   `question_user` pivot represents the settled answer; mid-stream
   attempt-1-wrong doesn't flip it. `assessment_sessions.item_count`
   increments only on final attempt too.
9. **Refresh and build the response**.

### Grading — `AnswerValidationService::checkAnswer`

`app/Services/AnswerValidationService.php:21-90`

Same MCQ logic (`(int)$correct === (int)$selected`).

FIB normalization (`checkFillInBlankAnswer`, lines 131-161) is stricter
than `Question::correctness`:

```text
for each slot 0..3:
    if correct_answer is null or '':
        skip (no expected answer)
    else:
        normalized_correct = trim(lower(correct_answer))
        normalized_user    = trim(lower(user[i] ?? ''))
        if they differ, the whole answer is wrong
```

So spaces and case don't matter on this path. `"3 "` matches `"3"`,
`"X"` matches `"x"`. **Different from Path 1** — track practice still
uses the loose `==` compare without normalization.

### Response — `GradeResult`

`app/DTOs/GradeResult.php`. Roughly:

```json
{
  "is_correct": true,
  "attempts_remaining": 0,
  "correct_answer": { "type": "mcq", "selected_option": 2 },
  "lives": {
    "current": 4,
    "max": 5,
    "unlimited": false,
    "deducted_this_attempt": false,
    "next_life_in_seconds": 1800,
    "next_life_at": "2026-05-21T15:30:00+00:00"
  },
  "kudos": { "awarded_this_attempt": 3, "user_total": 1243 },
  "maxile": {
    "delta": 0,
    "user_total_before": 312.5,
    "user_total_after": 312.5,
    "field_id": 1,
    "field_total_before": 400,
    "field_total_after": 400,
    "level_up": null
  },
  "session": {
    "id": 42,
    "mode": "track",
    "items_recorded": 5,
    "completed": false,
    "summary_url": null
  }
}
```

> **Maxile is stale on this endpoint.** Phase 1B explicitly defers
> the cascade — `delta` is always 0, `user_total_after == user_total_before`,
> `field_total_after == field_total_before`. See [[MAXILE.md#phase-1b]].

> **Session `completed` and `summary_url` are stubbed.** BE5 hasn't
> shipped — these are always `false` and `null`.

### Per-mode behavior

`mode` is one of `"diagnostic" | "kiasu" | "track"` per
`StoreAnswerRequest.php:45`. As of Phase 1B BE4, the grading
**doesn't branch on mode** — the service grades the same way and the
response includes the mode in `session.mode` for the client. Per-mode
response specifics (diagnostic field progress, kiasu cursor advance)
are explicitly out of scope for BE4; see the docblock at
`AnswerGradingService.php:31-38`.

### What it doesn't do

- **Does NOT run the maxile/mastery cascade.** No
  `Question::processProgressFor` call. Streaks, mastery flags, and
  skill_user/track_user/field_user are untouched. Maxile updates happen
  later via a separate `MaxileService::updateMaxilesFromQuestions` call
  (or, today, often not at all in this flow).
- **Does NOT use `KudosService`.** Kudos formula is inlined.
- **Does NOT update `question_user` until final attempt.** Attempt 1
  wrong leaves the pivot at the previous state.

This is intentional — the Phase 1B endpoint is for per-tap UX feedback.
The slow cascade work runs separately to keep the per-tap response
fast and deterministic.

---

## Path 4 — Diagnostic (`POST /api/diagnostic/submit`)

`app/Http/Controllers/DiagnosticController.php:132-460+`. Despite the
existence of `app/Services/DiagnosticService.php`, **no controller
calls it**. All diagnostic-submit work happens inline in this
controller. See [[QUESTION_ASSIGNMENT.md#3-diagnostic]] for the
selection side.

### Request

```http
POST /api/diagnostic/submit
{
  "session_id": 42,
  "answers": [
    { "question_id": 456, "selected_option_id": 2 },
    { "question_id": 789, "selected_option_id": 0 },
    ...
  ]
}
```

### Grading — `AnswerValidationService::checkAnswers`

`DiagnosticController.php:190-194` calls
`AnswerValidationService::checkAnswers($answersByQid, $user, false)` —
the `false` disables life deduction (diagnostic never deducts lives;
see the inline comment at `DiagnosticController.php:187-189`:
"Lives during diagnostic would corrupt IRT calibration by
incentivizing defensive guessing.").

Same MCQ branch as everywhere else:
`(int)$question->correct_answer === $selectedOption`. Diagnostic
questions are MCQ-only.

### IRT walk — boundary detection

`DiagnosticController.php:276-362`. Per `(session, field)`, maintain
`diagnostic_field_progress.level_history` — a map of `level_id → 'right'
| 'wrong'` (most recent outcome at each level wins).

```text
on each graded answer:
    find current level: highest l.start_maxile_level <= cursor
                        (start inclusive, end exclusive)
    record outcome in level_history[current_level.id]

    if correct:
        if next-up exists AND history[next-up.id] == 'wrong':
            # boundary crossed: right here, wrong above → lock here
            final_level = current.end_maxile_level
            completed   = true
        elif no next-up:
            # ceiling — true mastery in the public set
            final_level        = current.end_maxile_level
            at_system_maximum  = true
            completed          = true
        else:
            cursor = next-up.start_maxile_level   # step up

    if wrong:
        if next-down exists AND history[next-down.id] == 'right':
            # boundary crossed: wrong here, right below → lock at end of prev
            final_level = next-down.end_maxile_level
            completed   = true
        elif no next-down:
            # floor — below the assessable range
            final_level             = current.start_maxile_level
            below_assessment_range  = true
            completed               = true
        else:
            cursor = next-down.start_maxile_level   # step down
```

Adjacent levels share boundaries (level N's `end_maxile_level` ==
level N+1's `start_maxile_level`). The "highest start ≤ cursor"
selection uses start-as-inclusive / end-as-exclusive semantics
(`DiagnosticController.php:298-306`) so that stepping to a boundary
value selects the correct level rather than the lower one.

### Side effects

- **`attempt_ledger`** insert (session_id, question_id, skill_id,
  track_id, field_id, answer_given as JSON, is_correct)
  (`DiagnosticController.php:366-376`).
- **`question_user`** upsert with `test_type_id = 3`,
  `question_answered = 1`, `correct`, `answered_date = now()`,
  `attempts = 1`, `kudos = 0` (`DiagnosticController.php:379-392`).
  Diagnostic answers **do** land in `question_user` here — a difference
  from what the dead `DiagnosticService` would have done.
- **`diagnostic_field_progress`** save per field
  (`DiagnosticController.php:395-396`).
- **`field_user`** monotonic write when a field locks
  (`DiagnosticController.php:398-429`): only updates if
  `final_level > existing field_maxile` for `(user, field, current
  month)`. Replaces the deprecated `user_field_levels`.
- **Does NOT touch `skill_user` or `track_user`.** Diagnostic only
  records field-level maxile.
- **Does NOT call `Question::processProgressFor`.** No skill streaks,
  no mastery flips — diagnostic is for establishing initial ability,
  not progressing mastery.

### On all-fields-complete

After the loop, if every public field has `completed = true`, the
controller settles `assessment_sessions.{status = 'completed',
completed_at, end_maxile = avg(final_level)}` and writes
`users.maxile_level` from the field-finals average. This is one of
the few paths that updates `users.maxile_level` directly without
going through the per-field cascade in `Question::processProgressFor`.

---

## Cross-cutting: kudos, lives, and the test-completion check

These three concerns appear on multiple paths with subtle differences:

### Kudos

Two formulas live in the codebase, but **only one is live**:

1. **`(difficulty_id ?? 0) + 1`** — inline in
   `AnswerValidationService::checkAnswer` (`AnswerValidationService.php:64-66`)
   and re-implemented in `AnswerGradingService::grade`
   (`AnswerGradingService.php:127-131`). Used by every active path:
   - Path 1 track practice — via `AnswerProcessingService::processAnswers`
   - Path 2 Kiasu Path — same service
   - Path 3 Phase 1B — `AnswerGradingService` inlines the same formula
   - Path 4 diagnostic — uses `checkAnswers` but **awards zero
     kudos** (`AnswerProcessingService.php:142` skips the kudos
     increment when `$test->diagnostic`; `DiagnosticController`
     hardcodes `kudos = 0` on the pivot).

2. **`KudosService::calculateKudos`** — `correct_base +
   (difficulty_id * multiplier) + (streak bonus if enabled) + (time
   bonus if enabled)`. Configurable via `partners.php` per partner.
   **Dead code on every live path** — only the unrouted root-namespace
   `AnswerController::processSingleAnswer` (`AnswerController.php:165`)
   calls it. Same for `KudosService::getStreakCount`. The Phase 1B
   orchestrator explicitly notes this at `AnswerGradingService.php:26,
   126`.

For an Easy question (difficulty_id = 1), the live formula awards
**2**. Track-practice and Kiasu Path accumulate kudos across the batch
and increment `users.kudos` once at the end of the transaction
(`AnswerProcessingService.php:142-144`). Phase 1B increments per call.

**Implication**: the `partners.php` `kudos.*` block (`streak_bonus_enabled`,
`time_bonus_enabled`, `streak_bonus_multiplier`, etc.) is config
pointing at unreachable code. Flipping those flags has no runtime
effect today. See [[CONFIGURATION.md#7-configpartnersphp]].

### Lives

| Path | Lives deducted on wrong? | Service |
|---|---|---|
| Track practice | Yes (free users only) | `LivesService::deductLife` |
| Kiasu Path | Yes (Kiasu is premium-only, so usually unlimited) | `LivesService::deductLife` |
| Phase 1B per-tap | Only on final-attempt wrong (attempt 2) | `LiveService::deductLife` |
| Diagnostic | No | n/a |

### Test completion

| Path | Trigger | Effect |
|---|---|---|
| Track practice | All assigned questions answered | `tests.completed = true`, score computed |
| Kiasu Path | All questions in current test answered AND count >= `questions_per_test` | `tests.completed = true`, new test on next start |
| Phase 1B | (stubbed) `session.completed = false` always | None until BE5 |
| Diagnostic | All public fields settled | `assessment_sessions.status = 'completed'`, `users.maxile_level` updated |

---

## Gotchas

1. **One FIB normalization (not two).** Every live path goes through
   `AnswerValidationService::checkFillInBlankAnswer`
   (`AnswerValidationService.php:131-161`), which trims and lowercases
   per slot. The looser `Question::correctness` is dead code — only
   the unrouted legacy `AnswerController::answer` calls it. **Earlier
   versions of this doc claimed two coexisting behaviors; verified
   2026-05-21 that this is wrong.**

2. **MCQ `selected_option` validator allows 0-4.** Phase 1B's
   `StoreAnswerRequest.php:50` validates `between:0,4`, allowing five
   distinct values, but `questions` only stores `answer0..answer3`.
   Option 4 maps to no answer text. Either the validator should be
   `between:0,3` or the schema should grow another slot — flag as a
   bug.

3. **The Phase 1B endpoint doesn't update maxile or mastery.** Verified
   via grep: `AnswerGradingService::grade` does not call
   `Question::processProgressFor`, and `AnswerValidationService` has
   no cascade either. Don't build features that expect fresh maxile
   after a Phase 1B grade — it'll lie to you. See [[MAXILE.md]].

4. **Server-side grading is required at `/api/answers`.** Per
   `CLAUDE.md`: "never trust client-supplied `is_correct`." The endpoint
   grades authoritatively and returns the grade. The FE must not
   short-circuit.

5. **`AnswerProcessingService` swallows cascade failures.** A failure
   in `processProgressFor` is logged as a warning and does NOT roll
   back the outer transaction (`AnswerProcessingService.php:111-121`).
   So the answer save survives even when the maxile/mastery cascade
   fails halfway. Watch for partial state in error scenarios.

6. **`attempt_ledger` is the only durable record of attempt-1-wrong**
   on the Phase 1B path. `question_user` only sees the final attempt
   (`AnswerGradingService.php:136-149`). For analytics on first-try
   accuracy, query the ledger, not the pivot.

7. **Diagnostic submission DOES write to `question_user` on the live
   path** (`DiagnosticController.php:379-392`), with
   `test_type_id = 3`. The dead `DiagnosticService::submitAnswer`
   would have skipped this. So the daily-activity streak and other
   `question_user`-derived stats **do** see diagnostic activity today.

8. **Diagnostic kudos are zero.** `AnswerProcessingService` skips the
   kudos increment when `$test->diagnostic`
   (`AnswerProcessingService.php:142-151`), and the live
   `DiagnosticController` hardcodes `kudos = 0` on the pivot row.

9. **`DiagnosticService` is dead code.** The class still exists at
   `app/Services/DiagnosticService.php` (with the "hit ceiling twice
   OR floor once" algorithm) but **no controller calls it** — verified
   via grep (only self-references). Safe to delete; flag before doing
   so since QA tests may exercise the class.

10. **`KudosService` is dead code on every live path.** See the kudos
    section above. Same caveat — only the unrouted legacy controller
    references it.

11. **The legacy `AnswerController` (root namespace, `/test/answers`)
    still exists but its route is commented out in
    `routes/api.php:130-189`.** It's reachable only via direct
    controller wiring; production traffic shouldn't be hitting it.
    Don't add features there — if you need to extend grading, do it
    on Path 3.

See [[STREAKS.md]] for the kudos session streak inputs,
[[QUESTION_ASSIGNMENT.md]] for which questions get graded in the first
place, [[MASTERY.md]] for what `processProgressFor` does after a grade,
and [[CONFIGURATION.md]] for the lives/kudos config values.
