Open index.html straight off disk and the whole application is
running: eight plain script elements, one stylesheet, progress in
localStorage. There is no package manager, no bundler, and no
module loader. The files load in dependency order and hang their one
public object on window.
That constraint decided the deck format. The build tool writes
decks/deck.js as window.DOJO_DECK = {…} rather than
a JSON file, because a page served from file:// cannot
fetch a sibling document: the origin is opaque and the
request is blocked. A script tag is not. The identical payload is written
alongside as deck.json for tooling that has a filesystem.
The sample deck is scaffolding. Sixty cards across Linux shell, IP and subnetting, DNS and DHCP, HTTP semantics, git, and testing concepts exist to exercise every path in the engine and no further. The engine is the artifact.
Each category carries an Elo rating from 0 to 1000, starting at 330. Every
card carries a rating too, fixed by its difficulty. An answer is scored 1 for
got it, 0.5 for had to look it up, 0 for a miss, and the
rating moves by K × (score − expected) where
expected is the usual logistic, with a 300-point divisor
instead of chess's 400, so the curve is steeper and a category converges
inside a session rather than a season. K decays from 34 to 13 as
the category accumulates answers.
The second input is time. Every difficulty carries a budget: what a competent answer ought to cost.
| Difficulty | Card rating | Expected | What it asks for |
|---|---|---|---|
| d1 | 190 | 12 s | A single fact any practitioner knows |
| d2 | 385 | 18 s | Standard working knowledge |
| d3 | 565 | 27 s | Real understanding, not recall |
| d4 | 745 | 38 s | Multi-step reasoning, real output, edge cases |
| d5 | 905 | 50 s | The detail that separates deep familiarity from competence |
A rolling window of the last eight answers yields two numbers: accuracy on that same 1 / 0.5 / 0 scale, and speed as the median ratio of elapsed time to the budget for that card's difficulty. Median, not mean, and only computed once at least three of the eight were actually timed: one interrupted card should not be allowed to redefine your pace.
Target difficulty is then 1 + 4 × (mastery / 1000) plus the
global flow bias plus a per-category bias, clamped to the 1–5 range. Card
fit is a Gaussian around that target with σ = 0.95, multiplied by
priority, by how badly the card has gone before, by how overdue it is, and by a
final jitter of 0.86–1.18 so two identical sessions do not produce an
identical queue.
A card left on screen for more than 150 seconds is treated as “walked away”: the grade still counts toward mastery, but the stopwatch reading is thrown away rather than folded into your median. Without that, one coffee break would convince the engine you had become slow and it would spend the next twenty cards apologising for difficulty you can handle.
Weak categories are weighted up: the multiplier runs from 1× at full mastery to 3.2× at zero. But weak areas produce missed cards, missed cards come due in four minutes, and left alone that loop eats the whole session. So once at least eight answers are on the board, a category holding more than 45% of the last twelve has its weight cut to 0.45×; over 33%, to 0.72×. A category you are actively bleeding in is eased back another quarter rather than pushed harder.
Unseen material wins outright. While any card in the filtered pool has never been served, cards you have already answered are not candidates at all: not down-weighted, excluded. Re-serving a recent miss either rewards short-term recall or double-counts the same gap, and both poison the numbers the rest of the engine is reading.
An engine that only ever aims at the edge of your ability will eventually walk you off it. Two misses in a row is not a signal about difficulty calibration; it is a signal about the person holding the keyboard, and the response has to be different in kind.
So: after two consecutive misses (or three misses inside the last five
answers), the next card is chosen to be winnable. It comes from your
strongest category, with a difficulty ceiling set 1.4 below that
category's normal target, and the interface labels it
↺ confidence builder rather than slipping it in unannounced.
The learner is told the deck just dealt them an easy one. That is the honest
version of the mechanic, and it still works.
Never twice in a row. A boost sets a flag that blocks the next one outright; the win has to be followed by real work or it stops meaning anything. Never a foregone conclusion. The easy pool must offer at least three candidates; if the strongest category cannot supply them the engine widens to any card at difficulty ≤ 2, and if that is still thin it abandons the boost entirely rather than serving something predictable. “Strongest” must be earned. A category needs at least three answered cards before it can be nominated, so a lucky first question cannot become the comfort zone. Fresh-first still applies. The confidence builder has to find its easy win among cards you have never seen, which is a real constraint rather than a cosmetic one.
It only runs in adaptive mode. The narrowed review modes (missed cards, flagged cards, weakest categories) are chosen deliberately by someone who has asked to be uncomfortable, and softening those would be answering a question nobody asked.
Thirty-nine of the sixty cards can machine-check a typed answer. Those cards carry an accept pattern, a short token list in a sidecar file keyed by the card's index in its source deck. The same matching logic has to run in two places: in Python at build time, to prove a pattern is usable, and in JavaScript at runtime, to grade what you actually typed.
The rules the table pins down are the ones that are easy to get subtly wrong.
Matching is case-insensitive and anchored at the front of a word only.
A bare substring test is wrong: the token skin matched inside
asking. Anchoring both ends is also wrong, because patterns deliberately
use stems: expir is meant to catch expires,
expired and expiry. Tokens that begin with punctuation
(/etc/fstab, -p 8080:80) skip the leading
anchor entirely.
Numbers are compared as numbers, never as text. Integers must match exactly, so
exit code 200 can never satisfy a card asking for 203 and 62 does
not match “162 usable hosts”. Decimals match within 5% or 0.02,
whichever is larger, so “about 6 dB” satisfies an answer of 6.02
and 9 dB does not.
Every accept pattern is run at build time against the concatenation of its own
card's model answer and explanation. If the card's own canonical answer would
not satisfy the pattern, no human answer ever will (the check can only
produce false negatives), so it is deleted from the built deck and listed
in decks/REPORT.md. The card falls back to self-grading, which was
the honest outcome all along. The current build drops none.
A failed match returns couldn't confirm, and a partial match says so explicitly rather than collapsing to a flat no. The verdict you act on is still your own self-grade. This is also why twenty-one cards carry no pattern at all: an explain-it-in-your-own-words answer has no checkable string, and a matcher that calls you wrong when you were right is worse than no matcher.
A card's identity is sha1(category | normalized question) truncated
to twelve hex characters. Rewrite an explanation, retune a difficulty, add a tag,
reorder the file: the id is unchanged and your progress on that card
survives. Rewrite the question itself and you get a new card, which is the
correct answer: it is now a different question. The one thing reordering does
move is the accept sidecar, which is keyed by position rather than by id,
and the build's self-validation is what catches it, because a pattern
that has drifted onto the wrong card generally cannot match that card's own
answer.
The payload deliberately carries no build timestamp. An unchanged deck produces a
byte-identical deck.js, so rebuilding never shows up as a diff and
the generated file can live in version control without lying about what changed.
The fingerprint strips code fences, lowercases, drops a stopword list, and sorts the remaining words into a set, so two questions that differ only in phrasing collide. The second one is dropped and named in the report.
Three-word shingle overlap within a category, reported above 72%. Flagged, not dropped: recall, apply and debug angles on one fact are legitimately different work, and the builder is not qualified to decide which.
A category name outside the canonical table is preserved verbatim and listed as suspect, rather than discarded. A typo in a category name should cost you a line in the report, not ten cards.
Per-file kept and dropped counts, the difficulty spread per category, format mix, machine-checkable coverage, dropped checks, and coaching completeness. Currently 60 of 60 cards carry all four coaching fields.
say to pronounce sysadmin
The browser's speechSynthesis exposes only a subset of installed
system voices, and Chrome quietly substitutes its own network voices. So the
optional local server shells out to the macOS say binary instead:
real Enhanced and Premium voices, exact rate control, entirely offline, with a
SHA-1-keyed WAV cache pruned at 400 files. Premium voices render at 48 kHz
because downsampling them to 22 kHz is audible. The voice name is checked
against the parsed catalogue rather than passed through, the rate is clamped to
an integer range, and the text is handed over as a single argument after
-- with no shell anywhere in the path, so a request cannot
smuggle a flag into the subprocess.
Then there is the vocabulary problem: a speech synthesiser reads
systemctl as a word. A pronunciation map fixes the ones that matter:
systemctl becomes “system c t l”,
nginx becomes “engine ex”, cidr becomes
“cider”, yaml becomes “yamel”,
sudo becomes “soo doo”. Code spans get read the way a
person reads them aloud: -u becomes “dash u”,
&& becomes “and then”, >>
becomes “append to”, and /etc/hosts becomes
“slash etc slash hosts”.
The server refuses to start if anything is already listening, and it probes
::1 and 127.0.0.1 separately. A leftover
python3 -m http.server holds the IPv6 wildcard, which does
not prevent a bind to the IPv4 loopback, so the new server would
come up looking perfectly healthy while the browser, which resolves localhost to
::1 first, kept talking to the old one and 404'd every audio
request. The startup message prints the lsof line to find it.
The shipped deck is a demonstration. Sixty cards on fundamentals are enough to
exercise every branch in the engine and nothing like enough to learn a subject
from. Read them critically and expect to replace them. The authoring
contract in CARD-SPEC.md is the real deliverable on that side:
adding a subject means adding a row to the canonical category table and writing
one JSON file.
The rest are explain-it-out-loud answers with no single checkable string. The app shows you which is which rather than pretending to a confidence it does not have.
The 22-case suite executes against the Python matcher; the JavaScript is a deliberate line-for-line mirror held to the same table. It is a written specification, not yet a cross-runtime harness.
Intervals run from four minutes to a three-day cap and are biased toward this session, not a long-horizon calendar. It optimises what to serve you next, not what to serve you in March.
localStorage and nothing else
No accounts, no sync, and storage is per-origin: progress saved on
file:// does not follow you to localhost. Export and
import exist precisely because of that.
High-quality read-aloud is macOS-only. Everywhere else the server still serves files and the app falls back to the browser's own voices, which work and sound like it.
Public, under an educational-use licence. Roughly 340 lines of
engine.js hold the mastery model, the flow controller and the
selection weights; everything else is the harness that lets them run from a
double-clicked file. The cards are the part you throw away.