Environment
- Self-hosted instance, FreeLingo v1.8.45 (Docker Compose)
- Course: German (Deutsch), level A2, default plan shape (12 weeks x 4 days)
- Everything below comes from calling
distribute_units() directly, so it is independent of the LLM, the gateway and the model in use
What happens
The plan grid for 12w x 4d has 48 slots — 47 lessons plus the completion test. German A2 has 8 units. The distribution is:
| unit |
lessons |
| a2-unit-1 Perfekt mit haben und sein |
15 |
| a2-unit-2 … a2-unit-7 |
5 each |
| a2-unit-8 A2 Wiederholung |
2 |
| completion test |
1 |
So the learner spends weeks 1–4 on a single grammar topic — Perfekt mit haben und sein - Lektion 1 through Lektion 15, cycling grammar → vocabulary → reading → writing → review three times — before ever meeting Präteritum. The final unit, whose whole purpose is recapping the level, is truncated to 2 lessons and loses its reading, writing and review slots.
This is a follow-up to #295 / #300. The prompt-level fix landed and works in its own layer, but the lessons still converge, because of a second cause described below: every one of those 15 lessons is generated from a byte-identical input.
Cause 1 — the advance guard front-loads all surplus onto the first unit
backend/app/data/curriculum.py:193-198:
type_index += 1
if type_index % len(lt_list) == 0 and unit_index < len(units) - 1:
remaining_slots = lesson_slots - slot - 1
remaining_units = len(units) - unit_index - 1
if remaining_slots <= remaining_units * len(units[unit_index + 1].lesson_types):
unit_index += 1
The guard advances to the next unit only once the remaining slots have shrunk to roughly one cycle per remaining unit. At the start remaining_slots = 42 and remaining_units * 5 = 35, so the condition is false and the loop keeps cycling unit 1's lesson types. Every surplus slot (47 slots for 8 x 5 = 40 natural lessons) therefore lands on the first unit, and the tail is squeezed.
The same guard also drops units entirely in the deficit case: at 4w x 6d (23 slots) units 6, 7 and 8 get zero lessons, so the learner completes "A2" without ever meeting Nebensätze or Relativsätze — structures the A2 completion test itself covers.
Cause 2 — every lesson of a unit gets the same fixed slice of the unit's material
backend/app/data/curriculum.py:186-189:
"grammar_points": (unit.grammar_points[:2] if unit.grammar_points else []),
"vocabulary_set_ids": (unit.vocabulary_set_ids[:1] if unit.vocabulary_set_ids else []),
The slice is constant for every slot of the unit. For a2-unit-1 all 15 lessons receive:
grammar_points = ['perfekt-mit-haben', 'perfekt-mit-sein']
vocabulary_set_ids = ['erfahrungen_de_a2']
distinct (grammar_points, vocabulary_set_ids) tuples across the 15 lessons: 1
The generator is asked fifteen times for a different lesson on identical inputs. The sibling-context block from #300 tells the model not to repeat itself but gives it no new material to move to, which is why the duplication survived that fix.
The truncation also means a large part of every curriculum is never taught. German A2, counting only real lessons:
grammar points reaching a lesson: 14 / 22
never in any lesson: adjektivdeklination-null, nebensatz-obwohl, nebensatz-wenn,
partizip-ii, personalpronomen-akk-dat, praeteritum-modalverben,
vergleich-als-wie, wortstellung-nebensatz
vocabulary sets used: 8 / 15
a2-unit-6 Nebensätze declares 5 grammar points and teaches 2. a2-unit-8 A2 Wiederholung declares 12 and teaches 2.
This is not German-specific. Across all shipped curricula at the default plan shape:
| language |
grammar points reaching a lesson |
worst first-unit pile-up |
| en-GB |
69/83 (83%) |
20 lessons (C2 unit 1) |
| de-DE |
79/118 (66%) |
15 lessons (A1 unit 1) |
| es-ES |
84/137 (61%) |
15 lessons (A1 unit 1) |
| fr-FR |
84/124 (67%) |
15 lessons (A1 unit 1) |
| it-IT |
84/132 (63%) |
15 lessons (A1 unit 1) |
| pt-PT |
83/132 (62%) |
15 lessons (A1 unit 1) |
| ja-JP |
84/130 (64%) |
6 lessons (A1 unit 1) |
| ko-KR |
86/126 (68%) |
6 lessons (A1 unit 1) |
| zh-CN |
85/126 (67%) |
7 lessons (B2 unit 1) |
Side effect: the unit drawer promises material the course never teaches
frontend/src/components/plan/UnitDrawer.tsx:89 renders unit.grammar_points — all four for a2-unit-1, including partizip-ii and perfekt-vs-praeteritum. No lesson of that unit ever receives those two, so the drawer lists them as covered grammar while nothing in the unit covers them.
Reproduction
from app.data.curriculum import get_curriculum_units, distribute_units
from collections import Counter
units = get_curriculum_units("A2", "de-DE")
slots = distribute_units(units, 12, 4, "de-DE")
print(Counter(s["unit_id"] for s in slots))
lesson_slots = [s for s in slots if s["unit_id"] != "completion-test"]
print(len({(tuple(s["grammar_points"]), tuple(s["vocabulary_set_ids"]))
for s in lesson_slots if s["unit_id"] == "a2-unit-1"})) # -> 1
all_gp = {g for u in units for g in u.grammar_points}
used = {g for s in lesson_slots for g in s["grammar_points"]}
print(len(used), "/", len(all_gp), sorted(all_gp - used))
There are currently no tests over distribute_units() — grep -r distribute_units backend/tests/ returns nothing — which is how both behaviours survived.
Relation to #295 and #300
#295 diagnosed this as a prompt problem and #300 fixed the prompt. That was correct as far as it went, but the issue looked one layer too high. Two things I got wrong there and want to correct explicitly:
- The environment block recorded "Unit 1 (15 lessons)" as a fact and never questioned it. Fifteen lessons on one grammar point is the larger half of the problem.
- The third bullet of "Proposed direction" — "Optionally partition
vocabulary_set_ids and example themes across the unit so lessons draw from different slices instead of the whole set every time" — was the load-bearing item, and I marked it optional. It also understated the case: lessons do not draw from the whole set every time, they draw from a fixed one-element slice of it.
Proposed direction
Rewrite the allocation in distribute_units():
- Rotate the material slices per lesson. Lesson k of a unit takes a window of ~2 grammar points starting at
(k * 2) % len(points) and vocabulary_set_ids[k % len(sets)]. Cheapest change here and on its own it removes the identical-input problem; every declared grammar point and vocabulary set then reaches at least one lesson.
- Allocate slots evenly, cap the block, never drop a unit. Block width
w = clamp(lesson_slots // n_units, 3, 6); leftovers go to a consolidation phase at the end rather than widening any block, because a 7th consecutive lesson on one unit starts repeating regardless of slice rotation. In the deficit case thin every unit to 3 slots instead of dropping the tail — a plan that never reaches Relativsätze is a curriculum integrity problem, not a scheduling compromise.
- Interleave spaced revisits instead of an in-block review. A review lesson the day after the material adds little; displaced in time it is retrieval practice. A block of
[grammar(N), vocabulary(N), reading(N), revisit(N-2), writing(N), revisit(N-1)] caps the run on one topic at 3, and gives every unit revisits at expanding intervals (~6 and ~10 slots later, then consolidation) with no per-item bookkeeping. A revisit slot is an ordinary slot — unit_id of the revisited unit, lesson_type: "review", a rotated slice of its points — so the lesson generator needs no changes at all.
- Surplus becomes consolidation, never more same-type lessons. Mixed review across earlier units plus free production, which is also where the final "Wiederholung" unit belongs; generating
grammar and vocabulary lessons for a review unit is incoherent as it stands.
One interaction worth flagging: revisit slots carry lesson_type: "review", which build_grammar_exercise_ratio() (added in #300, following your review) maps to 70% grammar exercises. That is probably right for a revisit, but it should be a deliberate choice rather than a side effect.
Happy to open a PR for this if the direction looks right. My inclination is to do 1 and 2 first as the actual fix, then 3 and 4 separately, so the correctness change and the sequencing change can be reviewed apart.
Out of scope here
Environment
distribute_units()directly, so it is independent of the LLM, the gateway and the model in useWhat happens
The plan grid for 12w x 4d has 48 slots — 47 lessons plus the completion test. German A2 has 8 units. The distribution is:
So the learner spends weeks 1–4 on a single grammar topic —
Perfekt mit haben und sein - Lektion 1throughLektion 15, cycling grammar → vocabulary → reading → writing → review three times — before ever meeting Präteritum. The final unit, whose whole purpose is recapping the level, is truncated to 2 lessons and loses its reading, writing and review slots.This is a follow-up to #295 / #300. The prompt-level fix landed and works in its own layer, but the lessons still converge, because of a second cause described below: every one of those 15 lessons is generated from a byte-identical input.
Cause 1 — the advance guard front-loads all surplus onto the first unit
backend/app/data/curriculum.py:193-198:The guard advances to the next unit only once the remaining slots have shrunk to roughly one cycle per remaining unit. At the start
remaining_slots = 42andremaining_units * 5 = 35, so the condition is false and the loop keeps cycling unit 1's lesson types. Every surplus slot (47 slots for 8 x 5 = 40 natural lessons) therefore lands on the first unit, and the tail is squeezed.The same guard also drops units entirely in the deficit case: at 4w x 6d (23 slots) units 6, 7 and 8 get zero lessons, so the learner completes "A2" without ever meeting Nebensätze or Relativsätze — structures the A2 completion test itself covers.
Cause 2 — every lesson of a unit gets the same fixed slice of the unit's material
backend/app/data/curriculum.py:186-189:The slice is constant for every slot of the unit. For a2-unit-1 all 15 lessons receive:
The generator is asked fifteen times for a different lesson on identical inputs. The sibling-context block from #300 tells the model not to repeat itself but gives it no new material to move to, which is why the duplication survived that fix.
The truncation also means a large part of every curriculum is never taught. German A2, counting only real lessons:
a2-unit-6 Nebensätzedeclares 5 grammar points and teaches 2.a2-unit-8 A2 Wiederholungdeclares 12 and teaches 2.This is not German-specific. Across all shipped curricula at the default plan shape:
Side effect: the unit drawer promises material the course never teaches
frontend/src/components/plan/UnitDrawer.tsx:89rendersunit.grammar_points— all four for a2-unit-1, includingpartizip-iiandperfekt-vs-praeteritum. No lesson of that unit ever receives those two, so the drawer lists them as covered grammar while nothing in the unit covers them.Reproduction
There are currently no tests over
distribute_units()—grep -r distribute_units backend/tests/returns nothing — which is how both behaviours survived.Relation to #295 and #300
#295 diagnosed this as a prompt problem and #300 fixed the prompt. That was correct as far as it went, but the issue looked one layer too high. Two things I got wrong there and want to correct explicitly:
vocabulary_set_idsand example themes across the unit so lessons draw from different slices instead of the whole set every time" — was the load-bearing item, and I marked it optional. It also understated the case: lessons do not draw from the whole set every time, they draw from a fixed one-element slice of it.Proposed direction
Rewrite the allocation in
distribute_units():(k * 2) % len(points)andvocabulary_set_ids[k % len(sets)]. Cheapest change here and on its own it removes the identical-input problem; every declared grammar point and vocabulary set then reaches at least one lesson.w = clamp(lesson_slots // n_units, 3, 6); leftovers go to a consolidation phase at the end rather than widening any block, because a 7th consecutive lesson on one unit starts repeating regardless of slice rotation. In the deficit case thin every unit to 3 slots instead of dropping the tail — a plan that never reaches Relativsätze is a curriculum integrity problem, not a scheduling compromise.[grammar(N), vocabulary(N), reading(N), revisit(N-2), writing(N), revisit(N-1)]caps the run on one topic at 3, and gives every unit revisits at expanding intervals (~6 and ~10 slots later, then consolidation) with no per-item bookkeeping. A revisit slot is an ordinary slot —unit_idof the revisited unit,lesson_type: "review", a rotated slice of its points — so the lesson generator needs no changes at all.grammarandvocabularylessons for a review unit is incoherent as it stands.One interaction worth flagging: revisit slots carry
lesson_type: "review", whichbuild_grammar_exercise_ratio()(added in #300, following your review) maps to 70% grammar exercises. That is probably right for a revisit, but it should be a deliberate choice rather than a side effect.Happy to open a PR for this if the direction looks right. My inclination is to do 1 and 2 first as the actual fix, then 3 and 4 separately, so the correctness change and the sequencing change can be reviewed apart.
Out of scope here
type_indexnever resets per unit), so unit 2 starts at "Lektion 16". Cosmetic, separate.