Skip to content

Study plan distribution front-loads the first unit and gives every lesson of a unit the same material slice #316

Description

@arqo123

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():

  1. 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.
  2. 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.
  3. 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.
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions