Skip to content

feat(live): the text session loop — director, tutor, grader and learner model (Lunaris Live, Phase 2a) - #208

Merged
PouyanJay merged 9 commits into
mainfrom
feat/live-session-loop
Aug 11, 2026
Merged

feat(live): the text session loop — director, tutor, grader and learner model (Lunaris Live, Phase 2a)#208
PouyanJay merged 9 commits into
mainfrom
feat/live-session-loop

Conversation

@PouyanJay

Copy link
Copy Markdown
Owner

Lunaris Live, Phase 2a: the thing that walks the map. Phase 1 compiled a topic into a concept
graph; this teaches from one. A learner opens a session, the director picks the next move from
(graph, learner model, clock), the tutor teaches it, the learner answers in their own words, a
separate grader scores that answer against the concept's own do-statement, the belief moves, and
the director decides again — until it closes deliberately.

Nine tasks, each test-first, reviewed by the project's agents and mutation-checked before commit.

T1 Walking skeleton — web → API → lunaris_live.session → Supabase, one session_id
T2 Learner model — beliefs per concept, moved by evidence, decayed by turns
T3 The director — a scored rule set, four moves, every one carrying its reason
T4 The tutor — teaches a move over the concept's authored notes and misconceptions
T5 The grader — a staged criterion, a free-text answer, evidence written back
T6 Lifecycle — the session clock, a visible ending, an answer that names its turn
T7 The surface — the plainest text transcript that can carry a session
T8 Metering + admission — LIVE_SESSION spend, BYOK, caps and a per-session ceiling
T9 Variant coverage + the keyed simulated-learner eval

What it does

  • POST /api/live/sessions opens a session on a compiled map, already teaching.
  • POST /api/live/sessions/{id}/turns answers the turn in front of the learner and returns the
    whole session — the answered turn changes as well as gaining a successor.
  • GET /api/live/sessions/{id} resumes it, because a session is a row and not connection state.
  • /live gains a session surface: the transcript, what each move was for, what the learner was
    asked to demonstrate, what they said, and how it was marked.

The decisions worth arguing with

  • The grader is not the tutor. A teacher marking its own homework puts its bias into the model
    that decides what gets skipped. A grader that cannot answer is never read as a wrong answer.
  • The map is a map of the subject, never a route through the session. The conversation drives;
    the map keeps score.
  • What was earned is judged on the undecayed belief; only what has faded is judged on recall.
    Without that split, a concept sitting between the two thresholds was re-introduced from scratch to
    somebody who had just mastered it — found by running a whole session, not by reading the tests.
  • The transcript is written before the belief. A crash between the two writes then under-counts
    evidence instead of double-counting it, which is the direction that cannot hand somebody a concept
    they have not earned.
  • A session takes one turn at a time. Two answers sent at once each pay a grader and a tutor
    before either tries to write, so the cost ceiling can only mean something if the refusal happens
    before the money.

The eval ran

Three simulated learners against the real tutor and grader on a really-compiled map, asserting on
the director's trace rather than on prose. The sharpest run: a learner holding the map's own
authored misconception
insisted twice that a neuron's inputs pass through unmultiplied, was
refused both times despite sounding fluent, was remediated with a genuinely different explanation
each time (movie ratings → a shopping receipt → two numbers fighting for control → fill-in lines →
one input with nowhere to hide), and gave the idea up on the fifth turn.

Gates

Python 2631 passed · web 1511 passed · tests/db 80 against real Postgres · ruff, tsc, eslint,
prettier and supabase db lint clean.

One migration — 20260810000000_cost_subject_live_session.sql — widens the cost ledger's
subject_type CHECK to admit a session. Expand-only, NOT VALID then VALIDATE CONSTRAINT so the
scan does not block the previous release's writes during a deploy, and verified from a full
supabase db reset. RLS, grants and policies are untouched.

Not done, deliberately

  • Nobody has looked at the session surface in a browser — jsdom and role queries only.
  • One real session, run by a person, is the second half of this phase's exit criterion and is not
    something a test can discharge.
  • The eval digests in .eval/live-session/ want a domain-literate read.
  • Tier 1/Tier 2 generative surfaces and CopilotKit are P2b; the placement interview, material
    prefetch and the rich closing ceremony are P2c.

The walking skeleton for the session loop. A learner opens a session on a
graph, the session is persisted, and it comes back already teaching its
first turn. The director is a stub and the tutor's words are a format
string — what this proves is that the path is wired, with one session_id
correlating web contract, API, package and store.

Three shapes worth naming, because T2-T6 build on them:

- The turn is the unit, and it carries the move. The director's trace and
  the learner's transcript are the same sequence read two ways, so they
  cannot disagree about what happened. A move's reason is required: a
  session is dozens of choices made in seconds on someone's behalf.
- The graph is referenced, never embedded. C1 grows a map at runtime, so a
  copy taken at session start would be stale the first time the learner
  asked something the map did not cover.
- A session is a row, not connection state. Forty minutes of context about
  one learner must survive a page reload.

Review caught a real authorization bug in both stores: owner_id=None
skipped the owner check, so an unscoped read reached an owned session.
Phase 1 had already settled this for graphs — treating None as 'see
everything' is safe only while auth stays unconfigured, which is a property
of two config flags agreeing, not of the store. The deeper problem was that
nothing could have caught it: the API suite runs with auth off, so it
cannot exercise cross-owner isolation at all. There is now a store-contract
suite that went red on the real bug.

Also from review: X-Session-Id now rides failures rather than only success,
the router logs before letting an unexpected error become a 500, and the
resume path is correlated.
…a, T2)

The half of the loop the director reads and the grader writes: a belief per
concept, moved by graded evidence and decayed by turns without it.

Three choices worth naming:

- The stored estimate is the belief at its last evidence, NOT the belief
  now. Now is recall_of, a decay applied at read time. A persisted number
  that changed meaning with every passing turn could not be compared with
  itself between sessions.
- Decay is measured in turns, not wall time, so a session is reproducible —
  replaying the same answers gives the same beliefs, which is what will
  make the simulated-learner eval mean anything. Real cross-session
  forgetting happens over days and is deliberately NOT modelled; a curve
  invented now would be indistinguishable from a fitted one to everything
  downstream, which is the dangerous kind of placeholder.
- Recall decays towards a floor rather than to zero: a concept demonstrated
  once is not the same as one never seen, and the director should prefer
  retrieving the first over introducing it from scratch.

Knowledge is one row per concept, unlike its sibling tables. A turn
produces evidence about exactly one concept, so a per-concept upsert
touches one row where a document rewrite would rewrite the learner's whole
history of the map on every answer.

The RLS posture is sharper here than for graphs or sessions: the director
gates every introduction on this number, so a learner who could write it
could skip the curriculum. SELECT only, and the grader is the sole writer.
… T3)

The director: a pure function over (graph, learner model, clock). A scored
rule set rather than a model call, per plan section 7 — legible,
deterministic, exhaustively testable without a key, and the place
pedagogical iteration will happen once there is session data to iterate
against.

Rules run in a fixed order, and the order is the policy: the clock outranks
everything; a stuck learner is not walked away from; a slipping concept
interrupts new material; otherwise teach something new whose prerequisites
are met; nothing left is a reason to stop rather than loop. Most of the
tests put two rules in competition and pin which wins, because a director
that introduces while the learner is stuck is wrong in a way no single-rule
test would catch.

All 20 tests passed on the first implementation, which in this journey is a
warning rather than a result. Mutation testing found three real problems:

- _MIN_EVIDENCE was dead code. One answer already lands below the mastery
  threshold, so the guard restated a rule in a second place that could
  drift from the first. Deleted.
- The prerequisite gate was unobservable. Against a valid teaching order it
  is redundant, so hardcoding the branch true passed every test. It is kept
  — C1 grows maps at runtime and these are public functions anyone can call
  with a hand-built graph — and is now pinned by a test with a deliberately
  lying topo_order, the only state that can see it.
- One test was weak twice over: asserting "not b" passes when a lowered
  threshold picks the other root, and asserting the node id alone passes
  when the same change turns the move into a retrieval. It now pins both.
A session's opening turn is now real on both halves: the director picks it from what this
learner is already believed to know, and a tutor says it over the concept's authored
teaching notes rather than a fixed string.

The tutor is a seam with two implementations — a deterministic one for CI and keyless dev,
and a Claude one whose prompt is mostly the node's own objective and misconceptions, passed
verbatim. Phase 1 pays a model call per concept to author those; this is the moment they
were for. Each move asks for something different: an introduction opens from something
concrete, a retrieval refuses to re-explain, a remediation refuses to repeat what has
already failed.

A tutor that cannot speak fails the turn rather than falling back to the definition, and
nothing is persisted when it does — a saved shell would be a resumable transcript with
nothing in it. It surfaces as a retryable 503, told apart from storage being down.

Each turn now carries its own run_id, so a line of transcript can be traced to the model
calls behind it; a stored session this build cannot parse is told apart from an outage,
because "try again" on an unreadable row is an invitation to reload forever.

The stub compiler now authors teaching notes, so the offline path can exercise teaching at
all. Also fixes two typecheck errors already on this branch in the web session client test.
The turn now stages one of the concept's do-statements, the learner answers in their own
words, and a separate grader scores that answer against that statement. The belief moves,
and the director reads the moved belief to decide what happens next — so a session responds
to what the learner just demonstrated rather than to what they demonstrated a turn ago.

The grader is deliberately not the tutor: a teacher marking its own homework would put its
own bias into the model that decides what gets skipped. And a grader that cannot answer is
never read as a wrong answer — an outage must not teach the system that somebody does not
understand a concept.

Running a whole session, rather than reading the tests, found two things the suite could
not see. The director was re-introducing concepts the learner had already mastered, because
recall dips below the mastery bar long before it falls far enough to be worth revisiting;
what was earned is now judged on the undecayed belief and only what has faded on recall. And
the tutor was repeating its previous turn word for word, because it had never been told what
it had already said.

Answering is a new endpoint, and a session that has run out of material closes rather than
looping. The transcript is written before the belief: a crash between the two writes then
under-counts evidence instead of double-counting it, which is the direction that cannot
hand somebody a concept they have not earned.
…2a, T6)

A session now knows when it started, so the 25-40 minute budget the plan asks for is real
rather than a setting nothing could reach: the clock is wall time measured from the row, so
it survives a reload instead of resetting with the tab. When it runs out, the session says
so — the ending is a turn the learner can read, carrying the director's own reason, rather
than a status change that leaves the transcript looking like a crash.

An answer now names the turn it is answering. Pressing send twice used to grade the second
copy against the question that replaced the first, filing the words under a criterion they
were never written for. The sequential case is refused outright; the concurrent one cannot
be settled from a snapshot, because both requests read the same head and both are right
about it, so the store settles it instead: writing a turn is now conditional on the session
still being the length the request read.

A session written before sessions were dated is now unreadable rather than silently
re-clocked, which is the honest answer for one nobody can date — a default would have handed
it a fresh budget on every reload. And a host whose clock steps backwards mid-session no
longer fails the turn.
The transcript and a box to answer in — the plainest thing that can carry a session, on the
stack Phase 1 already proved. Each turn shows what the director chose and why, what the
tutor said, what the learner was asked to demonstrate, what they answered and how it was
marked. Showing the reason is deliberate: a session is dozens of choices made on somebody's
behalf in seconds, and being told why is the difference between being taught and being
steered.

Every state a session can really be in is rendered rather than assumed — opening, waiting on
the learner, marking their answer, ended, unable to open, and failing mid-session with the
transcript still on screen, because a failed send is not a lost session. Their own words
appear the moment they send them; the verdict waits for the server, because guessing one
would be the surface marking work it never marked.

Enter stays a newline and ⌘↵ sends, submit is never pre-disabled, an empty send explains
itself and puts the caret back, and the transcript announces as it grows.
A session spends on every turn, so it becomes a subject the ledger knows by name: its own
cost rows, keyed to the sitting rather than to the map it walks, since a map outlives every
session run on it and "what did this session cost" is a question about one sitting. The
ledger's key was generalised for exactly this, so all it needed was the enum value and a
constraint widened — validated without blocking the writes the previous release is still
making during a deploy.

A tenant's session now runs on their own key. The model client is built on first use inside
the tutor and reads its key off a contextvar, so resolving credentials without scoping the
call would have taught their session on the platform's key, on the surface that spends most.

Openings are rationed per learner per day, and a session takes one turn at a time. That
second one is what makes the cost ceiling mean anything: two answers sent at once both pass
every check made against the session they loaded, and both pay a grader and a tutor before
either tries to write — so the refusal has to happen before the money, not after. And the
ledger itself is now bounded in time as well as in failure: a store that hangs would
otherwise hold the turn open with no way out, which is worse than one that simply fails.
Two halves. The deterministic one asks whether the loop is total: every teaching depth,
every criterion kind, every verdict approached from both sides, maps that are one concept
wide and five deep and twenty across, concepts the compiler left unspecified, and the things
a learner actually types — one word, a shrug, an emoji, another script, markdown, a prompt
injection, nine thousand characters.

The keyed one asks whether the policy teaches, and asserts on the director's trace rather
than on prose: a session is one sample of a generative system, and an assertion about how
well an explanation reads would be a coin toss dressed as a test. Three simulated learners,
each of which has to force a move — somebody who cannot answer must be remediated rather
than marched onward, somebody fluent must be advanced, and somebody confidently wrong must
not be marked correct or built upon.

The wrong idea is the map's own. An earlier version hard-coded one about a concept eight
turns of teaching never reached, so the learner answered everything else correctly and the
test passed on an unrelated verdict; it now takes the misconception the compiler authored
for the concept the session opens on. Running it, the learner insisted twice that inputs
pass through a neuron unmultiplied, was refused twice, was remediated with a different
explanation each time, and gave the idea up on the fifth turn.
@PouyanJay
PouyanJay merged commit e0c330b into main Aug 11, 2026
6 checks passed
@PouyanJay
PouyanJay deleted the feat/live-session-loop branch August 11, 2026 01:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant