Docs / Technical / Session engine
Exercise-session generation.
The complete reference for how an exercise session is computed and authored: the pure planner that fixes the exercise set, the exact calibration constants, the prompt it assembles, and the validation loop that enforces it. Verified against the engine source: every symbol below is a real identifier.
Source of record: ignia_core/src/sessions/grid.rs, ignia_core/src/sessions/generate.rs, ignia_core/src/sessions/compose.rs, ignia_core/src/sessions/bloom.rs. Narrative version: exercise sessions.
The pipeline.
One session generation, end to end. The planner is pure (no database, no clock read), so the plan is reproducible from the state snapshot and the session seed.
The planner runs before any provider call. A grid whose every slot is bank-covered returns without an LLM call at all.
The six stages of grid().
Signature: grid(skind, &[LpState], base_lambda, n0, salt) -> Vec<Slot>. Returns empty for session kinds the grid does not own (Recalibrate, Project, Freeform, Placement), which fall back to the free-form prompt path.
Data structures.
Input snapshot and output slot. Both in sessions/grid.rs.
LpState, assembled by build_lp_states()
| Field | Type | Source |
|---|---|---|
| lp_id | String | learning point id |
| bloom | Bloom | Bloom::from_opt(lp.bloom_level). Null falls back to Understand |
| mastery | f64 | lp.mastery |
| reps | i64 | lp.reps |
| lapses_since_success | i64 | lp.lapses_since_success |
| forecast_retention | f64 | mastery::forecast_retention(state, now) |
| is_due | bool | mastery::is_due(state, now) |
| band | MasteryLevel | mastery::level_of: New / Weak <0.5 / Developing <0.8 / Strong |
| misconception | bool | mastery::open_misconception_phrases, one scan for the whole target set. The same scan supplies the recorded phrases to append_grid_plan, so the flag and its evidence cannot disagree |
| transfer_passed | bool | lp.transfer_passed. Monotone, set on a transfer-probe success |
Slot, one exercise the AI must author
| Field | Meaning | Reaches the prompt? |
|---|---|---|
| lp_id | Target learning point; resolved to its label for the prompt | yes |
| kind | The grid's preferred kind; always equals allowed_kinds[0]. Also the free-form fallback kind. | yes |
| allowed_kinds | Every kind the AI may pick, all in the slot's tier, floor- and cap-filtered. Length 1 means forced. | yes |
| scaffold_level | faded or solo: the support ladder. A faded slot carries an instruction to hand the learner part of the work; solo is the default and stays unmarked | yes |
| misconception_hint | Forced true_false whose FALSE option encodes the learner's recorded error, quoted into the clause. data.rationale is required on this slot and re-checked on the way back | yes |
| transfer_probe | Forced novel-context probe; persisted to questions.transfer_probe | yes |
Groups, floors and base mix.
Six cognitive tiers are the unit of proportion. true_false is deliberately absent from every member list, because it is injected orthogonally and is never a quota kind.
| Tier | Group | Member kinds (low load → high load) |
|---|---|---|
| 1 | Recall | flashcard |
| 2 | Recognition | matching · mcq |
| 3 | Cued | fill_blank · short_answer · word_order |
| 4 | Procedural | numeric_answer · fill_blank · solve_equation · code_trace |
| 5 | Analysis | categorize · ordering · code_bug_spot |
| 6 | Production | short_answer · code_bug_spot · writing |
base_mix(bloom) gives the percentage per tier at neutral band, λ = 0. floor_group_tier(bloom) is the lowest tier that carries mass for apply-and-above; below it the mix is zeroed and renormalised.
| Bloom | Floor | Recall | Recog | Cued | Proc | Analysis | Production |
|---|---|---|---|---|---|---|---|
| Remember | 1 | 40 | 25 | 35 | 0 | 0 | 0 |
| Understand | 3 | 5 | 20 | 50 | 5 | 20 | 0 |
| Apply | 4 | 0 | 0 | 15 | 65 | 15 | 5 |
| Analyze | 5 | 0 | 0 | 5 | 10 | 65 | 20 |
| Evaluate | 6 | 0 | 0 | 0 | 5 | 25 | 70 |
| Create | 6 | 0 | 0 | 0 | 5 | 20 | 75 |
The floor is applied only for bloom.tier() >= 3; for remember and understand the lower tiers stay legal. A separate per-kind floor, bloom::kind_violates_floor, removes individual kinds from the offered set (for example mcq and writing are both illegal on an Apply point: one too low, one too high).
Calibration constants.
All tunable; these are the v1 defaults actually compiled in.
| Constant | Value | Role |
|---|---|---|
| W_LAPSE / W_MAST / W_NEW | 0.45 / 0.35 / 0.20 | Struggle weights: s = W_LAPSE·min(lapses/3,1) + W_MAST·(1−mastery) + W_NEW·[reps==0] |
| LAPSE_NORM | 3.0 | Lapse count at which the lapse term saturates. A lapse is a genuine retrieval failure; a recalled-but-imperfect answer is not one |
| STRUGGLE_HIGH / STRUGGLE_LOW | 0.6 / 0.3 | Within-tier load lever: above → lowest-load member, below → highest-load, between → rotation |
| GS_HIGH / GS_LOAD | 0.5 / 0.8 | Mean struggle at which the session shortens, and the factor applied. The mean is taken over the concepts the learner has already worked and no others — a first encounter scores near the top of the struggle scale by construction, so including new concepts made a session of all-new material read as a struggling one. A session with no already-worked concept is not treated as struggling |
| COUNT_STRUGGLE / COUNT_FADE | 2.0 / 1.0 | Per-LP need weight: 1 + 2·s + 1·is_due·(1−forecast_retention) |
| TILT_STEP | 8.0 | Percentage points shifted per unit of depth tilt λ |
| MCQ_CAP | 0.30 | floor(0.30·n) slots may be offered mcq, session-wide |
| MAX_INJECTS_PER_SESSION | 2 | Misconception checks appended per session, ranked by struggle. Replaced the old MISC_THRESHOLD struggle gate, which was backwards: a misconception held with fluency keeps struggle low, so the gate never opened on the very case it mattered for |
| MAX_PROBES_PER_SESSION | 2 | Transfer probes appended per session, same selection rule |
| MAX_ATTEMPTS | 3 | Generate → validate → regenerate cycles before failing loudly |
| DEBT_SUSPEND_FACTOR | 2.0 | Backlog (in multiples of the slot budget) at which a course session drops all new material |
| NEW_FLOOR_SHARE | 0.3 | Fraction of budget reserved for new material before novelty scaling |
| DEFAULT_NOVELTY | 0.5 | Learner novelty appetite; global for now, per-learner later |
target_exercise_count(kind, duration_min) = base × clamp(duration/30, 0.5, 2.0), rounded, minimum 1.
| Session kind | Base at 30 min | Grid-specific behaviour |
|---|---|---|
| Course / Rewrite | 7 | Need weight 1 + 0.25·(tier−1); no inserts (first teaching is not the mastery boundary) |
| Practice / Review | 10 | Review applies λ − 0.5, a retrieval bias rather than a stretch |
| Exam | 8 | Mastery-blind: uniform needs, all solo, no inserts, no adaptation prose, learner preferences withheld |
| Transfer | 4 | λ = 2.0 forced: top-tier application |
| Recalibrate | 5 | Not grid-owned: separate prompt path |
| Project / Freeform | 3 | Not grid-owned |
| Placement | 6 | Not grid-owned; builds its own count from the depth-aware prompt |
depth_tilt(learning_depth) maps the course brief's depth string to λ by substring match:
| Depth | λ | Effect |
|---|---|---|
| surface | −1.0 | Drains the top tiers downward toward the relaxed floor |
| (unset) | 0.0 | Base mix unchanged |
| deep (practical) | +1.0 | Drains the lowest tiers into the highest non-empty one |
| deep theoretical | +1.5 | Same, further |
| full mastery | +2.0 | Same, further |
A strong band with struggle below STRUGGLE_LOW adds +0.5 to λ (a stretch notch), never on an exam.
Prompt assembly.
Two layers. The system prompt (SESSION_EXERCISES_SYSTEM_PROMPT, DB-overridable via the prompt registry) carries the JSON envelope, the per-kind data shapes, the alignment rules, the strict MCQ-correctness rules and the self-contained-text rule. The user prompt is built per call by build_user_prompt() and then extended by append_grid_plan().
| Block | Contents | Notes |
|---|---|---|
| Header | Skill title · duration target · cadence | |
| Objective | competency_statement, falling back to the parsed brief goal | Binding header: course content is declared reference material, explicitly not the thing tested |
| Learner preferences | skill.learner_notes | Excluded on Exam |
| Session | Kind · target count · per-kind style line | |
| Learning points | Label + [bloom: …] + [mastery: …] tags | Mastery tags excluded on Exam (mastery_aware = false) |
| Calibration prose | Per-band instructions (new/weak, developing, strong, due-but-competent) | Per-LP, not per-slot |
| Course content | Pinned section versions, truncated to 6000 chars each | Labelled "build tasks from it, do not test recall of it" |
| Exercise plan | One numbered line per slot: LP label · fixed kind or "choose ONE kind from …" · optional misconception and transfer-probe brackets | Declares the plan FIXED in count and order, and carries the scaffold instruction on faded slots |
Request parameters: json_mode = true, temperature = 0.7, max_tokens = 8192, plus a JSON schema (exercises_response_schema()) unless the skill is in free-form mode.
Validation and persistence.
Up to MAX_ATTEMPTS = 3 cycles. All problems in a response are collected and fed back together, so one regeneration fixes them all; a provider or network error does not burn a retry.
- Count. The response must contain exactly one exercise per plan item.
- Kind.
slot.allowed_kinds.contains(ex.kind)or the item is rejected by name and index. This is the hard boundary: the AI cannot escape the grid's tier decision. - Shape.
schema::validate_data(kind, data)rejects schema-valid-but-empty content, such as afill_blankwith a blankexpected. A bad item is never silently dropped, because that would leave a pedagogically incomplete session. - Persistence.
write_exercises()deletes this session's freshly-authored exercises and reinserts, so regeneration is idempotent. Reused references (session_exercise_refs.reused = 1) are excluded from the cascade so a shared prior-session exercise survives. - Parallel arrays.
scaffoldsandtransfer_probesare index-aligned with the merged exercise list and written toexercises.scaffold_levelandquestions.transfer_probe. The probe flag is an intrinsic authoring fact, read identically by live submission and by replay, so a regrade cannot disagree with the original grading. - What this loop does not check. All of the above verify structure: how many items came back, which kind each one is, and whether its fields are populated and internally consistent. None of them reads the exercise. Nothing here decides whether a statement is true, whether an answer key is right, whether the question actually tests the concept it is attached to, or whether the wording is answerable at all. Those rules exist — they are written into the generation prompt above — but a prompt states a rule, it does not enforce one, and this is the only place enforcement could happen. It is a real gap, it is named here on purpose, and it is why an exercise you find wrong or nonsensical is worth reporting.
Session composition (which concepts).
Upstream of the grid, compose.rs selects which learning points a session targets. Also pure. Retrieval kinds recompose skill-wide rather than trusting the plan's pinned list.
- Due queue. Due candidates sorted by ascending retrievability, tie-broken by higher goal leverage, then lower stability, then id. Most-overdue-first.
- Budget split.
budget_split(debt, k, mandate, nu). A Review mandate is pure consolidation. A Course mandate reserves a new floor ofround(k · 0.3 · (0.5 + nu)), clamped to at least 1 and at most the nominal ceiling, unless debt reaches2·k, at which point new material is dropped entirely and the session becomes catch-up. - Top-up. If the due queue cannot fill the review pool, encoded-but-not-due items are added nearest-to-due first. Never-started items are excluded, because those are new material rather than review.
- Interleave. Review and new picks are alternated rather than blocked, for discriminative spacing.
- Serving order. The step above orders the concepts; the grid then emits every slot of one concept before moving to the next, so a session composed as an alternation could still be served in blocks. A final pass spaces them out, and it is conditional on what you already know: a concept you have answered before has its items dealt round-robin against the others, while a concept you are meeting for the first time keeps its items together. Interleaving buys the ability to tell methods apart, which is only worth its switching cost once you can carry out the method at all — so consolidation comes first, discrimination after. The two groups are then served due material first, new material after: whoever stops a session early loses what came last, and a missed review costs retention on something already learnt while a postponed first encounter costs only progress.
Enforced invariants.
These hold by construction inside grid() and are covered by unit tests. They are properties of the offered set, so no AI choice can violate them.
| Invariant | Mechanism |
|---|---|
| No kind below the Bloom floor | kind_violates_floor filter in pick_kinds, re-checked at validation |
| MCQ ≤ 30% | Hard bound on the offered set: mcq is dropped from the choice set once the cap is spent |
| true_false is misconception-only | Absent from every group member list; only the orthogonal insert emits it |
| Writing guaranteed for evaluate / create | First Production slot returns ("writing", vec!["writing"]), forced, with no choice |
| ≥ 3 kinds when n ≥ 3 | Emergent from the tier decomposition; asserted per session |
| Exam is mastery-blind | Uniform needs, solo scaffold, no inserts, mastery_aware = false |
| Every LP gets ≥ 1 slot | allocate_counts raises n to the LP count if needed |
| Determinism | Same snapshot + same salt → identical Vec<Slot> |
Known gaps.
Open items on this surface, each with its location. Listed because a documented engine with undocumented holes is worse than no documentation.
| Gap | Location | Effect |
|---|---|---|
| CLOSED — One budget, two jobs | target_lp_count / LpBudget | The same number used to set both how many concepts a session touches and how many exercises it writes; since apportionment floors at one item per concept, the steady state was exactly one exercise per concept, and four adaptive mechanisms (need-weighting, load reduction, the faded → solo ladder, the cognitive-load ceiling) went inert once the learner knew more concepts than their session length had room for. The two are now distinct quantities: target_lp_count divides the exercise budget by TARGET_ITEMS_PER_LP (2), and the concept budget is a LpBudget newtype so passing the exercise count where a concept count belongs no longer compiles |
| CLOSED — One grading path where two are needed | apply_score_item / ItemContext::practice_repeat | Every graded item used to be treated as the concept's scheduling event, whether it was the first of the session or the seventh two minutes later. Measured at the time: 8 items on one concept in one session bought +0.10 of stability over a single item, and a recovery after a miss inside the same session locked a 2-day interval where the next day's would earn 9. A repeat inside a session now moves the competence signal only, and leaves the schedule, the counters and the durability flags to the concept's scheduling event |
| CLOSED — Inserts un-budgeted | grid() stage F | Appended after apportionment with no cap; measured, n0 = 10 with 10 strong-unverified LPs yielded 20 slots, in the most time-expensive kind. Both additive inserts are now capped at two per session (MAX_INJECTS_PER_SESSION, MAX_PROBES_PER_SESSION) |
| The explanation is optional on some kinds | data_schema_for / the deterministic scorers | Of 14 exercise kinds, 5 are graded by a language model and carry a required why. Of the 9 graded deterministically, 5 now have a field for a one-sentence explanation and it is required on the misconception check; 4 still have nowhere to put one, and where the field exists a model may leave it empty. A miss no longer restates the answer the card already marks: with no explanation the card says so plainly and links back to the section that teaches the point |
| Depth tilt inert at the top | apply_tilt / floor_clamp_and_normalize | For Evaluate and Create, all λ from −1 to +2 normalise to an identical 100% Production mix, because the floor for those levels is already the top tier. (The Apply / Analyze half of this gap is fixed: the negative tilt now aims at the floor instead of the tier below it) |
| One kind is domain-locked but sits in a general tier | Group::members | code_bug_spot is only excluded at Remember, and is the highest-load member of Group::Analysis, which carries 65% of the Analyze mix. Measured: on an analyze concept it is the preferred kind in 4–5 of 6 slots for any subject. Never forced (0 occurrences swept over Bloom × band × λ), so the AI can escape, but only via prompt prose. Related: the system prompt lists short_answer for analyze, the grid's Analysis tier does not contain it |
| Member lists are domain-blind | Group::members | code_trace / numeric_answer sit at the load extremes, so they become the preferred kind on non-technical points; only prompt prose and the AI's in-set choice rescue it |
| Prompt / grid contradiction | calibration prose | "favour recall and recognition" for new/weak points contradicts the Bloom floor, which zeroes those tiers for apply-and-above |
| Term-bank path bypasses the plan prompt | author_term_game | Bank-covered slots are removed before append_grid_plan, so per-slot prompt instructions do not reach them |