A star that means one thing
Every phrase Spark shows Dad carries a star. Tap it and the phrase is kept. The easy build would be a bookmark: one row per tap, pointing at the result he tapped. Spark is stricter. A kept phrase is one mark per text, whichever moment it came from.
The mark's id is built from the text itself: keep: followed by the result's source_key, which is the source with its whitespace collapsed and its case folded. Say Dad keeps ¿Me trae un vaso de agua, por favor? from a card I wrote for him at lunch, and a week later he meets the same sentence in a conversation and taps the star again. Both taps land on the same mark.
The first code block is the whole rule. If the mark exists and is active, the engine answers with it and writes nothing. If it was kept once and later removed, a new mark.add with the same id goes into the log, and the mirror's upsert flips the row back to active. Removing a mark appends mark.remove; the row stays with active set to 0, so the history of keeping and letting go is all in the log.
Every answer also carries met: how many results on live moments share that key. That number is free precisely because the mark is keyed by text. A per-result bookmark would give Dad three stars to manage for one sentence, and a kept list with duplicates in it. One key makes the star light up everywhere the text appears, and the count of meetings falls out of a single query.
Idempotent by shape
Most of Spark's writes carry a client request id, so a retry after a lost reply is recognised and answered from the mirror. Keep and unkeep carry none, and they do not need one. Their id is derived from the content, so pressing the star twice, or a phone retrying a request whose answer never arrived, lands on the same mark. When the identity of a record is a pure function of what it says, the write is safe to repeat without any bookkeeping.
That makes the folding function load-bearing. The engine folds with Python's str.casefold, and the clients are told to trust it. The web's star store and the phone's model both use the source_key the engine put on the result, and fall back to a simple lowercase fold of their own only for a result that arrived without one. The comment in both places says it plainly: Python casefold is the authority. Casefold and lowercase really do disagree. German Straße casefolds to strasse but lowercases to straße, so a client that folded on its own could light the wrong star, or none.
Recall without a schedule
Quick recall is Dad asking to re-meet a few phrases, and the engine chooses which. It chooses how many as well. POST /api/recall accepts a count, five by default and at most twelve, but neither the web nor the phone sends one, so every session today asks for five. What it does not have is a schedule. There is no due date per phrase, no ease factor, no table of past grades. The second code block computes the picks from the corpus every time it is asked:
- Kept phrases come first. Then phrases met in passing, but only of the kinds
expression,answerandcard: things Dad could say himself, never a menu line or a sign. A phrase met in passing also needs a translation to qualify. - Within each group, a phrase never recalled comes first, then the one recalled longest ago.
- Among phrases met in passing, ties go to the one met earliest. Among kept phrases, ties stay in the order the mirror lists marks: newest mark first.
The ranking is a tuple: group, then when it was last recalled (-1 for never), then, for a phrase met in passing, its position in the log. A kept phrase carries 0 in that last slot, so two kept phrases that tie compare equal, and Python's stable sort leaves them as marks() returned them, newest first. Python compares tuples left to right, so one sorted call does all of it. The docstring's "then the oldest met" is true of the second group only.
How does the engine know when a phrase was last recalled? It reads the earlier recall sessions. A recall session is itself a moment, of kind recall, carrying the items it asked about. So the history of recall lives in the corpus like everything else, and the docstring can say what the design promises: nothing else remembers.
| Question | A spaced-repetition deck | Spark's quick recall |
|---|---|---|
| What is stored? | A schedule and a grade per card | Nothing beyond the corpus |
| What comes next? | The card whose due date has passed | Kept first, then least recently recalled |
| What does an answer get? | Pass or fail, and a new interval | Confirmed, gently corrected, or one useful twist |
| What does a missed week cost? | A backlog of overdue cards | Nothing; there is no backlog to grow |
A session is a moment
POST /api/recall takes a client request id and the capture truth of the request, because a recall is a moment of Dad's day like any other. With nothing kept or met yet, the engine answers 400 and says so. Otherwise it records the recall moment, starts a turn of kind recall in that moment's own thread, and returns. The same request id sent again returns the same session instead of starting a second one.
The third code block is what I am told in that thread. One phrase at a time, the Korean first. Dad answers aloud or in text. I confirm, gently correct, or add one useful twist. No timer, no score, no streak, no grading words. The answers ride the system addendum and the moment's items, never the visible message: the message Dad sees is just Quick recall, please., and a test asserts that the expression itself is absent from it.
On the phone the recall moment shows how many phrases are waiting, marked untimed, then my opener, then two ways in. Answer aloud is the family's Talk with Pippa button, which hands the thread to the Pippa app's voice mode. Answer in text opens the same thread in Pippa. On the web, the Sidekick docks on the recall moment.
The architecture names the stance in one line: review is re-meeting, never drilling. A traveller meets phrases unevenly, a dozen in one afternoon and none for a week. A deck would turn that week into debt. Spark simply hands back what Dad cared about first, and spreads the rest by how long it has been.