@luraty/engine
v0.5.1
Published
Runtime-agnostic language-learning engine. No React, no React Native, no Expo, no database.
Readme
@luraty/engine
The runtime-agnostic core of Luraty — an adaptive language trainer for heritage speakers: people who grew up hearing a language, understand more than they can say, and are stuck between beginner courses that bore them and books that flatten them.
The engine is the backend. A mobile app is only a frontend. This package holds the decisions — what to teach, when, how an answer is judged, what happens next. It runs unchanged under Hermes (React Native), Node, and a browser, with zero framework imports.
Status: five slices shipped. Learner state and the evidence fold (
record), persistence (serialize/deserialize), language packs (createPack/checkPack), coverage, and the scheduler (plan). The rules and the boundary are in CLAUDE.md.Not built yet, and each is a missing input rather than a missing idea: interleaving by confusability (needs a relation the four-function pack contract cannot express), chunks as items (a unit key addresses a word), and fluency tasks (no timed-repetition model). See the notes on
planin src/core/plan.ts.
Thirty seconds
import { createProfile, day, learner } from '@luraty/engine';
import { de, vocabulary } from '@luraty/pack-de'; // already built — no createPack, no file reading
const d1 = day(1); // days start at 1 — `day(0)` is a compile error, 0 means "never" inside
// A learner who has met nothing. `variety` defaults to the pack's own id, `direction` to
// 'recognise', and `maxNew` below to half the session — all overridable, none invented.
let anna = learner(createProfile('de', d1), { pack: de, vocabulary });
// 1. PLACEMENT. However you asked, the answer is a list of claims — she says she knows these,
// and nobody has checked. This is what stops day one being an empty screen.
anna = anna.claim(vocabulary.slice(0, 400), d1);
// 2. The engine decides. `maxNew` caps how much NEW material may crowd out review; it defaults to
// half the session, which is where a year-long sweep puts the peak at every budget tested.
const session = anna.plan({ day: d1, maxItems: 12 });
// Every item carries a `why`: 'verify' (she claimed it), 'new', 'relearn', or 'review'.
// Show them differently — calling a word she grew up hearing "new" is the failure to avoid.
// 3. She answers. This is the only thing that can raise a word's rung.
anna = anna.answer('haus', 'known', d1);
// 4. How hard is this text for her now?
const hard = anna.coverage('Der Mann geht aus dem Haus.');
if (hard.kind === 'measured' && hard.band === 'in-band') {
// just right: dense enough to learn from, sparse enough to read
}
// 5. Save the profile, and snapshot the numbers you want to show her later.
storage.set(anna.save());
history.append(anna.summary()); // known · claims confirmed / refuted / still uncheckedEvery method above delegates to a free function that is still exported, and anna.profile is
always reachable — the handle is a convenience, never a wall.
The full loop, with a comment on every line, is
packs/de/src/example.test.ts. It is a test rather than a
snippet so it cannot rot: it compiles against this engine and runs against the real 10,000-word
German pack on every npm run check. This README once shipped a quickstart that had not compiled
for three commits, which is why.
Want more in one day? maxItems is per CALL, not per day. Record what she did and call plan
again — it dries up on its own, because answering sets lastAsked = today. Do not advance the
day to unlock more: that tells the engine a night of sleep happened, which is what every interval in
here is a claim about.
Watch it run for thirty days: npm run demo.
How fast is it on a phone? make bench — it runs the same bundle under Node and under Hermes,
which is the runtime React Native actually ships, and projects the result onto an older device.
make stress asks the other question: 250,000-unit profiles, megabyte texts, adversarial input,
every scenario in its own process so a limit is named rather than fatal. Read
docs/guides/benchmarking.md before quoting a number from it — in
particular the device multiplier is an estimate until you calibrate it, and the guide says how.
Why it is a separate package
An adaptive engine is only testable if it is deterministic and runnable headless — you validate it by simulating thousands of learners, not by tapping through a UI. An engine that imports React Native cannot run in that harness at all.
The previous version of this engine lived inside the app. When the app was rewritten, the engine went with it — not because it was wrong, but because it was in the wrong place. This package exists so that can't happen twice.
The shape
plan(profile, { day, maxItems, maxNew }); // → a session, plus a description of content it needs
record(profile, evidence); // → a new profile
coverage(profile, pack, query); // → how much of this text they know
serialize(profile) / deserialize(text); // → persistence, as pure total functions
createPack(config, data) / checkPack(pack, sample); // → a language, from JSONTwo of those signatures are shorter than you would expect, and both are findings rather than
oversights. plan takes no pack — work the four-function contract through and nothing on it is
reachable from a scheduling decision, so scheduling is language-free. It takes no seed either,
because selection is by days-waiting and the key tiebreak is total, so there is no tie randomness
could break. coverage takes raw text, not tokens, so nobody can hand it a token stream the
pack never produced. The long-form arguments are in
src/core/plan.ts and src/model/coverage.ts.
Everything is a value. No hidden state, no clock, no database. Time arrives as data (day) and
content as a request the host fulfils. That is what makes a session replayable, a learner forkable,
and two profiles diffable. Nothing in the engine draws a random number today; when something needs
to, it takes a seed and derives from it, so call order can never change a decision.
Language packs
The engine knows no language. Everything language-specific is injected as a pack — four small functions plus data:
| | what it does | French | Arabic | German |
| --------- | ------------------------ | ---------------- | --------------- | ----------------------------- |
| split | text → pieces | on spaces | on spaces | on spaces |
| key | are these the same word? | vais → aller | السوق → سوق | Bahnhofstraße → strasse |
| rank | how common is it | frequency list | frequency list | frequency list |
| compare | is this answer right | fold accents | fold diacritics | umlauts two-letter (ö→oe) |
A pack is config plus data files, not code — so adding a language is two files rather than a
pull request against the engine. Build one with createPack(config, data), then run
checkPack(pack, sample) against real text: it catches the failures no unit test can, because
fixtures are correct by construction and the input that breaks is the host's 20,000-word file.
See docs/guides/adding-a-language.md.
One profile per language, deliberately: you are a different person in each one.
Develop
npm install
npm run check # format + typecheck + lint + test — the whole gatemake on its own lists every command. Determinism is enforced by lint, not by convention:
Date.now(), Math.random() and argless new Date() are errors in src/ — as are Intl,
localeCompare() and normalize(), which work on Node and are missing or different under Hermes.
Use as a submodule
git submodule add https://github.com/younissk/luraty-engine.git engineThen install it from npm with npm install @luraty/engine, or link a local checkout with "@luraty/engine": "file:../engine". Do not put the
two in an npm workspace — hoisting would let react-native resolve from inside the engine and
silently destroy the boundary this package is built around.
