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

Two Facts on Every Turn

~13 min · data-model, polymorphism, wire-contract, recording

Level 0Muted
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Even in the middle of a normal conversation, switching modes has to be free. Voice to normal, normal to voice." — Dad, 2026-09-25

The Tempting Wrong Model

The obvious design is a voice conversation: a flag on the thread, a separate screen, maybe a separate history. It falls apart the first time you use it for real. Dad talks for ten minutes, then wants to paste a link. He types it. Is that still a voice conversation? Then he asks, in writing, for a table of what they just discussed. Does the table go into the voice thread, or a new one? A conversation-level flag forces a choice at every one of those moments, and every choice splits the record.

Two Independent Facts

The design that holds puts voice on the turn, as two facts that vary independently:

  • input_modality: typed or dictated. Where Dad's words came from. A dictated turn went through a transcriber, so it may carry transcription errors.
  • reply_modality: written or spoken. How the soul should answer. A spoken turn is composed for the ear and played aloud.

That gives four combinations, and every one is real. Typed and written is an ordinary turn. Dictated and written is the composer's microphone in normal mode, or a voice note. Typed and spoken is typing a link while voice mode is on. Dictated and spoken is voice mode proper. Voice mode is only a client preset: dictated, spoken, and a hands-free loop. Switching it sets the attributes of the next turn and nothing else.

So there is one conversation, one record, one memory. After twenty minutes of talking, "put what we just said in a table" in normal mode just works, because the talk is right there in the thread. Dad would call it polymorphism in his own sense: the conversation object stays the same, and each reply takes one of two forms.

Absent Means Ordinary

Both fields are optional on the wire. Absent means typed and written, so every existing client keeps working without a change, and an unknown value normalizes to absent instead of failing the turn. The server holds one small module that owns these rules, and every chat route calls it instead of restating them.

Record Both, in Both Places

The user's row records input_modality; the assistant's row records reply_modality. The per-conversation JSONL log, which is ground truth, carries the same facts in a voice object built from the same values as the database row, so the two can never tell different stories about one turn.

Restoring the Switch

Reopen a conversation and voice mode should come back the way you left it. The first rule was simple: if the last reply was spoken, voice is on. On 2026-09-28 Dad switched voice off, reloaded, and found it on again, because the last reply had been spoken. Now his own switch is remembered with the reply count at the moment he flipped it, and it wins until a newer reply settles the question.

Code

Voice as two optional facts on a turn·python
from dataclasses import dataclass
from typing import Literal

InputModality = Literal["typed", "dictated"]
ReplyModality = Literal["written", "spoken"]


def normalize(value: object, allowed: set[str]) -> str | None:
    """Unknown or absent -> None. A bad value never fails the turn."""
    if isinstance(value, str) and value.strip().lower() in allowed:
        return value.strip().lower()
    return None


@dataclass
class Turn:
    text: str
    input_modality: InputModality = "typed"
    reply_modality: ReplyModality = "written"

    @classmethod
    def from_wire(cls, body: dict) -> "Turn":
        return cls(
            text=body["message"],
            input_modality=normalize(body.get("input_modality"), {"typed", "dictated"}) or "typed",
            reply_modality=normalize(body.get("reply_modality"), {"written", "spoken"}) or "written",
        )


def jsonl_voice(turn: Turn) -> dict | None:
    """The ground-truth log's voice object, built from the SAME values as the row."""
    voice = {}
    if turn.input_modality != "typed":
        voice["input_modality"] = turn.input_modality
    if turn.reply_modality != "written":
        voice["reply_modality"] = turn.reply_modality
    return voice or None


# An old client, a voice-mode client, and a typo, in one conversation:
for body in (
    {"message": "what's the weather"},
    {"message": "weather tomorrow?", "input_modality": "dictated", "reply_modality": "spoken"},
    {"message": "here's the link", "reply_modality": "SPOKEN "},
    {"message": "table please", "reply_modality": "sung"},
):
    turn = Turn.from_wire(body)
    print(turn.input_modality, turn.reply_modality, jsonl_voice(turn))

External links

Exercise

Run the code block and read the four lines it prints. Then add a fifth wire body that a real client might send by mistake, and decide what the turn should become. Finally, sketch the restore rule: given a conversation's messages and a remembered switch (conversation id, on/off, reply count at the time), return whether voice mode opens on.
Hint
The restore rule has one comparison that does all the work: the remembered switch applies only while the conversation still has the same number of replies it had when Dad flipped it. One more reply, and the last reply's modality is the better evidence again.

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.