# Question Assignment

> **⚠ Status (2026-05-23)**: A new unified entry point now exists:
> `POST /api/tests/start` (mode = `track | kiasu | diagnostic`) goes
> through `TestStartOrchestrator`. It delegates to `TrackSelector`
> (track) or `FieldRoundSelector` (shared diagnostic + kiasu).
> Diagnostic gating is consolidated: plan-based `canAccessDiagnostic` +
> 30-day cooldown for free users. Kiasu's old level-walk selection is
> retired in favor of per-field cursor (`kiasu_field_progress`) with
> threshold-driven advance.
>
> Legacy endpoints (`GET /api/tracks/{id}/questions`,
> `GET /api/kiasu-path/start`, `POST /api/diagnostic/start`) are still
> live and feed the same underlying selectors. The Flutter app has not
> migrated to the new endpoint yet.
>
> Question selection now also filters `qa_status` (excludes `flagged`
> and `needs_revision`). See [SYSTEM.md](SYSTEM.md) for current truth.

This doc covers how questions get from the `questions` table onto a
user's screen. There are **three distinct selection paths**, each with
different rules, fallback chains, and target audiences:

1. **Track Practice** (the canonical "do a track" flow)
2. **Kiasu Path** (adaptive, premium feature, single rolling test)
3. **Diagnostic** (adaptive ability estimation, one question per field)

All three share a few primitives: `questions.status_id = 3` filters
public/published, `is_diagnostic` partitions diagnostic-only questions
from practice questions, and selection happens by joining questions →
skills → tracks → levels to reach a target maxile.

| Path | Service | Entry endpoint | Filters | Adaptive? |
|---|---|---|---|---|
| Track practice | `QuestionAssignmentService` | `GET /api/tracks/{track}/questions` | skill in track, `is_diagnostic = 0`, prefer unanswered | No |
| Kiasu Path | `KiasuPathService` | `GET /api/kiasu-path/start` | level matches floor(maxile/100)·100, walks up to 600 | Yes (level-based) |
| Diagnostic | `DiagnosticController` (inline) + `AdaptiveLevelService` | `POST /api/diagnostic/start`, `POST /api/diagnostic/submit` | `is_diagnostic = 1`, one per field, target level from progress | Yes (boundary-detection IRT walk) |

> **`DiagnosticService` is dead code.** The class file
> `app/Services/DiagnosticService.php` defines a different algorithm
> ("hit ceiling twice OR floor once") but no controller calls it —
> verified via grep, only self-references. The live diagnostic logic
> lives inline in `DiagnosticController` (`submitAnswers` for grading,
> `getNextQuestionBatch` for selection).

---

## 1. Track Practice — `QuestionAssignmentService`

Called when the user picks a track in the app and wants to practice.
Entry: `App\Http\Controllers\API\TrackController::getQuestions` →
`QuestionAssignmentService::getOrCreateTrackTest($userId, $trackId)`.

### Algorithm — `getOrCreateTrackTest`

`app/Services/QuestionAssignmentService.php:23-106`

1. **Resume check** — look for an existing active test for this
   `(user_id, track_id)` with uncompleted questions. If found, return
   its questions and skip the rest. (`findActiveTestWithUncompletedQuestions`)
2. **Create test** — insert a new row in `tests`:
   - `test_type_id = 2` (track practice)
   - `track_id`, `level_id` from the track
   - `diagnostic = false`, `number_of_tries_allowed = 999`,
     `test_maxile = track.level.start_maxile_level`
   - `start_available_time = now()`, `end_available_time = now() + 7d`
   - Also creates a `test_user` pivot row.
3. **Pick questions** — `findQuestionsForTrack`:
   - Pull all `question_user.question_id` the user has already answered
     (`question_answered = true`).
   - Try `questions_per_test` (default 20, from
     `Config::get('questions_per_test')`) **unanswered** questions where
     `skill_id IN track.skills`, `is_diagnostic = false`,
     `status_id = 3`, ordered `inRandomOrder()`.
   - If the unanswered pool is smaller than `questions_per_test`, fill
     the gap with **any** questions in those skills (including answered),
     same filters minus the unanswered exclusion. Random order.
4. **Assign** — `assignQuestionsToUser`:
   - Insert one `question_user` row per picked question with
     `question_answered = false`, `correct = false`, `attempts = 0`,
     `kudos = 0`, `test_type_id` from the test.
   - For each unique `skill_id`, ensure a `skill_user` row exists
     (initialize streaks at 0, see [[STREAKS.md]]).
   - Ensure a `track_user` row exists (`track_maxile =
     track.level.start_maxile_level`, `track_passed = 0`).
   - Ensure a `field_user` row for this `(user, field, current month)`
     exists with seed `field_maxile`.
   - Initialize `user_skill_levels`, `user_track_levels`,
     `user_field_levels` with `current_level = track.level_id`. **Note**:
     this stores a level **ID** not a maxile in these rows on initial
     insert — System B writes later overwrite with actual maxile values.
     See [[MAXILE.md#user_skill_levels-user_track_levels-user_field_levels]].
5. **Format** — pass each question through `AdaptiveLevelService::maxileQuestion`
   (which is mostly a passthrough; it returns `['question' => $question]`).

### Key properties

- **No adaptive filtering by difficulty.** All questions in the track's
  skills are equally eligible regardless of the user's current
  `difficulty_passed`. The adaptive logic happens at grading time, not
  selection time.
- **No repeat within a single test.** Questions already in
  `question_user` for this `test_id` are excluded.
- **Repeats across tests are allowed** as a fallback. If the user has
  worked through all unanswered questions in the track, they re-see
  questions to fill the 20.
- **`questions_per_test` is hardcoded-default 20 in `Config::get('questions_per_test', 20)`.**
  Note this is reading a Laravel config key, not the `configs` DB
  column (also named `questions_per_test`). The DB column default is
  10; the Laravel config key default is 20. Different paths use
  different defaults — see [[CONFIGURATION.md]].

---

## 2. Kiasu Path — `KiasuPathService`

The premium adaptive feature. Each user has **one rolling Kiasu Path test
at a time** (`test_type_id = "Kiasu Path"`); questions get added in
batches as the user works through them.

Entry: `GET /api/kiasu-path/start` →
`KiasuController::startKiasuPath` → `KiasuPathService::firstOrCreateKiasuPath`.

### Algorithm — `firstOrCreateKiasuPath`

`app/Services/KiasuPathService.php:17-114`

1. **Find or create** the user's incomplete Kiasu Path test.
2. **Read config** from the `configs` singleton row:
   - `kiasu_path_questions_per_batch` (default 5)
   - `questions_per_test` (default 10)
3. **Should we add questions?** If `uncompletedCount == 0` AND
   `currentQuestionCount < questionsPerTest`, fetch up to `min(remaining,
   batch)` new questions via `getKiasuPathQuestions`.
4. **Completion check.** Once the test has `questionsPerTest` questions
   AND all are answered, mark `completed = true`. Otherwise leave it
   open for the next batch.

### Question selection — `getKiasuPathQuestions`

`app/Services/KiasuPathService.php:118-298`

**Target level**: `floor(user.maxile_level / 100) * 100`. So a user at
maxile 437 targets level 400.

Three-tier fallback chain, each tier uses raw SQL:

1. **Walk up from target level to 600**, in 100-unit steps, up to 10
   attempts:
   - `WHERE l.start_maxile_level = $currentLevel`
   - `AND tu.track_passed IS NULL OR tu.track_passed = 0` (skip passed
     tracks)
   - `AND q.status_id = 3 AND q.is_diagnostic = 0`
   - `AND qu_test.question_id IS NULL` (not already in *this* test)
   - Order: `qu_count.test_count ASC, utl.current_level ASC, RAND()`
     (least-seen-first, then lower user-level-first, then random).
   - Stop when batch is full.
2. **Fallback 1** — drop the level filter, keep "any unpassed track":
   - `AND (tu.track_passed IS NULL OR tu.track_passed = 0)`
   - Same exclusions for already-in-test and least-seen ordering.
3. **Fallback 2** — anything. Drop the unpassed-track filter, just
   require `q.status_id = 3 AND q.is_diagnostic = 0` and "not already in
   this test." Last-resort to ensure the batch fills.

After selection, `assignQuestionsToTest` inserts `question_user` rows
with `test_type_id = 1` (the legacy default — not the Kiasu Path test
type ID, which is a known inconsistency in the row write).

### Key properties

- **Adaptive by level, not by difficulty.** Selection moves through
  levels (100, 200, …, 600) but doesn't filter by `questions.difficulty_id`.
- **Skips passed tracks** in the first two tiers. Once a track is
  mastered, the user doesn't get its questions again until the final
  fallback.
- **Least-seen-first ordering** for repeat suppression. Questions the
  user has been served fewer times across all tests get priority.
- **Maxile target updates implicitly**: as the user's `maxile_level`
  rises, the target floor rises, pulling them to higher-level questions
  on the next batch.
- **One test per user.** The whole Kiasu Path is one rolling `tests` row
  that gets completed and a new one started; not a series of small tests.

---

## 3. Diagnostic — inline in `DiagnosticController`

Adaptive ability estimation. The user gets **one question per field**
per round, and per-field progress drives both the next question's
difficulty and when that field is "settled".

Entry: `POST /api/diagnostic/start` →
`DiagnosticController::start` (creates an `assessment_sessions` row), then
`POST /api/diagnostic/submit` → `DiagnosticController::submitAnswers`.

> **Verified 2026-05-21**: `DiagnosticService` is referenced only by
> itself (grep). All diagnostic logic lives inline in
> `DiagnosticController`. The service-class algorithm
> ("hit ceiling twice OR floor once") is NOT the rule that actually runs.

### Grading — `AnswerValidationService::checkAnswers`

`DiagnosticController.php:190-194`. The submit endpoint validates the
batch, then calls `AnswerValidationService::checkAnswers($answersByQid,
$user, deductLivesOnWrong: false)`. The `false` is load-bearing — the
inline comment at `DiagnosticController.php:187-189` notes that
"lives during diagnostic would corrupt IRT calibration by incentivizing
defensive guessing."

Diagnostics are MCQ-only; the standard `correct_answer == selectedAnswer`
comparison applies.

### IRT walk — boundary detection

`DiagnosticController.php:276-362`. Per `(session, field)`, the
controller maintains `diagnostic_field_progress.level_history` (JSON
map of `level_id → 'right' | 'wrong'`; most recent outcome per level
wins). On each answer:

```text
find current level: highest l.start_maxile_level <= cursor in field
                    (start inclusive, end exclusive — boundaries belong
                     to the higher level)
record level_history[current.id] = (correct ? 'right' : 'wrong')

if correct:
    if next-up exists AND history[next-up.id] == 'wrong':
        # boundary crossed → lock at end of current
        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 → lock at end of previous level
        final_level = next-down.end_maxile_level
        completed   = true
    elif no next-down:
        # floor: below assessable range
        final_level             = current.start_maxile_level
        below_assessment_range  = true
        completed               = true
    else:
        cursor = next-down.start_maxile_level   # step down
```

So a field settles the moment the walk crosses an outcome boundary
(any "right at level N, wrong at level N+1" pair, or its mirror), or
when the walk hits the public-set ceiling/floor. **Different from the
dead `DiagnosticService` algorithm**, which used cumulative wrong
counts and could overshoot the boundary by one level.

The walk uses **level IDs** (FK) for history keys and adjacency, never
raw maxile values — robust to level-table edits that shift maxile
boundaries.

### Next-question selection — `DiagnosticController::getNextQuestionBatch`

For each incomplete field:

```sql
SELECT q.*
FROM questions q
JOIN skills s ON s.id = q.skill_id
JOIN skill_track st ON st.skill_id = s.id
JOIN tracks t ON t.id = st.track_id
JOIN levels l ON l.id = t.level_id
WHERE q.is_diagnostic = 1
  AND q.status_id = 3
  AND t.status_id = 3
  AND t.field_id = ?
  AND l.start_maxile_level = ?    -- field's current_level
  AND q.id NOT IN (already answered in this session)
ORDER BY RAND()
LIMIT 1
```

Returns one MCQ-shaped question per incomplete field.

### Side effects per answer

Inside one DB transaction:

- `attempt_ledger` row per answer
  (`DiagnosticController.php:366-376`): session_id, question_id,
  skill_id, track_id, field_id, answer_given as JSON, is_correct.
- `question_user` upsert (`DiagnosticController.php:379-392`) with
  `test_type_id = 3`, `attempts = 1`, `kudos = 0`, `correct`,
  `answered_date = now()`. **Diagnostic answers DO land in
  `question_user`** — the daily-activity streak in [[STREAKS.md]]
  does see diagnostic activity.
- `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.

### Completion

When 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. Track and skill maxiles are NOT touched.

### Key properties

- **One question per field per round.** A user with 5 public fields
  sees 5 questions per batch, then 5 more, until each field settles.
- **Field settlement is independent.** Math Field A can cement at
  level 300 after 3 questions while Field B is still walking up.
- **Diagnostic writes `users.maxile_level` directly.** After a
  diagnostic, the user's score is the avg of field finals; subsequent
  practice answers will overwrite via the cascade in [[MAXILE.md]].
- **`question_user` IS written by the diagnostic on the live path.**
  Earlier versions of this doc claimed otherwise — verified 2026-05-21
  that the live controller does write the pivot. So the
  daily-activity streak and `total_questions` count DO see diagnostic
  activity.
- **30-day cooldown for free users**
  (`HomeController::checkDiagnosticEligibility` at
  `app/Http/Controllers/HomeController.php:77-143`). Premium/trial
  users have no cooldown.

---

## Cross-cutting filters

These apply to every selection path:

| Filter | Why |
|---|---|
| `questions.status_id = 3` | Only published questions |
| `tracks.status_id = 3` | Only published tracks |
| `skills.status_id = 3` (some paths) | Only published skills |
| `questions.is_diagnostic = ?` | Partitions diagnostic-only vs practice |
| QA gate (`qa_status`) | Not currently filtered in selection — published is enough today, but `qa_status` flags (`flagged`, `needs_revision`) are advisory |

---

## What gets sent to the FE

All three paths eventually return question records to the Flutter app.
The shape differs slightly:

- **Track practice** → `formatQuestions` → wraps each as
  `{ 'question' => $question }` (the raw model serialization).
- **Kiasu Path** → assigned to `question_user`; returned via the same
  controller pattern (returns the test + questions).
- **Diagnostic** → `DiagnosticController` formats inline (the
  `formatQuestion` helper in the dead `DiagnosticService` is not the
  live shape, but the controller's version is similar):

```json
{
  "id": 123,
  "question": "...",
  "image_url": "...",
  "maxile_level": 300,
  "correct_option_id": 2,
  "field_id": 1,
  "field_name": "Numbers",
  "options": [
    {"id": 0, "text": "...", "image_url": null},
    {"id": 1, "text": "...", "image_url": null},
    {"id": 2, "text": "...", "image_url": null},
    {"id": 3, "text": "...", "image_url": null}
  ]
}
```

> **Phase 1B BE6**: `correct_option_id` will be stripped from
> Phase-1B-aware clients (`X-Client-Version` ≥ threshold) since the
> server now grades. Until then, it's still in the payload.

---

## Gotchas

1. **`questions_per_test` is read two different ways.** Track practice
   uses `Config::get('questions_per_test', 20)` (Laravel config, default
   20). Kiasu Path reads `configs.questions_per_test` (DB column,
   default 10). Same key name, different sources, different defaults.

2. **`test_type_id` on `question_user` is inconsistent.** Track practice
   sets it to the test's `test_type_id` (2). Kiasu Path's
   `assignQuestionsToTest` hardcodes `1`. If you query
   `question_user.test_type_id` for analytics, beware.

3. **Diagnostic DOES write `question_user` on the live path.**
   Verified 2026-05-21 against `DiagnosticController.php:379-392`. The
   dead `DiagnosticService` would have skipped this. The daily-activity
   streak and `subscription-status.total_questions` count include
   diagnostic activity today.

4. **Kiasu Path skips passed tracks**, but only in tiers 1-2. The final
   fallback drops that filter. So a fully-mastered user can still get
   re-served their old track questions — usually that's fine because
   they've already passed and the maxile is locked, but it's worth
   knowing for QA scenarios.

5. **Selection doesn't look at `difficulty_passed`.** Both track practice
   and Kiasu Path pick questions by track-level, not by the user's
   per-skill mastery tier (see [[MASTERY.md]]). A user with
   `difficulty_passed = 1` and one with `difficulty_passed = 3` on the
   same skill get the same question pool.

6. **`inRandomOrder()` and `RAND()` are query-time random.** No seeded
   determinism. If you need to reproduce a user's test, save the
   resulting `question_user` rows — the random draw can't be replayed.

7. **The `formatQuestion` in `AdaptiveLevelService::maxileQuestion` is
   nearly a no-op** — it returns `['question' => $question]` and was
   intended to compute a per-question maxile but never finished the
   wiring. Don't add logic relying on its return shape; check the
   actual return value before depending on it.

See [[MAXILE.md]] for the maxile math, [[ANSWER_GRADING.md]] for what
happens after a user answers, [[STREAKS.md]] for streak counters, and
[[CONFIGURATION.md]] for the config values referenced above.
