Holt
We replaced the level with a map. Holt keeps a live model of what you actually know, and assembles every session from that model - not from a lesson plan you share with everyone else.
Design + Build - strategy, interface, learning-science engineering, content pipeline · Next.js 16.2 · React 19 · Expo SDK 57 · Supabase / Deno edge functions · multi-model LLM
An adaptive language tutor built on a measured map of your knowledge - not another streak you're afraid to break.
Every mainstream language app optimizes the same number: your return visit. Streaks, leagues, a guilt-trip notification - retention mechanics wearing a lesson's clothes. And the unit they teach against is a level. B1, A2, "intermediate": an average that describes a population and fits nobody inside it. So you drill what you already hold and walk straight past the one thing underneath it that never set. The app knows how many days you showed up. It doesn't know what you know.
Holt starts from the other end. Before it teaches anything it measures - it finds the edge of what you already have and builds a graph from there: which skills hold, which are shaky, which were never touched. The interface is a forest under fog, and the fog lifts over the parts you've actually earned. Nothing burns down if you skip a week.
One line carries the whole architecture:
Math owns the schedule. AI owns the content. Not a wrapper around a model - two engines, and the one that decides what you see next never calls a model at all.
A graph, not a level
A level is a compression artifact. It takes a person who can read a contract but can't order coffee and hands them the same syllabus as someone with the mirror-image gap. Holt models the thing underneath instead: an ontology per language of roughly 130-150 skills, wired by prerequisite edges, each carrying its own state for you - mastery, stability, next due date, status.
Status is what the learner sees, and it is deliberately not a score. Five states plus one, and the ladder is asymmetric on purpose: a correct answer can never move you down. A learner who has just proven something should never be told they got worse for proving it.
new → learning → shaky → stable → mastered, plus provisional (presumption of knowledge).
learning : exposure ≥ 3, mastery < 0.5
shaky : 0.5 ≤ mastery < 0.7
stable : mastery ≥ 0.7 and stability ≥ 7d
mastered : mastery ≥ 0.85 and stability ≥ 21d
deriveStatus(prev, …) is monotone on success: a correct answer never lowers a status.
A brake on promotion slows the climb; it never triggers a demotion. `provisional` sits
outside the EWMA ladder entirely - it satisfies the introduce-new gate on equal terms
with `stable`, and converts on the first direct answer. Every threshold lives in a
`config` table, not in the code: behavior is retuned by a row, not a deploy.It measures before it teaches
The first session is not a lesson. It's an adaptive walk across the graph to find one thing: the boundary between what you have and what you don't. You self-assess once - that only picks the starting node, it is never trusted as an answer - and from there each probe moves the boundary, with a breadth phase so the estimate isn't one lucky corridor deep.
The payoff is what happens behind the boundary. Everything the walk implies you already know is seeded as provisional - a presumption, not a claim - through the implies closure of the prerequisite graph. You don't grind back up through a hundred skills you had before you arrived. And a presumption is cheap to be wrong about: the first direct question converts it either way.
Probes come from a frozen bank - 763 hand-gated usage items across six languages,
versioned and immutable, so calibration never depends on a model being available or in a
good mood. The walk is deterministic; only the top-up of an exhausted bucket touches an LLM.
Provisional seeding runs over `prerequisite_edges.kind = 'implies'`, with a frontier
guardrail (`pull_forward_max_frontier_distance`) so the presumption can't run away from the
measured boundary. Seeded skills carry exposure = 1 with zero cards shown - they are
explicitly *not* counted as "introducing new". The answer key for an open session is
service-only: the client can neither write the session journal nor read what it is being
graded against.Forgetting is scheduled, not guessed
Two independent clocks run on every skill and they are not the same quantity. Mastery is a belief about whether you currently hold something, and it decays toward the coin-flip - not toward zero - because forgetting makes you uncertain, not wrong. Scheduling is FSRS-6, the modern spaced-repetition model, driven by the desired-recall target rather than by a fixed ladder of intervals.
The interesting part is the asymmetry between them. Getting something right elsewhere in the graph gives credit downward through prerequisites - but that credit can only push a review further out, never pull one in. Getting something wrong pulls the parent in immediately. Evidence is allowed to buy you rest; it is never allowed to manufacture a review you didn't earn.
Mastery: EWMA with a 14-day half-life decaying toward 0.5 (not 0), learn rate 0.30.
Scheduling: FSRS-6, w[0..18], desired recall r = 0.90, interval capped at 90d
(300d in the vocabulary track, whose items are cheaper to re-meet).
Credit-down : k = 0.5 through prerequisite edges - may only push next_due later.
Penalty-up : pulls the parent in by 1 day - takes effect immediately.
Overdue is scaled, not cliffed (3-day scale), so a two-week absence is re-planned rather
than punished. The whole layer is a pure package with no I/O: same inputs, same schedule,
every time - which is what makes it testable at all.The session is assembled, not shuffled
A portion isn't a queue with a shuffle on it. It's assembled from four buckets - due, shaky, new, connective - against a mix that's a config row, and the connective share exists for a specific reason: a session made only of what's due teaches you nothing about how the pieces join.
The hard-won part is the dead end. Introducing new material has a cooldown, which is correct right up until the moment it isn't: a learner with nothing due and nothing shaky must not press "Study" and be told there's nothing to do. So the cooldown lifts itself when there's no other work - and it has to lift identically in both services that reason about what comes next, or the session builder and the card generator would quietly disagree about which skill you're on.
Mix: 60% due · 25% shaky · 15% new, connective share 0.18, all config rows.
The 0.5-day new-introduction cooldown is released whenever due + shaky falls below the
minimum portion size - in the portion builder and the card generator both, from one shared
comparator (priority desc → skillId), with the generator's per-batch ceiling held ≥ the
portion's new-skill target by construction.
Card hand-out is a compare-and-swap: two devices racing the same buffered card means one
wins and the other transparently re-draws, never a double-serve.
Buffer exhausted → generate → retry exactly once, then an honest "cards are being
prepared" - never an infinite spinner, never a fake card.A faculty, not a chatbot
There is no "the AI" in Holt. There are nine roles, and none of them is a chat window. Each one has a single job, its own contract with the system, its own model tier, and a fixed place in the pipeline - because "write a flashcard", "decide whether that flashcard is fit to show", "work out what this learner actually misunderstood" and "look at three weeks of history and say the pace is wrong" are four different professions, and one prompt doing all four does none of them well.
Six of them work inside a session: one writes the card, a second independently gates it before you ever see it, a third reads a wrong answer and names the misconception behind it rather than saying "incorrect", a fourth writes a short lesson but only when the error ladder asks for one, a fifth adds the small fact at the end, and a sixth tops up the calibration bank when a bucket runs dry. Three more watch the course of the learning itself - one daily, two weekly.
Routing between them is deliberate, not uniform. Cheap models classify and validate, strong models diagnose and re-plan; using an expensive model to check a card is as wrong as using a cheap one to re-read a month of someone's progress. Every role names its own primary and fallback in a database row, so retuning which model plays which part is an edit, not a deploy - and every call is logged with its cost, its latency, and the model that actually answered, not the one that was asked.
In a session tier job
cardGenerator mid writes the practice item for one skill
cardValidator cheap independent gate - a failed card never ships
diagnostician strong names the misconception behind a wrong answer
lessonAuthor mid a short lesson, only when the error ladder asks
didYouKnow cheap the small fact at the end
calibrationItem strong tops up an exhausted calibration bucket
Over days and weeks
planner mid daily - reads the queue, says what's happening
narrator mid weekly - narrates the week, and stays silent
when no status actually moved
methodist strong weekly - reviews the teaching, may adjust it
Each role carries {primary, fallback, tier} in a `config` row. Six further roles
(senseAuthor, glossAuthor, exampleAuthor, distractorAuthor, senseJudge, cefrJudge)
never touch a learner - they manufacture the vocabulary bank offline.The last one is the one with teeth. The methodist doesn't just comment - it can propose an adjustment to the plan: bring a skill forward, slow the pace down, put something back in rotation. So it runs on a leash. Every adjustment expires on its own after a bounded number of days, the number of skills one report can touch is capped, an adjustment naming no valid skill is dropped before it reaches the database, and when the advice conflicts with itself - speed up here, slow down there - caution wins and the acceleration is discarded before anything is written. A model is allowed to advise the schedule. It is never allowed to own it.
The vocabulary is built, not scraped
The second track is vocabulary, and it's where most of the manufacturing went. Not a scraped word list: a bank built pair by pair against a shared concept spine, so that "chair" in four target languages is one node with four surfaces rather than four unrelated rows that slowly drift apart.
Each language wave is generated, then it has to survive a set of gates that are all fail-closed - a gate that can't measure something refuses to pass it. Coverage is measured against the production spine, not against the repository, because the repository is the one that goes stale. Golden sets are blind and judged by a model that had no hand in making the material. Adversarial passes run two skeptics across two lenses, and only findings both of them reach independently count. And every load into production is strictly additive under its own source id - so a bad wave is retired, never rolled back over a learner's history.
gate-spine-coverage : coverage of the production concept catalogue, several candidates
per node (a single-candidate gate hides the real duplicate behind
the wrong one)
gate-bank-coverage : fail-closed loss classes, per-bank
gate-delta-glue : cross-wave duplicate scan on the delta's own glue
golden : blind sample, independent judge - 96.7% pass / 0.0% fail on the
most recent wave, with a regression run on every shipped band
Grading is per-language, not generic: accent folding, article channels, minimal pairs.
Russian needed its own rule - NFD decomposition folds "й" into "и", so a generic diacritic
strip silently graded "мои" as "мой". 21.5% of the Russian bank carries that letter.Honesty is the invariant
What makes it a tutor rather than a toy is what it refuses to do. The server is the only authority on whether you were right: the client sends the raw answer and nothing else - a client-side verdict isn't merely distrusted, it is not accepted as an input at all. The only thing the client may assert about an answer is that you skipped it. And when the model layer is unavailable the product degrades into a deterministic dictionary path - never into an invented card.
- No streaks, no leagues, no grades. Skipping a week costs you nothing but time.
- The home screen shows no number and gives no advice. Not a score, not a percentage, not a "you're 88% fluent."
- The server grades the raw answer. The client's own verdict is never accepted.
- No model output reaches a learner unvalidated, and no fallback invents content.
- Deleting a profile or an account deletes the data. It isn't hidden.
- The learner decides when to study. Nothing pushes.
Result
We built the thing the level was always standing in for: a tutor that holds a measured map of one person's knowledge and teaches against that map instead of against an average.
Six ontologies wired by 1,101 prerequisite edges · a frozen 763-item calibration bank across six languages · eight production vocabulary pairs over a bank of 38,989 lexical entries and 50,932 senses · nine AI roles under a deterministic engine that never calls one · 14 edge functions deployed from a single verified commit, behind 3,255 tests green in CI.
No streaks, no leagues, no grades, and not one number on the home screen - a schedule a model may advise and never owns, and a graph that only lights up where you actually earned it.
Next.js 16.2 (App Router, Server Components) + React 19.2 + TypeScript (strict) +
Tailwind 4 + next-intl · Expo SDK 57 / expo-router mobile client with email-OTP sign-in ·
Supabase (Postgres 17, row-level security, service-only writes on everything that touches
grading) · 14 Deno edge functions, each split index → pure handler → repo with a fake for
tests · a pure learning-science core in two layers - FSRS-6 and EWMA mastery with no I/O,
then deterministic orchestration on top · multi-model LLM through OpenRouter - nine runtime
roles plus six offline pipeline roles, each with its own tier, primary and fallback in a
config row - with three degrading call strategies (json_schema → tool_call → prompt-and-
parse), repair retries, per-call cost and latency logging, and per-user quotas gated at the
point of generation · content pipeline of gated, fail-closed build tools over a shared
concept spine · on Vercel, with an external uptime probe and a health endpoint.