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

Only the Current Reading Counts

~13 min · sqlite, views, versioning, rubric

Level 0Mise en Place
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

A plate can be read many times

One plate rarely gets exactly one reading. Dad presses "Read again". He saves a correction, which asks for a re-read. A new rubric version re-reads every plate at the next start. A correction that lands while Pippa is still reading queues another pass. Each of these ends, when it succeeds, in a new reading.recorded event with a fresh reading id.

Nothing is deleted when that happens. Every reading stays in the readings table and in the journal. What moves is a pointer: the reading.recorded handler sets the encounter's reading_id to the new reading. Sizzle's house sign states the rule as an invariant: a reading never edits an encounter. It is a new reading, and links count only for the encounter's current one.

Links remember which reading made them

Every row in links says who made it (source is reading or dad) and, for a reading, which one (reading_id). The primary key is the triple of encounter, class and source, and the projection writes links with INSERT OR REPLACE. Put those together and a re-read can go three ways:

The re-read...What happens to the rows
names the same dish againThe existing reading row is replaced, now carrying the new reading_id. It still counts.
names a different dishA new row appears for the new dish. The old row stays in the table, still bound to the old reading.
meets a link Dad made himselfDad's row has its own key (source = 'dad'), so it sits beside the reading's row and is never replaced by it.

One view does the forgetting

The second case leaves a stale row behind on purpose. Deciding which rows count is the job of a view:

current_links keeps a row if Dad made it, or if a reading made it and that reading is still the encounter's current one. Every question that counts plates asks this view: the evidence status of a class, the "In the tree" list on a plate, the plates shown on a class page, the dish chips in the plate list. None of those queries has to remember the rule, so none of them can get it wrong.

Here is what that looks like with generic dishes. A plate is first read as carbonara. Dad writes a correction: it is cacio e pepe. The re-read names cacio e pepe and links it. The carbonara row is still in links, bound to the first reading, and the view no longer returns it. If that was carbonara's only plate, carbonara drops back to inferred, and cacio e pepe now reads as eaten, or as cooked if the plate was marked as cooked at home. No row was updated by hand and no row was deleted.

What a superseded reading leaves behind

The view forgets claims. The tree does not forget proposals. The carbonara class the first reading proposed is still in classes, still a proposal, and any parent edge it gained is still in class_parents, because Sizzle has no event that deletes a class or an edge. That is a reasonable outcome here because both of its labels stay honest: it shows as proposed and as inferred, so it cannot pass for something Dad ate. A reader building the same thing should notice the asymmetry and decide whether orphaned proposals need a cleanup step of their own.

Principle: When a fact is derived and re-derivable, keep every version and put "which one counts" in a single view. Then the rest of your queries can stay simple.

The rubric bump

RUBRIC_VERSION is 2 on origin/main. Changing the reading's voice or schema means bumping it, and the bump is how every old plate gets read again. When the worker starts, it asks the store for stale_readings: plates whose reading finished and whose current reading was made under an older rubric. For each one it records reading.requested with the reason rubric 2, starts the worker task and wakes it.

The encounters themselves stay untouched. No encounter.captured event is rewritten. Each plate gets a new reading under the new rubric, its links rebind to that reading, and the old links fall out of the view without anyone touching them. The engine test plants a reading under RUBRIC_VERSION - 1, restarts the app on the same data, and checks that exactly one new reading was asked for and that the current reading now carries the current rubric.

One edge follows from the query. stale_readings selects only plates whose status is done, so a plate whose last attempt failed is not queued by a bump. It waits for "Read again" like any other failed plate.

Track 5 explains why the mirror these tables live in can be thrown away and rebuilt from the journal. The sqlite-quest's Writes, Transactions, and WAL track covers the conflict side of this, including UPSERT.

Code

cwkSizzle, backend/store.py: the links table, keyed by encounter, class and source (verbatim, lines 87-95)·sql
CREATE TABLE links (
    encounter_id TEXT NOT NULL,
    class_id TEXT NOT NULL,
    source TEXT NOT NULL,
    reading_id TEXT,
    confidence TEXT NOT NULL DEFAULT '',
    created_at TEXT NOT NULL,
    PRIMARY KEY (encounter_id, class_id, source)
);
cwkSizzle, backend/store.py: current_links, the one place that decides which links count (verbatim, lines 115-119)·sql
-- A reading's links count only while that reading is the encounter's
-- current one; Dad's own links always count.
CREATE VIEW current_links AS
    SELECT l.* FROM links l JOIN encounters e ON e.id = l.encounter_id
    WHERE l.source = 'dad' OR (l.source = 'reading' AND l.reading_id = e.reading_id);
cwkSizzle, backend/reading.py: the worker's start re-reads plates read under an older rubric (verbatim, lines 306-313)·python
    def start(self) -> None:
        self._loop = asyncio.get_running_loop()
        # A rubric bump re-reads every plate read under an older rubric; the
        # encounters stay untouched and each re-read is a new reading.
        for encounter_id in self.sizzle.store.stale_readings(RUBRIC_VERSION):
            self.sizzle.record("reading.requested", {"encounter_id": encounter_id, "reason": f"rubric {RUBRIC_VERSION}"})
        self._task = asyncio.create_task(self._run(), name="sizzle-reading-worker")
        self.kick()

External links

Exercise

In a database you control, model one derived fact that gets recomputed: an auto-assigned category, a computed match, a parsed address. Give the parent row a current-version pointer, tag every derived row with the version that produced it, and let a human add rows tagged as their own. Write a view that returns human rows plus derived rows of the current version only. Then write a test that re-derives with a different result and asserts the old derived row is still in the table but no longer in the view, while the human row is in both.
Hint
Put the source in the primary key so a human row and a derived row for the same pair can coexist. Point every read path at the view and none at the base table, the way evidence, the plate's tree list and the class page all read current_links.

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.