# AGS Math Pre-Beta: Cascade Wiring + Bug Fixes

**Goal:** Wire the existing `Question::processProgressFor()` cascade into the active answer-processing path (`AnswerProcessingService`), fix five bugs in `processProgressFor`, and migrate config reads from PHP files to the `configs` database table.

**Scope:** Tuesday beta launch. Estimated 8–10 hours including tests.

**Last updated:** 10 May 2026.

---

## Working autonomously — read first

Pam is not available to answer interim questions during this implementation. **Do not pause to ask her anything.** Make decisions, ship the work, and document choices in the PR description for her async review.

### Heuristics for ambiguity

- Two reasonable approaches → pick the simpler one, note the alternative in the PR.
- Schema/code disagrees with spec → spec wins for *new* behavior, but don't break *existing* behavior unless fixing one of the five named bugs.
- Test fails unexpectedly → investigate, fix if obvious, document and proceed if not.
- Missing precondition (e.g., a column the spec assumes) → check schema, log the gap, make the smallest change consistent with the spec.

### Don't ask, just decide

- Variable naming, code style, comment placement
- Helper extraction (do it if it improves readability)
- Log statements (yes for cascade failures and config reads)
- Type hints (add where they don't break existing signatures)
- Test coverage (yes for cascade math at minimum)

### Flag in PR description but proceed

- New assumptions made
- Unrelated bugs noticed (don't fix in this PR unless trivial)
- Deprecation candidates spotted
- Spec ambiguities encountered and how you resolved them

If you genuinely cannot proceed without input, leave a `TODO(pam)` comment at the blocked line, finish everything else, and call out blockers at the top of the PR description.

---

## Context

The cascade logic in `App\Models\Question::processProgressFor()` already implements ~80% of the spec but is not currently called by the new controllers (`KiasuController`, `App\Http\Controllers\API\TrackController`, `DiagnosticController`). This wires it in and fixes five known defects in the cascade method itself.

Reference: `mathapi/docs/algorithms/maxile-and-question-selection.md` is the canonical spec. Any divergence is a bug.

---

## Files to modify

1. `app/Services/AnswerProcessingService.php` — add cascade call
2. `app/Models/Question.php` — fix five bugs in `processProgressFor`
3. `config/app.php` — delete deprecated keys after grep verification
4. (Optional) `app/Models/Config.php`, `app/Models/Difficulty.php` — add memoized helpers

---

## Change 1 — Wire `processProgressFor` into `AnswerProcessingService`

In `app/Services/AnswerProcessingService.php`, inside the `foreach ($questionIds as $index => $questionId)` loop in `processAnswers()`, immediately after the `DB::table('question_user')->updateOrInsert(...)` block, add:

```php
// Cascade: skill_user → track_user → field_user → users.maxile_level
try {
    $question->processProgressFor($user, $isCorrect, $test);
} catch (\Throwable $e) {
    Log::warning('Cascade failed for question; answer save preserved', [
        'question_id' => $question->id,
        'user_id'     => $user->id,
        'test_id'     => $test->id,
        'error'       => $e->getMessage(),
    ]);
    // Do not rethrow — partial cascade failure must not block answer save batch.
}
```

**Why the try/catch:** the outer transaction would roll back ALL question_user writes if any single cascade call threw. The try/catch confines failure to the cascade for that one question; the outer transaction commits on the answer writes regardless.

---

## Change 2 — Five bug fixes in `Question::processProgressFor`

### Bug A — skill_maxile monotonicity violated

**Symptom:** A fail-streak drops `difficulty_passed`, which recomputes `skill_maxile` lower than its previously achieved value. Spec says skill_maxile is monotonic.

**Fix:** Read existing skill_maxile from the pivot, take the max.

Add near the top of the method, after the existing `$pivot = ...` line:

```php
$existing_skill_maxile = $pivot?->skill_maxile ?? 0;
```

Replace the existing `$skill_maxile = ...` assignment with the corrected expression in Bug B below, then add:

```php
$skill_maxile = max($existing_skill_maxile, $skill_maxile);
```

### Bug B — hardcoded 100 in maxile formula

**Symptom:** `start_maxile_level + ($difficulty_passed * 100 / $max_difficulty)` only works when level range = 100 points. Wider ranges silently break.

**Fix:** Replace the hardcoded `100` with the level's actual range.

Replace:

```php
$skill_maxile = $difficulty_passed > 0
    ? ($skill_passed
        ? $track->level->end_maxile_level
        : $track->level->start_maxile_level + ($difficulty_passed * 100 / $max_difficulty))
    : $track->level->start_maxile_level;
```

With:

```php
$level_range = $track->level->end_maxile_level - $track->level->start_maxile_level;
$skill_maxile = $difficulty_passed > 0
    ? ($skill_passed
        ? $track->level->end_maxile_level
        : $track->level->start_maxile_level + ($difficulty_passed * $level_range / $max_difficulty))
    : $track->level->start_maxile_level;
```

(Then apply the `max()` from Bug A.)

### Bug C — only first track considered, not highest level

**Symptom:** `$track = $skill->tracks()->first();` picks an arbitrary track. Spec says use the **highest level** track among those containing the skill.

**Fix:** Replace:

```php
$track = $skill->tracks()->first();
```

With:

```php
$track = $skill->tracks()
    ->join('levels', 'tracks.level_id', '=', 'levels.id')
    ->orderByDesc('levels.start_maxile_level')
    ->select('tracks.*')
    ->first();

// Re-load the level relationship since the join above selected only tracks.*
if ($track) {
    $track->load('level');
}
```

### Bug D — config source migration

**Symptom:** `config('app.difficulty_levels')`, `config('app.number_to_pass')`, `config('app.number_to_fail')` read from PHP config files; admins can't tune without redeploy. The `configs` DB table already has `no_rights_to_pass` and `no_wrongs_to_fail`, and `difficulties` row count gives tier count.

**Fix:** Replace:

```php
$max_difficulty = config('app.difficulty_levels');
$to_pass = config('app.number_to_pass');
$to_fail = config('app.number_to_fail');
```

With:

```php
$config = \App\Models\Config::first();  // request-scoped — Eloquent caches it
$max_difficulty = \App\Models\Difficulty::count();
$to_pass = $config->no_rights_to_pass;
$to_fail = $config->no_wrongs_to_fail;
```

**Optional optimization** (recommend if there's time): add memoized helpers to `Config` and `Difficulty` models so other services (`KiasuPathService`, etc.) can use them too.

```php
// In app/Models/Config.php
private static $cached;
public static function passThreshold(): int {
    return (self::$cached ??= self::first())->no_rights_to_pass;
}
public static function failThreshold(): int {
    return (self::$cached ??= self::first())->no_wrongs_to_fail;
}

// In app/Models/Difficulty.php
private static $cachedTierCount;
public static function tierCount(): int {
    return self::$cachedTierCount ??= self::count();
}
```

Then in `processProgressFor`:

```php
$max_difficulty = \App\Models\Difficulty::tierCount();
$to_pass = \App\Models\Config::passThreshold();
$to_fail = \App\Models\Config::failThreshold();
```

### Bug E — field maxile to average + monotonic

**Symptom:** Current code writes `max(track_maxile)` as `field_maxile`. Spec says average.

**Fix:** Replace:

```php
$highestTrackMaxile = $user->testedTracks()
    ->where('tracks.field_id', $field->id)
    ->wherePivot('track_maxile', '>', 0)
    ->max('track_maxile') ?? 0;
```

With:

```php
$avgTrackMaxile = $user->testedTracks()
    ->where('tracks.field_id', $field->id)
    ->wherePivot('track_maxile', '>', 0)
    ->avg('track_maxile') ?? 0;
```

Search/replace `$highestTrackMaxile` → `$avgTrackMaxile` everywhere in the method. Keep the existing `if ($avgTrackMaxile > $existing_field_maxile)` guard — that preserves field-maxile monotonicity.

Also update the return statement:

```php
return [
    'skill_maxile' => $skill_maxile,
    'track_maxile' => $track_maxile,
    'field_maxile' => max($existing_field_maxile, $avgTrackMaxile),
];
```

---

## Change 3 — Delete deprecated config keys

After Change 2 is applied, run:

```bash
grep -rn "config('app.difficulty_levels'\|config('app.number_to_pass'\|config('app.number_to_fail'" app/ config/
```

If no matches, delete those three keys from `config/app.php`.

If there ARE matches outside `processProgressFor`, leave the keys in place and log the file paths in the PR description for follow-up. Do NOT block on deciding what to do with those callers; flag and proceed.

---

## Verification

### Sanity check — fresh user smoke test

Mirror yesterday's BE5 deploy pattern. Use `php artisan tinker`.

```php
// 1. Create a fresh test user
$user = \App\Models\User::factory()->create([
    'email' => 'cascade-smoke@allgifted.com',
    'access_type' => 'premium',
]);

// 2. Trigger a kiasu/track/diagnostic flow that submits answers via
//    AnswerProcessingService — record the user_id, then check:

$skillUser = DB::table('skill_user')->where('user_id', $user->id)->get();
// Expect: rows with non-zero correct_streak/wrong_streak, difficulty_passed,
// skill_maxile, total_correct/incorrect_attempts after submitting answers.

$trackUser = DB::table('track_user')->where('user_id', $user->id)->get();
// Expect: track_maxile populated using passed-skill-ratio formula,
// track_passed flips when all skills pass.

$fieldUser = DB::table('field_user')->where('user_id', $user->id)->get();
// Expect: field_maxile populated as avg of track_maxiles in the field.

$user->refresh();
// Expect: users.maxile_level = avg of field_user.field_maxile values.
```

### Math check

For each `skill_user` row, hand-compute the expected `skill_maxile` and confirm. Take a level with range 100–300 (range = 200), `max_difficulty = 3`:

| difficulty_passed | Expected skill_maxile |
|---|---|
| 0 | 100 (level start) |
| 1 | 100 + 1×200/3 = 166.67 |
| 2 | 100 + 2×200/3 = 233.33 |
| 3 | 300 (level end, skill_passed=1) |

If a skill previously hit 233.33 then a fail-streak drops `difficulty_passed` to 1, expect `skill_maxile` to **stay at 233.33** (monotonic guard).

### Cleanup test data

After verification, follow yesterday's BE5 cleanup pattern:

```php
$user = \App\Models\User::where('email', 'cascade-smoke@allgifted.com')->first();
$uid = $user->id;

DB::table('attempt_ledger')->where('session_id', function($q) use ($uid) {
    $q->select('id')->from('assessment_sessions')->where('user_id', $uid);
})->delete();
DB::table('diagnostic_field_progress')->where('session_id', function($q) use ($uid) {
    $q->select('id')->from('assessment_sessions')->where('user_id', $uid);
})->delete();
DB::table('assessment_sessions')->where('user_id', $uid)->delete();
DB::table('question_user')->where('user_id', $uid)->delete();
DB::table('skill_user')->where('user_id', $uid)->delete();
DB::table('track_user')->where('user_id', $uid)->delete();
DB::table('field_user')->where('user_id', $uid)->delete();
DB::table('test_user')->where('user_id', $uid)->delete();
DB::table('tests')->where('user_id', $uid)->delete();
$user->delete();
```

---

## Out of scope (post-beta)

- Migrating `user_skill_levels` / `user_track_levels` / `user_field_levels` reads in `AdaptiveLevelService` and `Test::firstOrCreateDiagnostic` to `*_user` pivots.
- Adding `field_user.field_passed`, `field_user.field_passed_at`, `track_user.track_locked` columns.
- Migrating `App\Services\MaxileService` (BE5) — currently writes to deprecated `user_skill_levels`. Harmless because nothing reads those tables in the new flow, but redundant.
- Track re-test on all-skills-pass UX.
- Track lock UI.
- Unified `QuestionPickerService` to replace the three existing pickers (kiasu/track/diagnostic). Separate sprint.

---

## Don't touch

- `App\Http\Controllers\AnswerController` — deprecated, leave in place.
- `App\Services\MaxileService` — leave running. Writes to deprecated tables but doesn't interfere with `processProgressFor` writes to canonical pivots.
- `App\Services\AdaptiveLevelService` — keep using `user_*_levels`; migrate post-beta.
- `Test::firstOrCreateDiagnostic` — diagnostic flow's read of `user_field_levels` for level-anchoring on existing users; migrate post-beta.

---

## PR checklist

- [ ] `processProgressFor` call wired into `AnswerProcessingService::processAnswers` with try/catch wrapper
- [ ] Bug A: `max($existing, $computed)` guard on skill_maxile
- [ ] Bug B: hardcoded 100 → level range
- [ ] Bug C: `$track = $skill->tracks()` → highest-level track
- [ ] Bug D: `config('app.*')` → `Config::first()` + `Difficulty::count()`
- [ ] Bug E: `max(track_maxile)` → `avg(track_maxile)` (variable renamed throughout)
- [ ] Optional: `Config::passThreshold()`, `Config::failThreshold()`, `Difficulty::tierCount()` helpers added
- [ ] Grep verified, `config/app.php` deprecated keys removed (or follow-up logged)
- [ ] Smoke test passes on fresh user; hand-computed skill_maxile values match
- [ ] Test data cleaned up
- [ ] PR description includes math check from sample run
- [ ] PR description includes any decisions made under the autonomy heuristics above

---

## Decision log (locked by Pam)

1. Skill_maxile is monotonic via `max()` guard.
2. Tier maxile formula uses level range (not hardcoded 100).
3. Highest-level track is canonical when skill is in M:M.
4. Config from `configs` table + `difficulties` row count.
5. Field maxile = average of track_maxiles, monotonic guard preserved.
6. Track maxile keeps current passed-skill-ratio formula (NOT changed to spec average — better aligned with "all skills pass = end_maxile" philosophy). Spec doc updated to match.
7. Legacy `AnswerController` deprecated, no action.
8. `MaxileService` (BE5) left running; `user_skill_levels` writes harmless because nothing reads them in new flow.
9. `user_*_levels` migration deferred post-beta.
10. `field_passed` and `track_locked` columns deferred post-beta.
