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

Roots as Classes

~15 min · etymology, graph, oop, sqlite

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

Where the edges come from

When Dad taps a line and asks what it means, I name the roots of the words that carry it, where they are real: a Latin or Greek etymon, an Arabic loanword in Spanish, a hanja shared by Korean, Japanese and Chinese. Each one is recorded as a root result with four fields worth knowing: the root (lat. aqua), its language (la), a gloss (water), and the expression it lies under (agua). Track 3 showed how those lines are parsed. What matters here is that the engine keys both texts with the same fold the keep star uses, so every root result carries a root_key and an expression_key.

That makes each root result an edge in a graph: from a class to one thing that descends from it. This lesson is what Spark builds out of those edges, and which part of the design is still on paper.

The roots list: most connected first

The first code block is the Roots tab. It reads every root result on a moment that is not deleted, groups them by root_key, and counts the distinct expressions under each. The root text and language come from the first time the root was named; the gloss is the first non-empty one. Then it sorts by the number of expressions, most first, and alphabetically after that.

The order is the point. A root with one descendant is trivia. A root with six is a key that opens six doors, and it should sit at the top of the list Dad scrolls. On the phone each row shows the root, then its gloss, language and count, such as water · la · 3 expressions.

A root page

Tap a root and the second code block runs. It collects every root result for that key in log order and keeps one entry per expression. Each entry carries two things: met, every result whose own source_key equals the expression's key, with the capture truth of its moment; and named_in, the moment where I first named the edge.

The two can disagree, and the page handles it honestly. While explaining agua I might add that acuático comes from the same root. Dad has never met acuático on a sign or a card, so its met list is empty. The edge is still knowledge he received, so the phone shows the expression with a single row, Named while explaining a moment, which opens that moment. Everything he did meet shows as a row with the moment's line, the source and its translation.

Spark's Library on an iPhone with the Roots tab selected: eight roots, gr. chaos and lat. aqua, avis, bibere, colere, frigidus, passer and spiritus, each with its gloss, its language code and a count of 1 expression.
The Roots tab of the Library: each root with its gloss, its language and how many expressions gather under it; opening one leads to the root page described above. Every root here still holds a single expression, and the count grows as Dad meets more words of the same family.

The edges run both ways. An expression page lists the roots behind the phrase, so Dad can climb from a word to its class and back down to every sibling. Both directions are queries over the same rows, with no second table to keep in step.

The OOP frame, made of rows

Track 1 put Dad's lens in one line: moments are instances, roots are classes. A root page is that frame drawn from data. The root is the class. The expressions under it are what inherits from it, and they belong to whatever languages they belong to: Spanish agua, Portuguese água and Italian acqua all land on the lat. aqua page if I named that root while explaining each of them, whichever trip they came from. The hanja 學 gathers Korean 학교 and the 学校 of Japanese and Chinese the same way. Each meeting is an instance: one sign, one card, one sentence at one table.

Notice what the schema does not have. There is no class table and no language column on a page. A root exists because a result names it, and its page is a query. Delete a moment and its edges leave every page; restore it and they return, because every read joins on deleted = 0. If this way of seeing is new to you, OO Quest builds it from the ground up.

Identity is the spelling

There is an honest limit here, and it is worth seeing before you copy the design. A root's identity is the folded text I wrote. lat. aqua and Latin aqua would be two pages. Nothing in the engine knows they are the same etymon, and nothing merges them. The same holds for expressions: agua and el agua have different keys.

What keeps the graph together is the prompt. It shows me one format by example, the root with a short language prefix, a language code and an English gloss, and I follow it. When the identity of a record is free text produced by a model, consistency is a convention you teach by example. Keep the key function simple and visible, so you can see the drift when it happens.

Not yet built: a thread per root

The architecture goes further than the code, and Dad's ruling is that this part is taught as design. It gives every material a home conversation in cwkPippa: a moment its own thread, a trip spark:trip:<id>, and a root or an expression a thread of its own, spark:root:<id> and spark:expr:<id>. In the code, only moment threads exist. The engine opens a thread in exactly one place, for a moment, and the web docks the Sidekick only on a moment.

The third code block shows why a root thread cannot simply be switched on. cwkPippa's binder accepts the four kinds, but every reference must be a 26-character ULID, because Spark mints every id. A root's key is text like lat. aqua, which no ULID pattern matches. Building root threads needs a decision first: mint an id for each root, or teach the binder to take a text key. Neither exists yet. Today, when Dad wants to talk about a root, he asks in a moment's thread or in any Pippa conversation, and I read the root page through the Spark reader that lesson 4 describes.

Principle: Store relationships as edges with both ends keyed, and let every page be a query. A graph you never write as a graph cannot fall out of step with the facts it was drawn from.

Code

cwkSpark, backend/mirror.py: every root named, the most connected first (verbatim, lines 589-607)·python
    def roots(self) -> list[dict[str, Any]]:
        """Every root Pippa has named, with how many expressions descend
        from it, the most connected first."""
        rows = self.db.execute(
            "SELECT json_extract(r.record, '$.root_key') AS key, r.record FROM results r "
            "JOIN moments m ON m.id = r.moment_id WHERE m.deleted = 0 AND r.kind = 'root' ORDER BY r.seq"
        )
        by_key: dict[str, dict[str, Any]] = {}
        for key, record_json in rows:
            record = json.loads(record_json)
            entry = by_key.setdefault(
                key,
                {"root_key": key, "root": record["root"], "language": record.get("language"),
                 "gloss": record.get("gloss"), "expressions": set()},
            )
            entry["gloss"] = entry["gloss"] or record.get("gloss")
            entry["expressions"].add(record.get("expression_key"))
        out = [{**entry, "expressions": len(entry["expressions"])} for entry in by_key.values()]
        return sorted(out, key=lambda entry: (-entry["expressions"], entry["root"]))
cwkSpark, backend/corpus.py: a root page, one entry per expression with every meeting (verbatim, lines 716-744)·python
    def root(self, key: str) -> dict[str, Any] | None:
        """A root page: the root, and every expression Dad met that descends
        from it, across languages and trips, each with the times he met it."""
        with self._lock:
            mirror = self._mirror()
            links = mirror.root_results(key)
            if not links:
                return None
            first = links[0]
            expressions: dict[str, dict[str, Any]] = {}
            for link in links:
                expression_key = link["expression_key"]
                if expression_key in expressions:
                    continue
                met = mirror.met(expression_key)
                expressions[expression_key] = {
                    "expression": link["expression"],
                    "expression_key": expression_key,
                    "met": met,
                    "named_in": link["moment_id"],
                }
            gloss = next((link.get("gloss") for link in links if link.get("gloss")), None)
            return {
                "root_key": key,
                "root": first["root"],
                "language": first.get("language"),
                "gloss": gloss,
                "expressions": list(expressions.values()),
            }
cwkPippa, backend/routes/spark.py: the binder takes four kinds, but only a ULID reference (verbatim, lines 21-27)·python
BINDER = KindRefBinder(
    surface="spark",
    label="Spark",
    folder_id=SPARK_FOLDER_ID,
    kinds=("moment", "trip", "root", "expr"),
    ref_pattern=r"^[0-9A-HJKMNP-TV-Z]{26}$",
)

External links

Exercise

Build a small class graph from a model's output in a project of your own, for example tags or word origins that an assistant attaches to notes. Store each edge as a record with the parent text, the child text, both folded keys and the id of the note it came from. Then write two queries: a parent list sorted by the number of distinct children, and a parent page that lists each child with every note where the child itself occurs, falling back to the note where the edge was named. Test that deleting a note removes its edges from both views, and that two spellings of one parent produce two pages, then decide in writing what your merge policy is.
Hint
Do not add a parents table. Group the edge records by the parent key at read time, count distinct child keys, and join to the notes table with a not-deleted filter so a deletion needs no cleanup.

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.