Skip to content
C.W.K.
Lesson 01 of 06 · published

One Mark Per Text, and Recall Without Drills

~14 min · corpus, idempotency, unicode, recall

Level 0First Word
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

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, answer and card: 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.

QuestionA spaced-repetition deckSpark's quick recall
What is stored?A schedule and a grade per cardNothing beyond the corpus
What comes next?The card whose due date has passedKept first, then least recently recalled
What does an answer get?Pass or fail, and a new intervalConfirmed, gently corrected, or one useful twist
What does a missed week cost?A backlog of overdue cardsNothing; 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.

Principle: Derive what you can recompute. A schedule table is a second truth that drifts from the record; a choice computed from the record at the moment of asking always agrees with it.

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.

Code

cwkSpark, backend/corpus.py: one mark per text, re-activated when kept again (verbatim, lines 524-557)·python
    def keep(self, result_id: str) -> dict[str, Any] | None:
        """Keep the expression a result says: one mark per text, so keeping
        it again from another moment is the same mark (re-activated)."""
        with self._lock:
            mirror = self._mirror()
            result = mirror.result(result_id)
            if result is None:
                return None
            source = str(result.get("source") or "").strip()
            if not source:
                raise CorpusError("only a result with a source text can be kept")
            key = str(result.get("source_key") or source_key(source))
            mark_id = f"keep:{key}"
            existing = mirror.mark(mark_id)
            if existing is not None and existing["active"]:
                return {**existing, "met": len(mirror.met(key))}
            moment = mirror.moment(result["moment_id"], include_deleted=True) or {}
            self.record(
                {
                    "op": "mark.add",
                    "id": mark_id,
                    "kind": "keep",
                    "result_id": result_id,
                    "moment_id": result["moment_id"],
                    "source": source,
                    "source_key": key,
                    "translation": result.get("translation"),
                    "pronunciation": result.get("pronunciation"),
                    "use": result.get("use"),
                    "language": moment.get("scene_language"),
                    "kept_at": utcnow(),
                }
            )
            return {**(mirror.mark(mark_id) or {}), "met": len(mirror.met(key))}
cwkSpark, backend/corpus.py: recall picks derived from the corpus every time (verbatim, lines 626-669)·python
    def recall_picks(self, count: int) -> list[dict[str, Any]]:
        """A few expressions to re-meet: kept ones first, then ones met in
        passing; within each, the least recently recalled, then the oldest
        met. Derived from the corpus every time; nothing else remembers."""
        with self._lock:
            mirror = self._mirror()
            last_recalled: dict[str, int] = {}
            for order, moment in enumerate(mirror.recall_history()):
                for item in moment.get("items") or []:
                    last_recalled[str(item.get("source_key"))] = order
            candidates: dict[str, tuple[tuple[int, int, int], dict[str, Any]]] = {}
            for mark in mirror.marks():
                key = mark["source_key"]
                candidates[key] = (
                    (0, last_recalled.get(key, -1), 0),
                    {
                        "source": mark["source"],
                        "translation": mark.get("translation"),
                        "pronunciation": mark.get("pronunciation"),
                        "source_key": key,
                        "language": mark.get("language"),
                        "kept": True,
                    },
                )
            for seq, result, moment in mirror.results_met(RECALL_KINDS):
                key = str(result.get("source_key") or "")
                if not key or key in candidates or not result.get("translation"):
                    continue
                candidates[key] = (
                    (1, last_recalled.get(key, -1), seq),
                    {
                        "source": result.get("source"),
                        "translation": result.get("translation"),
                        "pronunciation": result.get("pronunciation"),
                        "source_key": key,
                        "language": moment.get("scene_language"),
                        "kept": False,
                        "moment_id": moment.get("id"),
                        "situation": moment.get("situation"),
                        "place_name": moment.get("place_name"),
                    },
                )
            ranked = sorted(candidates.values(), key=lambda item: item[0])
            return [item for _rank, item in ranked[:count]]
cwkSpark, backend/turns.py: the recall instructions; the answers stay out of the visible message (verbatim, lines 318-343)·python
def recall_addendum(moment: dict[str, Any]) -> str:
    items = moment.get("items") or []
    listed = "\n".join(
        f"{index}. {item.get('translation')} → {item.get('source')}"
        + (f" ({language_name(item.get('language'))})" if item.get("language") else "")
        + (" [kept]" if item.get("kept") else "")
        for index, item in enumerate(items, start=1)
    )
    return "\n".join(
        [
            _spark_frame(moment),
            "",
            "### This thread: quick recall",
            "",
            "Dad asked to re-meet a few expressions he kept or met. Give him the Korean of one at a time "
            "(and, briefly, where he met it when that helps), let him answer aloud or in text, then respond: "
            "confirm, gently correct, or add one useful twist. Untimed, no scores, no streaks, no grading "
            "words: this is re-meeting, never a test. Keep the answers to yourself until he has tried.",
            "",
            "The expressions (Korean → what he would say):",
            listed,
            "",
            "They are also recorded on this moment as `items`; the Spark reader shows them whenever you "
            "need them again later in this thread. Start with the first one now.",
        ]
    )

External links

Exercise

Add a keep feature to a note or vocabulary app of your own where the mark's id is derived from the normalized text, so the same phrase kept from two different records is one mark, and every answer reports how many records carry that text. Then add a recall endpoint that stores no schedule: it records each session as an ordinary record carrying its items, and ranks the next picks from those earlier sessions. Write three tests: keeping the same text from two records yields one mark met twice; a second session with a count of one returns a kept item first; the message shown to the user never contains the answers.
Hint
Normalize with casefold after collapsing whitespace, and have clients use the server's key. For the ranking, build a tuple of (kept or not, last session index with -1 for never, log position) and sort once. Give kept items a real tie-breaker on purpose: a constant in the last slot leaves them in whatever order you fed the sort.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.