# Longitudinal maxile measurement — feasibility (2026-05-28)

> Question: can we measure longitudinal gain in skill / track / field / user
> maxile? Short answer: **yes at all four levels**, but the path differs —
> some levels store history, none store skill/track history, and an
> append-only `attempt_ledger` makes the rest fully reconstructable.

## What the schema retains today (verified on prod, user_id=2)

| Level | Table | History? | What survives |
|---|---|---|---|
| Skill | `skill_user` | ❌ overwritten | 1 row / (user, skill). `skill_maxile` updated in place; only `skill_test_date` + `updated_at` remain. 85 rows for Pam = 85 skills, no trend. |
| Track | `track_user` | ❌ overwritten | 1 row / (user, track). Same. 36 rows = 36 tracks. |
| Field | `field_user` | ⚠️ monthly | Composite PK (user, field, **month_achieved**). One row per field *per calendar month of activity*. Becomes a series only as a user returns across months. Pam has 0 fields with >1 month → no trend yet. |
| User | `users.maxile_level` | ❌ overwritten | Current value only. **But** `tests.test_maxile` snapshots overall maxile at every completed test. Pam's real series: 650 (May 13) → 386 → 387 → 360 (May 27) → 286 (post inactive-field fix). |

## What does NOT help

- **`logs` table (RecordLog trait).** Fires only on Eloquent model saves. The
  live `MaxileCascade` writes via raw `DB::table()->update()`, which bypasses
  model events. No audit row is produced for cascade maxile changes. Do not
  rely on it.

## The substrate that DOES make this work: `attempt_ledger`

Append-only, written by `App\Traits\WritesAttemptLedger`, live and populated
(Pam's 2026-05-27 Kiasu attempts are in it). Each row:

```
session_id, question_id, skill_id, track_id, field_id,
is_correct, answer_given, created_at
```

Paired with `assessment_sessions` (user_id, test_id, mode, start_maxile,
started_at, completed_at, status).

Because every maxile in the system is a **pure function** of the attempt
history — `MaxileService::calculateSkillMaxile` = average of the last-N
attempts at the skill's level; track/field/user maxiles are deterministic
rollups — the entire 4-level curve at any past timestamp is **reconstructable
by replaying `attempt_ledger` in `created_at` order** through the System-B
formulas. The ledger is append-only, so unlike `skill_user`/`track_user` it is
never clobbered.

`attempt_ledger` is the preferred substrate over `question_user` because it
already carries the skill/track/field attribution per attempt (no re-derivation
of which track a skill was answered in) and is purpose-built append-only.

### Caveat: the 2026-05-24 formula switch

Maxile computation changed from the staircase state machine (System A) to the
last-N-average (System B) on 2026-05-24. A faithful replay **across** that
boundary must apply System A before the cutover and System B after. All data
from 2026-05-24 onward — i.e. essentially all launch data — replays cleanly
under a single formula.

## Options, by intended use

1. **Ad-hoc / one-off ("prove gains" number for SIMBA, marketing).**
   No schema change. Write an offline replay script over `attempt_ledger`
   (post-2026-05-24 data) that emits per-user, per-level maxile at each session
   boundary, then diff first vs latest. Fastest to a defensible figure.

2. **Student-facing progress chart (maxile trending up over time).**
   Replay-on-read is too expensive for an app screen. Add a
   `maxile_snapshots` ledger written by the cascade going forward — one row per
   cascade run (or per session-complete) capturing
   `(user_id, scope, scope_id, maxile, recorded_at)` for scope in
   {skill, track, field, user}. Cheap to query, renders directly. ~1 migration
   + a few lines in `MaxileCascade`.

3. **Parent / teacher report (per-child, per-field/skill breakdown).**
   Same `maxile_snapshots` substrate as (2), surfaced through the parent
   endpoints (`MathStateController`, already on prod but dormant) with
   per-field/skill granularity.

## Recommendation

The data side is **already solved going forward** by `attempt_ledger`; nothing
is being lost. The only decision is read-path cost:

- If the immediate need is a **number** (efficacy / pitch), do the offline
  replay — zero schema risk, available now.
- If the need is a **live in-app/portal chart**, add the `maxile_snapshots`
  write to the cascade so reads are O(1) instead of replay. Backfill the
  snapshot table once from `attempt_ledger` so historical curves aren't blank
  on day one.

Skill/track "history" being absent from `skill_user`/`track_user` is **not** a
blocker — it was never the system of record. `attempt_ledger` is.
