From 96614d957265544ad7b071053787c794091edd98 Mon Sep 17 00:00:00 2001 From: Pouyan Jahangiri Date: Sun, 9 Aug 2026 20:53:18 -0700 Subject: [PATCH 1/9] feat(live): open a session on a compiled map (Phase 2a, T1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/src/lunaris_api/app.py | 2 + apps/api/src/lunaris_api/live/dependencies.py | 11 +- .../src/lunaris_api/live/session/__init__.py | 3 + .../lunaris_api/live/session/dependencies.py | 36 ++++ .../src/lunaris_api/live/session/router.py | 88 ++++++++++ .../src/lunaris_api/live/session/schemas.py | 16 ++ .../src/lunaris_api/live/session/service.py | 62 +++++++ apps/api/tests/live/test_live_sessions_api.py | 116 +++++++++++++ apps/web/src/lib/liveSession.test.ts | 83 +++++++++ apps/web/src/lib/liveSession.ts | 122 +++++++++++++ .../live/src/lunaris_live/session/__init__.py | 27 +++ .../session/memory_session_store.py | 32 ++++ .../src/lunaris_live/session/open_session.py | 41 +++++ .../session/protocols/__init__.py | 3 + .../session/protocols/session_store.py | 21 +++ .../lunaris_live/session/schema/__init__.py | 15 ++ .../session/schema/director_move.py | 19 +++ .../lunaris_live/session/schema/move_kind.py | 19 +++ .../lunaris_live/session/schema/session.py | 22 +++ .../session/schema/session_status.py | 10 ++ .../session/schema/session_turn.py | 20 +++ .../session/supabase_session_store.py | 79 +++++++++ packages/live/tests/test_session_stores.py | 128 ++++++++++++++ .../20260809210000_live_sessions.sql | 61 +++++++ tests/db/test_live_sessions_rls.py | 160 ++++++++++++++++++ 25 files changed, 1193 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/lunaris_api/live/session/__init__.py create mode 100644 apps/api/src/lunaris_api/live/session/dependencies.py create mode 100644 apps/api/src/lunaris_api/live/session/router.py create mode 100644 apps/api/src/lunaris_api/live/session/schemas.py create mode 100644 apps/api/src/lunaris_api/live/session/service.py create mode 100644 apps/api/tests/live/test_live_sessions_api.py create mode 100644 apps/web/src/lib/liveSession.test.ts create mode 100644 apps/web/src/lib/liveSession.ts create mode 100644 packages/live/src/lunaris_live/session/__init__.py create mode 100644 packages/live/src/lunaris_live/session/memory_session_store.py create mode 100644 packages/live/src/lunaris_live/session/open_session.py create mode 100644 packages/live/src/lunaris_live/session/protocols/__init__.py create mode 100644 packages/live/src/lunaris_live/session/protocols/session_store.py create mode 100644 packages/live/src/lunaris_live/session/schema/__init__.py create mode 100644 packages/live/src/lunaris_live/session/schema/director_move.py create mode 100644 packages/live/src/lunaris_live/session/schema/move_kind.py create mode 100644 packages/live/src/lunaris_live/session/schema/session.py create mode 100644 packages/live/src/lunaris_live/session/schema/session_status.py create mode 100644 packages/live/src/lunaris_live/session/schema/session_turn.py create mode 100644 packages/live/src/lunaris_live/session/supabase_session_store.py create mode 100644 packages/live/tests/test_session_stores.py create mode 100644 supabase/migrations/20260809210000_live_sessions.sql create mode 100644 tests/db/test_live_sessions_rls.py diff --git a/apps/api/src/lunaris_api/app.py b/apps/api/src/lunaris_api/app.py index 76dc2950..35c64f58 100644 --- a/apps/api/src/lunaris_api/app.py +++ b/apps/api/src/lunaris_api/app.py @@ -27,6 +27,7 @@ get_video_storage, ) from .live import router as live_router +from .live.session import router as live_session_router from .routers import ( activity, admin_users, @@ -133,6 +134,7 @@ def _register_routers(app: FastAPI) -> None: app.include_router(courses.router) # Live's region, mounted as a whole; Studio's app never names lunaris_live itself. app.include_router(live_router) + app.include_router(live_session_router) app.include_router(briefs.router) app.include_router(runs.router) app.include_router(bridge.router) diff --git a/apps/api/src/lunaris_api/live/dependencies.py b/apps/api/src/lunaris_api/live/dependencies.py index 769de12e..2b75949e 100644 --- a/apps/api/src/lunaris_api/live/dependencies.py +++ b/apps/api/src/lunaris_api/live/dependencies.py @@ -37,8 +37,13 @@ _memory_graph_store = MemoryGraphStore() -def _resolve_graph_store(settings: Settings) -> IGraphStore: - """Durable where Supabase is configured, in-process otherwise (offline dev and the suite).""" +def resolve_graph_store(settings: Settings) -> IGraphStore: + """Durable where Supabase is configured, in-process otherwise (offline dev and the suite). + + Public because the session plane composes against the *same* store: a session reading from a + different store than the compiler wrote to would find no maps, and it would read as a data + problem rather than the wiring one it is. + """ return _supabase_graph_store if settings.has_supabase else _memory_graph_store @@ -100,7 +105,7 @@ def get_live_graph_service( """ return LiveGraphService( _resolve_compiler(settings), - _resolve_graph_store(settings), + resolve_graph_store(settings), cost_event_store=cost_event_store, subject_cost_store=subject_cost_store, credential_resolver=get_live_credential_resolver(settings), diff --git a/apps/api/src/lunaris_api/live/session/__init__.py b/apps/api/src/lunaris_api/live/session/__init__.py new file mode 100644 index 00000000..5bc0c2e6 --- /dev/null +++ b/apps/api/src/lunaris_api/live/session/__init__.py @@ -0,0 +1,3 @@ +from .router import router + +__all__ = ["router"] diff --git a/apps/api/src/lunaris_api/live/session/dependencies.py b/apps/api/src/lunaris_api/live/session/dependencies.py new file mode 100644 index 00000000..c61c2619 --- /dev/null +++ b/apps/api/src/lunaris_api/live/session/dependencies.py @@ -0,0 +1,36 @@ +from typing import Annotated + +from fastapi import Depends +from lunaris_live.session import ISessionStore, MemorySessionStore, SupabaseSessionStore + +from ...config import Settings, get_settings +from ..dependencies import resolve_graph_store +from .service import LiveSessionService + +# One durable store per process — same lazy-client rationale as the graph store: the service-role +# client is built on first write, so the singleton needs no creds and no network until then. +_supabase_session_store = SupabaseSessionStore() + +# The in-memory fallback MUST be a singleton: opening a session and the next turn of it are separate +# requests, so a per-request store would lose the session between them. +_memory_session_store = MemorySessionStore() + + +def _resolve_session_store(settings: Settings) -> ISessionStore: + """Durable where Supabase is configured, in-process otherwise (offline dev and the suite).""" + return _supabase_session_store if settings.has_supabase else _memory_session_store + + +def get_live_session_service( + settings: Annotated[Settings, Depends(get_settings)], +) -> LiveSessionService: + """Live's session plane as a request dependency. + + Takes the *same* graph store the compile plane writes to rather than composing a second one — + a session that read from a different store than the compiler wrote to would find no maps at all, + and it would look like a data problem rather than a wiring one. + """ + return LiveSessionService(resolve_graph_store(settings), _resolve_session_store(settings)) + + +LiveSessionServiceDep = Annotated[LiveSessionService, Depends(get_live_session_service)] diff --git a/apps/api/src/lunaris_api/live/session/router.py b/apps/api/src/lunaris_api/live/session/router.py new file mode 100644 index 00000000..4f059d9a --- /dev/null +++ b/apps/api/src/lunaris_api/live/session/router.py @@ -0,0 +1,88 @@ +from uuid import uuid4 + +import structlog +from fastapi import APIRouter, HTTPException, Response, status +from lunaris_live.session import Session +from lunaris_runtime.persistence import PersistenceError + +from ...dependencies import OptionalUserIdDep +from .dependencies import LiveSessionServiceDep +from .schemas import SessionStartRequest + +logger = structlog.get_logger() + +router = APIRouter(prefix="/api/live/sessions", tags=["live"]) + +_UNAVAILABLE = "Live is having trouble reaching its storage. Try again shortly." + + +@router.post("", response_model=Session, status_code=status.HTTP_201_CREATED) +async def start_session( + payload: SessionStartRequest, + service: LiveSessionServiceDep, + response: Response, + owner_id: OptionalUserIdDep, +) -> Session: + """Open a session on a compiled map and hand back its first turn. + + Answers 201 with the session already teaching rather than an empty shell the surface then has to + poll: a session that opens with nothing to show is a loading spinner with a database row behind + it. ``X-Session-Id`` rides the response so a learner reporting "it went wrong" can name the + session across every layer's logs. + """ + # Minted here, and put on the response before the work: a header set only after success is + # absent from exactly the failures somebody needs to report. Every raise below carries it + # explicitly, because raising an HTTPException discards the response object built here. + session_id = uuid4().hex + response.headers["X-Session-Id"] = session_id + correlated = {"X-Session-Id": session_id} + try: + return await service.start(payload.graph_id, session_id=session_id, owner_id=owner_id) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Map not found", headers=correlated + ) from exc + except Exception as exc: + # Logged with the traceback because nothing below the router does — the stores stay silent + # and ``guard`` only translates. Without this an outage is a bare 500 with no way to tell + # what broke, on the path where a learner has just lost a session before it began. + logger.warning("live.session.start_failed", session_id=session_id, exc_info=True) + if isinstance(exc, PersistenceError): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=_UNAVAILABLE, + headers=correlated, + ) from exc + raise + + +@router.get("/{session_id}", response_model=Session) +async def read_session( + session_id: str, + service: LiveSessionServiceDep, + response: Response, + owner_id: OptionalUserIdDep, +) -> Session: + """Re-read a session, so a reloaded tab lands back where the learner was (U2). + + Another learner's session is 404, not 403 — a session's existence is itself owner-scoped + information, the same posture Phase 1 took for graphs. + """ + correlated = {"X-Session-Id": session_id} + try: + session = await service.load(session_id, owner_id=owner_id) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Session not found", headers=correlated + ) from exc + except Exception as exc: + logger.warning("live.session.read_failed", session_id=session_id, exc_info=True) + if isinstance(exc, PersistenceError): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=_UNAVAILABLE, + headers=correlated, + ) from exc + raise + response.headers["X-Session-Id"] = session_id + return session diff --git a/apps/api/src/lunaris_api/live/session/schemas.py b/apps/api/src/lunaris_api/live/session/schemas.py new file mode 100644 index 00000000..16d54ba0 --- /dev/null +++ b/apps/api/src/lunaris_api/live/session/schemas.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class SessionStartRequest(BaseModel): + """Open a session on a map the learner already has. + + The graph id and nothing else: everything the session needs to teach is already on the map, and + a request carrying teaching preferences would be configuring the tutor at the door rather than + letting the director learn them from what the learner does (P2c's placement interview is where + priors come from). + """ + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + graph_id: str = Field(min_length=1, max_length=100) diff --git a/apps/api/src/lunaris_api/live/session/service.py b/apps/api/src/lunaris_api/live/session/service.py new file mode 100644 index 00000000..b4b9294b --- /dev/null +++ b/apps/api/src/lunaris_api/live/session/service.py @@ -0,0 +1,62 @@ +import asyncio + +import structlog +from lunaris_live.graph import IGraphStore +from lunaris_live.session import ISessionStore, Session, open_session +from lunaris_runtime.logging import bind_run_id + +logger = structlog.get_logger() + + +class LiveSessionService: + """Opens and re-reads a learner's sessions. + + Orchestration only, like ``LiveGraphService``: mint the id, bind correlation, read the map, take + the first turn, persist. What a turn *should* be is the director's and the tutor's business, and + they arrive behind their own seams (T3, T4) without this changing. + + The graph is read fresh on every session, never cached: C1 grows a map at runtime, so a copy + taken once would be stale the first time a learner asked something off it. + """ + + def __init__(self, graphs: IGraphStore, sessions: ISessionStore) -> None: + self._graphs = graphs + self._sessions = sessions + + async def start( + self, graph_id: str, *, session_id: str, owner_id: str | None = None + ) -> Session: + """Open a session on ``graph_id`` and take its first turn. + + The id is minted by the router and passed *in* so it can ride a failure response as well as + a success one — a learner reporting "it went wrong" needs to name the session precisely when + it went wrong, which is the case a header set after the work would never cover. + + Raises ``FileNotFoundError`` when the learner has no such map — including when it is + somebody else's, which is not-found rather than forbidden. + """ + # Bound before any I/O: the graph id is known from the request, so deferring this only means + # a hung read leaves no trace that the session was ever asked for. + bind_run_id(session_id, graph_id=graph_id, session_id=session_id) + logger.info("live.session.starting", graph_id=graph_id, session_id=session_id) + + # The stores are synchronous (supabase-py is), so keep the loop free while they work. + graph = await asyncio.to_thread(self._graphs.load, graph_id, owner_id=owner_id) + session = open_session(graph, session_id=session_id) + await asyncio.to_thread(self._sessions.save, session, owner_id=owner_id) + + # No explicit ids: this line rides the contextvars binding above, so the correlation test + # proves propagation rather than proving they were threaded through by hand. + logger.info("live.session.started", turn_count=len(session.turns)) + return session + + async def load(self, session_id: str, *, owner_id: str | None = None) -> Session: + """Re-read a session so a reloaded tab lands back in it (U2). + + Correlated like the open: the resume path is the one U2 exists to make work, so a resume + that fails must be as findable in the logs as an open that fails. + """ + bind_run_id(session_id, session_id=session_id) + session = await asyncio.to_thread(self._sessions.load, session_id, owner_id=owner_id) + logger.info("live.session.resumed", turn_count=len(session.turns)) + return session diff --git a/apps/api/tests/live/test_live_sessions_api.py b/apps/api/tests/live/test_live_sessions_api.py new file mode 100644 index 00000000..71228d57 --- /dev/null +++ b/apps/api/tests/live/test_live_sessions_api.py @@ -0,0 +1,116 @@ +"""Lunaris Live, Phase 2a — the walking skeleton, end to end through the API. + +A learner opens a session on a compiled graph, the session is persisted, and it comes back with its +first turn. Nothing here asserts *teaching*: the director is a stub that always opens on the map's +first concept and the tutor's words are a fixed string. What this pins is that the whole path is +wired — web-facing contract → service → ``lunaris_live.session`` → store — with one ``session_id`` +correlating the lot, and that a session is a row rather than connection state (U2), so a reload +returns the learner to where they were. + +Exercised through the real ASGI app over httpx; the only stub is the compiler behind the graph the +session runs on. +""" + +from collections.abc import AsyncIterator +from pathlib import Path + +import httpx +import pytest +from lunaris_api.app import create_app +from lunaris_api.config import Settings, get_settings + + +@pytest.fixture +async def client(tmp_path: Path) -> AsyncIterator[httpx.AsyncClient]: + app = create_app() + app.dependency_overrides[get_settings] = lambda: Settings( + pipeline="stub", course_dir=tmp_path, cors_origins=(), env_file=tmp_path / ".env" + ) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + + +async def _graph(client: httpx.AsyncClient) -> dict: + """A compiled map for the session to run on — Phase 1's surface, used as given.""" + return ( + await client.post("/api/live/graphs", json={"topic": "How neural networks learn"}) + ).json() + + +async def test_opening_a_session_returns_its_first_turn(client: httpx.AsyncClient) -> None: + # Arrange + graph = await _graph(client) + + # Act + response = await client.post("/api/live/sessions", json={"graphId": graph["graphId"]}) + + # Assert — the contract the surface renders: a live session, on a known map, already teaching. + assert response.status_code == 201, response.text + session = response.json() + assert session["graphId"] == graph["graphId"] + assert session["status"] == "active" + assert session["turns"], "a session that opens with nothing to show is a loading spinner" + + first = session["turns"][0] + assert first["seq"] == 1 + # Every turn is a director move plus what the tutor said about it (A2). The move is what makes + # the transcript auditable — without it a turn is prose nobody can explain the choice of. + assert first["move"]["kind"] == "introduce" + assert first["move"]["nodeId"] in {node["id"] for node in graph["nodes"]} + assert first["move"]["reason"], "a move with no reason cannot be audited (plan §7)" + assert first["tutor"].strip() + + # The run id has to ride the response: a session is many minutes of work across many turns, and + # a learner reporting "it went wrong" needs to name it. + assert response.headers["X-Session-Id"] == session["sessionId"] + + +async def test_the_first_concept_is_one_with_nothing_before_it(client: httpx.AsyncClient) -> None: + """Even a stub director may not open in the middle of the map. The whole point of Phase 1's + ordering is that a learner is never shown a concept whose prerequisites they have not met.""" + # Arrange + graph = await _graph(client) + + # Act + session = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() + + # Assert + opened_on = session["turns"][0]["move"]["nodeId"] + node = next(n for n in graph["nodes"] if n["id"] == opened_on) + assert node["requires"] == [], f"{opened_on} was taught before its prerequisites" + + +async def test_a_session_is_a_row_not_a_connection(client: httpx.AsyncClient) -> None: + """U2: a 25-40 minute session that dies on a refresh cannot be tested end to end, and Phase 1 + already learned this shape once when a dropped stream nearly cancelled its compile (AD12).""" + # Arrange + graph = await _graph(client) + opened = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() + + # Act — a second request, the way a reloaded tab re-opens the session it was in. + response = await client.get(f"/api/live/sessions/{opened['sessionId']}") + + # Assert — the same session, at the same turn, not a fresh one. + assert response.status_code == 200, response.text + resumed = response.json() + assert resumed["sessionId"] == opened["sessionId"] + assert resumed["turns"] == opened["turns"] + + +async def test_a_session_on_a_map_that_is_not_there_is_not_found( + client: httpx.AsyncClient, +) -> None: + # Act + response = await client.post("/api/live/sessions", json={"graphId": "no-such-map"}) + + # Assert — and the id rides the failure. A header set only once the work succeeded would be + # absent from exactly the responses somebody needs to report, which is the whole point of it. + assert response.status_code == 404 + assert response.headers["X-Session-Id"] + + +async def test_another_owners_session_is_not_found(client: httpx.AsyncClient) -> None: + """A session's existence is owner-scoped information, so a stranger gets 404 rather than 403 — + the same posture Phase 1 took for graphs.""" + assert (await client.get("/api/live/sessions/no-such-session")).status_code == 404 diff --git a/apps/web/src/lib/liveSession.test.ts b/apps/web/src/lib/liveSession.test.ts new file mode 100644 index 00000000..cb9d621f --- /dev/null +++ b/apps/web/src/lib/liveSession.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { loadSession, LiveSessionError, startSession } from "./liveSession"; + +const SESSION = { + sessionId: "s1", + graphId: "g1", + status: "active", + turns: [ + { + seq: 1, + move: { kind: "introduce", nodeId: "a", reason: "Opening concept." }, + tutor: "Let's start with Gravity.", + }, + ], +}; + +function withFetch(response: Response, run: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = (() => Promise.resolve(response)) as unknown as typeof fetch; + return run().finally(() => { + globalThis.fetch = original; + }); +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("liveSession — opening and resuming a session", () => { + it("returns a session that is already teaching", async () => { + const session = await withFetch(json(SESSION, 201), () => startSession("", "g1")); + + expect(session.sessionId).toBe("s1"); + // The move rides every turn: without it the transcript is prose nobody can explain the + // choice of, which is the whole point of the director emitting its reasoning. + expect(session.turns[0].move.kind).toBe("introduce"); + expect(session.turns[0].move.reason).toBe("Opening concept."); + }); + + it("resumes the session a reloaded tab was in", async () => { + const session = await withFetch(json(SESSION), () => loadSession("", "s1")); + + expect(session.turns).toHaveLength(1); + }); + + it("surfaces the server's own words when it refuses", async () => { + // "Map not found" and "storage is down" have different next steps for the learner, and a + // status code has neither. + await expect( + withFetch(json({ detail: "Map not found" }, 404), () => startSession("", "gone")), + ).rejects.toThrow(/Map not found/); + }); + + it("falls back to the status when a failure carries no message of ours", async () => { + await expect( + withFetch(new Response("nginx said no", { status: 502 }), () => startSession("", "g1")), + ).rejects.toThrow(/HTTP 502/); + }); + + it("rejects a session it cannot read rather than handing a half-shape to the view", async () => { + // The transcript maps over `turns` directly, so a payload missing it must fail here rather + // than as a raw TypeError inside render. + await expect( + withFetch(json({ sessionId: "s1", graphId: "g1", status: "active" }), () => + loadSession("", "s1"), + ), + ).rejects.toBeInstanceOf(LiveSessionError); + }); + + it("reports an unreachable server as a session failure, not a crash", async () => { + const original = globalThis.fetch; + globalThis.fetch = (() => Promise.reject(new Error("offline"))) as unknown as typeof fetch; + try { + await expect(startSession("", "g1")).rejects.toBeInstanceOf(LiveSessionError); + } finally { + globalThis.fetch = original; + } + }); +}); diff --git a/apps/web/src/lib/liveSession.ts b/apps/web/src/lib/liveSession.ts new file mode 100644 index 00000000..580ed403 --- /dev/null +++ b/apps/web/src/lib/liveSession.ts @@ -0,0 +1,122 @@ +import { authedFetch } from "./apiClient"; +import { detailOf } from "./apiErrors"; + +/** What the director decided to do next — the plan's four moves, and only these four. */ +export type MoveKind = "introduce" | "retrieve" | "remediate" | "close"; + +/** One decision by the director, with the reasoning that produced it. + * + * `reason` is not decoration: a session is dozens of choices made in seconds on the learner's + * behalf, and it is the only way to tell a good policy from a lucky one afterwards. */ +export interface DirectorMove { + kind: MoveKind; + /** The concept this move is about; null only for `close`, which is about the session. */ + nodeId: string | null; + reason: string; +} + +/** One beat of the loop: what the director chose, and what the tutor said about it. */ +export interface SessionTurn { + /** 1-based, monotonic — the order the learner lived it. */ + seq: number; + move: DirectorMove; + tutor: string; +} + +/** A learner's run at a concept graph. Persisted server-side, so a reload resumes it. */ +export interface LiveSession { + sessionId: string; + graphId: string; + status: "active" | "closed"; + turns: SessionTurn[]; +} + +/** Every way a session request can fail, as one error type — so the surface has one failure state + * to render rather than one per status and payload shape. */ +export class LiveSessionError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "LiveSessionError"; + } +} + +/** Open a session on a compiled map. Resolves with the session already teaching its first turn — + * an empty shell the surface then had to poll would be a loading spinner with a row behind it. */ +export async function startSession( + apiBaseUrl: string, + graphId: string, + signal?: AbortSignal, +): Promise { + return request( + apiBaseUrl, + `${apiBaseUrl}/api/live/sessions`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ graphId }), + ...(signal ? { signal } : {}), + }, + "Couldn't start a session on this map.", + ); +} + +/** Re-read a session, so a reloaded tab lands back where the learner was. */ +export async function loadSession( + apiBaseUrl: string, + sessionId: string, + signal?: AbortSignal, +): Promise { + return request( + apiBaseUrl, + `${apiBaseUrl}/api/live/sessions/${encodeURIComponent(sessionId)}`, + signal ? { signal } : {}, + "Couldn't reopen that session.", + ); +} + +async function request( + _apiBaseUrl: string, + url: string, + init: RequestInit, + fallback: string, +): Promise { + let response: Response; + try { + response = await authedFetch(url, init); + } catch (cause) { + throw new LiveSessionError("Could not reach the session.", { cause }); + } + if (!response.ok) { + // The server's own words where it has any — "map not found" and "storage is down" have + // different next steps for the learner, and a status code has neither. + throw new LiveSessionError( + (await detailOf(response)) ?? `${fallback} (HTTP ${response.status})`, + ); + } + const body: unknown = await response.json(); + if (!isSession(body)) { + throw new LiveSessionError("Couldn't read the session (unexpected response)."); + } + return body; +} + +/** Every field the surface reads, checked here so the view never has to. `turns` earns its place in + * particular: the transcript maps over it directly, so a payload missing it would throw a raw + * TypeError inside render rather than surfacing as the recoverable error this boundary promises. */ +function isSession(payload: unknown): payload is LiveSession { + const body = payload as LiveSession | null; + return ( + !!body && + typeof body.sessionId === "string" && + typeof body.graphId === "string" && + (body.status === "active" || body.status === "closed") && + Array.isArray(body.turns) && + body.turns.every( + (turn) => + typeof turn?.seq === "number" && + typeof turn?.tutor === "string" && + typeof turn?.move?.kind === "string" && + typeof turn?.move?.reason === "string", + ) + ); +} diff --git a/packages/live/src/lunaris_live/session/__init__.py b/packages/live/src/lunaris_live/session/__init__.py new file mode 100644 index 00000000..bb1ed863 --- /dev/null +++ b/packages/live/src/lunaris_live/session/__init__.py @@ -0,0 +1,27 @@ +"""Lunaris Live's session runtime — the loop that walks a compiled map with a learner. + +Phase 1 built the map (``lunaris_live.graph``); this is what walks it. The graph is a map of the +subject, never a route through the session: the conversation drives, and the map keeps score. + +Phase 2a holds the loop's skeleton — what a turn is, what the director decided, and where a session +lives between requests. The director's policy, the tutor, the grader and the learner model land on +top of these contracts without changing them. +""" + +from .memory_session_store import MemorySessionStore +from .open_session import open_session +from .protocols import ISessionStore +from .schema import DirectorMove, MoveKind, Session, SessionStatus, SessionTurn +from .supabase_session_store import SupabaseSessionStore + +__all__ = [ + "DirectorMove", + "ISessionStore", + "MemorySessionStore", + "MoveKind", + "Session", + "SessionStatus", + "SessionTurn", + "SupabaseSessionStore", + "open_session", +] diff --git a/packages/live/src/lunaris_live/session/memory_session_store.py b/packages/live/src/lunaris_live/session/memory_session_store.py new file mode 100644 index 00000000..1afe2598 --- /dev/null +++ b/packages/live/src/lunaris_live/session/memory_session_store.py @@ -0,0 +1,32 @@ +from .schema import Session + + +class MemorySessionStore: + """In-process session store — offline dev and the suite. + + A singleton in the composition root, like ``MemoryGraphStore``: opening a session and the next + turn of it are separate requests, so a per-request store would lose the session between them. + """ + + def __init__(self) -> None: + self._sessions: dict[str, Session] = {} + # Kept parallel to the sessions so ``Session`` stays a clean wire contract with no owner + # field on it — the same split the in-memory cost store uses. + self._owners: dict[str, str | None] = {} + + def save(self, session: Session, *, owner_id: str | None = None) -> None: + self._sessions[session.session_id] = session + self._owners[session.session_id] = owner_id + + def load(self, session_id: str, *, owner_id: str | None = None) -> Session: + session = self._sessions.get(session_id) + if session is None: + raise FileNotFoundError(session_id) + # Exact match, so an *unscoped* read cannot reach an owned session either. Treating + # ``owner_id=None`` as "see everything" would be safe only while auth stays unconfigured — + # a property of two config flags agreeing, not of this store. It refuses on its own terms, + # the same rule ``MemoryGraphStore`` settled in Phase 1, and it matters more here: a + # session is a transcript of somebody being taught, not a map of a subject. + if self._owners.get(session_id) != owner_id: + raise FileNotFoundError(session_id) + return session diff --git a/packages/live/src/lunaris_live/session/open_session.py b/packages/live/src/lunaris_live/session/open_session.py new file mode 100644 index 00000000..226b82d5 --- /dev/null +++ b/packages/live/src/lunaris_live/session/open_session.py @@ -0,0 +1,41 @@ +from ..graph import ConceptGraph +from .schema import DirectorMove, MoveKind, Session, SessionTurn + +#: The walking skeleton's stand-in for the tutor (T4 replaces it with a real one). Deliberately +#: names the concept rather than being lorem: a fixed string that mentioned nothing would let the +#: whole path be wired to the wrong node without any test noticing. +_OPENING = "Let's start with {name}. {definition}" + + +def open_session(graph: ConceptGraph, *, session_id: str) -> Session: + """Open a session on ``graph`` and take its first turn. + + The skeleton's move policy is "the first concept in the map's own teaching order", which is not + the director (T3) — but it is not arbitrary either: ``topo_order`` puts prerequisites first, so + the opening concept provably has nothing before it. A skeleton that opened in the middle of the + map would be wired correctly and pedagogically wrong, and the difference matters enough that the + test pins it now rather than after the director lands. + + Raises ``ValueError`` on a map with no concepts — there is nothing to teach, and a session that + opened on it would show the learner an empty transcript and call it a lesson. + """ + if not graph.topo_order: + raise ValueError(f"graph {graph.graph_id} has no concepts to teach") + + first = next(node for node in graph.nodes if node.id == graph.topo_order[0]) + move = DirectorMove( + kind=MoveKind.INTRODUCE, + node_id=first.id, + reason="Opening concept: it is first in the map's teaching order, so nothing precedes it.", + ) + return Session( + session_id=session_id, + graph_id=graph.graph_id, + turns=[ + SessionTurn( + seq=1, + move=move, + tutor=_OPENING.format(name=first.name, definition=first.definition), + ) + ], + ) diff --git a/packages/live/src/lunaris_live/session/protocols/__init__.py b/packages/live/src/lunaris_live/session/protocols/__init__.py new file mode 100644 index 00000000..e78dd9ab --- /dev/null +++ b/packages/live/src/lunaris_live/session/protocols/__init__.py @@ -0,0 +1,3 @@ +from .session_store import ISessionStore + +__all__ = ["ISessionStore"] diff --git a/packages/live/src/lunaris_live/session/protocols/session_store.py b/packages/live/src/lunaris_live/session/protocols/session_store.py new file mode 100644 index 00000000..3c313527 --- /dev/null +++ b/packages/live/src/lunaris_live/session/protocols/session_store.py @@ -0,0 +1,21 @@ +from typing import Protocol + +from ..schema import Session + + +class ISessionStore(Protocol): + """Persistence for a learner's sessions. + + Synchronous because the durable implementation is supabase-py, which is; callers hop it to a + thread rather than this protocol pretending to be async. Mirrors ``IGraphStore`` exactly, for + the same reason it did: the in-memory and Supabase implementations have to be substitutable + without a caller knowing which it has. + + ``owner_id`` is the authenticated learner. ``load`` raises ``FileNotFoundError`` when there is + no such session **for that owner** — another learner's session is not-found rather than + forbidden, because its existence is itself owner-scoped information. + """ + + def save(self, session: Session, *, owner_id: str | None = None) -> None: ... + + def load(self, session_id: str, *, owner_id: str | None = None) -> Session: ... diff --git a/packages/live/src/lunaris_live/session/schema/__init__.py b/packages/live/src/lunaris_live/session/schema/__init__.py new file mode 100644 index 00000000..e6235f32 --- /dev/null +++ b/packages/live/src/lunaris_live/session/schema/__init__.py @@ -0,0 +1,15 @@ +"""The session's contracts — what a turn is, and what the director decided to make it.""" + +from .director_move import DirectorMove +from .move_kind import MoveKind +from .session import Session +from .session_status import SessionStatus +from .session_turn import SessionTurn + +__all__ = [ + "DirectorMove", + "MoveKind", + "Session", + "SessionStatus", + "SessionTurn", +] diff --git a/packages/live/src/lunaris_live/session/schema/director_move.py b/packages/live/src/lunaris_live/session/schema/director_move.py new file mode 100644 index 00000000..dd0d1db0 --- /dev/null +++ b/packages/live/src/lunaris_live/session/schema/director_move.py @@ -0,0 +1,19 @@ +from pydantic import Field + +from ...graph.schema.base import LiveModel +from .move_kind import MoveKind + + +class DirectorMove(LiveModel): + """One decision by the director, with the reasoning that produced it. + + ``reason`` is not decoration. Plan §7: "the director emits its reasoning into the session trace + so every move is auditable" — a session is dozens of choices made in seconds on a learner's + behalf, and the only way to tell a good policy from a lucky one afterwards is to read why each + choice was made. It is written for a human reading a transcript, not for a parser. + """ + + kind: MoveKind + #: The concept this move is about; ``None`` only for ``CLOSE``, which is about the session. + node_id: str | None = None + reason: str = Field(min_length=1, max_length=500) diff --git a/packages/live/src/lunaris_live/session/schema/move_kind.py b/packages/live/src/lunaris_live/session/schema/move_kind.py new file mode 100644 index 00000000..e7a99b24 --- /dev/null +++ b/packages/live/src/lunaris_live/session/schema/move_kind.py @@ -0,0 +1,19 @@ +from enum import StrEnum + + +class MoveKind(StrEnum): + """What the director decided to do next — the plan's four moves (§7), and only these four. + + A closed set on purpose: the director is a *policy*, and a policy whose action space grows by + accident is one nobody can reason about. Adding a fifth move should be a deliberate change here + that every consumer of a trace is forced to notice. + """ + + #: Teach a concept the learner has not met, whose own prerequisites they have. + INTRODUCE = "introduce" + #: Come back to something learned earlier, before it decays past recall. + RETRIEVE = "retrieve" + #: The learner is stuck on the current concept — try it a different way. + REMEDIATE = "remediate" + #: Nothing left worth doing in this session, or the clock is spent. + CLOSE = "close" diff --git a/packages/live/src/lunaris_live/session/schema/session.py b/packages/live/src/lunaris_live/session/schema/session.py new file mode 100644 index 00000000..afba0ead --- /dev/null +++ b/packages/live/src/lunaris_live/session/schema/session.py @@ -0,0 +1,22 @@ +from pydantic import Field + +from ...graph.schema.base import LiveModel +from .session_status import SessionStatus +from .session_turn import SessionTurn + + +class Session(LiveModel): + """A learner's run at a concept graph: the turns so far, and whether it is still going. + + Persisted rather than held on a connection (U2). A session is 25-40 minutes of accumulated + context about one learner, and a page reload must not be able to destroy it — the same lesson + Phase 1 learned when a dropped stream nearly cancelled a three-minute compile (AD12). + + The graph is referenced, never embedded: it is mutable (C1 grows it mid-session), so a copy + taken at session start would be stale the first time the learner asked something off the map. + """ + + session_id: str = Field(min_length=1, max_length=100) + graph_id: str = Field(min_length=1, max_length=100) + status: SessionStatus = SessionStatus.ACTIVE + turns: list[SessionTurn] = Field(default_factory=list) diff --git a/packages/live/src/lunaris_live/session/schema/session_status.py b/packages/live/src/lunaris_live/session/schema/session_status.py new file mode 100644 index 00000000..0b8173b6 --- /dev/null +++ b/packages/live/src/lunaris_live/session/schema/session_status.py @@ -0,0 +1,10 @@ +from enum import StrEnum + + +class SessionStatus(StrEnum): + """Where a session is in its life. Bounded by design (plan §6: 25-40 minutes) — a session that + could run forever has no shape a learner can feel, and no cost ceiling.""" + + ACTIVE = "active" + #: The director closed it deliberately. Distinct from abandoned: this one ended *well*. + CLOSED = "closed" diff --git a/packages/live/src/lunaris_live/session/schema/session_turn.py b/packages/live/src/lunaris_live/session/schema/session_turn.py new file mode 100644 index 00000000..5c1b52d1 --- /dev/null +++ b/packages/live/src/lunaris_live/session/schema/session_turn.py @@ -0,0 +1,20 @@ +from pydantic import Field + +from ...graph.schema.base import LiveModel +from .director_move import DirectorMove + + +class SessionTurn(LiveModel): + """One beat of the loop: what the director chose, and what the tutor said about it. + + The turn is the unit that gets a row, so the director's trace and the learner's transcript are + the same sequence read two ways (A2) — which is what stops them ever disagreeing about what + happened. Later tasks add the learner's answer and its grade to this same record. + """ + + #: 1-based, monotonic within a session. The order the learner lived it. + seq: int = Field(ge=1) + move: DirectorMove + #: What the tutor said, in the learner's language. Empty is never valid: a turn the learner + #: cannot see is a decision that happened to them invisibly. + tutor: str = Field(min_length=1) diff --git a/packages/live/src/lunaris_live/session/supabase_session_store.py b/packages/live/src/lunaris_live/session/supabase_session_store.py new file mode 100644 index 00000000..35b9d093 --- /dev/null +++ b/packages/live/src/lunaris_live/session/supabase_session_store.py @@ -0,0 +1,79 @@ +import os + +from lunaris_runtime.persistence.guard import guard + +from .schema import Session + +_URL_ENV = "SUPABASE_URL" +_SERVICE_KEY_ENV = "SUPABASE_SERVICE_ROLE_KEY" +_TABLE = "live_sessions" + + +class SupabaseSessionStore: + """The durable session store: Supabase Postgres, ``jsonb`` payload, lazy service-role client. + + Same shape as ``SupabaseGraphStore`` — lazy construction so the composition root needs no creds + or network to build it, camelCase payload identical to the wire, and ``guard`` turning driver + failures into ``PersistenceError``. + + Like a graph and unlike a Studio course, a session is **mutable**: every turn rewrites the head. + So ``save`` upserts on ``id`` and the turns ride in the payload rather than in their own table. + One row per session, rewritten per turn, is right while a session is bounded to 25-40 minutes + (plan §6) and always read whole; a turns table earns its place when something wants to read one + turn without the rest, which nothing does yet. + """ + + def __init__( + self, + *, + url_env: str = _URL_ENV, + service_key_env: str = _SERVICE_KEY_ENV, + client: object | None = None, + ) -> None: + self._url_env = url_env + self._service_key_env = service_key_env + self._client = client + + def _ensure_client(self) -> object: + if self._client is None: + from supabase import create_client + + url = os.environ.get(self._url_env) + key = os.environ.get(self._service_key_env) + if not url or not key: + raise RuntimeError( + f"{self._url_env} / {self._service_key_env} not set; cannot persist sessions" + ) + self._client = create_client(url, key) + return self._client + + @guard("live_sessions upsert") + def save(self, session: Session, *, owner_id: str | None = None) -> None: + client = self._ensure_client() + row: dict[str, object] = { + "id": session.session_id, + "graph_id": session.graph_id, + "status": session.status.value, + # Lifted out of the payload because it is the one thing a resume needs to know without + # parsing the whole session: how far the learner got. + "turn_count": len(session.turns), + "payload": session.model_dump(mode="json", by_alias=True), + } + if owner_id is not None: + row["user_id"] = owner_id + client.table(_TABLE).upsert(row, on_conflict="id").execute() # type: ignore[attr-defined] + + @guard("live_sessions load") + def load(self, session_id: str, *, owner_id: str | None = None) -> Session: + client = self._ensure_client() + query = client.table(_TABLE).select("payload").eq("id", session_id) # type: ignore[attr-defined] + # Constrained in the query rather than checked after the read, and constrained EITHER WAY: + # the service-role client bypasses RLS, so this is the only thing standing between one + # learner's session and another's. An unscoped read matches only rows that are themselves + # unowned rather than seeing everything — the store must not depend on the auth wiring above + # it staying configured the way it is today. + query = query.is_("user_id", None) if owner_id is None else query.eq("user_id", owner_id) + rows = query.limit(1).execute().data + if not rows: + raise FileNotFoundError(session_id) + return Session.model_validate(rows[0]["payload"]) diff --git a/packages/live/tests/test_session_stores.py b/packages/live/tests/test_session_stores.py new file mode 100644 index 00000000..6e071a26 --- /dev/null +++ b/packages/live/tests/test_session_stores.py @@ -0,0 +1,128 @@ +"""What a session store owes its callers, whichever one is wired (Phase 2a, T1). + +The API suite runs with auth unconfigured, so ``owner_id`` is ``None`` everywhere in it — which +means it structurally *cannot* exercise cross-owner isolation. This is where that boundary is +proved, at the store, on its own terms. + +A session is the most personal row in the product: a transcript of somebody being taught, including +everything they got wrong. The store is the last thing between one learner's and another's, because +the loop writes through the service-role client, which bypasses RLS. +""" + +import pytest +from lunaris_live.session import ( + DirectorMove, + MemorySessionStore, + MoveKind, + Session, + SessionTurn, +) + + +def _session(session_id: str = "s1") -> Session: + return Session( + session_id=session_id, + graph_id="g1", + turns=[ + SessionTurn( + seq=1, + move=DirectorMove(kind=MoveKind.INTRODUCE, node_id="a", reason="Opening concept."), + tutor="Let's start with A.", + ) + ], + ) + + +def test_a_session_round_trips_for_its_owner() -> None: + # Arrange + store = MemorySessionStore() + store.save(_session(), owner_id="learner-1") + + # Act + loaded = store.load("s1", owner_id="learner-1") + + # Assert — the turns survive, not just the id: the transcript IS the session. + assert loaded.turns[0].move.kind is MoveKind.INTRODUCE + assert loaded.turns[0].tutor == "Let's start with A." + + +def test_another_learners_session_is_not_found() -> None: + """Not-found rather than forbidden: a session's existence is owner-scoped information, and + "that session exists but isn't yours" already tells a stranger something.""" + # Arrange + store = MemorySessionStore() + store.save(_session(), owner_id="learner-1") + + # Act / Assert + with pytest.raises(FileNotFoundError): + store.load("s1", owner_id="learner-2") + + +def test_an_owned_session_is_not_served_to_an_unscoped_read() -> None: + """A caller with no owner must not inherit access to an owned session. + + Unreachable through the API today — ``optional_user_id`` 401s anonymous callers whenever auth is + configured, so ``owner_id=None`` only happens on the auth-off single-user path. That is a + property of two config flags agreeing, not an invariant of this store, so the store refuses on + its own terms rather than trusting the wiring above it to stay that way. Phase 1's graph store + settled this exact question; a session holds more about a person than a graph does. + """ + # Arrange + store = MemorySessionStore() + store.save(_session(), owner_id="learner-1") + + # Act / Assert + with pytest.raises(FileNotFoundError): + store.load("s1", owner_id=None) + + +def test_an_unscoped_session_round_trips_in_the_auth_off_path() -> None: + # Arrange — no owner anywhere: offline dev, where there is exactly one user. + store = MemorySessionStore() + store.save(_session(), owner_id=None) + + # Act / Assert + assert store.load("s1", owner_id=None).session_id == "s1" + + +def test_an_unscoped_session_is_not_served_to_an_owner() -> None: + """The other direction of the same rule. Without it, one learner signing in would inherit every + session left behind by the single-user path.""" + # Arrange + store = MemorySessionStore() + store.save(_session(), owner_id=None) + + # Act / Assert + with pytest.raises(FileNotFoundError): + store.load("s1", owner_id="learner-1") + + +def test_a_session_that_was_never_saved_is_not_found() -> None: + with pytest.raises(FileNotFoundError): + MemorySessionStore().load("nope", owner_id="learner-1") + + +def test_saving_the_same_session_again_replaces_its_head() -> None: + """Every turn rewrites the row, so the store has to be an upsert on the id rather than an + append — a second save that created a second session would fork the transcript.""" + # Arrange + store = MemorySessionStore() + store.save(_session(), owner_id="learner-1") + grown = _session().model_copy( + update={ + "turns": [ + *_session().turns, + SessionTurn( + seq=2, + move=DirectorMove(kind=MoveKind.RETRIEVE, node_id="a", reason="Coming back."), + tutor="What happens when it doubles?", + ), + ] + } + ) + + # Act + store.save(grown, owner_id="learner-1") + + # Assert + assert [turn.seq for turn in store.load("s1", owner_id="learner-1").turns] == [1, 2] diff --git a/supabase/migrations/20260809210000_live_sessions.sql b/supabase/migrations/20260809210000_live_sessions.sql new file mode 100644 index 00000000..9fe5b0a0 --- /dev/null +++ b/supabase/migrations/20260809210000_live_sessions.sql @@ -0,0 +1,61 @@ +-- Lunaris Live, Phase 2a — the session: a learner's run at a compiled concept graph. +-- +-- One owner-scoped table holding the session's head. A session is MUTABLE and short-lived: every +-- turn rewrites the row, and the whole thing is bounded to 25-40 minutes (plan §6). It is persisted +-- rather than held on a connection because a reload must not be able to destroy 40 minutes of +-- accumulated context about one learner — the same lesson Phase 1 learned when a dropped stream +-- nearly cancelled a three-minute compile. +-- +-- Identity and shape notes: +-- * id is text (the service mints a uuid4 hex), matching `live_graphs` and `courses`. +-- * graph_id is a real column, NOT an FK. A graph can be purged independently, and a dangling +-- session is a better outcome than a purge that fails or cascades away a learner's history. +-- It is indexed because "my sessions on this map" is the one cross-session read that exists. +-- * status and turn_count are lifted OUT of the payload: a resume needs to know whether a session +-- is still going and how far it got without parsing the transcript, and a jsonb probe per row for +-- a list view is cheap to get right now and expensive to retrofit. +-- * payload is the camelCase wire JSON — the same bytes the web consumes and the same shape the +-- in-memory store holds, so a session round-trips identically through either store. +-- +-- One row per session with the turns inside the payload, rather than a turns table: a session is +-- always read whole, and nothing yet wants one turn without the rest. A turns table earns its place +-- when something does — appending a turn per row is the cheaper write, but only once the read +-- pattern justifies the join. +-- +-- Access posture: RLS enabled, OWNER-READ / SERVER-WRITE, identical to live_graphs. The loop runs on +-- the backend service_role client (it bypasses RLS, because a turn can outlive the caller's JWT), so +-- isolation rides on the service stamping the right user_id; `authenticated` gets SELECT and nothing +-- else. The revoke includes authenticated's DEFAULT grants so TRUNCATE/REFERENCES/TRIGGER never leak +-- (TRUNCATE is not governed by RLS). +-- +-- To reverse: DROP TABLE IF EXISTS public.live_sessions; + +create table if not exists public.live_sessions ( + id text primary key check (length(id) between 1 and 100), + user_id uuid references auth.users (id) on delete cascade, + graph_id text not null check (length(graph_id) between 1 and 100), + status text not null default 'active' check (status in ('active', 'closed')), + turn_count integer not null default 0 check (turn_count >= 0), + payload jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- The two read shapes beyond by-id: a learner's own sessions newest first, and their sessions on +-- one map (which is how "carry on where you left off" will find them). +create index if not exists live_sessions_user_idx on public.live_sessions (user_id, updated_at desc); +create index if not exists live_sessions_graph_idx on public.live_sessions (user_id, graph_id); + +-- RLS (BLOCKING): owner-read; the loop writes via service_role. +alter table public.live_sessions enable row level security; + +-- Defense in depth: drop ALL default grants (public, anon, and authenticated's defaults), then grant +-- back only SELECT. No learner ever writes a session directly — every turn goes through the API, +-- which owns the loop invariants (monotonic seq, a move behind every turn) a raw insert would +-- bypass. +revoke all on table public.live_sessions from public, anon, authenticated; +grant select on public.live_sessions to authenticated; + +-- `(select auth.uid())` (not bare auth.uid()) so Postgres evaluates it once per query, not per row. +create policy live_sessions_select_own on public.live_sessions + for select to authenticated using ((select auth.uid()) = user_id); diff --git a/tests/db/test_live_sessions_rls.py b/tests/db/test_live_sessions_rls.py new file mode 100644 index 00000000..2a58b09b --- /dev/null +++ b/tests/db/test_live_sessions_rls.py @@ -0,0 +1,160 @@ +"""Live-database proof of the ``live_sessions`` RLS posture (Lunaris Live, Phase 2a). + +``test_live_sessions_api.py`` proves the app belt — the store scopes by owner, so another learner's +session is 404. This suite executes the actual policies on a real Postgres with the migrations +applied: owner-only visibility, no write grant for a learner at all (every turn goes through the +API, which owns the loop's invariants), and no anon reach. + +A session is the most personal row in the product — it is a transcript of somebody being taught, +including everything they got wrong — so the posture matters more here than it did for a graph, and +is proved rather than assumed. + +Same harness and gating as the sibling suites: eval-marked, ``SUPABASE_DB_URL``-gated, one +rolled-back transaction per test. +""" + +import json +import os +import uuid +from collections.abc import Callable + +import pytest + +psycopg = pytest.importorskip("psycopg") + +_DB_URL = os.environ.get("SUPABASE_DB_URL", "") + +pytestmark = [ + pytest.mark.eval, + pytest.mark.skipif(not _DB_URL, reason="SUPABASE_DB_URL not set (needs a live database)"), +] + +_AsUser = Callable[["psycopg.Cursor", str], None] + + +def _seed_user(cur: "psycopg.Cursor", user_id: str) -> None: + cur.execute("insert into auth.users (id) values (%s) on conflict do nothing", (user_id,)) + + +def _insert_session(cur: "psycopg.Cursor", owner: str, session_id: str) -> None: + """Write a session the way the loop does — service_role, bypassing RLS.""" + payload = { + "sessionId": session_id, + "graphId": "g1", + "status": "active", + "turns": [ + { + "seq": 1, + "move": {"kind": "introduce", "nodeId": "a", "reason": "Opening concept."}, + "tutor": "Let's start with A.", + } + ], + } + cur.execute( + """ + insert into public.live_sessions (id, user_id, graph_id, status, turn_count, payload) + values (%s, %s, %s, 'active', 1, %s) + """, + (session_id, owner, "g1", json.dumps(payload)), + ) + + +def test_a_learner_sees_only_their_own_sessions(db: "psycopg.Cursor", as_user: _AsUser) -> None: + # Arrange — two learners, one session each. + mine, theirs = str(uuid.uuid4()), str(uuid.uuid4()) + my_session, their_session = uuid.uuid4().hex, uuid.uuid4().hex + _seed_user(db, mine) + _seed_user(db, theirs) + _insert_session(db, mine, my_session) + _insert_session(db, theirs, their_session) + + # Act — become the first learner, the way PostgREST does. + as_user(db, mine) + db.execute("select id from public.live_sessions") + + # Assert — the other learner's transcript is invisible, not merely unreadable. + assert [row[0] for row in db.fetchall()] == [my_session] + + +def test_a_learner_cannot_write_a_session_directly(db: "psycopg.Cursor", as_user: _AsUser) -> None: + """No INSERT grant. A hand-written session could claim turns that never happened — and the + learner model is built from exactly those turns, so it would be a way to fabricate mastery.""" + # Arrange + owner = str(uuid.uuid4()) + _seed_user(db, owner) + as_user(db, owner) + + # Act / Assert + with pytest.raises(psycopg.errors.InsufficientPrivilege): + _insert_session(db, owner, uuid.uuid4().hex) + + +def test_a_learner_cannot_edit_their_own_session(db: "psycopg.Cursor", as_user: _AsUser) -> None: + # Arrange — a session that really belongs to this learner, so only the grant can refuse. + owner = str(uuid.uuid4()) + session_id = uuid.uuid4().hex + _seed_user(db, owner) + _insert_session(db, owner, session_id) + as_user(db, owner) + + # Act / Assert — editing the transcript is editing the evidence the director reasons from. + with pytest.raises(psycopg.errors.InsufficientPrivilege): + db.execute("update public.live_sessions set status = 'closed' where id = %s", (session_id,)) + + +def test_a_learner_cannot_delete_a_session(db: "psycopg.Cursor", as_user: _AsUser) -> None: + """Deleting is a server operation: a session's purge has to take its cost rows with it.""" + # Arrange + owner = str(uuid.uuid4()) + session_id = uuid.uuid4().hex + _seed_user(db, owner) + _insert_session(db, owner, session_id) + as_user(db, owner) + + # Act / Assert + with pytest.raises(psycopg.errors.InsufficientPrivilege): + db.execute("delete from public.live_sessions where id = %s", (session_id,)) + + +def test_anon_reaches_nothing(db: "psycopg.Cursor") -> None: + # Arrange + owner = str(uuid.uuid4()) + _seed_user(db, owner) + _insert_session(db, owner, uuid.uuid4().hex) + + # Act — the unauthenticated PostgREST role. + db.execute("set local role anon") + + # Assert — anon has no grant at all, so this is a privilege error, not an empty result. + with pytest.raises(psycopg.errors.InsufficientPrivilege): + db.execute("select id from public.live_sessions") + + +def test_truncate_is_not_reachable_by_a_user(db: "psycopg.Cursor", as_user: _AsUser) -> None: + """TRUNCATE is a privilege RLS cannot police, so the revoke has to have caught it.""" + # Arrange + owner = str(uuid.uuid4()) + _seed_user(db, owner) + as_user(db, owner) + + # Act / Assert + with pytest.raises(psycopg.errors.InsufficientPrivilege): + db.execute("truncate public.live_sessions") + + +def test_a_session_status_is_constrained_to_the_two_it_can_be(db: "psycopg.Cursor") -> None: + """``SessionStatus`` is a closed set in Python; the column has to agree, or a bad write reaches + the surface as a status the web has no branch for.""" + # Arrange + owner = str(uuid.uuid4()) + _seed_user(db, owner) + + # Act / Assert — service_role bypasses RLS but not a check constraint. + with pytest.raises(psycopg.errors.CheckViolation): + db.execute( + """ + insert into public.live_sessions (id, user_id, graph_id, status, payload) + values (%s, %s, 'g1', 'abandoned', '{}'::jsonb) + """, + (uuid.uuid4().hex, owner), + ) From def8ba63c68e9bf011bd0990551990b7184c1714 Mon Sep 17 00:00:00 2001 From: Pouyan Jahangiri Date: Sun, 9 Aug 2026 21:48:49 -0700 Subject: [PATCH 2/9] feat(live): give the loop a memory of what the learner knows (Phase 2a, T2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../live/src/lunaris_live/session/__init__.py | 25 ++- .../lunaris_live/session/apply_evidence.py | 46 ++++ .../session/memory_knowledge_store.py | 22 ++ .../session/protocols/__init__.py | 3 +- .../session/protocols/knowledge_store.py | 22 ++ .../src/lunaris_live/session/recall_of.py | 38 ++++ .../lunaris_live/session/schema/__init__.py | 6 + .../session/schema/evidence_kind.py | 15 ++ .../session/schema/learner_model.py | 22 ++ .../session/schema/node_knowledge.py | 24 +++ .../session/supabase_knowledge_store.py | 88 ++++++++ packages/live/tests/test_knowledge_stores.py | 87 ++++++++ packages/live/tests/test_learner_model.py | 196 ++++++++++++++++++ .../20260809220000_live_knowledge.sql | 59 ++++++ tests/db/test_live_knowledge_rls.py | 176 ++++++++++++++++ 15 files changed, 826 insertions(+), 3 deletions(-) create mode 100644 packages/live/src/lunaris_live/session/apply_evidence.py create mode 100644 packages/live/src/lunaris_live/session/memory_knowledge_store.py create mode 100644 packages/live/src/lunaris_live/session/protocols/knowledge_store.py create mode 100644 packages/live/src/lunaris_live/session/recall_of.py create mode 100644 packages/live/src/lunaris_live/session/schema/evidence_kind.py create mode 100644 packages/live/src/lunaris_live/session/schema/learner_model.py create mode 100644 packages/live/src/lunaris_live/session/schema/node_knowledge.py create mode 100644 packages/live/src/lunaris_live/session/supabase_knowledge_store.py create mode 100644 packages/live/tests/test_knowledge_stores.py create mode 100644 packages/live/tests/test_learner_model.py create mode 100644 supabase/migrations/20260809220000_live_knowledge.sql create mode 100644 tests/db/test_live_knowledge_rls.py diff --git a/packages/live/src/lunaris_live/session/__init__.py b/packages/live/src/lunaris_live/session/__init__.py index bb1ed863..4f8a51d4 100644 --- a/packages/live/src/lunaris_live/session/__init__.py +++ b/packages/live/src/lunaris_live/session/__init__.py @@ -8,20 +8,41 @@ top of these contracts without changing them. """ +from .apply_evidence import apply_evidence +from .memory_knowledge_store import MemoryKnowledgeStore from .memory_session_store import MemorySessionStore from .open_session import open_session -from .protocols import ISessionStore -from .schema import DirectorMove, MoveKind, Session, SessionStatus, SessionTurn +from .protocols import IKnowledgeStore, ISessionStore +from .recall_of import recall_of +from .schema import ( + DirectorMove, + EvidenceKind, + LearnerModel, + MoveKind, + NodeKnowledge, + Session, + SessionStatus, + SessionTurn, +) +from .supabase_knowledge_store import SupabaseKnowledgeStore from .supabase_session_store import SupabaseSessionStore __all__ = [ "DirectorMove", + "EvidenceKind", + "IKnowledgeStore", "ISessionStore", + "LearnerModel", + "MemoryKnowledgeStore", "MemorySessionStore", "MoveKind", + "NodeKnowledge", "Session", "SessionStatus", "SessionTurn", + "SupabaseKnowledgeStore", "SupabaseSessionStore", + "apply_evidence", "open_session", + "recall_of", ] diff --git a/packages/live/src/lunaris_live/session/apply_evidence.py b/packages/live/src/lunaris_live/session/apply_evidence.py new file mode 100644 index 00000000..b844fb74 --- /dev/null +++ b/packages/live/src/lunaris_live/session/apply_evidence.py @@ -0,0 +1,46 @@ +from .schema import EvidenceKind, LearnerModel, NodeKnowledge + +#: Where one piece of evidence pulls the belief. MET pulls towards certainty, NOT_MET towards +#: ignorance, PARTIAL towards a middle that is genuinely "nearly" rather than a shrug. +_TARGET: dict[EvidenceKind, float] = { + EvidenceKind.MET: 1.0, + EvidenceKind.PARTIAL: 0.5, + EvidenceKind.NOT_MET: 0.0, +} + +#: How far the belief travels towards that target on a single piece of evidence. +#: +#: Deliberately well under 1: one right answer can be a guess, and the whole reason the director can +#: gate an introduction on "its prerequisites are mastered" is that mastery takes more than one. The +#: value is provisional — there is no session data to fit it to yet, and this is the dial the +#: simulated-learner eval (T9) exists to put pressure on. +_PULL = 0.45 + + +def apply_evidence( + model: LearnerModel, node_id: str, kind: EvidenceKind, *, at_turn: int +) -> LearnerModel: + """The learner model after one graded answer about ``node_id``. + + Returns a **new** model rather than mutating: the director reads the model while the grader + writes it, and a turn must never be able to see a half-applied belief. + + The update is an exponential move towards the evidence's target, which has the two properties + the loop actually depends on — evidence moves the belief in its own direction, and repeated + evidence moves it further than one piece does — without pretending to a precision nobody has + measured yet. It is a stand-in for a fitted model, and it is one function. + """ + current = model.nodes.get(node_id) + estimate = current.estimate if current is not None else 0.0 + target = _TARGET[kind] + + updated = NodeKnowledge( + node_id=node_id, + # Clamped because floating point can leave a hair outside the range on repeated pulls, and + # the director compares this against thresholds — a value outside [0, 1] would silently + # disable a rule rather than fail. + estimate=min(1.0, max(0.0, estimate + (target - estimate) * _PULL)), + evidence_count=(current.evidence_count if current is not None else 0) + 1, + last_evidence_turn=at_turn, + ) + return model.model_copy(update={"nodes": {**model.nodes, node_id: updated}}) diff --git a/packages/live/src/lunaris_live/session/memory_knowledge_store.py b/packages/live/src/lunaris_live/session/memory_knowledge_store.py new file mode 100644 index 00000000..07d2b1f1 --- /dev/null +++ b/packages/live/src/lunaris_live/session/memory_knowledge_store.py @@ -0,0 +1,22 @@ +from .schema import LearnerModel + + +class MemoryKnowledgeStore: + """In-process learner-model store — offline dev and the suite. + + A singleton in the composition root: what a session establishes has to be there for the next + one, and a per-request store would forget between turns, let alone between sessions. + """ + + def __init__(self) -> None: + # (owner, graph) -> beliefs. Keyed by both because node ids are graph-local, so one + # learner's models for two maps are unrelated records that must not merge. + self._models: dict[tuple[str | None, str], LearnerModel] = {} + + def save(self, model: LearnerModel, *, owner_id: str | None = None) -> None: + self._models[(owner_id, model.graph_id)] = model + + def load(self, graph_id: str, *, owner_id: str | None = None) -> LearnerModel: + # Absent is empty, not an error — a first session is the common case. The key includes the + # owner, so an unscoped read simply misses an owned model rather than reaching it. + return self._models.get((owner_id, graph_id)) or LearnerModel(graph_id=graph_id) diff --git a/packages/live/src/lunaris_live/session/protocols/__init__.py b/packages/live/src/lunaris_live/session/protocols/__init__.py index e78dd9ab..b9af843a 100644 --- a/packages/live/src/lunaris_live/session/protocols/__init__.py +++ b/packages/live/src/lunaris_live/session/protocols/__init__.py @@ -1,3 +1,4 @@ +from .knowledge_store import IKnowledgeStore from .session_store import ISessionStore -__all__ = ["ISessionStore"] +__all__ = ["IKnowledgeStore", "ISessionStore"] diff --git a/packages/live/src/lunaris_live/session/protocols/knowledge_store.py b/packages/live/src/lunaris_live/session/protocols/knowledge_store.py new file mode 100644 index 00000000..7c46f07d --- /dev/null +++ b/packages/live/src/lunaris_live/session/protocols/knowledge_store.py @@ -0,0 +1,22 @@ +from typing import Protocol + +from ..schema import LearnerModel + + +class IKnowledgeStore(Protocol): + """Persistence for what a learner knows about one map. + + Unlike the session and graph stores, ``load`` **never raises for absence**: a learner opening + their first session on a map genuinely has no history, and that is the common case rather + than an error. It answers an empty model, so callers do not each write the same try/except — + one of them would eventually get it wrong, and getting it wrong means treating "no history" + as a failure at the exact moment a learner starts. + + ``owner_id`` scopes it, and unscoped means *unowned* rather than *see everything*: another + learner's beliefs must never be returned, because acting on them would have the director skip + concepts this learner has never seen. + """ + + def save(self, model: LearnerModel, *, owner_id: str | None = None) -> None: ... + + def load(self, graph_id: str, *, owner_id: str | None = None) -> LearnerModel: ... diff --git a/packages/live/src/lunaris_live/session/recall_of.py b/packages/live/src/lunaris_live/session/recall_of.py new file mode 100644 index 00000000..ade1dd75 --- /dev/null +++ b/packages/live/src/lunaris_live/session/recall_of.py @@ -0,0 +1,38 @@ +from .schema import LearnerModel + +#: Turns without evidence for a belief to lose half the distance to its floor. +#: +#: Measured in TURNS, not wall time, so a session is reproducible: replaying the same answers yields +#: the same beliefs, which is what makes the simulated-learner eval (T9) mean anything. Real +#: cross-session forgetting happens over days and is NOT modelled here — recorded as an open +#: question rather than guessed at, because a curve invented now would be indistinguishable from a +#: fitted one to everything downstream, which is the dangerous kind of placeholder. +_HALF_LIFE_TURNS = 12.0 + +#: The share of a belief that decay can never take. A concept demonstrated once is not the same as +#: one never seen — the director should prefer *retrieving* the first over introducing it from +#: scratch — so recall approaches this floor rather than zero. +_FLOOR = 0.25 + + +def recall_of(model: LearnerModel, node_id: str, *, at_turn: int) -> float: + """What the learner is believed to recall about ``node_id`` **now**, at ``at_turn``. + + The stored estimate is the belief at its last evidence; this is that belief seen through the + turns since. Without decay, spaced retrieval would have nothing to be spaced against — every + concept would sit at whatever its last answer left it at forever, and the director would never + look back at anything. + + A concept with no evidence answers ``0.0``: assuming knowledge would have the director skip + concepts the learner has never seen, and ignorance is the safe direction to be wrong in. + """ + known = model.nodes.get(node_id) + if known is None: + return 0.0 + + elapsed = max(0, at_turn - known.last_evidence_turn) + # Half the *distance to the floor* per half-life, so a stronger belief is still ahead of a + # weaker one after the same wait — which is the whole point of repetition. + retained = 0.5 ** (elapsed / _HALF_LIFE_TURNS) + floor = known.estimate * _FLOOR + return floor + (known.estimate - floor) * retained diff --git a/packages/live/src/lunaris_live/session/schema/__init__.py b/packages/live/src/lunaris_live/session/schema/__init__.py index e6235f32..6520efa6 100644 --- a/packages/live/src/lunaris_live/session/schema/__init__.py +++ b/packages/live/src/lunaris_live/session/schema/__init__.py @@ -1,14 +1,20 @@ """The session's contracts — what a turn is, and what the director decided to make it.""" from .director_move import DirectorMove +from .evidence_kind import EvidenceKind +from .learner_model import LearnerModel from .move_kind import MoveKind +from .node_knowledge import NodeKnowledge from .session import Session from .session_status import SessionStatus from .session_turn import SessionTurn __all__ = [ "DirectorMove", + "EvidenceKind", + "LearnerModel", "MoveKind", + "NodeKnowledge", "Session", "SessionStatus", "SessionTurn", diff --git a/packages/live/src/lunaris_live/session/schema/evidence_kind.py b/packages/live/src/lunaris_live/session/schema/evidence_kind.py new file mode 100644 index 00000000..c3beadd1 --- /dev/null +++ b/packages/live/src/lunaris_live/session/schema/evidence_kind.py @@ -0,0 +1,15 @@ +from enum import StrEnum + + +class EvidenceKind(StrEnum): + """What one graded answer said about the learner. + + Three values, not a score: the grader is judging a free-text answer against one explicit + do-statement, and asking it for 0.73 would be asking for a precision it does not have. Three + verdicts are what a grader can defend and what a tutor can act on — "nearly" is a different + teaching move from "no", which is why PARTIAL exists rather than collapsing into a neighbour. + """ + + MET = "met" + PARTIAL = "partial" + NOT_MET = "not_met" diff --git a/packages/live/src/lunaris_live/session/schema/learner_model.py b/packages/live/src/lunaris_live/session/schema/learner_model.py new file mode 100644 index 00000000..29eeac84 --- /dev/null +++ b/packages/live/src/lunaris_live/session/schema/learner_model.py @@ -0,0 +1,22 @@ +from pydantic import Field + +from ...graph.schema.base import LiveModel +from .node_knowledge import NodeKnowledge + + +class LearnerModel(LiveModel): + """What the system believes one learner knows about one map. + + Keyed to a graph because node ids are graph-local — the compiler mints them per compile — so + mastery cannot transfer between two maps of the same subject without semantic matching, which is + not this journey's problem (R3). "Persists across sessions" (plan §11) means across sessions on + a map, which is what this gives. + + Absent is not zero-with-a-row: a concept nobody has evidence about simply has no entry, and + ``recall_of`` answers 0.0 for it. That keeps a fresh learner's model empty rather than a wall of + zeroes, and it makes "how much of this map has been touched" a length rather than a scan. + """ + + graph_id: str = Field(min_length=1, max_length=100) + #: Concept id → what is believed about it. Only concepts with evidence appear. + nodes: dict[str, NodeKnowledge] = Field(default_factory=dict) diff --git a/packages/live/src/lunaris_live/session/schema/node_knowledge.py b/packages/live/src/lunaris_live/session/schema/node_knowledge.py new file mode 100644 index 00000000..1f335299 --- /dev/null +++ b/packages/live/src/lunaris_live/session/schema/node_knowledge.py @@ -0,0 +1,24 @@ +from pydantic import Field + +from ...graph.schema.base import LiveModel + + +class NodeKnowledge(LiveModel): + """What the system believes about one concept, and what that belief rests on. + + ``estimate`` is the belief at the moment of its last evidence — NOT the belief now. Now is + ``recall_of``, which decays it by how long the learner has gone without demonstrating it. + Storing the undecayed value is what makes the row stable: a persisted number that changed + meaning with every passing turn could not be compared with itself between sessions. + + ``evidence_count`` is deliberately kept beside the belief rather than folded into it. They + answer different questions — the director gates on the belief, and a human auditing a session + needs to know whether it rests on one answer or five. + """ + + node_id: str = Field(min_length=1, max_length=100) + #: Belief at ``last_evidence_turn``, in [0, 1]. + estimate: float = Field(ge=0.0, le=1.0) + evidence_count: int = Field(default=0, ge=0) + #: The turn the last evidence arrived on — the origin decay is measured from. + last_evidence_turn: int = Field(default=0, ge=0) diff --git a/packages/live/src/lunaris_live/session/supabase_knowledge_store.py b/packages/live/src/lunaris_live/session/supabase_knowledge_store.py new file mode 100644 index 00000000..b376628a --- /dev/null +++ b/packages/live/src/lunaris_live/session/supabase_knowledge_store.py @@ -0,0 +1,88 @@ +import os + +from lunaris_runtime.persistence.guard import guard + +from .schema import LearnerModel, NodeKnowledge + +_URL_ENV = "SUPABASE_URL" +_SERVICE_KEY_ENV = "SUPABASE_SERVICE_ROLE_KEY" +_TABLE = "live_knowledge" + + +class SupabaseKnowledgeStore: + """The durable learner-model store: one row per concept the learner has evidence about. + + A row per concept rather than a document per map, unlike the session and graph stores. The write + pattern is the reason: 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. It also makes the reads P2b's mastery meters want ("this concept, across + sessions") a lookup rather than a scan through a payload. + """ + + def __init__( + self, + *, + url_env: str = _URL_ENV, + service_key_env: str = _SERVICE_KEY_ENV, + client: object | None = None, + ) -> None: + self._url_env = url_env + self._service_key_env = service_key_env + self._client = client + + def _ensure_client(self) -> object: + if self._client is None: + from supabase import create_client + + url = os.environ.get(self._url_env) + key = os.environ.get(self._service_key_env) + if not url or not key: + raise RuntimeError( + f"{self._url_env} / {self._service_key_env} not set; cannot persist knowledge" + ) + self._client = create_client(url, key) + return self._client + + @guard("live_knowledge upsert") + def save(self, model: LearnerModel, *, owner_id: str | None = None) -> None: + client = self._ensure_client() + rows = [ + { + "user_id": owner_id, + "graph_id": model.graph_id, + "node_id": known.node_id, + "estimate": known.estimate, + "evidence_count": known.evidence_count, + "last_evidence_turn": known.last_evidence_turn, + } + for known in model.nodes.values() + ] + if not rows: + # Nothing believed yet. Skipped rather than issued as an empty upsert, which some + # PostgREST versions answer with a 400 — a first session must never fail on its own + # emptiness. + return + client.table(_TABLE).upsert( # type: ignore[attr-defined] + rows, on_conflict="user_id,graph_id,node_id" + ).execute() + + @guard("live_knowledge load") + def load(self, graph_id: str, *, owner_id: str | None = None) -> LearnerModel: + client = self._ensure_client() + query = client.table(_TABLE).select("*").eq("graph_id", graph_id) # type: ignore[attr-defined] + # Constrained either way: the service-role client bypasses RLS, so an unscoped read must + # match only unowned rows rather than seeing every learner's beliefs. + query = query.is_("user_id", None) if owner_id is None else query.eq("user_id", owner_id) + rows = query.execute().data or [] + return LearnerModel( + graph_id=graph_id, + nodes={ + row["node_id"]: NodeKnowledge( + node_id=row["node_id"], + estimate=row["estimate"], + evidence_count=row["evidence_count"], + last_evidence_turn=row["last_evidence_turn"], + ) + for row in rows + }, + ) diff --git a/packages/live/tests/test_knowledge_stores.py b/packages/live/tests/test_knowledge_stores.py new file mode 100644 index 00000000..3ede3ba3 --- /dev/null +++ b/packages/live/tests/test_knowledge_stores.py @@ -0,0 +1,87 @@ +"""What a knowledge store owes its callers (Phase 2a, T2). + +The learner model is the one thing in a session that outlives it (R3, plan §11): a second session on +the same map has to open knowing what the first one established, or every session starts the learner +from nothing and "spaced retrieval" can never span more than one sitting. + +Same owner-scoping bar as the session store, and for a sharper reason: this is a record of what +somebody does and does not understand. +""" + +from lunaris_live.session import EvidenceKind, LearnerModel, MemoryKnowledgeStore, apply_evidence + + +def _model() -> LearnerModel: + return apply_evidence(LearnerModel(graph_id="g1"), "a", EvidenceKind.MET, at_turn=1) + + +def test_what_one_session_established_is_there_for_the_next() -> None: + """The whole point of persisting it.""" + # Arrange + store = MemoryKnowledgeStore() + store.save(_model(), owner_id="learner-1") + + # Act + loaded = store.load("g1", owner_id="learner-1") + + # Assert — the belief AND what it rests on survive; a belief without its evidence count is a + # number nobody can audit. + assert loaded.nodes["a"].estimate == _model().nodes["a"].estimate + assert loaded.nodes["a"].evidence_count == 1 + + +def test_a_learner_with_no_history_on_a_map_starts_empty_rather_than_missing() -> None: + """A first session is the common case, not an error. Raising here would make every caller + write the same try/except, and one of them would eventually get it wrong.""" + # Act + model = MemoryKnowledgeStore().load("g1", owner_id="learner-1") + + # Assert + assert model.graph_id == "g1" + assert model.nodes == {} + + +def test_another_learners_knowledge_is_never_returned() -> None: + """Not merely private — returning it would have the director skip concepts *this* learner has + never seen, on the strength of somebody else's answers.""" + # Arrange + store = MemoryKnowledgeStore() + store.save(_model(), owner_id="learner-1") + + # Act / Assert — empty, not an error: as far as learner-2 is concerned they have no history. + assert store.load("g1", owner_id="learner-2").nodes == {} + + +def test_an_owned_model_is_not_served_to_an_unscoped_read() -> None: + """Same rule the session and graph stores hold: ``owner_id=None`` means *unowned*, never + *unscoped*. A store must not depend on the auth wiring above it staying configured.""" + # Arrange + store = MemoryKnowledgeStore() + store.save(_model(), owner_id="learner-1") + + # Act / Assert + assert store.load("g1", owner_id=None).nodes == {} + + +def test_knowledge_is_scoped_to_the_map_it_was_learned_on() -> None: + """Node ids are graph-local, so mastery of "a" on one map says nothing about "a" on another.""" + # Arrange + store = MemoryKnowledgeStore() + store.save(_model(), owner_id="learner-1") + + # Act / Assert + assert store.load("g2", owner_id="learner-1").nodes == {} + + +def test_saving_again_replaces_the_belief_rather_than_appending_to_it() -> None: + """The model is a head, not a log. The evidence count inside it is the history.""" + # Arrange + store = MemoryKnowledgeStore() + store.save(_model(), owner_id="learner-1") + grown = apply_evidence(_model(), "a", EvidenceKind.MET, at_turn=2) + + # Act + store.save(grown, owner_id="learner-1") + + # Assert + assert store.load("g1", owner_id="learner-1").nodes["a"].evidence_count == 2 diff --git a/packages/live/tests/test_learner_model.py b/packages/live/tests/test_learner_model.py new file mode 100644 index 00000000..e1d22b0d --- /dev/null +++ b/packages/live/tests/test_learner_model.py @@ -0,0 +1,196 @@ +"""What the system believes the learner knows, and how that belief moves (Phase 2a, T2). + +This is the half of the loop the director reads and the grader writes. It is deliberately a small +pure domain — no I/O, no model call — because it is the one part of a session that must be +inspectable after the fact: "why did it teach me that" is answered by the graph plus this. + +Two properties matter more than the exact numbers, and the numbers are explicitly provisional +(there is no session data to fit a curve to yet): + +- **evidence moves the belief in the direction of the evidence**, and repeated evidence moves it + further than one piece of it; and +- **belief decays without evidence**, or spaced retrieval has nothing to be spaced against. +""" + +import pytest +from lunaris_live.session import ( + EvidenceKind, + LearnerModel, + NodeKnowledge, + apply_evidence, + recall_of, +) + + +def _model() -> LearnerModel: + return LearnerModel(graph_id="g1") + + +# ── what evidence does ──────────────────────────────────────────────────────────────────────── + + +def test_a_concept_nobody_has_evidence_about_is_not_assumed_known() -> None: + """The opening state of every node. Assuming knowledge would have the director skip concepts + the learner has never seen; assuming ignorance is the safe direction to be wrong in.""" + assert recall_of(_model(), "never-seen", at_turn=1) == 0.0 + + +def test_meeting_a_criterion_raises_the_belief() -> None: + # Arrange / Act + model = apply_evidence(_model(), "a", EvidenceKind.MET, at_turn=1) + + # Assert + assert recall_of(model, "a", at_turn=1) > 0.0 + + +def test_failing_a_criterion_lowers_a_belief_that_was_high() -> None: + """The direction that matters most: a learner who has drifted must be *findable*, or the + director will keep introducing new material on a foundation that has gone.""" + # Arrange — a well-established concept. + model = _model() + for turn in range(1, 4): + model = apply_evidence(model, "a", EvidenceKind.MET, at_turn=turn) + established = recall_of(model, "a", at_turn=3) + + # Act + model = apply_evidence(model, "a", EvidenceKind.NOT_MET, at_turn=4) + + # Assert + assert recall_of(model, "a", at_turn=4) < established + + +def test_repeated_success_believes_more_than_a_single_success() -> None: + """One right answer can be a guess. This is what makes "mastered" mean something the director + can gate an introduction on.""" + # Arrange + once = apply_evidence(_model(), "a", EvidenceKind.MET, at_turn=1) + + # Act + thrice = once + for turn in (2, 3): + thrice = apply_evidence(thrice, "a", EvidenceKind.MET, at_turn=turn) + + # Assert + assert recall_of(thrice, "a", at_turn=3) > recall_of(once, "a", at_turn=3) + + +def test_a_partial_answer_counts_for_less_than_a_full_one() -> None: + """The grader's middle verdict has to mean something, or it collapses into one of its + neighbours and the tutor loses the ability to say "nearly".""" + partial = apply_evidence(_model(), "a", EvidenceKind.PARTIAL, at_turn=1) + met = apply_evidence(_model(), "a", EvidenceKind.MET, at_turn=1) + + assert 0.0 < recall_of(partial, "a", at_turn=1) < recall_of(met, "a", at_turn=1) + + +def test_evidence_about_one_concept_moves_only_that_concept() -> None: + """The bug that would make the whole model meaningless: mastery has to be per concept, or the + director's "are its prerequisites met" question has no answer.""" + # Arrange + model = apply_evidence(_model(), "a", EvidenceKind.MET, at_turn=1) + + # Act / Assert + assert recall_of(model, "b", at_turn=1) == 0.0 + + +def test_the_model_records_how_much_evidence_it_has_seen() -> None: + """A belief and the evidence behind it are different things. The director gates on the belief; + a human auditing a session needs to know whether it rests on one answer or five.""" + # Arrange / Act + model = _model() + for turn in (1, 2): + model = apply_evidence(model, "a", EvidenceKind.MET, at_turn=turn) + + # Assert + assert model.nodes["a"].evidence_count == 2 + assert model.nodes["a"].last_evidence_turn == 2 + + +def test_applying_evidence_does_not_mutate_the_model_it_was_given() -> None: + """The director reads the model while the grader writes it. Returning a new one means a turn + can never see a half-applied belief.""" + # Arrange + before = apply_evidence(_model(), "a", EvidenceKind.MET, at_turn=1) + + # Act + after = apply_evidence(before, "a", EvidenceKind.NOT_MET, at_turn=2) + + # Assert + assert recall_of(before, "a", at_turn=1) != recall_of(after, "a", at_turn=2) + assert before.nodes["a"].evidence_count == 1 + + +# ── what time does ──────────────────────────────────────────────────────────────────────────── + + +def test_belief_decays_when_nothing_reinforces_it() -> None: + """Without this, spaced retrieval has nothing to be spaced against — every concept would stay + at the value its last answer left it at, forever, and the director would never look back.""" + # Arrange + model = apply_evidence(_model(), "a", EvidenceKind.MET, at_turn=1) + + # Act / Assert + assert recall_of(model, "a", at_turn=30) < recall_of(model, "a", at_turn=1) + + +def test_decay_never_reaches_certainty_that_the_learner_forgot() -> None: + """A concept demonstrated once is not the same as one never seen: the director should prefer + retrieving the first over introducing it from scratch, so it must not decay to zero.""" + # Arrange + model = apply_evidence(_model(), "a", EvidenceKind.MET, at_turn=1) + + # Act / Assert — far past any real session length. + assert recall_of(model, "a", at_turn=10_000) > 0.0 + + +def test_stronger_beliefs_survive_longer() -> None: + """The point of repetition. Two concepts left alone for the same span must not be equally + forgotten if one was better established.""" + # Arrange + weak = apply_evidence(_model(), "a", EvidenceKind.MET, at_turn=1) + strong = weak + for turn in (2, 3, 4): + strong = apply_evidence(strong, "a", EvidenceKind.MET, at_turn=turn) + + # Act / Assert — measured at the same distance from each one's last evidence. + assert recall_of(strong, "a", at_turn=24) > recall_of(weak, "a", at_turn=21) + + +def test_recall_is_asked_of_a_turn_and_never_of_a_clock() -> None: + """Decay is measured in turns, not wall time, so a session is reproducible: replaying the same + answers gives the same beliefs, which is what makes the simulated-learner eval (T9) mean + anything. Cross-session forgetting is real and is NOT this — an open question, not modelled.""" + # Arrange + model = apply_evidence(_model(), "a", EvidenceKind.MET, at_turn=1) + + # Act / Assert — the same turn always gives the same answer, however long the test takes. + assert recall_of(model, "a", at_turn=9) == recall_of(model, "a", at_turn=9) + + +@pytest.mark.parametrize("kind", list(EvidenceKind)) +def test_a_belief_is_always_a_probability(kind: EvidenceKind) -> None: + """Whatever the evidence, the number stays in [0, 1] — the director compares it against + thresholds, and a value outside the range would silently disable a rule.""" + # Arrange + model = _model() + + # Act — far more evidence than any real session produces. + for turn in range(1, 40): + model = apply_evidence(model, "a", kind, at_turn=turn) + + # Assert + assert 0.0 <= recall_of(model, "a", at_turn=40) <= 1.0 + assert 0.0 <= model.nodes["a"].estimate <= 1.0 + + +def test_a_knowledge_row_can_be_rebuilt_from_its_wire_shape() -> None: + """The model is persisted per node and read back on the next session (R3), so its round trip is + part of the contract rather than an implementation detail.""" + # Arrange + original = NodeKnowledge(node_id="a", estimate=0.62, evidence_count=3, last_evidence_turn=7) + + # Act + restored = NodeKnowledge.model_validate(original.model_dump(mode="json", by_alias=True)) + + # Assert + assert restored == original diff --git a/supabase/migrations/20260809220000_live_knowledge.sql b/supabase/migrations/20260809220000_live_knowledge.sql new file mode 100644 index 00000000..6f2141fa --- /dev/null +++ b/supabase/migrations/20260809220000_live_knowledge.sql @@ -0,0 +1,59 @@ +-- Lunaris Live, Phase 2a — the learner model: what one learner is believed to know about one map. +-- +-- The only thing in a session that outlives it. A second session on the same map opens knowing what +-- the first established, which is what makes "spaced retrieval" able to span more than one sitting +-- (plan §11: the model persists across sessions). +-- +-- Shape notes: +-- * One row per CONCEPT, not one document per map — unlike live_sessions and live_graphs. The write +-- pattern decides it: 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. It also makes P2b's mastery meters ("this concept, across sessions") a lookup. +-- * The natural key is (user_id, graph_id, node_id). node_id is graph-local — the compiler mints +-- ids per compile — so mastery of "a" on one map says nothing about "a" on another, and the +-- graph_id in the key is what keeps those apart. +-- * estimate is the belief AT last_evidence_turn, deliberately not the belief now. Now is a decay +-- applied at read time (`recall_of`). Storing the undecayed value is what makes the row stable: +-- a persisted number that changed meaning with every passing turn could not be compared with +-- itself between sessions. +-- * No FK to live_graphs: a graph can be purged independently, and orphaned beliefs are a better +-- outcome than a purge that fails. They are unreachable anyway once the map is gone. +-- +-- user_id is part of the primary key, so it cannot be null here — unlike live_graphs/live_sessions, +-- where the auth-off single-user path leaves it null. That path keeps its beliefs in the in-process +-- store (Supabase is not configured when auth is not), so nothing is lost by requiring it, and +-- requiring it removes the "unowned row" class from a table that records what a person understands. +-- +-- Access posture: RLS enabled, OWNER-READ / SERVER-WRITE, same as its siblings. The loop writes via +-- service_role; `authenticated` gets SELECT and nothing else, so a learner can see their own +-- progress (P2b renders it) but can never write a belief about themselves. +-- +-- To reverse: DROP TABLE IF EXISTS public.live_knowledge; + +create table if not exists public.live_knowledge ( + user_id uuid not null references auth.users (id) on delete cascade, + graph_id text not null check (length(graph_id) between 1 and 100), + node_id text not null check (length(node_id) between 1 and 100), + -- A probability. The director compares it against thresholds, so a value outside the range + -- would silently disable a rule rather than fail loudly. + estimate double precision not null check (estimate between 0 and 1), + evidence_count integer not null default 0 check (evidence_count >= 0), + last_evidence_turn integer not null default 0 check (last_evidence_turn >= 0), + updated_at timestamptz not null default now(), + primary key (user_id, graph_id, node_id) +); + +-- The read the loop actually does: every belief this learner holds about this map, once per turn. +-- Served by the primary key's leading columns, so no separate index is needed. + +alter table public.live_knowledge enable row level security; + +-- Defense in depth: drop ALL default grants (public, anon, and authenticated's defaults), then grant +-- back only SELECT. A learner writing their own mastery directly would be able to skip the entire +-- curriculum — the belief is only meaningful because the grader is the only thing that sets it. +revoke all on table public.live_knowledge from public, anon, authenticated; +grant select on public.live_knowledge to authenticated; + +-- `(select auth.uid())` (not bare auth.uid()) so Postgres evaluates it once per query, not per row. +create policy live_knowledge_select_own on public.live_knowledge + for select to authenticated using ((select auth.uid()) = user_id); diff --git a/tests/db/test_live_knowledge_rls.py b/tests/db/test_live_knowledge_rls.py new file mode 100644 index 00000000..b8c0dd55 --- /dev/null +++ b/tests/db/test_live_knowledge_rls.py @@ -0,0 +1,176 @@ +"""Live-database proof of the ``live_knowledge`` RLS posture (Lunaris Live, Phase 2a). + +This table records what a person does and does not understand, and — more sharply than the others — +what it says is *acted on*: the director gates every introduction on it. So the write posture +matters as much as the read one. A learner who could set their own mastery could skip the entire +curriculum, and the belief is only meaningful because the grader is the only thing that sets it. + +Same harness and gating as the sibling suites: eval-marked, ``SUPABASE_DB_URL``-gated, one +rolled-back transaction per test. +""" + +import os +import uuid +from collections.abc import Callable + +import pytest + +psycopg = pytest.importorskip("psycopg") + +_DB_URL = os.environ.get("SUPABASE_DB_URL", "") + +pytestmark = [ + pytest.mark.eval, + pytest.mark.skipif(not _DB_URL, reason="SUPABASE_DB_URL not set (needs a live database)"), +] + +_AsUser = Callable[["psycopg.Cursor", str], None] + + +def _seed_user(cur: "psycopg.Cursor", user_id: str) -> None: + cur.execute("insert into auth.users (id) values (%s) on conflict do nothing", (user_id,)) + + +def _believe(cur: "psycopg.Cursor", owner: str, graph_id: str, node_id: str = "a") -> None: + """Record a belief the way the grader does — service_role, bypassing RLS.""" + cur.execute( + """ + insert into public.live_knowledge + (user_id, graph_id, node_id, estimate, evidence_count, last_evidence_turn) + values (%s, %s, %s, 0.45, 1, 1) + """, + (owner, graph_id, node_id), + ) + + +def test_a_learner_sees_only_their_own_beliefs(db: "psycopg.Cursor", as_user: _AsUser) -> None: + # Arrange — two learners on the same map, so only the policy can separate them. + mine, theirs = str(uuid.uuid4()), str(uuid.uuid4()) + _seed_user(db, mine) + _seed_user(db, theirs) + _believe(db, mine, "g1") + _believe(db, theirs, "g1") + + # Act + as_user(db, mine) + db.execute("select user_id from public.live_knowledge") + + # Assert + assert [str(row[0]) for row in db.fetchall()] == [mine] + + +def test_a_learner_cannot_declare_their_own_mastery(db: "psycopg.Cursor", as_user: _AsUser) -> None: + """The load-bearing one. The director gates every introduction on this number, so a learner who + could write it could skip the curriculum — and the eval would still call the session a success. + """ + # Arrange + owner = str(uuid.uuid4()) + _seed_user(db, owner) + as_user(db, owner) + + # Act / Assert + with pytest.raises(psycopg.errors.InsufficientPrivilege): + _believe(db, owner, "g1") + + +def test_a_learner_cannot_raise_a_belief_the_grader_set( + db: "psycopg.Cursor", as_user: _AsUser +) -> None: + # Arrange — a real belief of this learner's, so only the grant can refuse. + owner = str(uuid.uuid4()) + _seed_user(db, owner) + _believe(db, owner, "g1") + as_user(db, owner) + + # Act / Assert + with pytest.raises(psycopg.errors.InsufficientPrivilege): + db.execute("update public.live_knowledge set estimate = 1.0 where user_id = %s", (owner,)) + + +def test_a_learner_cannot_forget_an_inconvenient_belief( + db: "psycopg.Cursor", as_user: _AsUser +) -> None: + """Deleting is the same attack as writing, in reverse: a concept with no belief looks like one + never taught, so the director would re-introduce it instead of remediating.""" + # Arrange + owner = str(uuid.uuid4()) + _seed_user(db, owner) + _believe(db, owner, "g1") + as_user(db, owner) + + # Act / Assert + with pytest.raises(psycopg.errors.InsufficientPrivilege): + db.execute("delete from public.live_knowledge where user_id = %s", (owner,)) + + +def test_anon_reaches_nothing(db: "psycopg.Cursor") -> None: + # Arrange + owner = str(uuid.uuid4()) + _seed_user(db, owner) + _believe(db, owner, "g1") + + # Act + db.execute("set local role anon") + + # Assert + with pytest.raises(psycopg.errors.InsufficientPrivilege): + db.execute("select estimate from public.live_knowledge") + + +def test_truncate_is_not_reachable_by_a_user(db: "psycopg.Cursor", as_user: _AsUser) -> None: + """TRUNCATE is a privilege RLS cannot police, so the revoke has to have caught it.""" + # Arrange + owner = str(uuid.uuid4()) + _seed_user(db, owner) + as_user(db, owner) + + # Act / Assert + with pytest.raises(psycopg.errors.InsufficientPrivilege): + db.execute("truncate public.live_knowledge") + + +def test_a_belief_outside_the_probability_range_is_refused(db: "psycopg.Cursor") -> None: + """The Python side clamps, but the column is the backstop — and this is the value the director + compares against thresholds, so a number outside [0, 1] would silently disable a rule.""" + # Arrange + owner = str(uuid.uuid4()) + _seed_user(db, owner) + + # Act / Assert — service_role bypasses RLS but not a check constraint. + with pytest.raises(psycopg.errors.CheckViolation): + db.execute( + """ + insert into public.live_knowledge + (user_id, graph_id, node_id, estimate) values (%s, 'g1', 'a', 1.5) + """, + (owner,), + ) + + +def test_one_learner_holds_one_belief_per_concept_per_map(db: "psycopg.Cursor") -> None: + """The natural key. Two rows for one concept would make "what do they know" ambiguous, and the + loop upserts on exactly this key — without the constraint it would append instead.""" + # Arrange + owner = str(uuid.uuid4()) + _seed_user(db, owner) + _believe(db, owner, "g1") + + # Act / Assert + with pytest.raises(psycopg.errors.UniqueViolation): + _believe(db, owner, "g1") + + +def test_the_same_concept_id_on_another_map_is_a_different_belief(db: "psycopg.Cursor") -> None: + """Node ids are graph-local, so "a" on one map and "a" on another are unrelated — the graph_id + in the key is the only thing keeping them apart.""" + # Arrange + owner = str(uuid.uuid4()) + _seed_user(db, owner) + _believe(db, owner, "g1") + + # Act — the same concept id on a different map inserts cleanly. + _believe(db, owner, "g2") + + # Assert + db.execute("select count(*) from public.live_knowledge where user_id = %s", (owner,)) + assert db.fetchone()[0] == 2 From bc21affcef36f40381aae80250abc7bcc3caa00e Mon Sep 17 00:00:00 2001 From: Pouyan Jahangiri Date: Sun, 9 Aug 2026 21:54:37 -0700 Subject: [PATCH 3/9] feat(live): decide what the session does next, and say why (Phase 2a, T3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../live/src/lunaris_live/session/__init__.py | 4 + .../src/lunaris_live/session/decide_move.py | 161 ++++++++ .../lunaris_live/session/schema/__init__.py | 2 + .../session/schema/session_clock.py | 24 ++ packages/live/tests/test_director.py | 356 ++++++++++++++++++ 5 files changed, 547 insertions(+) create mode 100644 packages/live/src/lunaris_live/session/decide_move.py create mode 100644 packages/live/src/lunaris_live/session/schema/session_clock.py create mode 100644 packages/live/tests/test_director.py diff --git a/packages/live/src/lunaris_live/session/__init__.py b/packages/live/src/lunaris_live/session/__init__.py index 4f8a51d4..e27a42bb 100644 --- a/packages/live/src/lunaris_live/session/__init__.py +++ b/packages/live/src/lunaris_live/session/__init__.py @@ -9,6 +9,7 @@ """ from .apply_evidence import apply_evidence +from .decide_move import decide_move from .memory_knowledge_store import MemoryKnowledgeStore from .memory_session_store import MemorySessionStore from .open_session import open_session @@ -21,6 +22,7 @@ MoveKind, NodeKnowledge, Session, + SessionClock, SessionStatus, SessionTurn, ) @@ -38,11 +40,13 @@ "MoveKind", "NodeKnowledge", "Session", + "SessionClock", "SessionStatus", "SessionTurn", "SupabaseKnowledgeStore", "SupabaseSessionStore", "apply_evidence", + "decide_move", "open_session", "recall_of", ] diff --git a/packages/live/src/lunaris_live/session/decide_move.py b/packages/live/src/lunaris_live/session/decide_move.py new file mode 100644 index 00000000..ce783201 --- /dev/null +++ b/packages/live/src/lunaris_live/session/decide_move.py @@ -0,0 +1,161 @@ +from ..graph import ConceptGraph, ConceptNode +from .recall_of import recall_of +from .schema import DirectorMove, EvidenceKind, LearnerModel, MoveKind, SessionClock + +#: Recall at or above this counts as "the learner has this". It gates introductions, so it is the +#: number that decides whether progress through the map is earned or waved through. Set above what a +#: single MET can reach (one piece of evidence lands at 0.45), so mastery takes more than one right +#: answer — a guess must not unlock a dependent. +_MASTERED = 0.6 + +#: Recall below this on a concept the learner HAS demonstrated means it is slipping and is worth +#: coming back to. Under ``_MASTERED`` by design: a concept between the two is neither solid enough +#: to build on nor faded enough to interrupt for. +_DECAYED = 0.45 + +#: Consecutive misses before the director stops advancing and changes approach. Two, not one: a +#: learner is allowed to be wrong once — that is what a first attempt is for — but a second miss on +#: the same concept means the explanation is not landing, and saying it louder will not help. +_STUCK_AFTER = 2 + + +def decide_move(graph: ConceptGraph, model: LearnerModel, clock: SessionClock) -> DirectorMove: + """What the session should do next, and why. + + A scored rule set rather than a model call (plan §7): legible, deterministic, exhaustively + testable without a key, and the place pedagogical iteration will happen once there is real + session data to iterate against. Rules are tried in a fixed order, and the order IS the policy: + + 1. **The clock outranks everything.** A session is bounded by design, and the strongest teaching + instinct must still not run it past its budget. + 2. **A stuck learner is not walked away from.** There is nearly always other material available; + leaving somebody stranded on the concept they just failed twice to reach for something easier + is the single worst thing this policy could do. + 3. **A slipping concept interrupts new material.** Spaced retrieval only exists if it can + interrupt — a director that introduced whenever anything was introducible would never come + back to anything. + 4. **Otherwise, teach something new whose prerequisites are met.** + 5. **Nothing left worth doing is a reason to stop**, not to loop. + """ + if clock.is_spent: + return DirectorMove( + kind=MoveKind.CLOSE, + reason=( + f"The session's {round(clock.budget_s / 60)} minutes are up. Stopping here so it " + "ends on a recap rather than mid-explanation." + ), + ) + + if (stuck := _stuck_on(graph, model)) is not None: + return DirectorMove( + kind=MoveKind.REMEDIATE, + node_id=stuck.id, + reason=( + f"{stuck.name} has been missed {_STUCK_AFTER} times running, so the explanation is " + "not landing. Trying it a different way rather than pressing on." + ), + ) + + if (slipping := _most_decayed(graph, model, clock)) is not None: + return DirectorMove( + kind=MoveKind.RETRIEVE, + node_id=slipping.id, + reason=( + f"{slipping.name} was understood earlier but has not been used since. Coming back " + "to it now, while recovering it is still cheap." + ), + ) + + if (next_up := _frontier(graph, model, clock)) is not None: + return DirectorMove( + kind=MoveKind.INTRODUCE, + node_id=next_up.id, + reason=( + f"Everything {next_up.name} depends on has been demonstrated, so it is the next " + "thing this map can teach." + ), + ) + + return DirectorMove( + kind=MoveKind.CLOSE, + reason=( + "Nothing on this map is left to introduce and nothing is due for review. Closing while " + "the session still has a shape rather than padding it out." + ), + ) + + +def _knows(model: LearnerModel, node_id: str, clock: SessionClock) -> bool: + """Whether the learner may be built on for ``node_id``: believed, now, at this turn. + + One threshold and no separate evidence-count guard, because the threshold already implies one. + A single piece of evidence moves the belief by ``_PULL`` (0.45), which is below ``_MASTERED`` + (0.6) by construction — so mastery necessarily takes more than one answer, and a guard saying so + again would be a second place to keep the same rule true. That relationship is what + ``test_one_right_answer_does_not_unlock_the_next_concept`` pins: raise the pull past the + threshold and it fails, which is the honest way to hold this invariant. + """ + known = model.nodes.get(node_id) + return known is not None and recall_of(model, node_id, at_turn=clock.turn) >= _MASTERED + + +def _stuck_on(graph: ConceptGraph, model: LearnerModel) -> ConceptNode | None: + """The concept the learner is stuck on, if any. + + Read off the *belief* rather than a miss counter, so a breakthrough clears it: a concept that + was hard once must not be remediated forever, or the session never moves. A learner is stuck + when they have real evidence about a concept and that evidence has left the belief where a + string of misses would — nowhere near mastery. + """ + for node in graph.nodes: + known = model.nodes.get(node.id) + if known is not None and known.evidence_count >= _STUCK_AFTER and known.estimate < _DECAYED: + return node + return None + + +def _most_decayed( + graph: ConceptGraph, model: LearnerModel, clock: SessionClock +) -> ConceptNode | None: + """The demonstrated concept that has slipped furthest, if one has slipped at all. + + Only concepts the learner has actually demonstrated are candidates: recall of an unseen concept + is 0.0, which is below any threshold, so a naive rule here would "retrieve" something that was + never taught. + """ + candidates = [ + (recall_of(model, node.id, at_turn=clock.turn), node) + for node in graph.nodes + if (known := model.nodes.get(node.id)) is not None and known.estimate >= _MASTERED + ] + due = [(recall, node) for recall, node in candidates if recall < _DECAYED] + return min(due, key=lambda pair: pair[0])[1] if due else None + + +def _frontier(graph: ConceptGraph, model: LearnerModel, clock: SessionClock) -> ConceptNode | None: + """The next concept worth teaching: not yet known, everything it needs already demonstrated. + + Walked in the map's own teaching order so two sessions on one map agree about what comes next, + and so the choice inherits Phase 1's ordering rather than inventing a second one. + + The prerequisite check looks redundant against a *valid* ``topo_order`` — the first unknown + concept in teaching order has all its prerequisites behind it, and they were only skipped + because they were known. It is kept because the director does not own the order it is handed. + C1 grows a map at runtime, ``prerequisites_of`` and ``resolve_request`` are public functions + anyone can call with a hand-built graph, and a stale or invented ``topo_order`` would otherwise + have this teach a concept on top of nothing. Pinned by + ``test_a_lying_teaching_order_cannot_smuggle_a_concept_past_its_prerequisites``. + """ + by_id = {node.id: node for node in graph.nodes} + for node_id in graph.topo_order: + node = by_id.get(node_id) + if node is None or _knows(model, node_id, clock): + continue + if all(_knows(model, required, clock) for required in node.requires): + return node + return None + + +#: Re-exported for the grader (T5), which needs the same notion of "met" the director gates on — +#: two definitions of mastery would let a session award progress the policy refuses to act on. +MASTERY_EVIDENCE = EvidenceKind.MET diff --git a/packages/live/src/lunaris_live/session/schema/__init__.py b/packages/live/src/lunaris_live/session/schema/__init__.py index 6520efa6..ce82de16 100644 --- a/packages/live/src/lunaris_live/session/schema/__init__.py +++ b/packages/live/src/lunaris_live/session/schema/__init__.py @@ -6,6 +6,7 @@ from .move_kind import MoveKind from .node_knowledge import NodeKnowledge from .session import Session +from .session_clock import SessionClock from .session_status import SessionStatus from .session_turn import SessionTurn @@ -16,6 +17,7 @@ "MoveKind", "NodeKnowledge", "Session", + "SessionClock", "SessionStatus", "SessionTurn", ] diff --git a/packages/live/src/lunaris_live/session/schema/session_clock.py b/packages/live/src/lunaris_live/session/schema/session_clock.py new file mode 100644 index 00000000..82b283fa --- /dev/null +++ b/packages/live/src/lunaris_live/session/schema/session_clock.py @@ -0,0 +1,24 @@ +from pydantic import Field + +from ...graph.schema.base import LiveModel + + +class SessionClock(LiveModel): + """Where a session is in its own life — the third input to every decision. + + Two clocks, because they measure different things and the policy needs both. ``turn`` is the + loop's own counter, and decay is measured in it so a session is reproducible: replaying the same + answers gives the same beliefs. ``elapsed_s`` is the learner's wall clock, and it is what bounds + the session (plan §6: 25-40 minutes) — a bound in turns would let a session of long, slow turns + run for hours. + """ + + #: 1-based, monotonic. The turn about to be taken. + turn: int = Field(ge=1) + elapsed_s: float = Field(ge=0.0) + #: The session's whole budget. Reaching it is a reason to close well, not to be cut off. + budget_s: float = Field(gt=0.0) + + @property + def is_spent(self) -> bool: + return self.elapsed_s >= self.budget_s diff --git a/packages/live/tests/test_director.py b/packages/live/tests/test_director.py new file mode 100644 index 00000000..31e92fcc --- /dev/null +++ b/packages/live/tests/test_director.py @@ -0,0 +1,356 @@ +"""The director: what the session does next, and why (Phase 2a, T3). + +Plan §7 makes this a *policy* — "a scored rule set over knowledge-state estimates", explicitly +legible, explicitly the place pedagogical iteration will happen once real session data exists. So +it is a pure function over `(graph, learner model, clock)`: no I/O, no model call, nothing to stub. +That is what lets it be exhaustively tested without a key, and it is the only deterministic part of +the loop. + +The thing under test is the *ordering of concerns*, not any one rule in isolation. A director that +introduces new material while the learner is stuck, or that never looks back at a decayed concept, +is wrong in a way no single-rule test would catch — so most of these put two rules in competition +and pin which wins. +""" + +import pytest +from lunaris_live.graph import ConceptGraph, ConceptNode +from lunaris_live.session import ( + EvidenceKind, + LearnerModel, + MoveKind, + SessionClock, + apply_evidence, + decide_move, +) + + +def _graph() -> ConceptGraph: + """A chain: a → b → c, plus an unrelated root d. Two independent frontiers on purpose.""" + return ConceptGraph( + graph_id="g1", + topic="A subject", + nodes=[ + ConceptNode(id="a", name="A", definition="The first idea."), + ConceptNode(id="b", name="B", definition="Builds on A.", requires=["a"]), + ConceptNode(id="c", name="C", definition="Builds on B.", requires=["b"]), + ConceptNode(id="d", name="D", definition="Unrelated root."), + ], + topo_order=["a", "d", "b", "c"], + is_acyclic=True, + ) + + +def _fresh() -> SessionClock: + return SessionClock(turn=1, elapsed_s=0.0, budget_s=1800.0) + + +def _mastered(model: LearnerModel, *node_ids: str, through_turn: int = 3) -> LearnerModel: + """Enough successful evidence that the director will treat these as met.""" + for node_id in node_ids: + for turn in range(1, through_turn + 1): + model = apply_evidence(model, node_id, EvidenceKind.MET, at_turn=turn) + return model + + +# ── introducing ─────────────────────────────────────────────────────────────────────────────── + + +def test_a_fresh_session_introduces_a_concept_with_no_prerequisites() -> None: + # Act + move = decide_move(_graph(), LearnerModel(graph_id="g1"), _fresh()) + + # Assert + assert move.kind is MoveKind.INTRODUCE + assert move.node_id in {"a", "d"} + assert move.reason + + +def test_a_concept_whose_prerequisites_are_unmet_is_never_introduced() -> None: + """The whole reason Phase 1 built prerequisite edges. Teaching C to somebody who has not met B + is the failure the graph exists to prevent, and it is the director that has to honour it.""" + # Arrange — nothing known at all. + model = LearnerModel(graph_id="g1") + + # Act + move = decide_move(_graph(), model, _fresh()) + + # Assert + assert move.node_id not in {"b", "c"} + + +def test_mastering_a_prerequisite_unlocks_what_it_gates() -> None: + """The load-bearing claim of the whole policy: progress through the map is *earned*, and the + learner model is what earns it.""" + # Arrange + model = _mastered(LearnerModel(graph_id="g1"), "a", "d") + + # Act + move = decide_move(_graph(), model, SessionClock(turn=4, elapsed_s=60.0, budget_s=1800.0)) + + # Assert + assert move.kind is MoveKind.INTRODUCE + assert move.node_id == "b" + + +def test_one_right_answer_does_not_unlock_the_next_concept() -> None: + """One right answer can be a guess. If a single MET unlocked a dependent, the map would be a + railway with an extra step, and the learner model would be decoration.""" + # Arrange + model = apply_evidence(LearnerModel(graph_id="g1"), "a", EvidenceKind.MET, at_turn=1) + + # Act + move = decide_move(_graph(), model, SessionClock(turn=2, elapsed_s=30.0, budget_s=1800.0)) + + # Assert — still INTRODUCING "a", not moving on and not congratulating itself by switching to + # review. Both halves are load-bearing: asserting merely "not b" passes when a lowered mastery + # threshold picks the other root instead, and asserting only the node id passes when the same + # threshold turns the move into a retrieval of a concept taught once. + assert (move.kind, move.node_id) == (MoveKind.INTRODUCE, "a") + + +# ── remediating ─────────────────────────────────────────────────────────────────────────────── + + +def test_a_learner_who_keeps_failing_is_remediated_not_advanced() -> None: + """The competition that matters most. There is other material available and the clock is fine — + the director must still not walk away from somebody who is stuck.""" + # Arrange — repeated failure on an available root. + model = LearnerModel(graph_id="g1") + for turn in (1, 2): + model = apply_evidence(model, "a", EvidenceKind.NOT_MET, at_turn=turn) + + # Act + move = decide_move(_graph(), model, SessionClock(turn=3, elapsed_s=90.0, budget_s=1800.0)) + + # Assert + assert move.kind is MoveKind.REMEDIATE + assert move.node_id == "a" + + +def test_one_wrong_answer_is_not_yet_being_stuck() -> None: + """Remediating on the first miss would make the session flinch. A learner is allowed to be + wrong once — that is what the first attempt is for.""" + # Arrange + model = apply_evidence(LearnerModel(graph_id="g1"), "a", EvidenceKind.NOT_MET, at_turn=1) + + # Act + move = decide_move(_graph(), model, SessionClock(turn=2, elapsed_s=30.0, budget_s=1800.0)) + + # Assert + assert move.kind is not MoveKind.REMEDIATE + + +def test_progress_after_a_struggle_stops_the_remediation() -> None: + """Otherwise a concept that was hard once is remediated forever, and the session never moves.""" + # Arrange — stuck, then a breakthrough. + model = LearnerModel(graph_id="g1") + for turn in (1, 2): + model = apply_evidence(model, "a", EvidenceKind.NOT_MET, at_turn=turn) + for turn in (3, 4, 5): + model = apply_evidence(model, "a", EvidenceKind.MET, at_turn=turn) + + # Act + move = decide_move(_graph(), model, SessionClock(turn=6, elapsed_s=200.0, budget_s=1800.0)) + + # Assert + assert move.kind is not MoveKind.REMEDIATE + + +# ── retrieving ──────────────────────────────────────────────────────────────────────────────── + + +def test_a_decayed_concept_is_retrieved_before_new_material_is_introduced() -> None: + """Spaced retrieval only exists if it can *interrupt*. A director that introduced whenever + anything was introducible would never come back to anything.""" + # Arrange — "a" mastered long ago, "d" never seen, so both a retrieval and an introduction are + # available and the director has to choose. + model = _mastered(LearnerModel(graph_id="g1"), "a") + + # Act — far enough past that evidence for recall to have decayed. + move = decide_move(_graph(), model, SessionClock(turn=40, elapsed_s=900.0, budget_s=1800.0)) + + # Assert + assert move.kind is MoveKind.RETRIEVE + assert move.node_id == "a" + + +def test_a_freshly_demonstrated_concept_is_not_retrieved() -> None: + """Retrieval immediately after the answer that established it is not spacing, it is nagging.""" + # Arrange + model = _mastered(LearnerModel(graph_id="g1"), "a") + + # Act + move = decide_move(_graph(), model, SessionClock(turn=4, elapsed_s=90.0, budget_s=1800.0)) + + # Assert + assert move.kind is not MoveKind.RETRIEVE + + +def test_a_concept_never_demonstrated_is_introduced_rather_than_retrieved() -> None: + """Recall of an unseen concept is 0.0, which is below any decay threshold — so a naive rule + would "retrieve" something the learner has never been taught.""" + # Act + move = decide_move(_graph(), LearnerModel(graph_id="g1"), _fresh()) + + # Assert + assert move.kind is MoveKind.INTRODUCE + + +# ── closing ─────────────────────────────────────────────────────────────────────────────────── + + +def test_the_session_closes_when_its_clock_is_spent() -> None: + """Bounded by design (plan §6): a session that could run forever has no shape a learner can + feel and no cost ceiling.""" + # Act — plenty left to teach, but the time is gone. + move = decide_move( + _graph(), + LearnerModel(graph_id="g1"), + SessionClock(turn=60, elapsed_s=1800.0, budget_s=1800.0), + ) + + # Assert + assert move.kind is MoveKind.CLOSE + assert move.node_id is None + + +def test_the_session_closes_when_the_map_is_exhausted() -> None: + """Nothing left worth doing is a reason to stop, not a reason to loop.""" + # Arrange — everything mastered, and recently, so no retrieval is due either. + model = _mastered(LearnerModel(graph_id="g1"), "a", "b", "c", "d") + + # Act + move = decide_move(_graph(), model, SessionClock(turn=4, elapsed_s=300.0, budget_s=1800.0)) + + # Assert + assert move.kind is MoveKind.CLOSE + + +def test_a_spent_clock_closes_even_on_a_learner_who_is_stuck() -> None: + """The clock outranks everything. Remediation is the strongest pull in the policy, and it still + must not run a session past its budget.""" + # Arrange + model = LearnerModel(graph_id="g1") + for turn in (1, 2, 3): + model = apply_evidence(model, "a", EvidenceKind.NOT_MET, at_turn=turn) + + # Act + move = decide_move(_graph(), model, SessionClock(turn=4, elapsed_s=2000.0, budget_s=1800.0)) + + # Assert + assert move.kind is MoveKind.CLOSE + + +def test_an_empty_map_closes_rather_than_failing() -> None: + """A director asked to teach nothing should end the session, not raise into the loop.""" + # Arrange + empty = ConceptGraph(graph_id="g1", topic="t", topo_order=[], is_acyclic=True) + + # Act / Assert + assert decide_move(empty, LearnerModel(graph_id="g1"), _fresh()).kind is MoveKind.CLOSE + + +# ── the trace ───────────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("model_builder", "clock"), + [ + pytest.param(lambda: LearnerModel(graph_id="g1"), _fresh(), id="introduce"), + pytest.param( + lambda: _mastered(LearnerModel(graph_id="g1"), "a"), + SessionClock(turn=40, elapsed_s=900.0, budget_s=1800.0), + id="retrieve", + ), + pytest.param( + lambda: apply_evidence( + apply_evidence(LearnerModel(graph_id="g1"), "a", EvidenceKind.NOT_MET, at_turn=1), + "a", + EvidenceKind.NOT_MET, + at_turn=2, + ), + SessionClock(turn=3, elapsed_s=90.0, budget_s=1800.0), + id="remediate", + ), + pytest.param( + lambda: LearnerModel(graph_id="g1"), + SessionClock(turn=60, elapsed_s=1800.0, budget_s=1800.0), + id="close", + ), + ], +) +def test_every_move_says_why_in_words_a_person_can_read( + model_builder: object, clock: SessionClock +) -> None: + """Plan §7: "the director emits its reasoning into the session trace so every move is + auditable". A session is dozens of choices made in seconds on a learner's behalf, and this is + the only way to tell a good policy from a lucky one afterwards. + + Asserted on every branch, because the branch that forgets is always the one nobody exercised. + """ + # Act + move = decide_move(_graph(), model_builder(), clock) # type: ignore[operator] + + # Assert — prose, not a rule id: the reason is read by a human, not parsed. + assert len(move.reason) > 20 + assert move.reason[0].isupper() + + +def test_the_reason_names_the_concept_the_move_is_about() -> None: + """A trace that said "introducing a new concept" without saying which is not auditable — it + describes the policy rather than the decision.""" + # Act + move = decide_move(_graph(), _mastered(LearnerModel(graph_id="g1"), "a", "d"), _fresh()) + + # Assert + assert move.node_id == "b" + assert "B" in move.reason or "b" in move.reason + + +def test_the_director_never_invents_a_concept_that_is_not_on_the_map() -> None: + """The one structural guarantee the loop above it relies on: whatever the policy decides, the + tutor has to be able to look the concept up.""" + # Arrange + graph = _graph() + ids = {node.id for node in graph.nodes} + + # Act / Assert — across a spread of states, the answer is always on the map or is a close. + model = LearnerModel(graph_id="g1") + for turn in range(1, 25): + move = decide_move( + graph, model, SessionClock(turn=turn, elapsed_s=turn * 30.0, budget_s=1800.0) + ) + assert move.node_id is None or move.node_id in ids + if move.node_id is not None: + model = apply_evidence(model, move.node_id, EvidenceKind.MET, at_turn=turn) + + +def test_a_lying_teaching_order_cannot_smuggle_a_concept_past_its_prerequisites() -> None: + """The director does not own the order it is handed. + + Against a *valid* ``topo_order`` the prerequisite check is redundant — the first unknown concept + in teaching order has all its prerequisites behind it, and they were only skipped because they + were known. So the check is only observable against an order that lies, which is exactly the + case it exists for: C1 grows maps at runtime, and these are public functions anyone can call + with a hand-built graph. Without this test the guard is invisible and the next refactor deletes + it as dead code. + """ + # Arrange — an order that puts C first, though C needs B which needs A. Nothing is known. + lying = ConceptGraph( + graph_id="g1", + topic="A subject", + nodes=[ + ConceptNode(id="a", name="A", definition="The first idea."), + ConceptNode(id="b", name="B", definition="Builds on A.", requires=["a"]), + ConceptNode(id="c", name="C", definition="Builds on B.", requires=["b"]), + ], + topo_order=["c", "b", "a"], + is_acyclic=True, + ) + + # Act + move = decide_move(lying, LearnerModel(graph_id="g1"), _fresh()) + + # Assert — it teaches the only concept that is actually reachable, not the one listed first. + assert move.kind is MoveKind.INTRODUCE + assert move.node_id == "a" From 3a4bdf5840c350cd0609b0e11a57bb8ec15aabc1 Mon Sep 17 00:00:00 2001 From: Pouyan Jahangiri Date: Sun, 9 Aug 2026 22:32:39 -0700 Subject: [PATCH 4/9] feat(live): teach the move the director chose (Phase 2a, T4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/src/lunaris_api/config.py | 5 + apps/api/src/lunaris_api/live/dependencies.py | 18 +- .../lunaris_api/live/session/dependencies.py | 51 ++- .../src/lunaris_api/live/session/router.py | 56 ++- .../src/lunaris_api/live/session/service.py | 67 +++- .../tests/live/test_live_session_service.py | 99 ++++++ apps/api/tests/live/test_live_sessions_api.py | 200 ++++++++++- apps/web/src/lib/liveSession.test.ts | 6 +- apps/web/src/lib/liveSession.ts | 3 + .../lunaris_live/graph/stub_graph_compiler.py | 45 ++- .../live/src/lunaris_live/session/__init__.py | 20 +- .../src/lunaris_live/session/claude_tutor.py | 169 +++++++++ .../src/lunaris_live/session/open_session.py | 54 +-- .../session/protocols/__init__.py | 3 +- .../lunaris_live/session/protocols/tutor.py | 26 ++ .../session/reject_unteachable_move.py | 14 + .../session/schema/session_turn.py | 5 + .../session/session_format_error.py | 15 + .../src/lunaris_live/session/stub_tutor.py | 42 +++ .../session/supabase_session_store.py | 12 +- .../session/tutor_unavailable_error.py | 10 + packages/live/tests/test_open_session.py | 195 +++++++++++ packages/live/tests/test_session_stores.py | 90 +++++ .../live/tests/test_stub_graph_compiler.py | 17 + packages/live/tests/test_tutor.py | 327 ++++++++++++++++++ 25 files changed, 1468 insertions(+), 81 deletions(-) create mode 100644 apps/api/tests/live/test_live_session_service.py create mode 100644 packages/live/src/lunaris_live/session/claude_tutor.py create mode 100644 packages/live/src/lunaris_live/session/protocols/tutor.py create mode 100644 packages/live/src/lunaris_live/session/reject_unteachable_move.py create mode 100644 packages/live/src/lunaris_live/session/session_format_error.py create mode 100644 packages/live/src/lunaris_live/session/stub_tutor.py create mode 100644 packages/live/src/lunaris_live/session/tutor_unavailable_error.py create mode 100644 packages/live/tests/test_open_session.py create mode 100644 packages/live/tests/test_tutor.py diff --git a/apps/api/src/lunaris_api/config.py b/apps/api/src/lunaris_api/config.py index 3c64705e..eb96b84c 100644 --- a/apps/api/src/lunaris_api/config.py +++ b/apps/api/src/lunaris_api/config.py @@ -68,6 +68,10 @@ class Settings: live_compile_max_concurrent: int = 1 live_extend_daily_cap: int = 50 live_graph_budget_usd: float = 10.0 + #: How long a Live session runs before the director closes it (plan §6: 25-40 minutes). A + #: bound on the learner's wall clock, not on turns — a session of long, slow turns would + #: otherwise run for hours. + live_session_budget_s: float = 1800.0 device_bridge_liveness_s: float = _DEFAULT_BRIDGE_LIMITS.liveness_s device_bridge_completion_timeout_s: float = _DEFAULT_BRIDGE_LIMITS.completion_timeout_s # The explainer-video operator kill-switch (plan §3.0 item 5). Default OFF — fail-closed, so a @@ -164,6 +168,7 @@ def get_settings() -> Settings: live_compile_max_concurrent=_env_int("LUNARIS_LIVE_COMPILE_MAX_CONCURRENT", default=1), live_extend_daily_cap=_env_int("LUNARIS_LIVE_EXTEND_DAILY_CAP", default=50), live_graph_budget_usd=_env_float("LUNARIS_LIVE_GRAPH_BUDGET_USD", default=10.0), + live_session_budget_s=_env_float("LUNARIS_LIVE_SESSION_BUDGET_S", default=1800.0), device_bridge_liveness_s=_env_float( "LUNARIS_DEVICE_BRIDGE_LIVENESS_S", default=_DEFAULT_BRIDGE_LIMITS.liveness_s ), diff --git a/apps/api/src/lunaris_api/live/dependencies.py b/apps/api/src/lunaris_api/live/dependencies.py index 2b75949e..cb539d07 100644 --- a/apps/api/src/lunaris_api/live/dependencies.py +++ b/apps/api/src/lunaris_api/live/dependencies.py @@ -25,9 +25,20 @@ #: Decomposition decides what the course is made of and every later phase reads it, so it runs on #: the strong tier, not the bulk one. Same env knob Studio's tiers use, so a tenant's model choice #: covers both products. (Splitting decomposition from spec authoring across two tiers is a -#: latency/cost lever T4 owns; one tier keeps this honest until there is a measurement to tune to.) +#: latency/cost lever; one tier keeps this honest until there is a measurement to tune to.) _DEFAULT_MODEL = "claude-opus-4-8" + +def resolve_strong_model() -> str: + """The model Live's quality surfaces run on — the compiler's decomposition and the tutor. + + One function rather than a constant per composition root, so a tenant's model choice cannot end + up meaning two different things inside one product: a session taught by a weaker model than the + map it walks would be a difference nobody configured. + """ + return resolve_config("LUNARIS_MODEL_STRONG") or _DEFAULT_MODEL + + # One durable store per process, same lazy-client rationale as Studio's stores: the service-role # client is built on first write, so the singleton needs no creds and no network until then. _supabase_graph_store = SupabaseGraphStore() @@ -56,10 +67,7 @@ def _resolve_compiler(settings: Settings) -> IGraphCompiler: """ if settings.pipeline == "stub": return StubGraphCompiler() - return ClaudeGraphCompiler( - resolve_config("LUNARIS_MODEL_STRONG") or _DEFAULT_MODEL, - deadline_s=settings.live_compile_deadline_s, - ) + return ClaudeGraphCompiler(resolve_strong_model(), deadline_s=settings.live_compile_deadline_s) @lru_cache diff --git a/apps/api/src/lunaris_api/live/session/dependencies.py b/apps/api/src/lunaris_api/live/session/dependencies.py index c61c2619..dd538af3 100644 --- a/apps/api/src/lunaris_api/live/session/dependencies.py +++ b/apps/api/src/lunaris_api/live/session/dependencies.py @@ -1,19 +1,32 @@ from typing import Annotated from fastapi import Depends -from lunaris_live.session import ISessionStore, MemorySessionStore, SupabaseSessionStore +from lunaris_live.session import ( + ClaudeTutor, + IKnowledgeStore, + ISessionStore, + ITutor, + MemoryKnowledgeStore, + MemorySessionStore, + StubTutor, + SupabaseKnowledgeStore, + SupabaseSessionStore, +) from ...config import Settings, get_settings -from ..dependencies import resolve_graph_store +from ..dependencies import resolve_graph_store, resolve_strong_model from .service import LiveSessionService # One durable store per process — same lazy-client rationale as the graph store: the service-role # client is built on first write, so the singleton needs no creds and no network until then. _supabase_session_store = SupabaseSessionStore() +_supabase_knowledge_store = SupabaseKnowledgeStore() -# The in-memory fallback MUST be a singleton: opening a session and the next turn of it are separate -# requests, so a per-request store would lose the session between them. +# The in-memory fallbacks MUST be singletons: opening a session and the next turn of it are +# separate requests, so a per-request store would lose the session — and the learner's beliefs — +# between them. _memory_session_store = MemorySessionStore() +_memory_knowledge_store = MemoryKnowledgeStore() def _resolve_session_store(settings: Settings) -> ISessionStore: @@ -21,16 +34,42 @@ def _resolve_session_store(settings: Settings) -> ISessionStore: return _supabase_session_store if settings.has_supabase else _memory_session_store +def _resolve_knowledge_store(settings: Settings) -> IKnowledgeStore: + """Durable where Supabase is configured, in-process otherwise (offline dev and the suite).""" + return _supabase_knowledge_store if settings.has_supabase else _memory_knowledge_store + + +def get_live_tutor(settings: Annotated[Settings, Depends(get_settings)]) -> ITutor: + """The model-backed tutor, or the deterministic one under ``LUNARIS_PIPELINE=stub``. + + A dependency in its own right rather than something the service composes privately, because it + is the collaborator most worth substituting: the failure that matters most here is a tutor that + cannot speak, and a test can only stage that by putting a silent one in its place. + + Teaching runs on the strong tier (A1) — the same tier the map was compiled on. It is the + quality surface of the whole product, and a session taught by a cheaper model than the map it + walks would be a difference no one chose. + """ + return StubTutor() if settings.pipeline == "stub" else ClaudeTutor(resolve_strong_model()) + + def get_live_session_service( settings: Annotated[Settings, Depends(get_settings)], + tutor: Annotated[ITutor, Depends(get_live_tutor)], ) -> LiveSessionService: """Live's session plane as a request dependency. Takes the *same* graph store the compile plane writes to rather than composing a second one — a session that read from a different store than the compiler wrote to would find no maps at all, - and it would look like a data problem rather than a wiring one. + and it would look like a data problem rather than the wiring one it is. """ - return LiveSessionService(resolve_graph_store(settings), _resolve_session_store(settings)) + return LiveSessionService( + resolve_graph_store(settings), + _resolve_session_store(settings), + knowledge=_resolve_knowledge_store(settings), + tutor=tutor, + session_budget_s=settings.live_session_budget_s, + ) LiveSessionServiceDep = Annotated[LiveSessionService, Depends(get_live_session_service)] diff --git a/apps/api/src/lunaris_api/live/session/router.py b/apps/api/src/lunaris_api/live/session/router.py index 4f059d9a..7feaa6f3 100644 --- a/apps/api/src/lunaris_api/live/session/router.py +++ b/apps/api/src/lunaris_api/live/session/router.py @@ -2,7 +2,7 @@ import structlog from fastapi import APIRouter, HTTPException, Response, status -from lunaris_live.session import Session +from lunaris_live.session import Session, SessionFormatError, TutorUnavailableError from lunaris_runtime.persistence import PersistenceError from ...dependencies import OptionalUserIdDep @@ -15,6 +15,15 @@ _UNAVAILABLE = "Live is having trouble reaching its storage. Try again shortly." +#: The tutor being down is a different outage from storage being down, and a learner can tell: one +#: means their session did not open, the other means it may not have been saved. Both end, so both +#: are worth retrying — which is why this is a 503 rather than a session opened with nothing in it. +_TUTOR_UNAVAILABLE = "Live's tutor could not reach its model. Try again shortly." + +#: A row this build cannot parse never becomes readable by waiting, so it must not read as an +#: outage: "try again" on a permanently unreadable session is an invitation to reload forever. +_UNREADABLE = "This session was saved in a format Live can no longer read." + @router.post("", response_model=Session, status_code=status.HTTP_201_CREATED) async def start_session( @@ -47,12 +56,8 @@ async def start_session( # and ``guard`` only translates. Without this an outage is a bare 500 with no way to tell # what broke, on the path where a learner has just lost a session before it began. logger.warning("live.session.start_failed", session_id=session_id, exc_info=True) - if isinstance(exc, PersistenceError): - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=_UNAVAILABLE, - headers=correlated, - ) from exc + if (translated := _translate(exc, correlated)) is not None: + raise translated from exc raise @@ -77,12 +82,37 @@ async def read_session( ) from exc except Exception as exc: logger.warning("live.session.read_failed", session_id=session_id, exc_info=True) - if isinstance(exc, PersistenceError): - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=_UNAVAILABLE, - headers=correlated, - ) from exc + if (translated := _translate(exc, correlated)) is not None: + raise translated from exc raise response.headers["X-Session-Id"] = session_id return session + + +def _translate(exc: Exception, correlated: dict[str, str]) -> HTTPException | None: + """The HTTP answer to a failure a learner could plausibly act on, or ``None`` to let it fly. + + One place, because the two entry points fail in the same ways and drifting apart would mean a + learner learning what "try again" means from whichever endpoint they hit first. Ordered + deliberately: ``SessionFormatError`` IS a ``PersistenceError``, and reading as a retryable + outage is exactly the mistake it exists to prevent. + """ + if isinstance(exc, SessionFormatError): + return HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=_UNREADABLE, + headers=correlated, + ) + if isinstance(exc, TutorUnavailableError): + return HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=_TUTOR_UNAVAILABLE, + headers=correlated, + ) + if isinstance(exc, PersistenceError): + return HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=_UNAVAILABLE, + headers=correlated, + ) + return None diff --git a/apps/api/src/lunaris_api/live/session/service.py b/apps/api/src/lunaris_api/live/session/service.py index b4b9294b..34595097 100644 --- a/apps/api/src/lunaris_api/live/session/service.py +++ b/apps/api/src/lunaris_api/live/session/service.py @@ -1,9 +1,17 @@ import asyncio +from uuid import uuid4 import structlog from lunaris_live.graph import IGraphStore -from lunaris_live.session import ISessionStore, Session, open_session -from lunaris_runtime.logging import bind_run_id +from lunaris_live.session import ( + IKnowledgeStore, + ISessionStore, + ITutor, + Session, + SessionClock, + open_session, +) +from lunaris_runtime.logging import bind_request_id, bind_run_id logger = structlog.get_logger() @@ -11,17 +19,29 @@ class LiveSessionService: """Opens and re-reads a learner's sessions. - Orchestration only, like ``LiveGraphService``: mint the id, bind correlation, read the map, take - the first turn, persist. What a turn *should* be is the director's and the tutor's business, and - they arrive behind their own seams (T3, T4) without this changing. + Orchestration only, like ``LiveGraphService``: mint the ids, bind correlation, read the map and + what this learner already knows of it, take the turn, persist. What the turn *should* be belongs + to the director and the tutor, which is why both arrive from outside rather than being built + here. The graph is read fresh on every session, never cached: C1 grows a map at runtime, so a copy taken once would be stale the first time a learner asked something off it. """ - def __init__(self, graphs: IGraphStore, sessions: ISessionStore) -> None: + def __init__( + self, + graphs: IGraphStore, + sessions: ISessionStore, + *, + knowledge: IKnowledgeStore, + tutor: ITutor, + session_budget_s: float, + ) -> None: self._graphs = graphs self._sessions = sessions + self._knowledge = knowledge + self._tutor = tutor + self._session_budget_s = session_budget_s async def start( self, graph_id: str, *, session_id: str, owner_id: str | None = None @@ -33,16 +53,35 @@ async def start( it went wrong, which is the case a header set after the work would never cover. Raises ``FileNotFoundError`` when the learner has no such map — including when it is - somebody else's, which is not-found rather than forbidden. + somebody else's, which is not-found rather than forbidden. Raises + ``TutorUnavailableError`` when the first turn could not be taught; nothing is persisted in + that case, because a session whose first turn never happened is not a session. """ - # Bound before any I/O: the graph id is known from the request, so deferring this only means - # a hung read leaves no trace that the session was ever asked for. - bind_run_id(session_id, graph_id=graph_id, session_id=session_id) + # Two ids, because they answer different questions (R6): ``session_id`` is the learner's + # whole session and ``run_id`` is the work of taking THIS turn. Both bound before any I/O — + # the run is the reading and the teaching, not just the model call — so a hung read still + # leaves a trace that the turn was attempted. + run_id = uuid4().hex + bind_run_id(run_id, graph_id=graph_id, session_id=session_id) logger.info("live.session.starting", graph_id=graph_id, session_id=session_id) # The stores are synchronous (supabase-py is), so keep the loop free while they work. graph = await asyncio.to_thread(self._graphs.load, graph_id, owner_id=owner_id) - session = open_session(graph, session_id=session_id) + # What this learner already knows of this map (T2). Without it every session would open on + # the map's first concept and re-teach a returning learner what they came back having + # learned — the director cannot adapt to a model nobody read. + known = await asyncio.to_thread(self._knowledge.load, graph_id, owner_id=owner_id) + + session = await open_session( + graph, + known, + SessionClock(turn=1, elapsed_s=0.0, budget_s=self._session_budget_s), + session_id=session_id, + run_id=run_id, + tutor=self._tutor, + ) + # After the turn, deliberately: a session row written before the tutor spoke would be a + # resumable transcript with nothing in it if the tutor then failed. await asyncio.to_thread(self._sessions.save, session, owner_id=owner_id) # No explicit ids: this line rides the contextvars binding above, so the correlation test @@ -54,9 +93,11 @@ async def load(self, session_id: str, *, owner_id: str | None = None) -> Session """Re-read a session so a reloaded tab lands back in it (U2). Correlated like the open: the resume path is the one U2 exists to make work, so a resume - that fails must be as findable in the logs as an open that fails. + that fails must be as findable in the logs as an open that fails. As a *request* rather than + a run, though — a resume takes no turn, and a ``run_id`` that was really a session id would + undo the distinction the turns depend on. """ - bind_run_id(session_id, session_id=session_id) + bind_request_id(session_id, session_id=session_id) session = await asyncio.to_thread(self._sessions.load, session_id, owner_id=owner_id) logger.info("live.session.resumed", turn_count=len(session.turns)) return session diff --git a/apps/api/tests/live/test_live_session_service.py b/apps/api/tests/live/test_live_session_service.py new file mode 100644 index 00000000..f53b5fcd --- /dev/null +++ b/apps/api/tests/live/test_live_session_service.py @@ -0,0 +1,99 @@ +"""The session plane below HTTP: what the service does with a learner's beliefs (Phase 2a, T4). + +Nothing writes a belief through the API yet — the grader that does is T5 — so the claim that a +returning learner is met where they left off cannot be made through the endpoint. It is made here +instead, against the real stores and the real package, because the alternative is shipping the +knowledge read untested and discovering at T5 that the service was passing an empty model all along. + +Owner scoping rides along rather than getting its own test here: the beliefs are stored under the +learner, so a service that read them unscoped would find an empty model and open at the root, which +is exactly what the first test would catch. The leak in the other direction — one learner reaching +another's map — is closed a layer earlier by the graph store, which refuses the read outright. +""" + +import pytest +from lunaris_api.live.session.service import LiveSessionService +from lunaris_live.graph import ConceptGraph, MemoryGraphStore, StubGraphCompiler +from lunaris_live.session import ( + EvidenceKind, + LearnerModel, + MemoryKnowledgeStore, + MemorySessionStore, + StubTutor, + apply_evidence, +) + +_TOPIC = "How neural networks learn" + + +async def _map() -> ConceptGraph: + return await StubGraphCompiler().compile(_TOPIC, graph_id="g1", run_id="r0") + + +def _mastering(graph: ConceptGraph, node_id: str) -> LearnerModel: + """Beliefs as the grader will write them (T5): repeated met evidence on one concept.""" + model = LearnerModel(graph_id=graph.graph_id) + for turn in range(1, 4): + model = apply_evidence(model, node_id, EvidenceKind.MET, at_turn=turn) + return model + + +@pytest.fixture +async def wired() -> tuple[LiveSessionService, ConceptGraph, MemoryKnowledgeStore]: + graph = await _map() + graphs, knowledge = MemoryGraphStore(), MemoryKnowledgeStore() + graphs.save(graph, owner_id="learner-1") + service = LiveSessionService( + graphs, + MemorySessionStore(), + knowledge=knowledge, + tutor=StubTutor(), + session_budget_s=1800.0, + ) + return service, graph, knowledge + + +async def test_a_returning_learner_is_met_where_their_beliefs_left_them( + wired: tuple[LiveSessionService, ConceptGraph, MemoryKnowledgeStore], +) -> None: + # Arrange — they demonstrated the opening concept in an earlier session. + service, graph, knowledge = wired + knowledge.save(_mastering(graph, graph.topo_order[0]), owner_id="learner-1") + + # Act + session = await service.start("g1", session_id="s1", owner_id="learner-1") + + # Assert — the next concept, not the one they already have. + assert session.turns[0].move.node_id == graph.topo_order[1] + + +async def test_a_first_session_opens_at_the_start_of_the_map( + wired: tuple[LiveSessionService, ConceptGraph, MemoryKnowledgeStore], +) -> None: + """The other half of the same claim: with no beliefs stored, the director gets an empty model + rather than a missing one, and the session opens where the map does.""" + # Arrange + service, graph, _ = wired + + # Act + session = await service.start("g1", session_id="s1", owner_id="learner-1") + + # Assert + assert session.turns[0].move.node_id == graph.topo_order[0] + + +async def test_beliefs_stored_for_one_learner_are_not_read_for_another( + wired: tuple[LiveSessionService, ConceptGraph, MemoryKnowledgeStore], +) -> None: + """The knowledge read has to carry the owner through. Unscoped it would return an empty model + here — harmless — but on a store where a stranger's row *could* match it would walk somebody + past concepts they have never met, and it would look like the product working.""" + # Arrange — the beliefs belong to another learner entirely. + service, graph, knowledge = wired + knowledge.save(_mastering(graph, graph.topo_order[0]), owner_id="someone-else") + + # Act + session = await service.start("g1", session_id="s2", owner_id="learner-1") + + # Assert + assert session.turns[0].move.node_id == graph.topo_order[0] diff --git a/apps/api/tests/live/test_live_sessions_api.py b/apps/api/tests/live/test_live_sessions_api.py index 71228d57..9a9bad25 100644 --- a/apps/api/tests/live/test_live_sessions_api.py +++ b/apps/api/tests/live/test_live_sessions_api.py @@ -1,16 +1,20 @@ -"""Lunaris Live, Phase 2a — the walking skeleton, end to end through the API. +"""Lunaris Live, Phase 2a — a session, end to end through the API. A learner opens a session on a compiled graph, the session is persisted, and it comes back with its -first turn. Nothing here asserts *teaching*: the director is a stub that always opens on the map's -first concept and the tutor's words are a fixed string. What this pins is that the whole path is -wired — web-facing contract → service → ``lunaris_live.session`` → store — with one ``session_id`` -correlating the lot, and that a session is a row rather than connection state (U2), so a reload -returns the learner to where they were. - -Exercised through the real ASGI app over httpx; the only stub is the compiler behind the graph the -session runs on. +first turn. T1 pinned the wiring — web-facing contract → service → ``lunaris_live.session`` → store, +with one ``session_id`` correlating the lot, and a session as a row rather than connection state +(U2) so a reload returns the learner to where they were. + +T4 makes the turn real: the move is the director's (T3) over what this learner already knows (T2), +and the words are the tutor's, written over the concept's authored teaching notes. What the API +level proves is that those notes *travel* — compiled into the map, through the store, into the +session, out to the learner — which no test of any one layer can see. + +Exercised through the real ASGI app over httpx; the stubs are the compiler behind the graph and the +tutor behind the turn, both real implementations of their protocols. """ +import json from collections.abc import AsyncIterator from pathlib import Path @@ -18,6 +22,18 @@ import pytest from lunaris_api.app import create_app from lunaris_api.config import Settings, get_settings +from lunaris_api.live.dependencies import resolve_graph_store +from lunaris_api.live.session.dependencies import get_live_session_service, get_live_tutor +from lunaris_api.live.session.service import LiveSessionService +from lunaris_live.graph import ConceptNode +from lunaris_live.session import ( + DirectorMove, + MemoryKnowledgeStore, + Session, + SessionFormatError, + StubTutor, + TutorUnavailableError, +) @pytest.fixture @@ -31,6 +47,53 @@ async def client(tmp_path: Path) -> AsyncIterator[httpx.AsyncClient]: yield http_client +@pytest.fixture +async def client_with_a_silent_tutor(tmp_path: Path) -> AsyncIterator[httpx.AsyncClient]: + """The same app with a tutor that cannot speak — the provider being down, mid-open.""" + + class SilentTutor: + async def teach( + self, move: DirectorMove, node: ConceptNode, *, topic: str, run_id: str + ) -> str: + raise TutorUnavailableError("provider is down") + + app = create_app() + app.dependency_overrides[get_settings] = lambda: Settings( + pipeline="stub", course_dir=tmp_path, cors_origins=(), env_file=tmp_path / ".env" + ) + app.dependency_overrides[get_live_tutor] = lambda: SilentTutor() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + + +@pytest.fixture +async def client_with_an_unreadable_session(tmp_path: Path) -> AsyncIterator[httpx.AsyncClient]: + """The same app over a store holding a session this build can no longer parse.""" + + class UnreadableStore: + def save(self, session: Session, *, owner_id: str | None = None) -> None: ... + + def load(self, session_id: str, *, owner_id: str | None = None) -> Session: + raise SessionFormatError(f"session {session_id} is not in a readable format") + + settings = Settings( + pipeline="stub", course_dir=tmp_path, cors_origins=(), env_file=tmp_path / ".env" + ) + app = create_app() + app.dependency_overrides[get_settings] = lambda: settings + app.dependency_overrides[get_live_session_service] = lambda: LiveSessionService( + resolve_graph_store(settings), + UnreadableStore(), + knowledge=MemoryKnowledgeStore(), + tutor=StubTutor(), + session_budget_s=settings.live_session_budget_s, + ) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + + async def _graph(client: httpx.AsyncClient) -> dict: """A compiled map for the session to run on — Phase 1's surface, used as given.""" return ( @@ -66,9 +129,124 @@ async def test_opening_a_session_returns_its_first_turn(client: httpx.AsyncClien assert response.headers["X-Session-Id"] == session["sessionId"] +async def test_the_opening_turn_teaches_what_the_map_authored(client: httpx.AsyncClient) -> None: + """The claim no single layer can make: a concept's teaching notes reach the learner. + + Phase 1 pays a model call per concept to author misconceptions — "the wrong models learners + commonly hold, stated as the learner would believe them" — precisely so the tutor can go after + the specific wrong idea in front of it. If they stop travelling anywhere between the compiler + and the transcript, every session silently degrades to a definition read aloud, and the map + still looks perfect. + """ + # Arrange + graph = await _graph(client) + + # Act + session = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() + + # Assert — the authored text itself, taken from the compiled map rather than restated here, so + # this cannot pass by agreeing with a copy of the stub. + taught_on = session["turns"][0]["move"]["nodeId"] + node = next(n for n in graph["nodes"] if n["id"] == taught_on) + misconception = node["teachingSpec"]["misconceptions"][0] + assert misconception in session["turns"][0]["tutor"] + + +async def test_a_turn_is_correlated_by_the_run_that_produced_it(client: httpx.AsyncClient) -> None: + """R6: the session carries a ``session_id``, each turn carries its own ``run_id``. A turn is + now one or more model calls, and the two ids answer different questions — "show me this + learner's session" and "show me what the tutor was asked on this line". + + Deliberately the *shape* half of the claim: on its own this would pass on a constant. The test + below it is the binding half, and neither is the whole claim without the other. + """ + # Arrange + graph = await _graph(client) + + # Act + session = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() + + # Assert + run_id = session["turns"][0]["runId"] + assert run_id + assert run_id != session["sessionId"], "a turn's run is not the session it belongs to" + + +async def test_the_turns_run_id_is_the_one_its_work_logged_under( + client: httpx.AsyncClient, capsys: pytest.CaptureFixture[str] +) -> None: + """Otherwise the id on the turn is decoration. The point of storing it is that somebody holding + a line of transcript can find what actually happened underneath it — so the id the learner gets + back has to be the id the work was logged with, at every layer it crossed.""" + # Arrange + graph = await _graph(client) + + # Act + session = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() + + # Assert — read off the stdout JSON rather than structlog's capture_logs, which cannot + # intercept a module logger another test has already cached (house pattern, see + # test_live_graphs_api.py). + run_id = session["turns"][0]["runId"] + correlated = [line for line in _json_log_lines(capsys) if line.get("run_id") == run_id] + events = {line.get("event") for line in correlated} + # ``live.session.started`` passes no ids of its own — it can only appear here if the contextvars + # binding propagated, which is what stops this passing with the binding deleted and the id + # merely threaded through arguments by hand. + assert "live.session.started" in events, f"turn {run_id} left no trace; saw {events}" + assert all(line.get("session_id") == session["sessionId"] for line in correlated), ( + "a turn's run must stay attached to the session it belongs to" + ) + + +def _json_log_lines(capsys: pytest.CaptureFixture[str]) -> list[dict[str, object]]: + """The structured stdout log lines emitted so far (the project logs JSON to stdout).""" + return [ + json.loads(line) + for line in capsys.readouterr().out.splitlines() + if line.startswith("{") and line.endswith("}") + ] + + +async def test_a_tutor_that_cannot_speak_leaves_no_session_behind( + client_with_a_silent_tutor: httpx.AsyncClient, +) -> None: + """A session whose first turn never happened is not a session. Persisting the shell would give + a learner a resumable transcript with nothing in it — and ``turns`` is what the surface renders, + so it would read as a session that opened and then went quiet.""" + # Arrange + graph = await _graph(client_with_a_silent_tutor) + + # Act + response = await client_with_a_silent_tutor.post( + "/api/live/sessions", json={"graphId": graph["graphId"]} + ) + + # Assert — retryable (the provider may come back), correlated, and nothing persisted. + assert response.status_code == 503, response.text + session_id = response.headers["X-Session-Id"] + assert session_id + resumed = await client_with_a_silent_tutor.get(f"/api/live/sessions/{session_id}") + assert resumed.status_code == 404 + + +async def test_a_session_this_build_cannot_read_is_not_offered_as_a_retry( + client_with_an_unreadable_session: httpx.AsyncClient, +) -> None: + """Told apart from storage being down, because the advice differs: an outage ends and a reload + fixes it, a row written by a schema this build no longer understands never becomes readable. As + a 503 it would invite a learner to reload forever.""" + # Act + response = await client_with_an_unreadable_session.get("/api/live/sessions/s1") + + # Assert + assert response.status_code == 500, response.text + assert response.headers["X-Session-Id"] == "s1" + + async def test_the_first_concept_is_one_with_nothing_before_it(client: httpx.AsyncClient) -> None: - """Even a stub director may not open in the middle of the map. The whole point of Phase 1's - ordering is that a learner is never shown a concept whose prerequisites they have not met.""" + """The director may not open in the middle of the map. The whole point of Phase 1's ordering is + that a learner is never shown a concept whose prerequisites they have not met.""" # Arrange graph = await _graph(client) diff --git a/apps/web/src/lib/liveSession.test.ts b/apps/web/src/lib/liveSession.test.ts index cb9d621f..0008df39 100644 --- a/apps/web/src/lib/liveSession.test.ts +++ b/apps/web/src/lib/liveSession.test.ts @@ -11,6 +11,7 @@ const SESSION = { seq: 1, move: { kind: "introduce", nodeId: "a", reason: "Opening concept." }, tutor: "Let's start with Gravity.", + runId: "r1", }, ], }; @@ -37,8 +38,9 @@ describe("liveSession — opening and resuming a session", () => { expect(session.sessionId).toBe("s1"); // The move rides every turn: without it the transcript is prose nobody can explain the // choice of, which is the whole point of the director emitting its reasoning. - expect(session.turns[0].move.kind).toBe("introduce"); - expect(session.turns[0].move.reason).toBe("Opening concept."); + const [first] = session.turns; + expect(first?.move.kind).toBe("introduce"); + expect(first?.move.reason).toBe("Opening concept."); }); it("resumes the session a reloaded tab was in", async () => { diff --git a/apps/web/src/lib/liveSession.ts b/apps/web/src/lib/liveSession.ts index 580ed403..a6324024 100644 --- a/apps/web/src/lib/liveSession.ts +++ b/apps/web/src/lib/liveSession.ts @@ -21,6 +21,9 @@ export interface SessionTurn { seq: number; move: DirectorMove; tutor: string; + /** The run that produced this turn — what a learner reporting a problem can name, and what ties + * a line of transcript to the model calls behind it. Not rendered; carried. */ + runId: string; } /** A learner's run at a concept graph. Persisted server-side, so a reload resumes it. */ diff --git a/packages/live/src/lunaris_live/graph/stub_graph_compiler.py b/packages/live/src/lunaris_live/graph/stub_graph_compiler.py index 6ecd6bde..3f6e8296 100644 --- a/packages/live/src/lunaris_live/graph/stub_graph_compiler.py +++ b/packages/live/src/lunaris_live/graph/stub_graph_compiler.py @@ -5,7 +5,16 @@ from .assembly import assemble from .protocols import ICompileProgressSink from .report_progress import report_progress -from .schema import CompilePhase, ConceptGraph, ConceptNode, GraphEdit, NodeProvenance +from .schema import ( + CompilePhase, + ConceptGraph, + ConceptNode, + GraphEdit, + MasteryCriterion, + MasteryCriterionKind, + NodeProvenance, + TeachingSpec, +) logger = structlog.get_logger() @@ -46,18 +55,24 @@ async def compile( id=f"{slug}-foundations", name=f"Foundations of {label}", definition=f"The ideas you need in place before {label} makes sense.", + teaching_spec=_spec(f"Foundations of {label}"), + mastery_criteria=_criteria(f"Foundations of {label}"), ), ConceptNode( id=f"{slug}-core", name=f"Core of {label}", definition=f"The central mechanism of {label}.", requires=[f"{slug}-foundations"], + teaching_spec=_spec(f"Core of {label}"), + mastery_criteria=_criteria(f"Core of {label}"), ), ConceptNode( id=slug, name=topic, definition=f"{topic}, put together.", requires=[f"{slug}-core"], + teaching_spec=_spec(label), + mastery_criteria=_criteria(label), ), ] for done, _ in enumerate(nodes, start=1): @@ -95,6 +110,8 @@ async def extend( definition=f"{_shorten(request)}, asked for mid-session.", requires=[anchor for anchor in anchors if anchor in known], provenance=NodeProvenance.EXTENDED, + teaching_spec=_spec(_shorten(request)), + mastery_criteria=_criteria(_shorten(request)), ) version = graph.version + 1 edit = GraphEdit( @@ -124,6 +141,32 @@ async def extend( return extended +def _spec(name: str) -> TeachingSpec: + """Teaching notes for a stub concept — enough that the session loop has something to run on. + + Not decoration. A node without them is teachable *in principle* and useless in practice: the + tutor has a definition and nothing to teach around, and the grader has no criterion to stage. + The offline path is what CI and keyless dev run, so a stub map without notes would leave the + whole loop below the API untested. + """ + return TeachingSpec( + objective=f"Explain {name} in your own words and say where it applies.", + # Named after the concept so a session wired to the wrong node is visible rather than + # plausible — the same reason the stub's definitions mention the topic. + misconceptions=[f"{name} is a label to memorise rather than something to understand."], + ) + + +def _criteria(name: str) -> list[MasteryCriterion]: + """One do-statement per stub concept: what the learner would be asked to demonstrate.""" + return [ + MasteryCriterion( + kind=MasteryCriterionKind.EXPLAIN, + statement=f"Explain {name} back in your own words.", + ) + ] + + def _unused(candidate: str, known: set[str], version: int) -> str: """``candidate`` if the map does not already use it, else an id disambiguated by version.""" if candidate not in known: diff --git a/packages/live/src/lunaris_live/session/__init__.py b/packages/live/src/lunaris_live/session/__init__.py index e27a42bb..914ba53f 100644 --- a/packages/live/src/lunaris_live/session/__init__.py +++ b/packages/live/src/lunaris_live/session/__init__.py @@ -3,18 +3,21 @@ Phase 1 built the map (``lunaris_live.graph``); this is what walks it. The graph is a map of the subject, never a route through the session: the conversation drives, and the map keeps score. -Phase 2a holds the loop's skeleton — what a turn is, what the director decided, and where a session -lives between requests. The director's policy, the tutor, the grader and the learner model land on -top of these contracts without changing them. +The loop is split along one line — what happens next is a *policy* (``decide_move``: deterministic, +legible, testable without a key) and how it is said is *generative* (``ITutor``: a seam, with a +deterministic implementation for the offline path). The learner model is what connects them: it is +moved by graded evidence, and the director reads it back. """ from .apply_evidence import apply_evidence +from .claude_tutor import ClaudeTutor from .decide_move import decide_move from .memory_knowledge_store import MemoryKnowledgeStore from .memory_session_store import MemorySessionStore from .open_session import open_session -from .protocols import IKnowledgeStore, ISessionStore +from .protocols import IKnowledgeStore, ISessionStore, ITutor from .recall_of import recall_of +from .reject_unteachable_move import reject_unteachable_move from .schema import ( DirectorMove, EvidenceKind, @@ -26,14 +29,19 @@ SessionStatus, SessionTurn, ) +from .session_format_error import SessionFormatError +from .stub_tutor import StubTutor from .supabase_knowledge_store import SupabaseKnowledgeStore from .supabase_session_store import SupabaseSessionStore +from .tutor_unavailable_error import TutorUnavailableError __all__ = [ + "ClaudeTutor", "DirectorMove", "EvidenceKind", "IKnowledgeStore", "ISessionStore", + "ITutor", "LearnerModel", "MemoryKnowledgeStore", "MemorySessionStore", @@ -41,12 +49,16 @@ "NodeKnowledge", "Session", "SessionClock", + "SessionFormatError", "SessionStatus", "SessionTurn", + "StubTutor", "SupabaseKnowledgeStore", "SupabaseSessionStore", + "TutorUnavailableError", "apply_evidence", "decide_move", "open_session", "recall_of", + "reject_unteachable_move", ] diff --git a/packages/live/src/lunaris_live/session/claude_tutor.py b/packages/live/src/lunaris_live/session/claude_tutor.py new file mode 100644 index 00000000..1d5e527f --- /dev/null +++ b/packages/live/src/lunaris_live/session/claude_tutor.py @@ -0,0 +1,169 @@ +import asyncio + +import structlog +from lunaris_runtime.resilience import build_chat_model, retry_on_transient + +from ..graph.schema import ConceptNode +from .reject_unteachable_move import reject_unteachable_move +from .schema import DirectorMove, MoveKind +from .tutor_unavailable_error import TutorUnavailableError + +logger = structlog.get_logger() + +#: A learner is watching a cursor blink while this runs, so the ceiling is a pause in a conversation +#: rather than a batch job's patience. Past it they are better served by an error they can retry +#: than by a turn that may still be coming. Longer than C1's 15 s extension budget because this is +#: the thing they are actually waiting for, not a repair happening behind the talking. +_DEFAULT_DEADLINE_S = 30.0 + +#: Matches the compiler's: three attempts inside a four-second ceiling survives a dropped socket +#: without eating the deadline above. The library defaults (eight attempts, thirty seconds) are +#: sized for a batch job and would turn a fast clean failure into a slow cancelled one. +_TRANSIENT_ATTEMPTS = 3 +_TRANSIENT_MAX_DELAY_S = 4.0 + +_PROMPT = """You are tutoring one learner, one to one, in a live text session about "{topic}". + +The concept in front of you: {name} — {definition} +{notes} +{instruction} + +Write only what you would say to them next, in your own voice, addressed to them directly. Under \ +120 words. No headings, no bullet lists, no markdown. Finish with one question that makes them \ +think, not one they can answer with yes.""" + +#: What the move means for the person speaking. The director's whole output is the move, so a tutor +#: that ignored it would make the policy decorative: the trace would record adaptation the learner +#: never heard. +_INSTRUCTION: dict[MoveKind, str] = { + MoveKind.INTRODUCE: ( + "This concept is new to them. Start from something concrete they already have, then the " + "idea itself. Where one of the wrong models above fits, teach past it — do not announce it " + "back at them as a mistake they have not made yet." + ), + MoveKind.RETRIEVE: ( + "They met this earlier and it is fading. Do NOT re-explain it. Ask them to recall it and " + "use it, so the remembering is theirs — that effort is the entire point of coming back." + ), + MoveKind.REMEDIATE: ( + "They have been taught this and it has not landed. Do NOT repeat the explanation they have " + "already heard — come at it a different way: a different example, a different " + "representation, a smaller step. Assume one of the wrong models above is what they are " + "holding, and go after it." + ), +} + + +class ClaudeTutor: + """Teaches one director move with Claude, over the concept's own authored notes. + + One call per turn, and the concept's ``teaching_spec`` is most of the prompt. Phase 1 spends a + model call per concept authoring misconceptions "as the learner would believe them" for exactly + this moment: a tutor that only knows what is true explains into the air, while one that knows + how people usually get this wrong can go after the specific wrong model in front of it. Passing + them verbatim rather than summarised is what keeps the two halves of the product in agreement. + + Unlike the compiler, this cannot degrade. A compile that loses one concept's notes still hands + back a map; a turn with no words is not a turn, so a failure here is a failure (A2 — + ``SessionTurn.tutor`` is ``min_length=1``, and a turn the learner cannot see is a decision that + happened to them invisibly). + """ + + def __init__( + self, + model_name: str, + *, + client: object | None = None, + deadline_s: float = _DEFAULT_DEADLINE_S, + ) -> None: + self._model_name = model_name + # Injected in tests; production leaves it None so the client is built on first use and + # constructing the tutor needs no API key. + self._client = client + self._deadline_s = deadline_s + + async def teach(self, move: DirectorMove, node: ConceptNode, *, topic: str, run_id: str) -> str: + instruction = _INSTRUCTION.get(move.kind) + if instruction is None: + reject_unteachable_move(move.kind) + + prompt = _PROMPT.format( + topic=topic, + name=node.name, + definition=node.definition, + notes=_notes_on(node), + instruction=instruction, + ) + said = await self._say(prompt, run_id=run_id, node_id=node.id) + + if not said: + # Caught here, where it can still be named, rather than as a validation error thrown + # from inside session assembly with nothing to say about which turn produced it. + logger.warning("live.tutor.said_nothing", run_id=run_id, node=node.id) + raise TutorUnavailableError(f"tutor returned nothing for {node.id}") + + logger.info( + "live.tutor.taught", + run_id=run_id, + node=node.id, + move=move.kind.value, + # Length rather than the text: operational logs are for debugging, and a transcript of + # somebody being taught is not something to scatter through them. + chars=len(said), + ) + return said + + async def _say(self, prompt: str, *, run_id: str, node_id: str) -> str: + """One bounded attempt at speaking, with every way it can fail named the same way. + + A learner cannot be left waiting because each individual retry was technically still inside + its own budget, so the deadline wraps the whole call rather than one attempt — and whatever + comes back out of it, the caller has exactly one failure to handle. + """ + try: + async with asyncio.timeout(self._deadline_s): + return await self._ask(prompt) + except TimeoutError as exc: + logger.warning( + "live.tutor.timed_out", run_id=run_id, node=node_id, deadline_s=self._deadline_s + ) + raise TutorUnavailableError(f"tutor timed out on {node_id}") from exc + except Exception as exc: + logger.warning("live.tutor.call_failed", run_id=run_id, node=node_id, exc_info=True) + raise TutorUnavailableError(f"tutor could not teach {node_id}") from exc + + async def _ask(self, prompt: str) -> str: + if self._client is None: + # No ``max_tokens``: the answer is bounded to 120 words by the prompt, which sits well + # inside the provider default — a ceiling here would only ever truncate mid-sentence. + self._client = build_chat_model(self._model_name) + message = await retry_on_transient( + lambda: self._client.ainvoke(prompt), # type: ignore[attr-defined] + max_attempts=_TRANSIENT_ATTEMPTS, + max_delay_s=_TRANSIENT_MAX_DELAY_S, + ) + content = message.content + return (content if isinstance(content, str) else str(content)).strip() + + +def _notes_on(node: ConceptNode) -> str: + """The concept's teaching notes as the tutor reads them, or nothing at all. + + ``teaching_spec`` is optional by contract: one failed authoring call in Phase 1 leaves a concept + teachable-in-principle, and a tutor that refused it would turn a degraded compile into an + unteachable map. So an unspecified concept is taught from its definition alone, which is worse + teaching and still teaching. + """ + spec = node.teaching_spec + if spec is None: + return "" + + lines = [f"What they should be able to do with it: {spec.objective}"] + if spec.misconceptions: + lines.append( + "Wrong models people commonly hold about this, written as the learner would believe " + "them:" + ) + lines.extend(f"- {misconception}" for misconception in spec.misconceptions) + lines.append(f"Teaching stance: {spec.depth.value}") + return "\n".join(lines) + "\n" diff --git a/packages/live/src/lunaris_live/session/open_session.py b/packages/live/src/lunaris_live/session/open_session.py index 226b82d5..3d85b826 100644 --- a/packages/live/src/lunaris_live/session/open_session.py +++ b/packages/live/src/lunaris_live/session/open_session.py @@ -1,41 +1,47 @@ from ..graph import ConceptGraph -from .schema import DirectorMove, MoveKind, Session, SessionTurn +from .decide_move import decide_move +from .protocols import ITutor +from .schema import LearnerModel, Session, SessionClock, SessionTurn -#: The walking skeleton's stand-in for the tutor (T4 replaces it with a real one). Deliberately -#: names the concept rather than being lorem: a fixed string that mentioned nothing would let the -#: whole path be wired to the wrong node without any test noticing. -_OPENING = "Let's start with {name}. {definition}" - -def open_session(graph: ConceptGraph, *, session_id: str) -> Session: +async def open_session( + graph: ConceptGraph, + model: LearnerModel, + clock: SessionClock, + *, + session_id: str, + run_id: str, + tutor: ITutor, +) -> Session: """Open a session on ``graph`` and take its first turn. - The skeleton's move policy is "the first concept in the map's own teaching order", which is not - the director (T3) — but it is not arbitrary either: ``topo_order`` puts prerequisites first, so - the opening concept provably has nothing before it. A skeleton that opened in the middle of the - map would be wired correctly and pedagogically wrong, and the difference matters enough that the - test pins it now rather than after the director lands. + Both halves of the turn are real here: the director picks the move from what this learner is + believed to know (T2, T3), and the tutor says it (T4). The learner model is why a returning + learner does not start over at the root — a session that always opened on ``topo_order[0]`` + would be correctly wired and would re-teach somebody the thing they came back having learned. + + Raises ``ValueError`` when the map has nothing to teach: an empty graph, or one whose teaching + order names concepts it does not contain, leaves the director with nothing to introduce and its + first move is to close. Handing a learner a session that opens on "we're done" is worse than + telling the caller the map is broken. - Raises ``ValueError`` on a map with no concepts — there is nothing to teach, and a session that - opened on it would show the learner an empty transcript and call it a lesson. + Raises ``TutorUnavailableError`` when the tutor cannot speak — the turn did not happen, so + neither did the session. """ - if not graph.topo_order: - raise ValueError(f"graph {graph.graph_id} has no concepts to teach") + move = decide_move(graph, model, clock) + node = next((n for n in graph.nodes if n.id == move.node_id), None) + if node is None: + raise ValueError(f"graph {graph.graph_id} has nothing to teach") - first = next(node for node in graph.nodes if node.id == graph.topo_order[0]) - move = DirectorMove( - kind=MoveKind.INTRODUCE, - node_id=first.id, - reason="Opening concept: it is first in the map's teaching order, so nothing precedes it.", - ) return Session( session_id=session_id, graph_id=graph.graph_id, turns=[ SessionTurn( - seq=1, + seq=clock.turn, move=move, - tutor=_OPENING.format(name=first.name, definition=first.definition), + tutor=await tutor.teach(move, node, topic=graph.topic, run_id=run_id), + run_id=run_id, ) ], ) diff --git a/packages/live/src/lunaris_live/session/protocols/__init__.py b/packages/live/src/lunaris_live/session/protocols/__init__.py index b9af843a..95c9438a 100644 --- a/packages/live/src/lunaris_live/session/protocols/__init__.py +++ b/packages/live/src/lunaris_live/session/protocols/__init__.py @@ -1,4 +1,5 @@ from .knowledge_store import IKnowledgeStore from .session_store import ISessionStore +from .tutor import ITutor -__all__ = ["IKnowledgeStore", "ISessionStore"] +__all__ = ["IKnowledgeStore", "ISessionStore", "ITutor"] diff --git a/packages/live/src/lunaris_live/session/protocols/tutor.py b/packages/live/src/lunaris_live/session/protocols/tutor.py new file mode 100644 index 00000000..b4794009 --- /dev/null +++ b/packages/live/src/lunaris_live/session/protocols/tutor.py @@ -0,0 +1,26 @@ +from typing import Protocol + +from ...graph.schema import ConceptNode +from ..schema import DirectorMove + + +class ITutor(Protocol): + """Turns a director's move into what the learner reads. + + The two halves of a turn are split here on purpose. The director is a *policy* and is + deterministic by design (plan §7), so it can be exhaustively tested without a key; the tutor is + generative and cannot be, so it sits behind a seam with a deterministic implementation for the + offline path. Fusing them would make the policy untestable without a provider and the teaching + unchangeable without touching the policy. + + ``move`` is passed whole rather than as a node plus a kind: the tutor teaches *this move on this + concept*, and a remediation reads nothing like the introduction that already failed. It also + means a fifth move kind cannot be added without every tutor being confronted with it. + + ``run_id`` is the turn's own run (R6), not the session's — a turn is one or more model calls, + and what the tutor was asked has to be findable from a line in a stored transcript. + """ + + async def teach( + self, move: DirectorMove, node: ConceptNode, *, topic: str, run_id: str + ) -> str: ... diff --git a/packages/live/src/lunaris_live/session/reject_unteachable_move.py b/packages/live/src/lunaris_live/session/reject_unteachable_move.py new file mode 100644 index 00000000..c9fb29fe --- /dev/null +++ b/packages/live/src/lunaris_live/session/reject_unteachable_move.py @@ -0,0 +1,14 @@ +from typing import NoReturn + +from .schema import MoveKind + + +def reject_unteachable_move(kind: MoveKind) -> NoReturn: + """Refuse a move that is about the session rather than a concept. + + Shared by every tutor rather than written out in each, because it is one rule: CLOSE arrives + with no ``node_id`` at all, so teaching whatever node was passed alongside it would be a bug + that reads as a lesson. Two implementations each holding their own copy is how T1's owner check + came to be wrong in one store and right in the other. + """ + raise ValueError(f"{kind} is not a move a tutor teaches") diff --git a/packages/live/src/lunaris_live/session/schema/session_turn.py b/packages/live/src/lunaris_live/session/schema/session_turn.py index 5c1b52d1..d6b453fa 100644 --- a/packages/live/src/lunaris_live/session/schema/session_turn.py +++ b/packages/live/src/lunaris_live/session/schema/session_turn.py @@ -18,3 +18,8 @@ class SessionTurn(LiveModel): #: What the tutor said, in the learner's language. Empty is never valid: a turn the learner #: cannot see is a decision that happened to them invisibly. tutor: str = Field(min_length=1) + #: The run that produced this turn (R6) — the session's id answers "show me this learner's + #: session", this one answers "show me what the tutor was actually asked on this line". A turn + #: is one or more model calls, so without it a stored transcript is unattached to the logs that + #: explain it. + run_id: str = Field(min_length=1, max_length=100) diff --git a/packages/live/src/lunaris_live/session/session_format_error.py b/packages/live/src/lunaris_live/session/session_format_error.py new file mode 100644 index 00000000..8fc39b13 --- /dev/null +++ b/packages/live/src/lunaris_live/session/session_format_error.py @@ -0,0 +1,15 @@ +from lunaris_runtime.persistence import PersistenceError + + +class SessionFormatError(PersistenceError): + """A stored session this build cannot parse. + + A subclass rather than a sibling, for two reasons. ``guard`` lets ``PersistenceError`` through + untranslated, so this survives the store boundary it is raised inside; and every caller that + only knows about storage failing keeps working, leaving the distinction available to the one + place that wants it. + + That place is the router, because the two failures want opposite things from a learner. Storage + being down is worth retrying and ends by itself; a row written by a schema this build no longer + understands never will, and telling somebody to try again is an invitation to reload forever. + """ diff --git a/packages/live/src/lunaris_live/session/stub_tutor.py b/packages/live/src/lunaris_live/session/stub_tutor.py new file mode 100644 index 00000000..9e400eab --- /dev/null +++ b/packages/live/src/lunaris_live/session/stub_tutor.py @@ -0,0 +1,42 @@ +from ..graph.schema import ConceptNode +from .reject_unteachable_move import reject_unteachable_move +from .schema import DirectorMove, MoveKind + +#: One phrasing per move, because a stub that said the same thing for every move would let a +#: surface be built — and reviewed, and shipped — against a session that never appeared to adapt. +#: Each names the concept, so a session wired to the wrong node is visible rather than plausible. +_SCRIPT: dict[MoveKind, str] = { + MoveKind.INTRODUCE: "Let's start with {name}. {definition}", + MoveKind.RETRIEVE: ( + "Before we go on, bring {name} back to mind: what was it, in your own words?" + ), + MoveKind.REMEDIATE: ( + "{name} hasn't landed yet, so let's come at it a different way. {definition}" + ), +} + +#: Appended when the concept names one. The offline path exercises the same claim the keyed one +#: does — that a node's authored notes reach the learner — and it is the only place the API suite +#: can prove it, since the API suite has no provider. +_WATCH_FOR = " A lot of people think {misconception} Worth watching for." + + +class StubTutor: + """A tutor that needs no model, no key and no network. + + Not lorem: it teaches the concept it was handed, in words that differ by move, and it surfaces + the misconception the node names. That is what lets the offline path stand in for the real one + in CI and keyless dev — a fixed string would let the whole session be wired to the wrong node, + or ignore the director entirely, without a single test noticing. + """ + + async def teach(self, move: DirectorMove, node: ConceptNode, *, topic: str, run_id: str) -> str: + script = _SCRIPT.get(move.kind) + if script is None: + reject_unteachable_move(move.kind) + + said = script.format(name=node.name, definition=node.definition) + misconceptions = node.teaching_spec.misconceptions if node.teaching_spec else [] + if misconceptions: + said += _WATCH_FOR.format(misconception=misconceptions[0]) + return said diff --git a/packages/live/src/lunaris_live/session/supabase_session_store.py b/packages/live/src/lunaris_live/session/supabase_session_store.py index 35b9d093..9c84a837 100644 --- a/packages/live/src/lunaris_live/session/supabase_session_store.py +++ b/packages/live/src/lunaris_live/session/supabase_session_store.py @@ -1,8 +1,10 @@ import os from lunaris_runtime.persistence.guard import guard +from pydantic import ValidationError from .schema import Session +from .session_format_error import SessionFormatError _URL_ENV = "SUPABASE_URL" _SERVICE_KEY_ENV = "SUPABASE_SERVICE_ROLE_KEY" @@ -76,4 +78,12 @@ def load(self, session_id: str, *, owner_id: str | None = None) -> Session: rows = query.limit(1).execute().data if not rows: raise FileNotFoundError(session_id) - return Session.model_validate(rows[0]["payload"]) + try: + return Session.model_validate(rows[0]["payload"]) + except ValidationError as exc: + # Told apart from a backend failure because the two want opposite things from the + # learner: an outage ends and is worth retrying, a row written by a schema this build no + # longer understands does not. Undistinguished, ``guard`` would turn this into the same + # "storage is having trouble" a reload is the right answer to. Live under a rolling + # deploy, and the turn schema is still growing (T4 added ``run_id``; T5, T6 add more). + raise SessionFormatError(f"session {session_id} is not in a readable format") from exc diff --git a/packages/live/src/lunaris_live/session/tutor_unavailable_error.py b/packages/live/src/lunaris_live/session/tutor_unavailable_error.py new file mode 100644 index 00000000..93094569 --- /dev/null +++ b/packages/live/src/lunaris_live/session/tutor_unavailable_error.py @@ -0,0 +1,10 @@ +class TutorUnavailableError(RuntimeError): + """The tutor could not say anything, so the turn did not happen. + + Deliberately not a degraded turn taught from the concept's definition. A session that quietly + fell back would look to the learner model like teaching that landed badly, and to anyone reading + the transcript like a tutor doing a poor job — when what actually happened is that nobody taught + anything. The distinction survives only if the failure stays a failure. + + Retryable in principle: the provider being down is a state that ends. + """ diff --git a/packages/live/tests/test_open_session.py b/packages/live/tests/test_open_session.py new file mode 100644 index 00000000..e75e645c --- /dev/null +++ b/packages/live/tests/test_open_session.py @@ -0,0 +1,195 @@ +"""Opening a session: the director picks the first move, the tutor teaches it (Phase 2a, T4). + +T1's skeleton opened on ``topo_order[0]`` with a fixed string, which was honest for a skeleton and +wrong for a product: it could not tell a learner returning to a map from one meeting it, and it said +the same thing either way. This is where the two real collaborators land — T3's policy and T4's +tutor — behind the same entry point, so nothing above has to know a session got smarter. +""" + +import pytest +from lunaris_live.graph import ConceptGraph, ConceptNode +from lunaris_live.session import ( + DirectorMove, + EvidenceKind, + LearnerModel, + MoveKind, + SessionClock, + StubTutor, + apply_evidence, + open_session, +) + + +def _graph() -> ConceptGraph: + """A chain a → b, so "the first concept" and "the next one" are different answers.""" + return ConceptGraph( + graph_id="g1", + topic="A subject", + nodes=[ + ConceptNode(id="a", name="A", definition="The first idea."), + ConceptNode(id="b", name="B", definition="Builds on A.", requires=["a"]), + ], + topo_order=["a", "b"], + is_acyclic=True, + ) + + +def _clock() -> SessionClock: + return SessionClock(turn=1, elapsed_s=0.0, budget_s=1800.0) + + +def _mastered(model: LearnerModel, node_id: str) -> LearnerModel: + for turn in range(1, 4): + model = apply_evidence(model, node_id, EvidenceKind.MET, at_turn=turn) + return model + + +class SpyTutor: + """Records what it was asked to teach; answers something the learner could read.""" + + def __init__(self) -> None: + self.calls: list[tuple[DirectorMove, ConceptNode, str, str]] = [] + + async def teach(self, move: DirectorMove, node: ConceptNode, *, topic: str, run_id: str) -> str: + self.calls.append((move, node, topic, run_id)) + return f"Teaching {node.name}." + + +async def test_a_returning_learner_does_not_start_over_at_the_root() -> None: + """The whole reason T2 persists beliefs. A session that always opened on ``topo_order[0]`` + would be wired correctly and would re-teach somebody the thing they came back having already + learned — which is the single fastest way to lose them.""" + # Arrange + known = _mastered(LearnerModel(graph_id="g1"), "a") + + # Act + session = await open_session( + _graph(), known, _clock(), session_id="s1", run_id="r1", tutor=StubTutor() + ) + + # Assert — the director's answer, not the map's first entry. + assert session.turns[0].move.node_id == "b" + assert session.turns[0].move.kind is MoveKind.INTRODUCE + + +async def test_a_fresh_learner_still_opens_on_a_concept_with_nothing_before_it() -> None: + """T1's guarantee, kept: whatever the policy becomes, a session may not open in the middle of + the map. Phase 1's ordering exists so a learner is never shown a concept they cannot meet.""" + # Act + session = await open_session( + _graph(), + LearnerModel(graph_id="g1"), + _clock(), + session_id="s1", + run_id="r1", + tutor=StubTutor(), + ) + + # Assert + opened_on = session.turns[0].move.node_id + assert next(node for node in _graph().nodes if node.id == opened_on).requires == [] + + +async def test_the_tutor_teaches_the_concept_the_director_chose() -> None: + """The two collaborators have to agree about the subject of a turn. If the tutor could be + handed a different node than the move names, the transcript and the trace would be describing + two different lessons (A2).""" + # Arrange + tutor = SpyTutor() + known = _mastered(LearnerModel(graph_id="g1"), "a") + + # Act + session = await open_session( + _graph(), known, _clock(), session_id="s1", run_id="r1", tutor=tutor + ) + + # Assert + move, node, topic, run_id = tutor.calls[0] + assert node.id == move.node_id == session.turns[0].move.node_id + assert topic == "A subject" + assert run_id == "r1" + + +async def test_the_turn_the_learner_reads_is_the_tutors() -> None: + # Act + session = await open_session( + _graph(), + LearnerModel(graph_id="g1"), + _clock(), + session_id="s1", + run_id="r1", + tutor=SpyTutor(), + ) + + # Assert + assert session.turns[0].tutor == "Teaching A." + + +async def test_a_turn_carries_the_run_that_produced_it() -> None: + """R6: a session carries a ``session_id``, each turn carries a ``run_id``. A turn is now one or + more model calls, and without the id on the turn there is no way to take a line out of a stored + transcript and find what the tutor was actually asked.""" + # Act + session = await open_session( + _graph(), + LearnerModel(graph_id="g1"), + _clock(), + session_id="s1", + run_id="r1", + tutor=StubTutor(), + ) + + # Assert — the turn's own id, not the session's. + assert session.turns[0].run_id == "r1" + assert session.session_id == "s1" + + +async def test_a_map_with_no_concepts_is_not_a_session() -> None: + """Refused *here*, and asserted with a tutor that would teach anything. + + Both real tutors happen to refuse a CLOSE move themselves, so a test using one of them passes + whether or not this function checks at all — which is how a guard that looks tested becomes + dead code at the next refactor. The spy accepts every move, so only the check in + ``open_session`` can produce this failure, and the untouched ``calls`` says it fired first. + """ + # Arrange + tutor = SpyTutor() + + # Act / Assert + with pytest.raises(ValueError): + await open_session( + ConceptGraph(graph_id="g1", topic="A subject"), + LearnerModel(graph_id="g1"), + _clock(), + session_id="s1", + run_id="r1", + tutor=tutor, + ) + assert tutor.calls == [], "a map with nothing to teach must not cost a model call" + + +async def test_a_session_that_would_open_by_closing_is_refused() -> None: + """A map whose teaching order names concepts it does not have leaves the director with nothing + to introduce, so its first move is CLOSE. Handing a learner a session that opens on "we're + done" is worse than telling the caller the map is broken.""" + # Arrange — an order that points at nothing real. + broken = ConceptGraph( + graph_id="g1", + topic="A subject", + nodes=[ConceptNode(id="a", name="A", definition="The first idea.")], + topo_order=["ghost"], + is_acyclic=True, + ) + tutor = SpyTutor() + + # Act / Assert + with pytest.raises(ValueError): + await open_session( + broken, + LearnerModel(graph_id="g1"), + _clock(), + session_id="s1", + run_id="r1", + tutor=tutor, + ) + assert tutor.calls == [] diff --git a/packages/live/tests/test_session_stores.py b/packages/live/tests/test_session_stores.py index 6e071a26..edcffd8e 100644 --- a/packages/live/tests/test_session_stores.py +++ b/packages/live/tests/test_session_stores.py @@ -9,14 +9,19 @@ the loop writes through the service-role client, which bypasses RLS. """ +from typing import Any + import pytest from lunaris_live.session import ( DirectorMove, MemorySessionStore, MoveKind, Session, + SessionFormatError, SessionTurn, + SupabaseSessionStore, ) +from lunaris_runtime.persistence import PersistenceError def _session(session_id: str = "s1") -> Session: @@ -28,6 +33,7 @@ def _session(session_id: str = "s1") -> Session: seq=1, move=DirectorMove(kind=MoveKind.INTRODUCE, node_id="a", reason="Opening concept."), tutor="Let's start with A.", + run_id="r1", ) ], ) @@ -116,6 +122,7 @@ def test_saving_the_same_session_again_replaces_its_head() -> None: seq=2, move=DirectorMove(kind=MoveKind.RETRIEVE, node_id="a", reason="Coming back."), tutor="What happens when it doubles?", + run_id="r2", ), ] } @@ -126,3 +133,86 @@ def test_saving_the_same_session_again_replaces_its_head() -> None: # Assert assert [turn.seq for turn in store.load("s1", owner_id="learner-1").turns] == [1, 2] + + +# ── a row this build cannot read ─────────────────────────────────────────────────────────────── + + +class FakeSupabase: + """The narrow slice of supabase-py the session store uses: a chainable query over one row.""" + + def __init__(self, row: dict[str, Any] | None) -> None: + self._row = row + + def table(self, _name: str) -> "FakeSupabase": + return self + + def select(self, *_columns: str) -> "FakeSupabase": + return self + + def eq(self, _column: str, _value: object) -> "FakeSupabase": + return self + + def is_(self, _column: str, _value: object) -> "FakeSupabase": + return self + + def limit(self, _count: int) -> "FakeSupabase": + return self + + def execute(self) -> Any: + class Result: + data = [] if self._row is None else [self._row] + + return Result() + + +def test_a_session_this_build_cannot_parse_is_not_an_outage() -> None: + """The failure has to be distinguishable from a backend being down, because the two want + opposite things from the caller: an outage is worth retrying and a row written by a schema this + build no longer understands never will be. Undistinguished, it reads as a transient 503 and + invites a learner to reload forever on a session that cannot come back. + + Live under a rolling deploy: T4 added ``run_id`` to a turn, and T5 and T6 both add more. + """ + # Arrange — a session saved before turns carried the run that produced them. + stale = { + "sessionId": "s1", + "graphId": "g1", + "status": "active", + "turns": [ + { + "seq": 1, + "move": {"kind": "introduce", "nodeId": "a", "reason": "Opening concept."}, + "tutor": "Let's start with A.", + } + ], + } + store = SupabaseSessionStore(client=FakeSupabase({"payload": stale})) + + # Act / Assert + with pytest.raises(SessionFormatError): + store.load("s1", owner_id=None) + + +def test_a_readable_row_still_loads_through_the_same_path() -> None: + """The guard above must not be a wall: the ordinary row goes through untouched.""" + # Arrange + payload = _session().model_dump(mode="json", by_alias=True) + store = SupabaseSessionStore(client=FakeSupabase({"payload": payload})) + + # Act + loaded = store.load("s1", owner_id=None) + + # Assert + assert loaded.turns[0].run_id == "r1" + + +def test_a_format_failure_is_still_a_persistence_failure_to_anyone_not_looking_for_it() -> None: + """Callers that only know about ``PersistenceError`` keep working — the distinction is + available to the router that wants it, not imposed on everything that touches a store.""" + # Arrange + store = SupabaseSessionStore(client=FakeSupabase({"payload": {"nonsense": True}})) + + # Act / Assert + with pytest.raises(PersistenceError): + store.load("s1", owner_id=None) diff --git a/packages/live/tests/test_stub_graph_compiler.py b/packages/live/tests/test_stub_graph_compiler.py index 99b9e7c0..f609a9b7 100644 --- a/packages/live/tests/test_stub_graph_compiler.py +++ b/packages/live/tests/test_stub_graph_compiler.py @@ -32,6 +32,23 @@ async def test_a_compile_is_reproducible_for_the_same_topic() -> None: assert [node.id for node in first.nodes] == [node.id for node in second.nodes] +async def test_every_stub_concept_is_teachable_in_practice() -> None: + """A node without teaching notes is teachable *in principle* and useless to a session: the + tutor has a definition and nothing to teach around, and the grader (T5) has no criterion to + stage. The offline path is what CI and keyless dev run, so a stub map that could not be taught + would leave the whole session loop untested below the API. + """ + # Act + graph = await _compiled() + + # Assert + for node in graph.nodes: + assert node.teaching_spec is not None, f"{node.id} has no teaching notes" + assert node.teaching_spec.objective + assert node.teaching_spec.misconceptions, f"{node.id} names no misconception to teach past" + assert node.mastery_criteria, f"{node.id} has nothing the learner could be asked to do" + + async def test_every_compiled_node_is_marked_as_compiled() -> None: # Act graph = await _compiled() diff --git a/packages/live/tests/test_tutor.py b/packages/live/tests/test_tutor.py new file mode 100644 index 00000000..c50fcbd0 --- /dev/null +++ b/packages/live/tests/test_tutor.py @@ -0,0 +1,327 @@ +"""The tutor: how a director's move becomes something a learner actually reads (Phase 2a, T4). + +The director decides *what* happens next and is deterministic by design; the tutor decides *how it +is said* and cannot be. So this is a seam with two implementations — a stub for the offline path and +a model-backed one for production — and these tests are about what the tutor does with a concept, +never about prose quality, which is T9's keyed eval. + +The load-bearing claim is that the node's ``teaching_spec`` reaches the teaching. A tutor that only +knows what is true teaches *at* the learner; the misconceptions are what let it go looking for the +specific wrong model in front of it, and they are the whole reason Phase 1 paid a model call per +concept to author them. A tutor that ignored them would be indistinguishable from an encyclopaedia +and no test of the wiring alone would notice. +""" + +import asyncio + +import pytest +from langchain_core.messages import AIMessage +from lunaris_live.graph import ( + ConceptNode, + MasteryCriterion, + MasteryCriterionKind, + TeachingDepth, + TeachingSpec, +) +from lunaris_live.session import ( + ClaudeTutor, + DirectorMove, + MoveKind, + StubTutor, + TutorUnavailableError, +) + +_MISCONCEPTION = "A derivative is a formula to memorise, not a slope you can see." + +_TEACHING = ( + "Picture the loss as a hillside you are standing on. The gradient is just which way is " + "downhill from where you stand, and how steep it is. Which way would you step?" +) + + +def _node(*, spec: TeachingSpec | None = None) -> ConceptNode: + return ConceptNode( + id="gradient", + name="Gradient", + definition="The slope of the loss with respect to each weight.", + teaching_spec=spec + if spec is not None + else TeachingSpec( + objective="Say which way is downhill and how steep it is there.", + misconceptions=[_MISCONCEPTION], + depth=TeachingDepth.INTUITION_FIRST, + ), + mastery_criteria=[ + MasteryCriterion( + kind=MasteryCriterionKind.PREDICT, + statement="Point at a curve and say which way lowers it.", + ) + ], + ) + + +def _move(kind: MoveKind = MoveKind.INTRODUCE) -> DirectorMove: + return DirectorMove(kind=kind, node_id="gradient", reason="Because the test says so.") + + +class ScriptedModel: + """Replays one response and records the prompts it was asked with.""" + + def __init__(self, reply: str = _TEACHING) -> None: + self._reply = reply + self.prompts: list[str] = [] + + async def ainvoke(self, prompt: str) -> AIMessage: + self.prompts.append(prompt) + return AIMessage(content=self._reply) + + +async def _taught( + model: object, *, move: DirectorMove | None = None, node: ConceptNode | None = None +) -> str: + return await ClaudeTutor("m", client=model).teach( + move or _move(), + node or _node(), + topic="How neural networks learn", + run_id="r1", + ) + + +# ── the concept reaches the teaching ─────────────────────────────────────────────────────────── + + +async def test_the_misconception_the_node_names_is_what_the_tutor_is_told_to_look_for() -> None: + """The task's RED assertion. Phase 1 spends a model call per concept authoring these, and they + exist for exactly one purpose: a tutor that knows how people get this wrong can hunt the wrong + model in front of it instead of explaining into the air.""" + # Arrange + model = ScriptedModel() + + # Act + await _taught(model) + + # Assert — verbatim, not paraphrased: the tutor passes the authored text through rather than + # summarising it, so what Phase 1 wrote is what the tutor reads. + assert _MISCONCEPTION in model.prompts[0] + + +async def test_the_tutor_teaches_this_concept_and_not_the_map_around_it() -> None: + # Arrange + model = ScriptedModel() + node = _node() + + # Act + await _taught(model) + + # Assert — name, definition and objective all reach it. Without the objective the tutor knows + # what the concept *is* but not what the learner is meant to be able to do with it, which is + # the difference between a lecture and a lesson. + prompt = model.prompts[0] + assert node.name in prompt + assert node.definition in prompt + assert node.teaching_spec is not None + assert node.teaching_spec.objective in prompt + assert "How neural networks learn" in prompt + + +async def test_what_the_learner_reads_is_the_tutors_words_not_the_prompt() -> None: + # Act + taught = await _taught(ScriptedModel(f" {_TEACHING} ")) + + # Assert — trimmed, and nothing else: a tutor that decorated the response would be putting + # words in a teacher's mouth. + assert taught == _TEACHING + + +async def test_a_concept_the_compiler_left_unspecified_is_still_teachable() -> None: + """``teaching_spec`` is optional on purpose: one failed authoring call must not make a concept + unteachable, only less well taught.""" + # Arrange + model = ScriptedModel() + bare = ConceptNode(id="gradient", name="Gradient", definition="The slope of the loss.") + + # Act + taught = await _taught(model, node=bare) + + # Assert + assert taught == _TEACHING + assert "Gradient" in model.prompts[0] + assert "The slope of the loss." in model.prompts[0] + + +# ── the move is what the tutor is doing ──────────────────────────────────────────────────────── + + +async def test_every_teachable_move_asks_the_tutor_for_something_different() -> None: + """The director's whole output is the move. A tutor that said the same thing for all three + would make the policy decorative — the trace would show adaptation the learner never saw.""" + # Arrange + model = ScriptedModel() + + # Act — every kind against the same concept, so the only difference is the move. + for kind in (MoveKind.INTRODUCE, MoveKind.RETRIEVE, MoveKind.REMEDIATE): + await _taught(model, move=_move(kind)) + + # Assert — three prompts, pairwise distinct. + assert len(set(model.prompts)) == 3 + + +async def test_remediation_is_told_not_to_repeat_the_explanation_that_already_failed() -> None: + """The director only remediates after two misses (``_STUCK_AFTER``). Saying the same thing + louder is precisely what it is trying to avoid, so the instruction has to carry that. + + Asserted on the prohibition itself, not on "a different way" nearby: with the softer wording + checked, the clause this test is named for could be deleted from the prompt outright and the + test would stay green — which is the exact defect the T3 pass went looking for. + """ + # Arrange + model = ScriptedModel() + + # Act + await _taught(model, move=_move(MoveKind.REMEDIATE)) + + # Assert + prompt = model.prompts[0].lower() + assert "do not repeat the explanation they have already heard" in prompt + assert "a different example" in prompt + + +async def test_retrieval_asks_the_learner_to_recall_rather_than_re_explaining() -> None: + """Spaced retrieval only works if the learner does the retrieving. A tutor that re-taught the + concept would leave the belief untouched and the director looping on it — so the prohibition, + not the word "recall" beside it, is what has to be pinned.""" + # Arrange + model = ScriptedModel() + + # Act + await _taught(model, move=_move(MoveKind.RETRIEVE)) + + # Assert + prompt = model.prompts[0].lower() + assert "do not re-explain it" in prompt + assert "ask them to recall it" in prompt + + +async def test_closing_is_not_something_the_tutor_is_asked_to_teach() -> None: + """A close is about the session, not a concept — the director sends it with no ``node_id`` at + all. Handing it to the tutor as if it were a concept is a bug worth failing loudly on rather + than teaching whatever node happened to be passed.""" + # Act / Assert + with pytest.raises(ValueError): + await _taught(ScriptedModel(), move=DirectorMove(kind=MoveKind.CLOSE, reason="Time is up.")) + + +# ── failing honestly ─────────────────────────────────────────────────────────────────────────── + + +async def test_a_provider_failure_is_a_turn_that_did_not_happen() -> None: + """Not a turn taught from the definition. A degraded turn would look to the learner model like + teaching that landed badly, and to a reader of the transcript like a tutor doing a poor job — + when what actually happened is that nobody taught anything.""" + + class Broken: + async def ainvoke(self, prompt: str) -> AIMessage: + raise RuntimeError("provider is down") + + # Act / Assert + with pytest.raises(TutorUnavailableError): + await _taught(Broken()) + + +async def test_a_blank_response_is_a_failure_rather_than_an_empty_turn() -> None: + """``SessionTurn.tutor`` is ``min_length=1``: a turn the learner cannot see is a decision that + happened to them invisibly. Caught here, where it can still be named, rather than as a + validation error thrown from inside session assembly.""" + # Act / Assert + with pytest.raises(TutorUnavailableError): + await _taught(ScriptedModel(" \n ")) + + +async def test_the_tutor_gives_up_before_the_learner_does() -> None: + """A learner is watching a cursor blink. A provider call that never returns has to become a + failure they can retry, not a session that hangs.""" + + class Hanging: + async def ainvoke(self, prompt: str) -> AIMessage: + await asyncio.sleep(30) + raise AssertionError("should have been cancelled") + + # Act / Assert + async with asyncio.timeout(5): + with pytest.raises(TutorUnavailableError): + await ClaudeTutor("m", client=Hanging(), deadline_s=0.05).teach( + _move(), _node(), topic="How neural networks learn", run_id="r1" + ) + + +# ── the offline tutor ────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "kind", [MoveKind.INTRODUCE, MoveKind.RETRIEVE, MoveKind.REMEDIATE], ids=lambda k: k.value +) +async def test_the_stub_teaches_the_concept_it_was_given(kind: MoveKind) -> None: + """The offline path is what CI and keyless dev run, so the stub has to be a real implementation + of the contract rather than lorem: text that named no concept would let the whole session be + wired to the wrong node without a single test noticing.""" + # Act + taught = await StubTutor().teach( + _move(kind), _node(), topic="How neural networks learn", run_id="r1" + ) + + # Assert + assert "Gradient" in taught + + +async def test_the_stub_says_something_different_for_each_move() -> None: + """Same reason the real tutor does: a surface built against a stub that ignored the move would + look finished while showing the learner one thing forever.""" + # Act + said = { + kind: await StubTutor().teach( + _move(kind), _node(), topic="How neural networks learn", run_id="r1" + ) + for kind in (MoveKind.INTRODUCE, MoveKind.RETRIEVE, MoveKind.REMEDIATE) + } + + # Assert + assert len(set(said.values())) == 3 + + +async def test_the_stub_surfaces_the_misconception_too() -> None: + """So the offline path exercises the same claim the keyed one does — the API-level test that + proves a node's authored notes reach the learner runs on this tutor.""" + # Act + taught = await StubTutor().teach( + _move(), _node(), topic="How neural networks learn", run_id="r1" + ) + + # Assert + assert _MISCONCEPTION in taught + + +async def test_the_stub_refuses_a_close_the_same_way_the_real_tutor_does() -> None: + """Both tutors have to agree on what a tutor is for, or the offline path would prove a contract + production does not hold. ``open_session`` filters CLOSE out before either of them sees it, so + this is the only place the rule is visible — untested, it becomes dead code at the next + refactor, and the first caller to reach it directly gets an ``AttributeError`` on ``None``.""" + # Act / Assert + with pytest.raises(ValueError): + await StubTutor().teach( + DirectorMove(kind=MoveKind.CLOSE, reason="Time is up."), + _node(), + topic="How neural networks learn", + run_id="r1", + ) + + +async def test_the_stub_teaches_a_concept_with_no_notes_at_all() -> None: + # Arrange + bare = ConceptNode(id="gradient", name="Gradient", definition="The slope of the loss.") + + # Act + taught = await StubTutor().teach(_move(), bare, topic="How neural networks learn", run_id="r1") + + # Assert + assert taught.strip() + assert "Gradient" in taught From f473befb5cd3db10e6b1486a9779a44e47eb0ef1 Mon Sep 17 00:00:00 2001 From: Pouyan Jahangiri Date: Sun, 9 Aug 2026 23:07:30 -0700 Subject: [PATCH 5/9] =?UTF-8?q?feat(live):=20close=20the=20loop=20?= =?UTF-8?q?=E2=80=94=20an=20answer=20becomes=20evidence=20(Phase=202a,=20T?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/src/lunaris_api/live/dependencies.py | 10 + .../lunaris_api/live/session/dependencies.py | 21 +- .../src/lunaris_api/live/session/router.py | 61 ++- .../src/lunaris_api/live/session/schemas.py | 21 + .../src/lunaris_api/live/session/service.py | 49 ++ .../tests/live/test_live_session_service.py | 50 ++ apps/api/tests/live/test_live_sessions_api.py | 167 ++++++- apps/web/src/lib/liveSession.test.ts | 36 +- apps/web/src/lib/liveSession.ts | 43 ++ .../graph/claude_graph_compiler.py | 29 +- packages/live/src/lunaris_live/model_json.py | 28 ++ .../live/src/lunaris_live/session/__init__.py | 21 +- .../src/lunaris_live/session/claude_grader.py | 120 +++++ .../src/lunaris_live/session/claude_tutor.py | 59 ++- .../src/lunaris_live/session/decide_move.py | 46 +- .../session/grader_unavailable_error.py | 8 + .../lunaris_live/session/max_answer_chars.py | 7 + .../src/lunaris_live/session/open_session.py | 9 +- .../session/protocols/__init__.py | 3 +- .../lunaris_live/session/protocols/grader.py | 22 + .../lunaris_live/session/protocols/tutor.py | 22 +- .../lunaris_live/session/schema/__init__.py | 2 + .../session/schema/session_turn.py | 14 + .../lunaris_live/session/schema/turn_grade.py | 17 + .../session/session_closed_error.py | 8 + .../lunaris_live/session/stage_criterion.py | 15 + .../src/lunaris_live/session/stub_grader.py | 76 +++ .../src/lunaris_live/session/stub_tutor.py | 29 +- .../src/lunaris_live/session/take_turn.py | 169 +++++++ .../src/lunaris_live/session/turn_outcome.py | 20 + packages/live/tests/test_director.py | 54 +++ packages/live/tests/test_grader.py | 204 ++++++++ packages/live/tests/test_open_session.py | 12 +- packages/live/tests/test_take_turn.py | 435 ++++++++++++++++++ packages/live/tests/test_tutor.py | 122 +++++ 35 files changed, 1945 insertions(+), 64 deletions(-) create mode 100644 packages/live/src/lunaris_live/model_json.py create mode 100644 packages/live/src/lunaris_live/session/claude_grader.py create mode 100644 packages/live/src/lunaris_live/session/grader_unavailable_error.py create mode 100644 packages/live/src/lunaris_live/session/max_answer_chars.py create mode 100644 packages/live/src/lunaris_live/session/protocols/grader.py create mode 100644 packages/live/src/lunaris_live/session/schema/turn_grade.py create mode 100644 packages/live/src/lunaris_live/session/session_closed_error.py create mode 100644 packages/live/src/lunaris_live/session/stage_criterion.py create mode 100644 packages/live/src/lunaris_live/session/stub_grader.py create mode 100644 packages/live/src/lunaris_live/session/take_turn.py create mode 100644 packages/live/src/lunaris_live/session/turn_outcome.py create mode 100644 packages/live/tests/test_grader.py create mode 100644 packages/live/tests/test_take_turn.py diff --git a/apps/api/src/lunaris_api/live/dependencies.py b/apps/api/src/lunaris_api/live/dependencies.py index cb539d07..c5687469 100644 --- a/apps/api/src/lunaris_api/live/dependencies.py +++ b/apps/api/src/lunaris_api/live/dependencies.py @@ -39,6 +39,16 @@ def resolve_strong_model() -> str: return resolve_config("LUNARIS_MODEL_STRONG") or _DEFAULT_MODEL +#: Grading one answer against one explicit do-statement is a classification, not a quality surface, +#: so it runs on the bulk tier — the same knob Studio's workers read. +_DEFAULT_WORKER_MODEL = "claude-haiku-4-5-20251001" + + +def resolve_worker_model() -> str: + """The model Live's classification surfaces run on (A1).""" + return resolve_config("LUNARIS_MODEL_WORKER") or _DEFAULT_WORKER_MODEL + + # One durable store per process, same lazy-client rationale as Studio's stores: the service-role # client is built on first write, so the singleton needs no creds and no network until then. _supabase_graph_store = SupabaseGraphStore() diff --git a/apps/api/src/lunaris_api/live/session/dependencies.py b/apps/api/src/lunaris_api/live/session/dependencies.py index dd538af3..79f5c9aa 100644 --- a/apps/api/src/lunaris_api/live/session/dependencies.py +++ b/apps/api/src/lunaris_api/live/session/dependencies.py @@ -2,19 +2,22 @@ from fastapi import Depends from lunaris_live.session import ( + ClaudeGrader, ClaudeTutor, + IGrader, IKnowledgeStore, ISessionStore, ITutor, MemoryKnowledgeStore, MemorySessionStore, + StubGrader, StubTutor, SupabaseKnowledgeStore, SupabaseSessionStore, ) from ...config import Settings, get_settings -from ..dependencies import resolve_graph_store, resolve_strong_model +from ..dependencies import resolve_graph_store, resolve_strong_model, resolve_worker_model from .service import LiveSessionService # One durable store per process — same lazy-client rationale as the graph store: the service-role @@ -53,9 +56,24 @@ def get_live_tutor(settings: Annotated[Settings, Depends(get_settings)]) -> ITut return StubTutor() if settings.pipeline == "stub" else ClaudeTutor(resolve_strong_model()) +def get_live_grader(settings: Annotated[Settings, Depends(get_settings)]) -> IGrader: + """The model-backed grader, or the deterministic one under ``LUNARIS_PIPELINE=stub``. + + A dependency of its own for the same reason the tutor is: a grader that cannot answer is the + failure worth staging in a test, and an answer wrongly scored is the mistake that compounds — + every verdict it gets wrong is written into a belief the director acts on for the rest of the + session. + + Runs on the worker tier (A1): teaching is the quality surface, judging one answer against one + explicit do-statement is a classification. + """ + return StubGrader() if settings.pipeline == "stub" else ClaudeGrader(resolve_worker_model()) + + def get_live_session_service( settings: Annotated[Settings, Depends(get_settings)], tutor: Annotated[ITutor, Depends(get_live_tutor)], + grader: Annotated[IGrader, Depends(get_live_grader)], ) -> LiveSessionService: """Live's session plane as a request dependency. @@ -68,6 +86,7 @@ def get_live_session_service( _resolve_session_store(settings), knowledge=_resolve_knowledge_store(settings), tutor=tutor, + grader=grader, session_budget_s=settings.live_session_budget_s, ) diff --git a/apps/api/src/lunaris_api/live/session/router.py b/apps/api/src/lunaris_api/live/session/router.py index 7feaa6f3..32ebb7e7 100644 --- a/apps/api/src/lunaris_api/live/session/router.py +++ b/apps/api/src/lunaris_api/live/session/router.py @@ -2,12 +2,18 @@ import structlog from fastapi import APIRouter, HTTPException, Response, status -from lunaris_live.session import Session, SessionFormatError, TutorUnavailableError +from lunaris_live.session import ( + GraderUnavailableError, + Session, + SessionClosedError, + SessionFormatError, + TutorUnavailableError, +) from lunaris_runtime.persistence import PersistenceError from ...dependencies import OptionalUserIdDep from .dependencies import LiveSessionServiceDep -from .schemas import SessionStartRequest +from .schemas import AnswerRequest, SessionStartRequest logger = structlog.get_logger() @@ -20,6 +26,10 @@ #: are worth retrying — which is why this is a 503 rather than a session opened with nothing in it. _TUTOR_UNAVAILABLE = "Live's tutor could not reach its model. Try again shortly." +#: The grader is a different outage from the tutor, and the learner's next step differs: their +#: answer was not scored, so nothing they said has been lost or held against them. +_GRADER_UNAVAILABLE = "Live could not score that answer just now. Try sending it again." + #: A row this build cannot parse never becomes readable by waiting, so it must not read as an #: outage: "try again" on a permanently unreadable session is an invitation to reload forever. _UNREADABLE = "This session was saved in a format Live can no longer read." @@ -61,6 +71,36 @@ async def start_session( raise +@router.post("/{session_id}/turns", response_model=Session) +async def answer_turn( + session_id: str, + payload: AnswerRequest, + service: LiveSessionServiceDep, + response: Response, + owner_id: OptionalUserIdDep, +) -> Session: + """Answer the criterion the last turn staged, and get the session back with its next turn. + + The whole session comes back rather than just the new turn: the answered turn changes too — it + gains the learner's words and the verdict on them — and a surface patching two shapes together + is a surface that can disagree with the row behind it. + """ + correlated = {"X-Session-Id": session_id} + try: + session = await service.answer(session_id, payload.answer, owner_id=owner_id) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Session not found", headers=correlated + ) from exc + except Exception as exc: + logger.warning("live.session.answer_failed", session_id=session_id, exc_info=True) + if (translated := _translate(exc, correlated)) is not None: + raise translated from exc + raise + response.headers["X-Session-Id"] = session_id + return session + + @router.get("/{session_id}", response_model=Session) async def read_session( session_id: str, @@ -92,7 +132,7 @@ async def read_session( def _translate(exc: Exception, correlated: dict[str, str]) -> HTTPException | None: """The HTTP answer to a failure a learner could plausibly act on, or ``None`` to let it fly. - One place, because the two entry points fail in the same ways and drifting apart would mean a + One place, because every entry point fails in the same ways and drifting apart would mean a learner learning what "try again" means from whichever endpoint they hit first. Ordered deliberately: ``SessionFormatError`` IS a ``PersistenceError``, and reading as a retryable outage is exactly the mistake it exists to prevent. @@ -103,6 +143,21 @@ def _translate(exc: Exception, correlated: dict[str, str]) -> HTTPException | No detail=_UNREADABLE, headers=correlated, ) + if isinstance(exc, SessionClosedError): + # The request is well formed and the session is real; it is the session's *state* that + # refuses. A learner with a stale tab should be told it ended, not that they did something + # wrong and not that it might work on a retry. + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This session has already ended.", + headers=correlated, + ) + if isinstance(exc, GraderUnavailableError): + return HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=_GRADER_UNAVAILABLE, + headers=correlated, + ) if isinstance(exc, TutorUnavailableError): return HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, diff --git a/apps/api/src/lunaris_api/live/session/schemas.py b/apps/api/src/lunaris_api/live/session/schemas.py index 16d54ba0..eb5da89f 100644 --- a/apps/api/src/lunaris_api/live/session/schemas.py +++ b/apps/api/src/lunaris_api/live/session/schemas.py @@ -1,3 +1,4 @@ +from lunaris_live.session import MAX_ANSWER_CHARS from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel @@ -14,3 +15,23 @@ class SessionStartRequest(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) graph_id: str = Field(min_length=1, max_length=100) + + +class AnswerRequest(BaseModel): + """What the learner said in reply to the criterion the last turn staged. + + Bounded at both ends at the trust boundary. Empty is refused rather than graded as a miss: an + empty POST is a client bug, and scoring it would lower a belief — and so change what the + director teaches next — on the strength of a stray keystroke. The ceiling is generous enough for + somebody explaining a concept properly and small enough that a pasted chapter never becomes a + model prompt and a stored row. + """ + + # Stripped BEFORE the length check, or " " passes it and reaches the grader as "" — which the + # stub scores NOT_MET, lowering a belief and changing what the director teaches next on the + # strength of one stray keystroke. The bound is the domain's, imported rather than restated. + model_config = ConfigDict( + alias_generator=to_camel, populate_by_name=True, str_strip_whitespace=True + ) + + answer: str = Field(min_length=1, max_length=MAX_ANSWER_CHARS) diff --git a/apps/api/src/lunaris_api/live/session/service.py b/apps/api/src/lunaris_api/live/session/service.py index 34595097..5d1152c8 100644 --- a/apps/api/src/lunaris_api/live/session/service.py +++ b/apps/api/src/lunaris_api/live/session/service.py @@ -4,12 +4,14 @@ import structlog from lunaris_live.graph import IGraphStore from lunaris_live.session import ( + IGrader, IKnowledgeStore, ISessionStore, ITutor, Session, SessionClock, open_session, + take_turn, ) from lunaris_runtime.logging import bind_request_id, bind_run_id @@ -35,12 +37,14 @@ def __init__( *, knowledge: IKnowledgeStore, tutor: ITutor, + grader: IGrader, session_budget_s: float, ) -> None: self._graphs = graphs self._sessions = sessions self._knowledge = knowledge self._tutor = tutor + self._grader = grader self._session_budget_s = session_budget_s async def start( @@ -89,6 +93,51 @@ async def start( logger.info("live.session.started", turn_count=len(session.turns)) return session + async def answer(self, session_id: str, answer: str, *, owner_id: str | None = None) -> Session: + """Score what the learner said, move what the system believes, and take the next turn. + + Two writes, not one transaction, and the order is chosen for how each half fails. The + transcript goes first and the belief second, so a crash between them under-counts evidence + rather than over-counting it: the learner sees a graded turn whose belief did not move, and + the concept simply comes round again. The other order looks safer and is not — the response + is a "try again" (503), a retry re-grades the same answer against a transcript that never + recorded it, and ``apply_evidence`` runs twice on one answer. That is the one thing the + ``_PULL`` / ``_MASTERED`` relationship exists to prevent: two pulls clear the mastery bar, + so a single lucky guess plus a storage blip would unlock a dependent concept. + + Raises ``FileNotFoundError`` (no such session for this learner), ``SessionClosedError`` + (the director already ended it), and ``GraderUnavailableError`` / ``TutorUnavailableError`` + when the turn could not be taken at all — nothing has moved in that case, so a retry means + exactly what the learner expects it to. + """ + run_id = uuid4().hex + bind_run_id(run_id, session_id=session_id) + + session = await asyncio.to_thread(self._sessions.load, session_id, owner_id=owner_id) + graph = await asyncio.to_thread(self._graphs.load, session.graph_id, owner_id=owner_id) + known = await asyncio.to_thread(self._knowledge.load, session.graph_id, owner_id=owner_id) + + outcome = await take_turn( + session, + graph, + known, + answer=answer, + grader=self._grader, + tutor=self._tutor, + run_id=run_id, + budget_s=self._session_budget_s, + ) + + await asyncio.to_thread(self._sessions.save, outcome.session, owner_id=owner_id) + await asyncio.to_thread(self._knowledge.save, outcome.model, owner_id=owner_id) + + logger.info( + "live.session.answered", + turn_count=len(outcome.session.turns), + status=outcome.session.status.value, + ) + return outcome.session + async def load(self, session_id: str, *, owner_id: str | None = None) -> Session: """Re-read a session so a reloaded tab lands back in it (U2). diff --git a/apps/api/tests/live/test_live_session_service.py b/apps/api/tests/live/test_live_session_service.py index f53b5fcd..cd254e6e 100644 --- a/apps/api/tests/live/test_live_session_service.py +++ b/apps/api/tests/live/test_live_session_service.py @@ -19,9 +19,12 @@ LearnerModel, MemoryKnowledgeStore, MemorySessionStore, + Session, + StubGrader, StubTutor, apply_evidence, ) +from lunaris_runtime.persistence import PersistenceError _TOPIC = "How neural networks learn" @@ -48,6 +51,7 @@ async def wired() -> tuple[LiveSessionService, ConceptGraph, MemoryKnowledgeStor MemorySessionStore(), knowledge=knowledge, tutor=StubTutor(), + grader=StubGrader(), session_budget_s=1800.0, ) return service, graph, knowledge @@ -97,3 +101,49 @@ async def test_beliefs_stored_for_one_learner_are_not_read_for_another( # Assert assert session.turns[0].move.node_id == graph.topo_order[0] + + +async def test_the_transcript_is_written_before_the_belief() -> None: + """Two writes, not one transaction, so the order is chosen for how each half fails. + + Transcript first means a crash between them under-counts evidence: the learner sees a graded + turn whose belief did not move, and the concept comes round again. The other order looks safer + and is not — the response is a retryable 503, a retry re-grades the same answer against a + transcript that never recorded it, and one lucky guess plus a storage blip clears the mastery + bar that ``_PULL`` was sized to keep two answers away. + """ + # Arrange — a session store that reads back fine and refuses every write. + graph = await _map() + graphs, knowledge = MemoryGraphStore(), MemoryKnowledgeStore() + graphs.save(graph, owner_id="learner-1") + opened = await LiveSessionService( + graphs, + MemorySessionStore(), + knowledge=knowledge, + tutor=StubTutor(), + grader=StubGrader(), + session_budget_s=1800.0, + ).start("g1", session_id="s1", owner_id="learner-1") + + class RefusesToWrite: + def save(self, session: Session, *, owner_id: str | None = None) -> None: + raise PersistenceError("storage is having trouble") + + def load(self, session_id: str, *, owner_id: str | None = None) -> Session: + return opened + + service = LiveSessionService( + graphs, + RefusesToWrite(), + knowledge=knowledge, + tutor=StubTutor(), + grader=StubGrader(), + session_budget_s=1800.0, + ) + + # Act + with pytest.raises(PersistenceError): + await service.answer("s1", "I have no idea.", owner_id="learner-1") + + # Assert — the belief never landed, because the transcript never did. + assert knowledge.load("g1", owner_id="learner-1").nodes == {} diff --git a/apps/api/tests/live/test_live_sessions_api.py b/apps/api/tests/live/test_live_sessions_api.py index 9a9bad25..49a3224c 100644 --- a/apps/api/tests/live/test_live_sessions_api.py +++ b/apps/api/tests/live/test_live_sessions_api.py @@ -23,15 +23,22 @@ from lunaris_api.app import create_app from lunaris_api.config import Settings, get_settings from lunaris_api.live.dependencies import resolve_graph_store -from lunaris_api.live.session.dependencies import get_live_session_service, get_live_tutor +from lunaris_api.live.session.dependencies import ( + get_live_grader, + get_live_session_service, + get_live_tutor, +) from lunaris_api.live.session.service import LiveSessionService -from lunaris_live.graph import ConceptNode +from lunaris_live.graph import ConceptNode, MasteryCriterion from lunaris_live.session import ( DirectorMove, + GraderUnavailableError, MemoryKnowledgeStore, Session, SessionFormatError, + StubGrader, StubTutor, + TurnGrade, TutorUnavailableError, ) @@ -53,7 +60,13 @@ async def client_with_a_silent_tutor(tmp_path: Path) -> AsyncIterator[httpx.Asyn class SilentTutor: async def teach( - self, move: DirectorMove, node: ConceptNode, *, topic: str, run_id: str + self, + move: DirectorMove, + node: ConceptNode, + *, + topic: str, + criterion: MasteryCriterion | None = None, + run_id: str, ) -> str: raise TutorUnavailableError("provider is down") @@ -87,6 +100,7 @@ def load(self, session_id: str, *, owner_id: str | None = None) -> Session: UnreadableStore(), knowledge=MemoryKnowledgeStore(), tutor=StubTutor(), + grader=StubGrader(), session_budget_s=settings.live_session_budget_s, ) transport = httpx.ASGITransport(app=app) @@ -94,6 +108,31 @@ def load(self, session_id: str, *, owner_id: str | None = None) -> Session: yield http_client +@pytest.fixture +async def client_with_a_broken_grader(tmp_path: Path) -> AsyncIterator[httpx.AsyncClient]: + """The same app with a grader that cannot score — the provider being down, mid-session.""" + + class BrokenGrader: + async def grade( + self, + answer: str, + *, + criterion: MasteryCriterion, + node: ConceptNode, + run_id: str, + ) -> TurnGrade: + raise GraderUnavailableError("provider is down") + + app = create_app() + app.dependency_overrides[get_settings] = lambda: Settings( + pipeline="stub", course_dir=tmp_path, cors_origins=(), env_file=tmp_path / ".env" + ) + app.dependency_overrides[get_live_grader] = lambda: BrokenGrader() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + + async def _graph(client: httpx.AsyncClient) -> dict: """A compiled map for the session to run on — Phase 1's surface, used as given.""" return ( @@ -230,6 +269,128 @@ async def test_a_tutor_that_cannot_speak_leaves_no_session_behind( assert resumed.status_code == 404 +async def _answer(client: httpx.AsyncClient, session_id: str, answer: str) -> httpx.Response: + return await client.post(f"/api/live/sessions/{session_id}/turns", json={"answer": answer}) + + +async def test_answering_takes_the_session_to_its_next_turn(client: httpx.AsyncClient) -> None: + """The loop, through the endpoint the surface will drive: the learner answers what the last + turn asked, and what comes back is a session with one more beat in it.""" + # Arrange + graph = await _graph(client) + opened = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() + asked = opened["turns"][0] + assert asked["criterion"], "a turn with nothing staged can never be answered" + + # Act + response = await _answer(client, opened["sessionId"], "I have no idea about any of this.") + + # Assert — the answered turn keeps its question, gains the answer and the verdict on it. + assert response.status_code == 200, response.text + session = response.json() + answered = session["turns"][0] + assert answered["answer"] == "I have no idea about any of this." + assert answered["grade"]["kind"] == "not_met" + assert answered["grade"]["reason"], "a verdict with no reason is not feedback" + assert len(session["turns"]) == 2 + + +async def test_what_a_learner_demonstrated_outlives_the_session(client: httpx.AsyncClient) -> None: + """The claim T2 was built for and nothing could prove until now: evidence written by one + session is read by the next. Without it a learner would be met at the root of the map every + time, however much they had already shown.""" + # Arrange — answer the opening concept well enough to master it. + graph = await _graph(client) + opened = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() + session_id, first_concept = opened["sessionId"], opened["turns"][0]["move"]["nodeId"] + statement = opened["turns"][0]["criterion"]["statement"] + for _ in range(3): + answered = (await _answer(client, session_id, statement)).json() + if answered["status"] != "active": + break + + # Act — a brand new session on the same map. + reopened = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() + + # Assert — it does not start over on the concept they just demonstrated. + assert reopened["sessionId"] != session_id + assert reopened["turns"][0]["move"]["nodeId"] != first_concept + + +async def test_a_session_that_has_closed_does_not_take_another_answer( + client: httpx.AsyncClient, +) -> None: + """A stale tab answering into a session the director ended would run it past the bound the + close exists to enforce. 409: the request is fine, the session's state is not.""" + # Arrange — a map the stub compiles to three concepts, answered until the director closes. + graph = await _graph(client) + opened = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() + session_id = opened["sessionId"] + session = opened + for _ in range(12): + session = (await _answer(client, session_id, session["turns"][-1]["tutor"])).json() + if session["status"] == "closed": + break + assert session["status"] == "closed", "the director never ran out of material" + + # Act + response = await _answer(client, session_id, "One more thing?") + + # Assert + assert response.status_code == 409, response.text + + +async def test_an_answer_with_nothing_in_it_is_refused_at_the_door( + client: httpx.AsyncClient, +) -> None: + """Not graded as a miss. An empty POST is a client bug, and recording it as evidence would + lower a belief on the strength of somebody's stray keystroke.""" + # Arrange + graph = await _graph(client) + opened = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() + + # Act / Assert — including whitespace, which is the version a real client actually sends: a + # space passes a raw length check and reaches the grader as an empty answer, which scores as a + # miss and lowers a belief the director then acts on. + assert (await _answer(client, opened["sessionId"], "")).status_code == 422 + assert (await _answer(client, opened["sessionId"], " ")).status_code == 422 + assert (await _answer(client, opened["sessionId"], "x" * 4001)).status_code == 422 + + +async def test_an_answer_that_could_not_be_scored_changes_nothing( + client_with_a_broken_grader: httpx.AsyncClient, +) -> None: + """The failure U1's design exists to survive. An outage must never read as a wrong answer — the + belief the director gates progress on would move against a learner because the provider had a + bad minute — so the turn is refused whole, retryably, with the transcript untouched.""" + # Arrange + graph = await _graph(client_with_a_broken_grader) + opened = ( + await client_with_a_broken_grader.post( + "/api/live/sessions", json={"graphId": graph["graphId"]} + ) + ).json() + + # Act + response = await _answer(client_with_a_broken_grader, opened["sessionId"], "A real attempt.") + + # Assert — retryable, and the session is where it was: one turn, unanswered, ungraded. + assert response.status_code == 503, response.text + resumed = ( + await client_with_a_broken_grader.get(f"/api/live/sessions/{opened['sessionId']}") + ).json() + assert len(resumed["turns"]) == 1 + assert resumed["turns"][0]["answer"] is None + assert resumed["turns"][0]["grade"] is None + + +async def test_answering_a_session_that_is_not_there_is_not_found( + client: httpx.AsyncClient, +) -> None: + # Act / Assert + assert (await _answer(client, "no-such-session", "Anything.")).status_code == 404 + + async def test_a_session_this_build_cannot_read_is_not_offered_as_a_retry( client_with_an_unreadable_session: httpx.AsyncClient, ) -> None: diff --git a/apps/web/src/lib/liveSession.test.ts b/apps/web/src/lib/liveSession.test.ts index 0008df39..6bdf2127 100644 --- a/apps/web/src/lib/liveSession.test.ts +++ b/apps/web/src/lib/liveSession.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { loadSession, LiveSessionError, startSession } from "./liveSession"; +import { answerTurn, loadSession, LiveSessionError, startSession } from "./liveSession"; const SESSION = { sessionId: "s1", @@ -12,6 +12,9 @@ const SESSION = { move: { kind: "introduce", nodeId: "a", reason: "Opening concept." }, tutor: "Let's start with Gravity.", runId: "r1", + criterion: { kind: "explain", statement: "Explain gravity in your own words." }, + answer: null, + grade: null, }, ], }; @@ -43,6 +46,37 @@ describe("liveSession — opening and resuming a session", () => { expect(first?.move.reason).toBe("Opening concept."); }); + it("sends an answer and gets the session back with the turn it produced", async () => { + // The answered turn changes as well as gaining a successor, which is why the whole session + // comes back rather than just the new turn. + const answered = { + ...SESSION, + turns: [ + { + ...SESSION.turns[0], + answer: "It pulls things together.", + grade: { kind: "partial", reason: "Nearly." }, + }, + { + seq: 2, + move: { kind: "retrieve", nodeId: "a", reason: "Coming back." }, + tutor: "Once more.", + runId: "r2", + criterion: null, + answer: null, + grade: null, + }, + ], + }; + + const session = await withFetch(json(answered), () => + answerTurn("", "s1", "It pulls things together."), + ); + + expect(session.turns).toHaveLength(2); + expect(session.turns[0]?.grade?.kind).toBe("partial"); + }); + it("resumes the session a reloaded tab was in", async () => { const session = await withFetch(json(SESSION), () => loadSession("", "s1")); diff --git a/apps/web/src/lib/liveSession.ts b/apps/web/src/lib/liveSession.ts index a6324024..25bcf10b 100644 --- a/apps/web/src/lib/liveSession.ts +++ b/apps/web/src/lib/liveSession.ts @@ -15,6 +15,20 @@ export interface DirectorMove { reason: string; } +/** What one answer was judged to show, and why. Three verdicts and not a score: the grader marks + * one answer against one explicit do-statement, and a number would claim a precision it lacks. */ +export interface TurnGrade { + kind: "met" | "partial" | "not_met"; + reason: string; +} + +/** The one thing the learner was asked to DO on this turn — the bar their answer is marked against. + * Copied onto the turn server-side, so a transcript cannot come to misreport what was asked. */ +export interface StagedCriterion { + kind: "predict" | "manipulate" | "explain"; + statement: string; +} + /** One beat of the loop: what the director chose, and what the tutor said about it. */ export interface SessionTurn { /** 1-based, monotonic — the order the learner lived it. */ @@ -24,6 +38,13 @@ export interface SessionTurn { /** The run that produced this turn — what a learner reporting a problem can name, and what ties * a line of transcript to the model calls behind it. Not rendered; carried. */ runId: string; + /** What the learner was asked to demonstrate, or null when the concept has nothing a text + * session can check (its criteria need a simulator — Phase 3). */ + criterion: StagedCriterion | null; + /** What they said. Null on the turn in front of them, which is what makes it the open one. */ + answer: string | null; + /** What that answer was judged to show. Null when nothing was staged to judge it against. */ + grade: TurnGrade | null; } /** A learner's run at a concept graph. Persisted server-side, so a reload resumes it. */ @@ -77,6 +98,28 @@ export async function loadSession( ); } +/** Answer the criterion the last turn staged. Resolves with the WHOLE session, not just the new + * turn: the answered turn changes too — it gains the learner's words and the verdict on them — and + * a surface stitching two shapes together is one that can disagree with the row behind it. */ +export async function answerTurn( + apiBaseUrl: string, + sessionId: string, + answer: string, + signal?: AbortSignal, +): Promise { + return request( + apiBaseUrl, + `${apiBaseUrl}/api/live/sessions/${encodeURIComponent(sessionId)}/turns`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ answer }), + ...(signal ? { signal } : {}), + }, + "Couldn't send that answer.", + ); +} + async function request( _apiBaseUrl: string, url: string, diff --git a/packages/live/src/lunaris_live/graph/claude_graph_compiler.py b/packages/live/src/lunaris_live/graph/claude_graph_compiler.py index d16156fe..97668c8d 100644 --- a/packages/live/src/lunaris_live/graph/claude_graph_compiler.py +++ b/packages/live/src/lunaris_live/graph/claude_graph_compiler.py @@ -1,5 +1,4 @@ import asyncio -import json import time from collections.abc import Iterable from typing import Any @@ -8,6 +7,7 @@ from lunaris_runtime.resilience import build_chat_model, retry_on_transient from pydantic import ValidationError +from ..model_json import parse_json_object from .assembly import assemble from .graph_compilation_error import GraphCompilationError from .protocols import ICompileProgressSink @@ -243,7 +243,7 @@ async def _propose_branch( known="\n".join(f"- {node.id}: {node.name}" for node in graph.nodes), ) ) - payload = _parse_json_object(response) + payload = parse_json_object(response) raw = payload.get("concepts") if payload else None raw = raw if isinstance(raw, list) else [] # Anything already on the map is dropped rather than merged: honouring a restatement is how @@ -259,7 +259,7 @@ async def _decompose(self, topic: str, *, run_id: str) -> list[dict[str, Any]]: response = await self._ask( _DECOMPOSE_PROMPT.format(topic=topic), max_tokens=_DECOMPOSE_TOKENS ) - payload = _parse_json_object(response) + payload = parse_json_object(response) raw = payload.get("concepts") if payload else None raw = raw if isinstance(raw, list) else [] concepts = _distinct(concept for concept in raw if _is_usable(concept)) @@ -330,7 +330,7 @@ async def _author( ) return known - payload = _parse_json_object(response) + payload = parse_json_object(response) if payload is None: logger.warning("live.graph.spec_unparseable", run_id=run_id, concept=known.id) return known @@ -415,27 +415,6 @@ def _identity_of(concept: dict[str, Any]) -> ConceptNode: ) -def _parse_json_object(text: str) -> dict[str, Any] | None: - """The JSON object in a model response, or ``None``. - - Models wrap JSON in prose and fences however the mood takes them, and a re-ask costs a call and - seconds we do not have. So this normalises deterministically rather than repairing by prompt. - - It decodes forward from the first brace rather than slicing to the last one: trailing prose can - easily contain a stray brace, and a fence marker can appear *inside* a string value the model - wrote. Reading one well-formed object and ignoring whatever follows is both more forgiving and - incapable of mangling the content it accepts. - """ - start = text.find("{") - if start == -1: - return None - try: - parsed, _ = json.JSONDecoder().raw_decode(text[start:]) - except json.JSONDecodeError: - return None - return parsed if isinstance(parsed, dict) else None - - def _parse_aliases(raw: object) -> list[str]: """Other names a learner might use for a concept, deduplicated and bounded.""" if not isinstance(raw, list): diff --git a/packages/live/src/lunaris_live/model_json.py b/packages/live/src/lunaris_live/model_json.py new file mode 100644 index 00000000..1c29e164 --- /dev/null +++ b/packages/live/src/lunaris_live/model_json.py @@ -0,0 +1,28 @@ +import json +from typing import Any + + +def parse_json_object(text: str) -> dict[str, Any] | None: + """The JSON object in a model response, or ``None``. + + Models wrap JSON in prose and fences however the mood takes them, and a re-ask costs a call and + seconds a learner is sitting through — so this normalises deterministically rather than + repairing by prompt. + + It decodes forward from the first brace rather than slicing to the last one: trailing prose can + easily contain a stray brace, and a fence marker can appear *inside* a string value the model + wrote. Reading one well-formed object and ignoring whatever follows is both more forgiving and + incapable of mangling the content it accepts. + + Shared by every adapter in the package. It was written twice before this — once in the compiler + and once in the grader — with the second copy's docstring saying "same normalisation the + compiler uses", which is the point at which two copies should have become one. + """ + start = text.find("{") + if start == -1: + return None + try: + parsed, _ = json.JSONDecoder().raw_decode(text[start:]) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None diff --git a/packages/live/src/lunaris_live/session/__init__.py b/packages/live/src/lunaris_live/session/__init__.py index 914ba53f..f85c93d1 100644 --- a/packages/live/src/lunaris_live/session/__init__.py +++ b/packages/live/src/lunaris_live/session/__init__.py @@ -10,12 +10,15 @@ """ from .apply_evidence import apply_evidence +from .claude_grader import ClaudeGrader from .claude_tutor import ClaudeTutor from .decide_move import decide_move +from .grader_unavailable_error import GraderUnavailableError +from .max_answer_chars import MAX_ANSWER_CHARS from .memory_knowledge_store import MemoryKnowledgeStore from .memory_session_store import MemorySessionStore from .open_session import open_session -from .protocols import IKnowledgeStore, ISessionStore, ITutor +from .protocols import IGrader, IKnowledgeStore, ISessionStore, ITutor from .recall_of import recall_of from .reject_unteachable_move import reject_unteachable_move from .schema import ( @@ -28,17 +31,27 @@ SessionClock, SessionStatus, SessionTurn, + TurnGrade, ) +from .session_closed_error import SessionClosedError from .session_format_error import SessionFormatError +from .stage_criterion import stage_criterion +from .stub_grader import StubGrader from .stub_tutor import StubTutor from .supabase_knowledge_store import SupabaseKnowledgeStore from .supabase_session_store import SupabaseSessionStore +from .take_turn import take_turn +from .turn_outcome import TurnOutcome from .tutor_unavailable_error import TutorUnavailableError __all__ = [ + "MAX_ANSWER_CHARS", + "ClaudeGrader", "ClaudeTutor", "DirectorMove", "EvidenceKind", + "GraderUnavailableError", + "IGrader", "IKnowledgeStore", "ISessionStore", "ITutor", @@ -49,16 +62,22 @@ "NodeKnowledge", "Session", "SessionClock", + "SessionClosedError", "SessionFormatError", "SessionStatus", "SessionTurn", + "StubGrader", "StubTutor", "SupabaseKnowledgeStore", "SupabaseSessionStore", + "TurnGrade", + "TurnOutcome", "TutorUnavailableError", "apply_evidence", "decide_move", "open_session", "recall_of", "reject_unteachable_move", + "stage_criterion", + "take_turn", ] diff --git a/packages/live/src/lunaris_live/session/claude_grader.py b/packages/live/src/lunaris_live/session/claude_grader.py new file mode 100644 index 00000000..0f514959 --- /dev/null +++ b/packages/live/src/lunaris_live/session/claude_grader.py @@ -0,0 +1,120 @@ +import asyncio + +import structlog +from lunaris_runtime.resilience import build_chat_model, retry_on_transient + +from ..graph.schema import ConceptNode, MasteryCriterion +from ..model_json import parse_json_object +from .grader_unavailable_error import GraderUnavailableError +from .max_answer_chars import MAX_ANSWER_CHARS +from .schema import EvidenceKind, TurnGrade + +logger = structlog.get_logger() + +#: Shorter than the tutor's: this is one classification of one answer, and the learner is sitting on +#: the other side of it waiting to find out whether they were right. +_DEFAULT_DEADLINE_S = 20.0 + +_TRANSIENT_ATTEMPTS = 3 +_TRANSIENT_MAX_DELAY_S = 4.0 + +_PROMPT = """You are marking one answer from a learner in a live tutoring session. Judge ONLY \ +whether they met the specific bar below — not whether the answer is impressive, well written, or \ +says something else true about the topic. + +The concept: {name} — {definition} +What they were asked to do: {statement} +What they said: "{answer}" + +Judge it as one of: +- "met" — they did the thing that was asked, in substance. Wording, length and polish do not matter. +- "partial" — the right idea is there but incomplete, or right for the wrong reason. +- "not_met" — they did not do it, said they do not know, or got it wrong. + +Be strict about the bar and generous about the phrasing: a learner explaining it in their own \ +clumsy words has met it, and a learner restating the question in fluent words has not. + +Respond with ONLY a JSON object, no prose: +{{"verdict": "met", "reason": "One sentence, addressed to the learner, saying what they did or \ +missed."}}""" + +#: What the grader is allowed to have decided. An unrecognised verdict is refused rather than +#: coerced: the belief this moves is what the director gates progress on, so guessing which side of +#: the line "mostly" falls on is guessing with somebody's curriculum. +_VERDICTS = {kind.value: kind for kind in EvidenceKind} + + +class ClaudeGrader: + """Scores an answer against one staged criterion with Claude. + + Runs on the worker tier (A1): teaching is the quality surface, while judging one answer against + one explicit do-statement is a classification. Revisit if the simulated-learner eval (T9) shows + the grader is the weak link — it is the one component whose mistakes compound, since every + verdict it gets wrong is written into a belief the director then acts on for the rest of the + session. + """ + + def __init__( + self, + model_name: str, + *, + client: object | None = None, + deadline_s: float = _DEFAULT_DEADLINE_S, + ) -> None: + self._model_name = model_name + self._client = client + self._deadline_s = deadline_s + + async def grade( + self, answer: str, *, criterion: MasteryCriterion, node: ConceptNode, run_id: str + ) -> TurnGrade: + prompt = _PROMPT.format( + name=node.name, + definition=node.definition, + statement=criterion.statement, + # Bounded here rather than trusted: the answer is learner input arriving at a model + # prompt, and an unbounded one would be a cost and a truncation risk at once. + answer=answer.strip()[:MAX_ANSWER_CHARS], + ) + payload = parse_json_object(await self._ask(prompt, run_id=run_id, node_id=node.id)) + + verdict = _VERDICTS.get(str(payload.get("verdict"))) if payload else None + if verdict is None: + logger.warning( + "live.grader.verdict_unusable", + run_id=run_id, + node=node.id, + verdict=str(payload.get("verdict"))[:40] if payload else None, + ) + raise GraderUnavailableError(f"grader returned no usable verdict for {node.id}") + + reason = payload.get("reason") + logger.info("live.grader.graded", run_id=run_id, node=node.id, verdict=verdict.value) + return TurnGrade( + kind=verdict, + # A verdict with no words is still a usable verdict — the belief moves either way — so + # a missing reason degrades to a plain one rather than throwing the grade away. + reason=(reason.strip()[:500] if isinstance(reason, str) and reason.strip() else "—"), + ) + + async def _ask(self, prompt: str, *, run_id: str, node_id: str) -> str: + try: + async with asyncio.timeout(self._deadline_s): + if self._client is None: + self._client = build_chat_model(self._model_name) + message = await retry_on_transient( + lambda: self._client.ainvoke(prompt), # type: ignore[attr-defined] + max_attempts=_TRANSIENT_ATTEMPTS, + max_delay_s=_TRANSIENT_MAX_DELAY_S, + ) + except TimeoutError as exc: + logger.warning( + "live.grader.timed_out", run_id=run_id, node=node_id, deadline_s=self._deadline_s + ) + raise GraderUnavailableError(f"grader timed out on {node_id}") from exc + except Exception as exc: + logger.warning("live.grader.call_failed", run_id=run_id, node=node_id, exc_info=True) + raise GraderUnavailableError(f"grader could not score {node_id}") from exc + + content = message.content + return content if isinstance(content, str) else str(content) diff --git a/packages/live/src/lunaris_live/session/claude_tutor.py b/packages/live/src/lunaris_live/session/claude_tutor.py index 1d5e527f..cf307705 100644 --- a/packages/live/src/lunaris_live/session/claude_tutor.py +++ b/packages/live/src/lunaris_live/session/claude_tutor.py @@ -1,9 +1,10 @@ import asyncio +from collections.abc import Sequence import structlog from lunaris_runtime.resilience import build_chat_model, retry_on_transient -from ..graph.schema import ConceptNode +from ..graph.schema import ConceptNode, MasteryCriterion from .reject_unteachable_move import reject_unteachable_move from .schema import DirectorMove, MoveKind from .tutor_unavailable_error import TutorUnavailableError @@ -25,12 +26,41 @@ _PROMPT = """You are tutoring one learner, one to one, in a live text session about "{topic}". The concept in front of you: {name} — {definition} -{notes} +{notes}{history} {instruction} Write only what you would say to them next, in your own voice, addressed to them directly. Under \ -120 words. No headings, no bullet lists, no markdown. Finish with one question that makes them \ -think, not one they can answer with yes.""" +120 words. No headings, no bullet lists, no markdown. {closing}""" + +#: How a turn ends. The staged form is U1's whole mechanism: the tutor asks for the concept's own +#: do-statement, the learner answers in prose, and a separate grader scores that answer against that +#: statement. Asked in the tutor's words rather than pasted, because a do-statement is written for +#: the system ("Say which way a weight should move") and a question is written for a person. +_STAGE = ( + 'End by asking them to do exactly this, in your own words, as a question: "{statement}" ' + "Ask for it directly — this is what they will be marked on, so a question they could answer " + "without doing it would mark them on nothing." +) + +#: When the concept has nothing a text session can check (every criterion needs a simulator), the +#: turn still has to end somewhere. Nothing the learner says next can move a belief, so this asks +#: for thought rather than for evidence. +_OPEN_ENDED = "Finish with one question that makes them think, not one they can answer with yes." + +#: What this tutor has already said to this learner about this concept. Passed verbatim, because +#: the instruction is not to avoid the topic but to avoid the *words and the analogy* — a +#: remediation that reopens with the same hillside is the same explanation in a different order. +_ALREADY_SAID = """ +You have ALREADY said this to them, word for word, earlier in this session: +{said} +Do not repeat it and do not re-use its analogy or its example. They have heard it and it did not \ +land; saying it again more slowly is the one thing that cannot work. +""" + +#: How much of the history to carry. The last two turns on a concept is what a remediation needs to +#: avoid repeating itself; the whole session would be most of the prompt and most of the cost. +_HISTORY_DEPTH = 2 +_HISTORY_CHARS = 600 #: What the move means for the person speaking. The director's whole output is the move, so a tutor #: that ignored it would make the policy decorative: the trace would record adaptation the learner @@ -82,7 +112,16 @@ def __init__( self._client = client self._deadline_s = deadline_s - async def teach(self, move: DirectorMove, node: ConceptNode, *, topic: str, run_id: str) -> str: + async def teach( + self, + move: DirectorMove, + node: ConceptNode, + *, + topic: str, + criterion: MasteryCriterion | None = None, + already_said: Sequence[str] = (), + run_id: str, + ) -> str: instruction = _INSTRUCTION.get(move.kind) if instruction is None: reject_unteachable_move(move.kind) @@ -92,7 +131,9 @@ async def teach(self, move: DirectorMove, node: ConceptNode, *, topic: str, run_ name=node.name, definition=node.definition, notes=_notes_on(node), + history=_history_of(already_said), instruction=instruction, + closing=_STAGE.format(statement=criterion.statement) if criterion else _OPEN_ENDED, ) said = await self._say(prompt, run_id=run_id, node_id=node.id) @@ -146,6 +187,14 @@ async def _ask(self, prompt: str) -> str: return (content if isinstance(content, str) else str(content)).strip() +def _history_of(already_said: Sequence[str]) -> str: + """What the tutor has already told this learner about this concept, or nothing at all.""" + recent = [said.strip() for said in already_said if said.strip()][-_HISTORY_DEPTH:] + if not recent: + return "" + return _ALREADY_SAID.format(said="\n\n".join(f'"{said[:_HISTORY_CHARS]}"' for said in recent)) + + def _notes_on(node: ConceptNode) -> str: """The concept's teaching notes as the tutor reads them, or nothing at all. diff --git a/packages/live/src/lunaris_live/session/decide_move.py b/packages/live/src/lunaris_live/session/decide_move.py index ce783201..ef3bd016 100644 --- a/packages/live/src/lunaris_live/session/decide_move.py +++ b/packages/live/src/lunaris_live/session/decide_move.py @@ -1,6 +1,6 @@ from ..graph import ConceptGraph, ConceptNode from .recall_of import recall_of -from .schema import DirectorMove, EvidenceKind, LearnerModel, MoveKind, SessionClock +from .schema import DirectorMove, LearnerModel, MoveKind, SessionClock #: Recall at or above this counts as "the learner has this". It gates introductions, so it is the #: number that decides whether progress through the map is earned or waved through. Set above what a @@ -66,13 +66,20 @@ def decide_move(graph: ConceptGraph, model: LearnerModel, clock: SessionClock) - ), ) - if (next_up := _frontier(graph, model, clock)) is not None: + if (next_up := _frontier(graph, model)) is not None: return DirectorMove( kind=MoveKind.INTRODUCE, node_id=next_up.id, + # Two readings of one rule, because the trace is read by a human deciding whether the + # policy is any good. A second pass over a concept the learner has already met is not + # "the next thing this map can teach", and a reason that said so would be the one part + # of the record that cannot be checked quietly telling them something untrue. reason=( - f"Everything {next_up.name} depends on has been demonstrated, so it is the next " - "thing this map can teach." + f"{next_up.name} has been started but not yet shown, so the session stays with it " + "rather than moving on." + if next_up.id in model.nodes + else f"Everything {next_up.name} depends on has been demonstrated, so it is the " + "next thing this map can teach." ), ) @@ -85,18 +92,24 @@ def decide_move(graph: ConceptGraph, model: LearnerModel, clock: SessionClock) - ) -def _knows(model: LearnerModel, node_id: str, clock: SessionClock) -> bool: - """Whether the learner may be built on for ``node_id``: believed, now, at this turn. +def _demonstrated(model: LearnerModel, node_id: str) -> bool: + """Whether the learner has ever shown this concept — the belief at its last evidence, undecayed. + + Deliberately NOT the decayed recall, and running a whole session is what settled it. Recall + dips below ``_MASTERED`` long before it falls under ``_DECAYED``, so a concept sitting in that + band was neither "known" (the frontier offered it again) nor faded enough to retrieve — and the + director taught it from scratch to somebody who had just proved it. Judging *what was earned* + on the undecayed belief and *what has faded* on the decayed one keeps both rules honest, and it + makes an unlock permanent: progress through the map is earned once, not re-earned every turn. One threshold and no separate evidence-count guard, because the threshold already implies one. A single piece of evidence moves the belief by ``_PULL`` (0.45), which is below ``_MASTERED`` (0.6) by construction — so mastery necessarily takes more than one answer, and a guard saying so again would be a second place to keep the same rule true. That relationship is what - ``test_one_right_answer_does_not_unlock_the_next_concept`` pins: raise the pull past the - threshold and it fails, which is the honest way to hold this invariant. + ``test_one_right_answer_does_not_unlock_the_next_concept`` pins. """ known = model.nodes.get(node_id) - return known is not None and recall_of(model, node_id, at_turn=clock.turn) >= _MASTERED + return known is not None and known.estimate >= _MASTERED def _stuck_on(graph: ConceptGraph, model: LearnerModel) -> ConceptNode | None: @@ -132,9 +145,13 @@ def _most_decayed( return min(due, key=lambda pair: pair[0])[1] if due else None -def _frontier(graph: ConceptGraph, model: LearnerModel, clock: SessionClock) -> ConceptNode | None: +def _frontier(graph: ConceptGraph, model: LearnerModel) -> ConceptNode | None: """The next concept worth teaching: not yet known, everything it needs already demonstrated. + "Not yet known" is read off the undecayed belief, so a concept the learner has demonstrated is + never introduced a second time: whatever else the session does with it, teaching it again from + scratch is the one move that tells somebody their work did not count. + Walked in the map's own teaching order so two sessions on one map agree about what comes next, and so the choice inherits Phase 1's ordering rather than inventing a second one. @@ -149,13 +166,8 @@ def _frontier(graph: ConceptGraph, model: LearnerModel, clock: SessionClock) -> by_id = {node.id: node for node in graph.nodes} for node_id in graph.topo_order: node = by_id.get(node_id) - if node is None or _knows(model, node_id, clock): + if node is None or _demonstrated(model, node_id): continue - if all(_knows(model, required, clock) for required in node.requires): + if all(_demonstrated(model, required) for required in node.requires): return node return None - - -#: Re-exported for the grader (T5), which needs the same notion of "met" the director gates on — -#: two definitions of mastery would let a session award progress the policy refuses to act on. -MASTERY_EVIDENCE = EvidenceKind.MET diff --git a/packages/live/src/lunaris_live/session/grader_unavailable_error.py b/packages/live/src/lunaris_live/session/grader_unavailable_error.py new file mode 100644 index 00000000..0612a7d0 --- /dev/null +++ b/packages/live/src/lunaris_live/session/grader_unavailable_error.py @@ -0,0 +1,8 @@ +class GraderUnavailableError(RuntimeError): + """The answer could not be scored, so no evidence exists about it. + + Never a default verdict. Reading a failure as NOT_MET would have an outage teach the system + that the learner does not understand the concept — and the director gates progress on exactly + that belief, so a bad minute for the provider would cost the learner a remediation loop. An + ungraded answer leaves the model untouched instead. + """ diff --git a/packages/live/src/lunaris_live/session/max_answer_chars.py b/packages/live/src/lunaris_live/session/max_answer_chars.py new file mode 100644 index 00000000..72b22c76 --- /dev/null +++ b/packages/live/src/lunaris_live/session/max_answer_chars.py @@ -0,0 +1,7 @@ +#: The learner's answer, bounded once for the whole product. +#: +#: Long enough for somebody explaining a concept properly in their own words, short enough that a +#: pasted chapter never becomes a model prompt and a stored row. One constant because the API's +#: validator, the domain's truncation, the persisted contract and the grader's prompt were all +#: enforcing it separately: agreeing today, and free to drift into silently different truncations. +MAX_ANSWER_CHARS = 4000 diff --git a/packages/live/src/lunaris_live/session/open_session.py b/packages/live/src/lunaris_live/session/open_session.py index 3d85b826..897a9566 100644 --- a/packages/live/src/lunaris_live/session/open_session.py +++ b/packages/live/src/lunaris_live/session/open_session.py @@ -2,6 +2,7 @@ from .decide_move import decide_move from .protocols import ITutor from .schema import LearnerModel, Session, SessionClock, SessionTurn +from .stage_criterion import stage_criterion async def open_session( @@ -33,6 +34,9 @@ async def open_session( if node is None: raise ValueError(f"graph {graph.graph_id} has nothing to teach") + # Staged now rather than when the answer arrives: the tutor has to ask for it in its own words + # as part of the teaching, and the turn has to record what was asked (U1). + staged = stage_criterion(node) return Session( session_id=session_id, graph_id=graph.graph_id, @@ -40,8 +44,11 @@ async def open_session( SessionTurn( seq=clock.turn, move=move, - tutor=await tutor.teach(move, node, topic=graph.topic, run_id=run_id), + tutor=await tutor.teach( + move, node, topic=graph.topic, criterion=staged, run_id=run_id + ), run_id=run_id, + criterion=staged, ) ], ) diff --git a/packages/live/src/lunaris_live/session/protocols/__init__.py b/packages/live/src/lunaris_live/session/protocols/__init__.py index 95c9438a..b931a2f1 100644 --- a/packages/live/src/lunaris_live/session/protocols/__init__.py +++ b/packages/live/src/lunaris_live/session/protocols/__init__.py @@ -1,5 +1,6 @@ +from .grader import IGrader from .knowledge_store import IKnowledgeStore from .session_store import ISessionStore from .tutor import ITutor -__all__ = ["IKnowledgeStore", "ISessionStore", "ITutor"] +__all__ = ["IGrader", "IKnowledgeStore", "ISessionStore", "ITutor"] diff --git a/packages/live/src/lunaris_live/session/protocols/grader.py b/packages/live/src/lunaris_live/session/protocols/grader.py new file mode 100644 index 00000000..d9f9d940 --- /dev/null +++ b/packages/live/src/lunaris_live/session/protocols/grader.py @@ -0,0 +1,22 @@ +from typing import Protocol + +from ...graph.schema import ConceptNode, MasteryCriterion +from ..schema import TurnGrade + + +class IGrader(Protocol): + """Scores a free-text answer against the one criterion it was asked to meet. + + Separate from the tutor on purpose (U1). A tutor scoring its own teaching is the teacher marking + its own homework, and the learner model — which decides what gets skipped — would be built on + that bias. Rejected for the same reason: asking the learner to rate themselves, which is poorly + correlated with mastery and makes them do the system's job. + + ``criterion`` is passed rather than looked up from ``node``: the map can grow and change + mid-session (C1), so the thing being graded has to be the thing that was actually staged. The + node comes too, because a criterion read without its concept is a sentence with no subject. + """ + + async def grade( + self, answer: str, *, criterion: MasteryCriterion, node: ConceptNode, run_id: str + ) -> TurnGrade: ... diff --git a/packages/live/src/lunaris_live/session/protocols/tutor.py b/packages/live/src/lunaris_live/session/protocols/tutor.py index b4794009..d62044a0 100644 --- a/packages/live/src/lunaris_live/session/protocols/tutor.py +++ b/packages/live/src/lunaris_live/session/protocols/tutor.py @@ -1,6 +1,7 @@ +from collections.abc import Sequence from typing import Protocol -from ...graph.schema import ConceptNode +from ...graph.schema import ConceptNode, MasteryCriterion from ..schema import DirectorMove @@ -17,10 +18,27 @@ class ITutor(Protocol): concept*, and a remediation reads nothing like the introduction that already failed. It also means a fifth move kind cannot be added without every tutor being confronted with it. + ``criterion`` is the do-statement this turn stages (U1): the tutor asks for it in its own + words, the learner answers in prose, and a *separate* grader scores that answer against it. + ``None`` means the concept has nothing a text session can check, so the tutor closes however it + likes — and no belief can move from that turn. + + ``already_said`` is what this tutor has already told this learner about *this* concept in this + session, oldest first. Without it "come at it a different way" is an instruction no tutor can + follow: a second turn on one concept produced the first turn's words verbatim, which is the one + thing a remediation must never be. + ``run_id`` is the turn's own run (R6), not the session's — a turn is one or more model calls, and what the tutor was asked has to be findable from a line in a stored transcript. """ async def teach( - self, move: DirectorMove, node: ConceptNode, *, topic: str, run_id: str + self, + move: DirectorMove, + node: ConceptNode, + *, + topic: str, + criterion: MasteryCriterion | None, + already_said: Sequence[str] = (), + run_id: str, ) -> str: ... diff --git a/packages/live/src/lunaris_live/session/schema/__init__.py b/packages/live/src/lunaris_live/session/schema/__init__.py index ce82de16..e97160b6 100644 --- a/packages/live/src/lunaris_live/session/schema/__init__.py +++ b/packages/live/src/lunaris_live/session/schema/__init__.py @@ -9,6 +9,7 @@ from .session_clock import SessionClock from .session_status import SessionStatus from .session_turn import SessionTurn +from .turn_grade import TurnGrade __all__ = [ "DirectorMove", @@ -20,4 +21,5 @@ "SessionClock", "SessionStatus", "SessionTurn", + "TurnGrade", ] diff --git a/packages/live/src/lunaris_live/session/schema/session_turn.py b/packages/live/src/lunaris_live/session/schema/session_turn.py index d6b453fa..e726b3ea 100644 --- a/packages/live/src/lunaris_live/session/schema/session_turn.py +++ b/packages/live/src/lunaris_live/session/schema/session_turn.py @@ -1,7 +1,10 @@ from pydantic import Field from ...graph.schema.base import LiveModel +from ...graph.schema.mastery_criterion import MasteryCriterion +from ..max_answer_chars import MAX_ANSWER_CHARS from .director_move import DirectorMove +from .turn_grade import TurnGrade class SessionTurn(LiveModel): @@ -23,3 +26,14 @@ class SessionTurn(LiveModel): #: is one or more model calls, so without it a stored transcript is unattached to the logs that #: explain it. run_id: str = Field(min_length=1, max_length=100) + #: The do-statement the tutor put in front of the learner, copied onto the turn rather than + #: referenced: C1 lets the map change mid-session, so a transcript pointing at a node id could + #: come to misreport what somebody was actually asked. ``None`` when the concept's only criteria + #: need a simulator (Phase 3) — taught here, not checkable here. + criterion: MasteryCriterion | None = None + #: What the learner said, in their words. ``None`` until they answer, which is also what makes + #: "the turn in front of them" the last one with no answer. + answer: str | None = Field(default=None, max_length=MAX_ANSWER_CHARS) + #: What that answer was judged to show. ``None`` when nothing was staged to judge it against — + #: never a stand-in for "we could not tell", which leaves the turn ungraded rather than failed. + grade: TurnGrade | None = None diff --git a/packages/live/src/lunaris_live/session/schema/turn_grade.py b/packages/live/src/lunaris_live/session/schema/turn_grade.py new file mode 100644 index 00000000..03a01939 --- /dev/null +++ b/packages/live/src/lunaris_live/session/schema/turn_grade.py @@ -0,0 +1,17 @@ +from pydantic import Field + +from ...graph.schema.base import LiveModel +from .evidence_kind import EvidenceKind + + +class TurnGrade(LiveModel): + """What one answer was judged to show, and why. + + The reason is not decoration and not for the model: a learner being told they have not met a + bar needs to know what was missing, and a human auditing a session needs to be able to tell a + grader that is quietly wrong from a learner who is quietly lost. It is the grader's counterpart + to ``DirectorMove.reason``. + """ + + kind: EvidenceKind + reason: str = Field(min_length=1, max_length=500) diff --git a/packages/live/src/lunaris_live/session/session_closed_error.py b/packages/live/src/lunaris_live/session/session_closed_error.py new file mode 100644 index 00000000..9582c00f --- /dev/null +++ b/packages/live/src/lunaris_live/session/session_closed_error.py @@ -0,0 +1,8 @@ +class SessionClosedError(RuntimeError): + """The session has already ended, so it cannot take another turn. + + Reachable from an ordinary stale tab: the director closes a session, and a learner who left the + page open answers into it a minute later. Refusing is not pedantry — reopening it would let a + session run past the bound the director just enforced, and the close is the one decision the + whole clock exists to make. + """ diff --git a/packages/live/src/lunaris_live/session/stage_criterion.py b/packages/live/src/lunaris_live/session/stage_criterion.py new file mode 100644 index 00000000..07fec4b6 --- /dev/null +++ b/packages/live/src/lunaris_live/session/stage_criterion.py @@ -0,0 +1,15 @@ +from ..graph.schema import ConceptNode, MasteryCriterion + + +def stage_criterion(node: ConceptNode) -> MasteryCriterion | None: + """The do-statement a text session can actually put in front of the learner, if there is one. + + Criteria marked ``needs_sim`` are skipped rather than asked in prose: they exist to be + demonstrated in an interactive simulator (Phase 3), and asking somebody to *describe* driving a + rate up until it diverges grades their imagination instead of the thing the criterion names. + + ``None`` is a real answer — a concept whose every criterion needs a simulator can be taught here + and cannot be checked here. That is honest, and it is what tells Phase 3 which sims to build + first. + """ + return next((c for c in node.mastery_criteria if not c.needs_sim), None) diff --git a/packages/live/src/lunaris_live/session/stub_grader.py b/packages/live/src/lunaris_live/session/stub_grader.py new file mode 100644 index 00000000..cf98cc8d --- /dev/null +++ b/packages/live/src/lunaris_live/session/stub_grader.py @@ -0,0 +1,76 @@ +import re + +from ..graph.schema import ConceptNode, MasteryCriterion +from .schema import EvidenceKind, TurnGrade + +#: Words that carry no signal about whether a bar was met. Tokens under four characters go the same +#: way, which takes out most of English's connective tissue for free. +_NOISE = frozenset( + { + "about", + "because", + "could", + "does", + "from", + "should", + "that", + "them", + "then", + "there", + "these", + "they", + "this", + "those", + "what", + "when", + "which", + "with", + "would", + "your", + "yours", + } +) + +_MIN_WORD = 4 + +#: Share of the criterion's content words the answer has to touch. Two thresholds, because the loop +#: needs all three verdicts to be reachable offline: a stub that could only pass or only fail would +#: leave either remediation or progression untested below the API. +_MET_OVERLAP = 0.5 +_PARTIAL_OVERLAP = 0.25 + + +class StubGrader: + """A grader that needs no model, no key and no network. + + It is emphatically not pretending to judge understanding — it measures how much of the + criterion's own vocabulary the answer touches. That is a crude proxy and a deliberate one: it is + deterministic, it is directionally sane (a shrug fails, a restatement in kind passes), and it + can reach all three verdicts, which is what lets the offline suite drive the loop *both* ways. + Real judgement is ``ClaudeGrader``, behind the same protocol. + """ + + async def grade( + self, answer: str, *, criterion: MasteryCriterion, node: ConceptNode, run_id: str + ) -> TurnGrade: + asked = _content_words(criterion.statement) + said = _content_words(answer) + touched = asked & said + overlap = len(touched) / len(asked) if asked else 0.0 + + if overlap >= _MET_OVERLAP: + return TurnGrade(kind=EvidenceKind.MET, reason="That covers what you were asked to do.") + if overlap >= _PARTIAL_OVERLAP: + return TurnGrade( + kind=EvidenceKind.PARTIAL, reason="Part of it is there; the rest is missing." + ) + return TurnGrade(kind=EvidenceKind.NOT_MET, reason="That does not answer what was asked.") + + +def _content_words(text: str) -> set[str]: + """The words in ``text`` worth comparing: lowercased, de-punctuated, short ones dropped.""" + return { + word + for word in re.split(r"[^a-z0-9]+", text.lower()) + if len(word) >= _MIN_WORD and word not in _NOISE + } diff --git a/packages/live/src/lunaris_live/session/stub_tutor.py b/packages/live/src/lunaris_live/session/stub_tutor.py index 9e400eab..49029d0a 100644 --- a/packages/live/src/lunaris_live/session/stub_tutor.py +++ b/packages/live/src/lunaris_live/session/stub_tutor.py @@ -1,4 +1,6 @@ -from ..graph.schema import ConceptNode +from collections.abc import Sequence + +from ..graph.schema import ConceptNode, MasteryCriterion from .reject_unteachable_move import reject_unteachable_move from .schema import DirectorMove, MoveKind @@ -20,6 +22,16 @@ #: can prove it, since the API suite has no provider. _WATCH_FOR = " A lot of people think {misconception} Worth watching for." +#: The staged do-statement, asked plainly. The offline path has to put a real question in front of +#: the learner or nothing downstream — the grader, the belief, the director's next move — can be +#: exercised without a provider. +_ASK = " So: {statement}" + +#: Opens a turn the tutor has already spoken on. The offline path has to be able to *show* that a +#: second pass over one concept is not the first pass repeated, or a surface — and a review — could +#: pass over a tutor that says the same thing forever. +_AGAIN = "Another way to see it. " + class StubTutor: """A tutor that needs no model, no key and no network. @@ -30,13 +42,26 @@ class StubTutor: or ignore the director entirely, without a single test noticing. """ - async def teach(self, move: DirectorMove, node: ConceptNode, *, topic: str, run_id: str) -> str: + async def teach( + self, + move: DirectorMove, + node: ConceptNode, + *, + topic: str, + criterion: MasteryCriterion | None = None, + already_said: Sequence[str] = (), + run_id: str, + ) -> str: script = _SCRIPT.get(move.kind) if script is None: reject_unteachable_move(move.kind) said = script.format(name=node.name, definition=node.definition) + if already_said: + said = _AGAIN + said misconceptions = node.teaching_spec.misconceptions if node.teaching_spec else [] if misconceptions: said += _WATCH_FOR.format(misconception=misconceptions[0]) + if criterion is not None: + said += _ASK.format(statement=criterion.statement) return said diff --git a/packages/live/src/lunaris_live/session/take_turn.py b/packages/live/src/lunaris_live/session/take_turn.py new file mode 100644 index 00000000..ddd1de57 --- /dev/null +++ b/packages/live/src/lunaris_live/session/take_turn.py @@ -0,0 +1,169 @@ +import structlog + +from ..graph import ConceptGraph, ConceptNode +from .apply_evidence import apply_evidence +from .decide_move import decide_move +from .grader_unavailable_error import GraderUnavailableError +from .max_answer_chars import MAX_ANSWER_CHARS +from .protocols import IGrader, ITutor +from .schema import ( + DirectorMove, + LearnerModel, + MoveKind, + Session, + SessionClock, + SessionStatus, + SessionTurn, + TurnGrade, +) +from .session_closed_error import SessionClosedError +from .stage_criterion import stage_criterion +from .turn_outcome import TurnOutcome + +logger = structlog.get_logger() + + +async def take_turn( + session: Session, + graph: ConceptGraph, + model: LearnerModel, + *, + answer: str, + grader: IGrader, + tutor: ITutor, + run_id: str, + budget_s: float, +) -> TurnOutcome: + """The loop, once: score what the learner just said, move the belief, decide what happens next. + + The order is the point. The answer is graded against the criterion the *last* turn staged, the + belief that produces is written before anything is decided, and only then does the director + read the model — so a turn's move is a response to what the learner actually demonstrated + rather than to what they demonstrated one turn ago. + + An ungraded answer is a real outcome and not a failure: a concept whose criteria all need a + simulator (Phase 3) stages nothing, so there is nothing to score. The answer is still recorded + and the session still advances, because stranding somebody on a concept the map cannot yet + check would be the map's problem made into the learner's. + + Raises ``SessionClosedError`` on a session the director has already ended, and + ``GraderUnavailableError`` / ``TutorUnavailableError`` when a turn could not be taken at all — + in which case nothing has moved and the caller can offer the learner a retry that means + something. + """ + if session.status is not SessionStatus.ACTIVE: + raise SessionClosedError(f"session {session.session_id} has already closed") + if not session.turns: + raise ValueError(f"session {session.session_id} has no turn to answer") + + asked = session.turns[-1] + said = answer.strip()[:MAX_ANSWER_CHARS] + graded = await _grade(asked, graph, said=said, grader=grader, run_id=run_id) + + # Written before the director looks: a move decided against the pre-answer belief would be one + # turn behind the learner, which is exactly the lag adaptive teaching exists to remove. + model = _moved_by(model, asked, graded) + turns = [*session.turns[:-1], asked.model_copy(update={"answer": said, "grade": graded})] + + clock = SessionClock(turn=len(turns) + 1, elapsed_s=0.0, budget_s=budget_s) + move = decide_move(graph, model, clock) + if move.kind is MoveKind.CLOSE: + return TurnOutcome(session=_closed(session, turns, move, run_id=run_id), model=model) + + taught = await _teach(graph, move, turns, tutor=tutor, run_id=run_id) + logger.info( + "live.session.turn_taken", + run_id=run_id, + session_id=session.session_id, + seq=taught.seq, + move=move.kind.value, + node=move.node_id, + # The verdict, never the answer: an operational log is not the place for a transcript of + # somebody being taught, and this is enough to read a session's shape from the outside. + graded=graded.kind.value if graded else None, + ) + return TurnOutcome(session=session.model_copy(update={"turns": [*turns, taught]}), model=model) + + +def _moved_by(model: LearnerModel, asked: SessionTurn, graded: TurnGrade | None) -> LearnerModel: + """The belief after this answer — unchanged when there was nothing to score it against.""" + if graded is None or asked.move.node_id is None: + return model + return apply_evidence(model, asked.move.node_id, graded.kind, at_turn=asked.seq) + + +def _closed( + session: Session, turns: list[SessionTurn], move: DirectorMove, *, run_id: str +) -> Session: + """The session, ended. The closing words are P2c's ceremony; what T5 owes is that a session + which has run out of material stops rather than looping, and that the last thing left in the + transcript is still the turn the learner answered.""" + logger.info( + "live.session.closed", + run_id=run_id, + session_id=session.session_id, + reason=move.reason, + turn_count=len(turns), + ) + return session.model_copy(update={"turns": turns, "status": SessionStatus.CLOSED}) + + +async def _teach( + graph: ConceptGraph, + move: DirectorMove, + turns: list[SessionTurn], + *, + tutor: ITutor, + run_id: str, +) -> SessionTurn: + """The next turn: the move, said out loud, with something staged for the learner to meet.""" + node = _node_of(graph, move.node_id) if move.node_id is not None else None + if node is None: + # Unreachable from ``decide_move``, which only ever names a concept it read off this graph. + # Kept because it is the one assumption a turn makes about its collaborator, and a broken + # one would otherwise surface as an AttributeError from inside the tutor. + raise ValueError(f"{move.node_id} is not a concept on graph {graph.graph_id}") + + staged = stage_criterion(node) + return SessionTurn( + seq=len(turns) + 1, + move=move, + tutor=await tutor.teach( + move, + node, + topic=graph.topic, + criterion=staged, + # Only this concept's history: a tutor told everything it has ever said would spend the + # prompt on material the learner is not being taught right now. + already_said=[turn.tutor for turn in turns if turn.move.node_id == node.id], + run_id=run_id, + ), + run_id=run_id, + criterion=staged, + ) + + +async def _grade( + asked: SessionTurn, graph: ConceptGraph, *, said: str, grader: IGrader, run_id: str +) -> TurnGrade | None: + """The verdict on ``said``, or ``None`` when the turn staged nothing to be scored on. + + A grader that cannot answer is *not* a wrong answer — ``GraderUnavailableError`` is left to + propagate rather than folded into NOT_MET, because a bad minute for the provider must never + teach the system that a learner does not understand a concept. + """ + if asked.criterion is None or asked.move.node_id is None: + return None + node = _node_of(graph, asked.move.node_id) + if node is None: + # The map moved under the session (C1 can rewrite what a node is called, and a graph can be + # re-read between turns). Refusing to invent a subject for the grading is the honest end. + logger.warning( + "live.grader.node_gone", run_id=run_id, node=asked.move.node_id, seq=asked.seq + ) + raise GraderUnavailableError(f"{asked.move.node_id} is no longer on the map") + return await grader.grade(said, criterion=asked.criterion, node=node, run_id=run_id) + + +def _node_of(graph: ConceptGraph, node_id: str) -> ConceptNode | None: + return next((node for node in graph.nodes if node.id == node_id), None) diff --git a/packages/live/src/lunaris_live/session/turn_outcome.py b/packages/live/src/lunaris_live/session/turn_outcome.py new file mode 100644 index 00000000..c37306d1 --- /dev/null +++ b/packages/live/src/lunaris_live/session/turn_outcome.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from .schema import LearnerModel, Session + + +@dataclass(frozen=True) +class TurnOutcome: + """What one turn of the loop produced: the session as it now reads, and the belief it moved. + + Both, because they are written to different places and must not drift: the session is a + transcript row and the model is per-concept state keyed to the learner and the map. Returning + them together makes it impossible to persist one and forget the other — which would show up as + a learner whose transcript says they answered and whose director thinks they never did. + + A frozen dataclass rather than a Pydantic model: this never crosses the wire, it is a value the + caller unpacks and throws away. + """ + + session: Session + model: LearnerModel diff --git a/packages/live/tests/test_director.py b/packages/live/tests/test_director.py index 31e92fcc..749c0149 100644 --- a/packages/live/tests/test_director.py +++ b/packages/live/tests/test_director.py @@ -21,6 +21,7 @@ SessionClock, apply_evidence, decide_move, + recall_of, ) @@ -108,6 +109,22 @@ def test_one_right_answer_does_not_unlock_the_next_concept() -> None: assert (move.kind, move.node_id) == (MoveKind.INTRODUCE, "a") +def test_the_trace_does_not_call_a_second_pass_a_first_one() -> None: + """The reason is the audit trail (plan §7), and the part nobody can check against anything + else. A director reporting "the next thing this map can teach" while going back over a concept + the learner has already met would be lying in the only record of its own judgement.""" + # Arrange — one right answer: not enough for mastery, so the session stays on "a". + model = apply_evidence(LearnerModel(graph_id="g1"), "a", EvidenceKind.MET, at_turn=1) + + # Act + move = decide_move(_graph(), model, SessionClock(turn=2, elapsed_s=30.0, budget_s=1800.0)) + + # Assert + assert (move.kind, move.node_id) == (MoveKind.INTRODUCE, "a") + assert "next thing this map can teach" not in move.reason + assert "not yet shown" in move.reason + + # ── remediating ─────────────────────────────────────────────────────────────────────────────── @@ -186,6 +203,43 @@ def test_a_freshly_demonstrated_concept_is_not_retrieved() -> None: assert move.kind is not MoveKind.RETRIEVE +def test_a_concept_already_demonstrated_is_never_introduced_again() -> None: + """Found by running a whole session rather than by reading the rules (T5). + + Recall slips under ``_MASTERED`` long before it falls under ``_DECAYED``, and a concept sitting + in that band used to be neither known (so the frontier offered it) nor faded enough to retrieve + — so the director taught it again from the beginning, to a learner who had just proved it. The + fix is that what was *earned* is judged on the undecayed belief; only what has *faded* is judged + on recall. + """ + # Arrange — "a" mastered, then far enough on that its recall has slipped below the mastery bar + # but not yet far enough to be due for retrieval. + model = _mastered(LearnerModel(graph_id="g1"), "a") + clock = SessionClock(turn=14, elapsed_s=300.0, budget_s=1800.0) + assert 0.45 <= recall_of(model, "a", at_turn=clock.turn) < 0.6, "the band this test is about" + + # Act + move = decide_move(_graph(), model, clock) + + # Assert — on to the untouched root instead. + assert (move.kind, move.node_id) == (MoveKind.INTRODUCE, "d") + + +def test_a_faded_prerequisite_does_not_lock_a_learner_out_of_what_they_earned() -> None: + """The other half: an unlock is earned once. If a prerequisite's *decayed* recall gated the + frontier, a learner would lose access to material they had already opened simply by spending + turns elsewhere — and with nothing introducible left, the session would close early.""" + # Arrange — "a" and "d" mastered long enough ago that recall has slipped below the bar. + model = _mastered(LearnerModel(graph_id="g1"), "a", "d") + clock = SessionClock(turn=14, elapsed_s=300.0, budget_s=1800.0) + + # Act + move = decide_move(_graph(), model, clock) + + # Assert + assert (move.kind, move.node_id) == (MoveKind.INTRODUCE, "b") + + def test_a_concept_never_demonstrated_is_introduced_rather_than_retrieved() -> None: """Recall of an unseen concept is 0.0, which is below any decay threshold — so a naive rule would "retrieve" something the learner has never been taught.""" diff --git a/packages/live/tests/test_grader.py b/packages/live/tests/test_grader.py new file mode 100644 index 00000000..7863b4b4 --- /dev/null +++ b/packages/live/tests/test_grader.py @@ -0,0 +1,204 @@ +"""The grader: turning a free-text answer into evidence (Phase 2a, T5). + +U1 is the decision this file exists to hold: the tutor stages one of the concept's do-statements, +the learner answers in prose, and a *separate* grader scores that answer against that criterion. +Rejected alternatives were the tutor reporting its own confidence (the teacher marking its own +homework, with the learner model then built on that bias) and learner self-rating (poorly correlated +with mastery, and it makes the learner do the system's job). + +Three verdicts and not a score: the grader is judging one answer against one explicit do-statement, +and asking it for 0.73 would be asking for a precision it does not have. +""" + +import asyncio + +import pytest +from langchain_core.messages import AIMessage +from lunaris_live.graph import ConceptNode, MasteryCriterion, MasteryCriterionKind +from lunaris_live.session import ( + ClaudeGrader, + EvidenceKind, + GraderUnavailableError, + StubGrader, +) + +_CRITERION = MasteryCriterion( + kind=MasteryCriterionKind.PREDICT, + statement="Say which way a weight should move to lower the loss.", +) + +_NODE = ConceptNode( + id="gradient", + name="Gradient", + definition="How much the loss changes for a small change in each weight.", +) + + +class ScriptedModel: + """Replays one response and records the prompts it was asked with.""" + + def __init__(self, reply: str) -> None: + self._reply = reply + self.prompts: list[str] = [] + + async def ainvoke(self, prompt: str) -> AIMessage: + self.prompts.append(prompt) + return AIMessage(content=self._reply) + + +async def _graded(model: object, answer: str = "Downhill, opposite the gradient."): + return await ClaudeGrader("m", client=model).grade( + answer, criterion=_CRITERION, node=_NODE, run_id="r1" + ) + + +# ── what the grader is judging ───────────────────────────────────────────────────────────────── + + +async def test_the_answer_is_judged_against_the_staged_criterion() -> None: + """Not against the concept in general. A grader marking "did they say something sensible about + gradients" would pass an answer that never did the thing the learner was asked to do, and the + learner model would then record mastery of a criterion nobody checked.""" + # Arrange + model = ScriptedModel('{"verdict": "met", "reason": "Named the descent direction."}') + + # Act + await _graded(model) + + # Assert — the do-statement itself, verbatim, plus the answer being judged. + prompt = model.prompts[0] + assert _CRITERION.statement in prompt + assert "Downhill, opposite the gradient." in prompt + + +@pytest.mark.parametrize( + ("verdict", "expected"), + [ + ("met", EvidenceKind.MET), + ("partial", EvidenceKind.PARTIAL), + ("not_met", EvidenceKind.NOT_MET), + ], +) +async def test_every_verdict_the_grader_may_return_is_understood( + verdict: str, expected: EvidenceKind +) -> None: + # Arrange + model = ScriptedModel(f'{{"verdict": "{verdict}", "reason": "Because."}}') + + # Act + grade = await _graded(model) + + # Assert + assert grade.kind is expected + + +async def test_the_grade_carries_the_reasoning_that_produced_it() -> None: + """A learner is told they have not met a bar; "why" is the difference between feedback and a + verdict. It is also the only way to audit a grader that is quietly wrong.""" + # Arrange + model = ScriptedModel('{"verdict": "partial", "reason": "Right direction, no magnitude."}') + + # Act + grade = await _graded(model) + + # Assert + assert grade.reason == "Right direction, no magnitude." + + +# ── failing honestly ─────────────────────────────────────────────────────────────────────────── + + +async def test_an_unrecognised_verdict_is_not_guessed_at() -> None: + """Coercing "mostly" to MET would move a learner's belief on the strength of a word nobody + defined — and the director gates introductions on that belief.""" + # Arrange + model = ScriptedModel('{"verdict": "mostly", "reason": "Close enough."}') + + # Act / Assert + with pytest.raises(GraderUnavailableError): + await _graded(model) + + +async def test_an_unparseable_response_is_not_evidence() -> None: + # Act / Assert + with pytest.raises(GraderUnavailableError): + await _graded(ScriptedModel("I think they did fine, honestly.")) + + +async def test_a_provider_failure_is_not_evidence_either() -> None: + """Silence must never read as a wrong answer. Defaulting a failed grade to NOT_MET would have + an outage teach the system that the learner does not understand the concept.""" + + class Broken: + async def ainvoke(self, prompt: str) -> AIMessage: + raise RuntimeError("provider is down") + + # Act / Assert + with pytest.raises(GraderUnavailableError): + await _graded(Broken()) + + +async def test_the_grader_gives_up_before_the_learner_does() -> None: + """Grading is one classification of one answer; a learner is waiting on it to find out whether + they were right, so a hung call has to become a failure they can retry.""" + + class Hanging: + async def ainvoke(self, prompt: str) -> AIMessage: + await asyncio.sleep(30) + raise AssertionError("should have been cancelled") + + # Act / Assert + async with asyncio.timeout(5): + with pytest.raises(GraderUnavailableError): + await ClaudeGrader("m", client=Hanging(), deadline_s=0.05).grade( + "Downhill.", criterion=_CRITERION, node=_NODE, run_id="r1" + ) + + +# ── the offline grader ───────────────────────────────────────────────────────────────────────── + + +async def test_the_stub_can_reach_every_verdict() -> None: + """The offline path has to be able to drive the loop BOTH ways — a stub that always passed + would leave remediation, the whole reason the director has a stuck rule, permanently untested + below the API.""" + # Act + strong = await StubGrader().grade( + "The weight should move which way lowers the loss, opposite the gradient.", + criterion=_CRITERION, + node=_NODE, + run_id="r1", + ) + partial = await StubGrader().grade( + "The loss should go down.", criterion=_CRITERION, node=_NODE, run_id="r1" + ) + missed = await StubGrader().grade( + "No idea, sorry.", criterion=_CRITERION, node=_NODE, run_id="r1" + ) + + # Assert + assert (strong.kind, partial.kind, missed.kind) == ( + EvidenceKind.MET, + EvidenceKind.PARTIAL, + EvidenceKind.NOT_MET, + ) + + +async def test_the_stub_says_why_as_well() -> None: + """Same contract as the real one, or a surface built against the stub would have an empty + feedback line the keyed path then fills — and nobody would have designed for it.""" + # Act + grade = await StubGrader().grade( + "No idea, sorry.", criterion=_CRITERION, node=_NODE, run_id="r1" + ) + + # Assert + assert grade.reason.strip() + + +async def test_an_empty_answer_is_not_a_pass() -> None: + # Act + grade = await StubGrader().grade(" ", criterion=_CRITERION, node=_NODE, run_id="r1") + + # Assert + assert grade.kind is EvidenceKind.NOT_MET diff --git a/packages/live/tests/test_open_session.py b/packages/live/tests/test_open_session.py index e75e645c..4e27f240 100644 --- a/packages/live/tests/test_open_session.py +++ b/packages/live/tests/test_open_session.py @@ -7,7 +7,7 @@ """ import pytest -from lunaris_live.graph import ConceptGraph, ConceptNode +from lunaris_live.graph import ConceptGraph, ConceptNode, MasteryCriterion from lunaris_live.session import ( DirectorMove, EvidenceKind, @@ -50,7 +50,15 @@ class SpyTutor: def __init__(self) -> None: self.calls: list[tuple[DirectorMove, ConceptNode, str, str]] = [] - async def teach(self, move: DirectorMove, node: ConceptNode, *, topic: str, run_id: str) -> str: + async def teach( + self, + move: DirectorMove, + node: ConceptNode, + *, + topic: str, + criterion: MasteryCriterion | None = None, + run_id: str, + ) -> str: self.calls.append((move, node, topic, run_id)) return f"Teaching {node.name}." diff --git a/packages/live/tests/test_take_turn.py b/packages/live/tests/test_take_turn.py new file mode 100644 index 00000000..4c22b612 --- /dev/null +++ b/packages/live/tests/test_take_turn.py @@ -0,0 +1,435 @@ +"""The loop closing: an answer becomes evidence, and evidence changes what happens next (T5). + +Until now the learner model was something only tests filled in. This is where the loop actually +turns: the learner answers the criterion the last turn staged, the grader scores it, the belief +moves, and the director reads the moved belief to pick the next move. The RED assertion for the +task is the third of those — a wrong answer lowers the estimate for *that* concept and no other. + +The tutor and grader here are deterministic stubs; what is under test is the wiring of a turn, not +the quality of teaching or judging. +""" + +import pytest +from lunaris_live.graph import ( + ConceptGraph, + ConceptNode, + MasteryCriterion, + MasteryCriterionKind, + TeachingSpec, +) +from lunaris_live.session import ( + EvidenceKind, + GraderUnavailableError, + LearnerModel, + MoveKind, + Session, + SessionClock, + SessionClosedError, + SessionStatus, + StubGrader, + StubTutor, + TutorUnavailableError, + open_session, + recall_of, + take_turn, +) + +_BUDGET_S = 1800.0 + + +def _node(node_id: str, name: str, *, requires: list[str] | None = None) -> ConceptNode: + return ConceptNode( + id=node_id, + name=name, + definition=f"What {name} is.", + requires=requires or [], + teaching_spec=TeachingSpec(objective=f"Use {name}.", misconceptions=[f"{name} is magic."]), + mastery_criteria=[ + MasteryCriterion( + kind=MasteryCriterionKind.EXPLAIN, + statement=f"Explain {name} in your own words.", + ) + ], + ) + + +def _graph() -> ConceptGraph: + return ConceptGraph( + graph_id="g1", + topic="A subject", + nodes=[_node("a", "Alpha"), _node("b", "Beta", requires=["a"])], + topo_order=["a", "b"], + is_acyclic=True, + ) + + +async def _opened() -> Session: + return await open_session( + _graph(), + LearnerModel(graph_id="g1"), + SessionClock(turn=1, elapsed_s=0.0, budget_s=_BUDGET_S), + session_id="s1", + run_id="r1", + tutor=StubTutor(), + ) + + +async def _answered(answer: str, *, model: LearnerModel | None = None): + return await take_turn( + await _opened(), + _graph(), + model or LearnerModel(graph_id="g1"), + answer=answer, + grader=StubGrader(), + tutor=StubTutor(), + run_id="r2", + budget_s=_BUDGET_S, + ) + + +# ── the criterion the learner was asked to meet ──────────────────────────────────────────────── + + +async def test_the_opening_turn_stages_something_the_learner_can_be_scored_on() -> None: + """Without a staged criterion there is nothing to grade, so the loop cannot turn at all: the + tutor talks, the learner answers, and no belief ever moves.""" + # Act + session = await _opened() + + # Assert + staged = session.turns[0].criterion + assert staged is not None + assert staged.statement == "Explain Alpha in your own words." + + +async def test_the_criterion_the_learner_met_is_kept_on_the_turn_itself() -> None: + """Not looked up from the map afterwards. C1 lets the map change mid-session, so a transcript + that pointed at a node id would be able to misreport what somebody was actually asked.""" + # Act + outcome = await _answered("Explain Alpha in your own words: it is what Alpha is.") + + # Assert + answered = outcome.session.turns[0] + assert answered.criterion is not None + assert answered.answer == "Explain Alpha in your own words: it is what Alpha is." + assert answered.grade is not None + + +# ── the RED assertion: evidence moves one belief ─────────────────────────────────────────────── + + +async def test_a_wrong_answer_lowers_the_estimate_for_that_concept_and_no_other() -> None: + # Act + outcome = await _answered("No idea, sorry.") + + # Assert — the concept that was asked about moved down to where a miss puts it (a fresh belief + # pulled towards 0.0), and nothing else acquired a belief at all. Pinned to the value rather + # than to "below the mastery bar": a NOT_MET target of 0.4 would still be under any loose bound + # while quietly meaning a wrong answer teaches the system something. + moved = outcome.model.nodes["a"] + assert moved.estimate == pytest.approx(0.0) + assert moved.evidence_count == 1 + assert set(outcome.model.nodes) == {"a"} + + +async def test_a_good_answer_raises_the_estimate_for_that_concept() -> None: + # Act + outcome = await _answered("I can explain Alpha: it is what Alpha is.") + + # Assert + assert outcome.session.turns[0].grade is not None + assert outcome.session.turns[0].grade.kind is EvidenceKind.MET + assert recall_of(outcome.model, "a", at_turn=1) > 0.0 + + +async def test_the_grade_the_learner_reads_is_the_one_the_belief_was_moved_by() -> None: + """Two records of one judgement is how a transcript comes to disagree with the model behind + it — the learner told they passed while the director acts as though they did not.""" + # Act + outcome = await _answered("No idea, sorry.") + + # Assert + grade = outcome.session.turns[0].grade + assert grade is not None + assert grade.kind is EvidenceKind.NOT_MET + assert outcome.model.nodes["a"].estimate < 0.5 + + +# ── and the loop turns ───────────────────────────────────────────────────────────────────────── + + +async def test_the_answer_is_followed_by_the_next_turn() -> None: + """A loop that graded and stopped would be a quiz. The point of the model moving is that the + director reads it and decides again.""" + # Act + outcome = await _answered("No idea, sorry.") + + # Assert + assert [turn.seq for turn in outcome.session.turns] == [1, 2] + assert outcome.session.turns[1].run_id == "r2" + assert outcome.session.turns[1].tutor.strip() + + +async def test_a_learner_who_keeps_missing_is_not_marched_onward() -> None: + """The director's stuck rule, reached through the real loop rather than a hand-built model: two + misses on the concept in front of them and the next move is remediation, not new material.""" + # Arrange + first = await _answered("No idea, sorry.") + + # Act — the same shrug again, against the belief the first one left. + second = await take_turn( + first.session, + _graph(), + first.model, + answer="Still no idea.", + grader=StubGrader(), + tutor=StubTutor(), + run_id="r3", + budget_s=_BUDGET_S, + ) + + # Assert + assert second.session.turns[-1].move.kind is MoveKind.REMEDIATE + assert second.session.turns[-1].move.node_id == "a" + + +async def test_a_map_with_nothing_left_to_teach_closes_rather_than_looping() -> None: + """The last thing a session should do is keep talking after it has run out of material.""" + # Arrange — a one-concept map, answered well enough to master. + single = ConceptGraph( + graph_id="g1", + topic="A subject", + nodes=[_node("a", "Alpha")], + topo_order=["a"], + is_acyclic=True, + ) + session = await open_session( + single, + LearnerModel(graph_id="g1"), + SessionClock(turn=1, elapsed_s=0.0, budget_s=_BUDGET_S), + session_id="s1", + run_id="r1", + tutor=StubTutor(), + ) + model = LearnerModel(graph_id="g1") + + # Act — answer well until the director has nothing left to introduce. + for index in range(4): + outcome = await take_turn( + session, + single, + model, + answer="I can explain Alpha: it is what Alpha is.", + grader=StubGrader(), + tutor=StubTutor(), + run_id=f"r{index + 2}", + budget_s=_BUDGET_S, + ) + session, model = outcome.session, outcome.model + if session.status is SessionStatus.CLOSED: + break + + # Assert — closed, and the last thing in the transcript is still the answered turn. + assert session.status is SessionStatus.CLOSED + assert session.turns[-1].answer is not None + + +# ── the tutor is told what it has already said ───────────────────────────────────────────────── + + +async def test_the_tutor_is_handed_what_it_already_said_about_this_concept() -> None: + """And nothing it said about another one. Passing the whole session would spend the prompt on + material the learner is not being taught, and passing nothing is what let a remediation come + back as the introduction repeated word for word.""" + + # Arrange — a spy that answers differently each time, so history and repetition are visible. + class SpyTutor: + def __init__(self) -> None: + self.histories: list[tuple[str, list[str]]] = [] + + async def teach(self, move, node, *, topic, criterion=None, already_said=(), run_id) -> str: + self.histories.append((node.id, list(already_said))) + return f"Teaching {node.name} #{len(self.histories)}." + + tutor = SpyTutor() + session = await open_session( + _graph(), + LearnerModel(graph_id="g1"), + SessionClock(turn=1, elapsed_s=0.0, budget_s=_BUDGET_S), + session_id="s1", + run_id="r1", + tutor=tutor, + ) + + # Act — one wrong answer, so the director stays on the same concept. + outcome = await take_turn( + session, + _graph(), + LearnerModel(graph_id="g1"), + answer="No idea, sorry.", + grader=StubGrader(), + tutor=tutor, + run_id="r2", + budget_s=_BUDGET_S, + ) + + # Act again — answer well twice so the session masters "a" and moves to "b". + for index, reply in enumerate(["I can explain Alpha: it is what Alpha is."] * 2): + outcome = await take_turn( + outcome.session, + _graph(), + outcome.model, + answer=reply, + grader=StubGrader(), + tutor=tutor, + run_id=f"r{index + 3}", + budget_s=_BUDGET_S, + ) + + # Assert — the first turn had nothing behind it; the second was handed exactly what had been + # said about "a"; and the turn that moved on to "b" was handed NOTHING, because none of the + # words spoken about "a" are words this tutor has already used on "b". + assert tutor.histories[0] == ("a", []) + assert tutor.histories[1] == ("a", ["Teaching Alpha #1."]) + moved_on = next((concept, said) for concept, said in tutor.histories if concept == "b") + assert moved_on == ("b", []) + + +# ── when the loop cannot turn at all ─────────────────────────────────────────────────────────── + + +async def test_a_grader_that_cannot_answer_moves_nothing() -> None: + """The failure U1's whole design exists to survive. An outage must not read as a wrong answer: + the belief the director gates progress on would move against a learner for a bad minute on the + provider's side, and the turn would be recorded as scored when nothing scored it.""" + + class BrokenGrader: + async def grade(self, answer, *, criterion, node, run_id): + raise GraderUnavailableError("provider is down") + + # Arrange + session = await _opened() + model = LearnerModel(graph_id="g1") + + # Act / Assert — it propagates rather than degrading into evidence... + with pytest.raises(GraderUnavailableError): + await take_turn( + session, + _graph(), + model, + answer="A real attempt at an answer.", + grader=BrokenGrader(), + tutor=StubTutor(), + run_id="r2", + budget_s=_BUDGET_S, + ) + + # ... and nothing the caller holds has changed, so a retry means what the learner expects. + assert model.nodes == {} + assert session.turns[-1].answer is None + assert session.turns[-1].grade is None + + +async def test_a_tutor_that_cannot_speak_mid_session_moves_nothing_either() -> None: + """The answer was graded before the tutor was asked, so this is the one path where a failure + could half-happen: a belief moved for a turn the learner never receives. Nothing is returned, + so nothing is persisted, and the same answer can be sent again.""" + + class SilentTutor: + async def teach(self, move, node, *, topic, criterion=None, already_said=(), run_id): + raise TutorUnavailableError("provider is down") + + # Arrange + session = await _opened() + model = LearnerModel(graph_id="g1") + + # Act / Assert + with pytest.raises(TutorUnavailableError): + await take_turn( + session, + _graph(), + model, + answer="No idea, sorry.", + grader=StubGrader(), + tutor=SilentTutor(), + run_id="r2", + budget_s=_BUDGET_S, + ) + assert model.nodes == {} + assert session.turns[-1].answer is None + + +# ── answering something that cannot be answered ──────────────────────────────────────────────── + + +async def test_a_closed_session_does_not_take_another_turn() -> None: + """A learner with a stale tab must not be able to reopen a session the director ended — + reopening it would run the session past the bound the close was there to enforce.""" + # Arrange + closed = (await _opened()).model_copy(update={"status": SessionStatus.CLOSED}) + + # Act / Assert + with pytest.raises(SessionClosedError): + await take_turn( + closed, + _graph(), + LearnerModel(graph_id="g1"), + answer="Anything.", + grader=StubGrader(), + tutor=StubTutor(), + run_id="r2", + budget_s=_BUDGET_S, + ) + + +async def test_a_turn_that_asked_nothing_gradeable_still_moves_the_session_on() -> None: + """A concept whose only criteria need a simulator (Phase 3) stages nothing, so there is nothing + to score. The answer is still part of the transcript and the session still advances — refusing + it would strand the learner on a concept the map cannot yet check.""" + # Arrange — a map whose one concept can only be demonstrated in a sim. + sim_only = ConceptGraph( + graph_id="g1", + topic="A subject", + nodes=[ + ConceptNode( + id="a", + name="Alpha", + definition="What Alpha is.", + mastery_criteria=[ + MasteryCriterion( + kind=MasteryCriterionKind.MANIPULATE, + statement="Drive the rate up until it diverges.", + needs_sim=True, + ) + ], + ) + ], + topo_order=["a"], + is_acyclic=True, + ) + session = await open_session( + sim_only, + LearnerModel(graph_id="g1"), + SessionClock(turn=1, elapsed_s=0.0, budget_s=_BUDGET_S), + session_id="s1", + run_id="r1", + tutor=StubTutor(), + ) + assert session.turns[0].criterion is None + + # Act + outcome = await take_turn( + session, + sim_only, + LearnerModel(graph_id="g1"), + answer="I think it blows up.", + grader=StubGrader(), + tutor=StubTutor(), + run_id="r2", + budget_s=_BUDGET_S, + ) + + # Assert — recorded, ungraded, and no belief invented from an unscored answer. + assert outcome.session.turns[0].answer == "I think it blows up." + assert outcome.session.turns[0].grade is None + assert outcome.model.nodes == {} diff --git a/packages/live/tests/test_tutor.py b/packages/live/tests/test_tutor.py index c50fcbd0..3e88cff0 100644 --- a/packages/live/tests/test_tutor.py +++ b/packages/live/tests/test_tutor.py @@ -211,6 +211,128 @@ async def test_closing_is_not_something_the_tutor_is_asked_to_teach() -> None: await _taught(ScriptedModel(), move=DirectorMove(kind=MoveKind.CLOSE, reason="Time is up.")) +# ── not saying the same thing twice ──────────────────────────────────────────────────────────── + + +async def test_the_tutor_is_told_what_it_has_already_said_about_this_concept() -> None: + """Found by running a whole session (T5): the second turn on a concept came back as the first + turn's words, verbatim. "Come at it a different way" is an instruction no tutor can follow + without knowing which way it already came at it — the remediation prompt was asking for + something the tutor had no way to do.""" + # Arrange + model = ScriptedModel() + first = "Picture the loss as a hillside you are standing on." + + # Act + await ClaudeTutor("m", client=model).teach( + _move(MoveKind.REMEDIATE), + _node(), + topic="How neural networks learn", + already_said=[first], + run_id="r1", + ) + + # Assert — the words themselves, and the prohibition that makes carrying them worth anything. + prompt = model.prompts[0] + assert first in prompt + assert "do not re-use its analogy" in prompt.lower() + + +async def test_a_first_turn_on_a_concept_is_not_told_to_avoid_anything() -> None: + """An empty history must not become an instruction about nothing — a tutor told not to repeat + itself before it has said anything is being handed a puzzle instead of a concept.""" + # Arrange + model = ScriptedModel() + + # Act + await _taught(model) + + # Assert + assert "already said" not in model.prompts[0].lower() + + +async def test_the_stub_does_not_repeat_itself_on_a_second_pass_either() -> None: + """The offline path has to be able to show it, or a surface built and reviewed against the stub + would pass over a tutor that says one thing forever.""" + # Arrange + tutor = StubTutor() + first = await tutor.teach(_move(), _node(), topic="A subject", run_id="r1") + + # Act + second = await tutor.teach( + _move(MoveKind.REMEDIATE), + _node(), + topic="A subject", + already_said=[first], + run_id="r2", + ) + + # Assert + assert second != first + assert second.startswith("Another way to see it.") + + +# ── staging what the learner will be marked on ───────────────────────────────────────────────── + + +async def test_the_turn_ends_by_asking_for_the_criterion_that_will_be_graded() -> None: + """U1's mechanism: the tutor stages one of the concept's do-statements, the learner answers in + prose, and a *separate* grader scores that answer against that same statement. If the tutor + ends on a question of its own devising, the grader marks an answer to a question nobody + recorded — and the belief that moves is about something else entirely.""" + # Arrange + model = ScriptedModel() + criterion = MasteryCriterion( + kind=MasteryCriterionKind.PREDICT, + statement="Say which way a weight should move to lower the loss.", + ) + + # Act + await ClaudeTutor("m", client=model).teach( + _move(), _node(), topic="How neural networks learn", criterion=criterion, run_id="r1" + ) + + # Assert — the statement verbatim, and the instruction that makes it the closing question. + prompt = model.prompts[0] + assert criterion.statement in prompt + assert "end by asking them to do exactly this" in prompt.lower() + + +async def test_a_concept_with_nothing_checkable_still_ends_somewhere() -> None: + """Every criterion needing a simulator (Phase 3) is a real state: the concept can be taught + here and cannot be checked here. The turn still has to end on something a learner can reply to, + and nothing they say can move a belief.""" + # Arrange + model = ScriptedModel() + + # Act + await ClaudeTutor("m", client=model).teach( + _move(), _node(), topic="How neural networks learn", criterion=None, run_id="r1" + ) + + # Assert + prompt = model.prompts[0].lower() + assert "end by asking them to do exactly this" not in prompt + assert "question that makes them think" in prompt + + +async def test_the_stub_asks_the_staged_criterion_too() -> None: + """The offline path has to put a real question in front of the learner, or nothing downstream — + the grader, the belief, the director's next move — can be exercised without a provider.""" + # Arrange + criterion = MasteryCriterion( + kind=MasteryCriterionKind.EXPLAIN, statement="Explain the gradient in your own words." + ) + + # Act + said = await StubTutor().teach( + _move(), _node(), topic="How neural networks learn", criterion=criterion, run_id="r1" + ) + + # Assert + assert criterion.statement in said + + # ── failing honestly ─────────────────────────────────────────────────────────────────────────── From f204c32a8486f7e3d30e98fb31fa30f6158e6cd1 Mon Sep 17 00:00:00 2001 From: Pouyan Jahangiri Date: Sun, 9 Aug 2026 23:28:38 -0700 Subject: [PATCH 6/9] feat(live): give a session a beginning, a bound and an ending (Phase 2a, T6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/lunaris_api/live/session/router.py | 14 +- .../src/lunaris_api/live/session/schemas.py | 4 + .../src/lunaris_api/live/session/service.py | 24 +- .../tests/live/test_live_session_service.py | 42 +++- apps/api/tests/live/test_live_sessions_api.py | 95 +++++++- apps/web/src/lib/liveSession.test.ts | 3 +- apps/web/src/lib/liveSession.ts | 12 +- .../live/src/lunaris_live/session/__init__.py | 2 + .../src/lunaris_live/session/decide_move.py | 4 +- .../session/memory_session_store.py | 22 +- .../src/lunaris_live/session/open_session.py | 4 + .../session/protocols/session_store.py | 11 +- .../lunaris_live/session/schema/session.py | 12 + .../session/stale_answer_error.py | 12 + .../session/supabase_session_store.py | 27 ++- .../src/lunaris_live/session/take_turn.py | 46 +++- packages/live/tests/test_session_lifecycle.py | 207 ++++++++++++++++++ packages/live/tests/test_session_stores.py | 82 +++++++ packages/live/tests/test_take_turn.py | 24 +- 19 files changed, 619 insertions(+), 28 deletions(-) create mode 100644 packages/live/src/lunaris_live/session/stale_answer_error.py create mode 100644 packages/live/tests/test_session_lifecycle.py diff --git a/apps/api/src/lunaris_api/live/session/router.py b/apps/api/src/lunaris_api/live/session/router.py index 32ebb7e7..b077decc 100644 --- a/apps/api/src/lunaris_api/live/session/router.py +++ b/apps/api/src/lunaris_api/live/session/router.py @@ -7,6 +7,7 @@ Session, SessionClosedError, SessionFormatError, + StaleAnswerError, TutorUnavailableError, ) from lunaris_runtime.persistence import PersistenceError @@ -87,7 +88,9 @@ async def answer_turn( """ correlated = {"X-Session-Id": session_id} try: - session = await service.answer(session_id, payload.answer, owner_id=owner_id) + session = await service.answer( + session_id, payload.answer, answering_seq=payload.answering_seq, owner_id=owner_id + ) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Session not found", headers=correlated @@ -143,6 +146,15 @@ def _translate(exc: Exception, correlated: dict[str, str]) -> HTTPException | No detail=_UNREADABLE, headers=correlated, ) + if isinstance(exc, StaleAnswerError): + # The same 409 family as a closed session, and for the same reason: the request is well + # formed and the learner did nothing wrong — the question they answered is simply not the + # one in front of them any more. Re-reading the session is the recovery, not retrying. + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="That question has already been answered. Reload to catch up.", + headers=correlated, + ) if isinstance(exc, SessionClosedError): # The request is well formed and the session is real; it is the session's *state* that # refuses. A learner with a stale tab should be told it ended, not that they did something diff --git a/apps/api/src/lunaris_api/live/session/schemas.py b/apps/api/src/lunaris_api/live/session/schemas.py index eb5da89f..1cddaf68 100644 --- a/apps/api/src/lunaris_api/live/session/schemas.py +++ b/apps/api/src/lunaris_api/live/session/schemas.py @@ -35,3 +35,7 @@ class AnswerRequest(BaseModel): ) answer: str = Field(min_length=1, max_length=MAX_ANSWER_CHARS) + #: The turn the learner was looking at. Required, not inferred: a double-submit would otherwise + #: be graded against the question that replaced it, and the words would go into the record under + #: a criterion they were never written for. + answering_seq: int = Field(ge=1) diff --git a/apps/api/src/lunaris_api/live/session/service.py b/apps/api/src/lunaris_api/live/session/service.py index 5d1152c8..590bde72 100644 --- a/apps/api/src/lunaris_api/live/session/service.py +++ b/apps/api/src/lunaris_api/live/session/service.py @@ -1,4 +1,5 @@ import asyncio +from datetime import UTC, datetime from uuid import uuid4 import structlog @@ -93,7 +94,9 @@ async def start( logger.info("live.session.started", turn_count=len(session.turns)) return session - async def answer(self, session_id: str, answer: str, *, owner_id: str | None = None) -> Session: + async def answer( + self, session_id: str, answer: str, *, answering_seq: int, owner_id: str | None = None + ) -> Session: """Score what the learner said, move what the system believes, and take the next turn. Two writes, not one transaction, and the order is chosen for how each half fails. The @@ -105,6 +108,11 @@ async def answer(self, session_id: str, answer: str, *, owner_id: str | None = N ``_PULL`` / ``_MASTERED`` relationship exists to prevent: two pulls clear the mastery bar, so a single lucky guess plus a storage blip would unlock a dependent concept. + The session's age is measured from the row rather than from anything held in this process: + a session outlives the request that opened it and every process that has served it since, + and a clock that reset on a reload would let a learner extend a bounded session forever by + refreshing. + Raises ``FileNotFoundError`` (no such session for this learner), ``SessionClosedError`` (the director already ended it), and ``GraderUnavailableError`` / ``TutorUnavailableError`` when the turn could not be taken at all — nothing has moved in that case, so a retry means @@ -122,13 +130,25 @@ async def answer(self, session_id: str, answer: str, *, owner_id: str | None = N graph, known, answer=answer, + answering_seq=answering_seq, grader=self._grader, tutor=self._tutor, run_id=run_id, + # Clamped: ``SessionClock.elapsed_s`` is ``ge=0``, and a host whose clock steps + # backwards between opening a session and answering in it (NTP correction, a container + # resync) would otherwise fail the turn on a validation error the router cannot + # translate. Same guard ``recall_of`` applies to its own elapsed count. + elapsed_s=max(0.0, (datetime.now(UTC) - session.started_at).total_seconds()), budget_s=self._session_budget_s, ) - await asyncio.to_thread(self._sessions.save, outcome.session, owner_id=owner_id) + # Conditional on the session still being the length this request read. Two answers in + # flight at once both pass ``take_turn``'s check — they loaded the same head — and only the + # store can settle which one lands. The loser is a stale answer, which is what the learner + # is told (409), rather than a graded turn that quietly disappeared. + await asyncio.to_thread( + self._sessions.save, outcome.session, owner_id=owner_id, expect_turns=len(session.turns) + ) await asyncio.to_thread(self._knowledge.save, outcome.model, owner_id=owner_id) logger.info( diff --git a/apps/api/tests/live/test_live_session_service.py b/apps/api/tests/live/test_live_session_service.py index cd254e6e..726b275f 100644 --- a/apps/api/tests/live/test_live_session_service.py +++ b/apps/api/tests/live/test_live_session_service.py @@ -11,6 +11,8 @@ another's map — is closed a layer earlier by the graph store, which refuses the read outright. """ +from datetime import UTC, datetime, timedelta + import pytest from lunaris_api.live.session.service import LiveSessionService from lunaris_live.graph import ConceptGraph, MemoryGraphStore, StubGraphCompiler @@ -126,7 +128,13 @@ async def test_the_transcript_is_written_before_the_belief() -> None: ).start("g1", session_id="s1", owner_id="learner-1") class RefusesToWrite: - def save(self, session: Session, *, owner_id: str | None = None) -> None: + def save( + self, + session: Session, + *, + owner_id: str | None = None, + expect_turns: int | None = None, + ) -> None: raise PersistenceError("storage is having trouble") def load(self, session_id: str, *, owner_id: str | None = None) -> Session: @@ -143,7 +151,37 @@ def load(self, session_id: str, *, owner_id: str | None = None) -> Session: # Act with pytest.raises(PersistenceError): - await service.answer("s1", "I have no idea.", owner_id="learner-1") + await service.answer("s1", "I have no idea.", answering_seq=1, owner_id="learner-1") # Assert — the belief never landed, because the transcript never did. assert knowledge.load("g1", owner_id="learner-1").nodes == {} + + +async def test_a_clock_that_stepped_backwards_does_not_break_a_turn() -> None: + """Hosts correct their clocks. If the machine answering a turn has stepped behind the one that + opened the session, the elapsed time is negative — and ``SessionClock.elapsed_s`` is ``ge=0``, + so the turn would fail on a validation error no handler translates and the learner would get a + bare 500 for something entirely on our side.""" + # Arrange — a session stamped in the future, which is what a backward step looks like. + graph = await _map() + graphs, sessions, knowledge = MemoryGraphStore(), MemorySessionStore(), MemoryKnowledgeStore() + graphs.save(graph, owner_id="learner-1") + service = LiveSessionService( + graphs, + sessions, + knowledge=knowledge, + tutor=StubTutor(), + grader=StubGrader(), + session_budget_s=1800.0, + ) + opened = await service.start("g1", session_id="s1", owner_id="learner-1") + sessions.save( + opened.model_copy(update={"started_at": datetime.now(UTC) + timedelta(minutes=5)}), + owner_id="learner-1", + ) + + # Act + answered = await service.answer("s1", "An answer.", answering_seq=1, owner_id="learner-1") + + # Assert — the turn happened, treated as no time having passed. + assert answered.turns[0].answer == "An answer." diff --git a/apps/api/tests/live/test_live_sessions_api.py b/apps/api/tests/live/test_live_sessions_api.py index 49a3224c..a0d52adc 100644 --- a/apps/api/tests/live/test_live_sessions_api.py +++ b/apps/api/tests/live/test_live_sessions_api.py @@ -108,6 +108,22 @@ def load(self, session_id: str, *, owner_id: str | None = None) -> Session: yield http_client +@pytest.fixture +async def client_with_no_time_left(tmp_path: Path) -> AsyncIterator[httpx.AsyncClient]: + """The same app with a session budget already spent by the time anyone answers.""" + app = create_app() + app.dependency_overrides[get_settings] = lambda: Settings( + pipeline="stub", + course_dir=tmp_path, + cors_origins=(), + env_file=tmp_path / ".env", + live_session_budget_s=0.001, + ) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: + yield http_client + + @pytest.fixture async def client_with_a_broken_grader(tmp_path: Path) -> AsyncIterator[httpx.AsyncClient]: """The same app with a grader that cannot score — the provider being down, mid-session.""" @@ -269,8 +285,14 @@ async def test_a_tutor_that_cannot_speak_leaves_no_session_behind( assert resumed.status_code == 404 -async def _answer(client: httpx.AsyncClient, session_id: str, answer: str) -> httpx.Response: - return await client.post(f"/api/live/sessions/{session_id}/turns", json={"answer": answer}) +async def _answer( + client: httpx.AsyncClient, session_id: str, answer: str, *, seq: int = 1 +) -> httpx.Response: + """Answer the turn the learner is looking at. The seq is part of the contract (T6): a duplicate + submit must not be graded against the question that replaced the one it was written for.""" + return await client.post( + f"/api/live/sessions/{session_id}/turns", json={"answer": answer, "answeringSeq": seq} + ) async def test_answering_takes_the_session_to_its_next_turn(client: httpx.AsyncClient) -> None: @@ -304,8 +326,11 @@ async def test_what_a_learner_demonstrated_outlives_the_session(client: httpx.As opened = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() session_id, first_concept = opened["sessionId"], opened["turns"][0]["move"]["nodeId"] statement = opened["turns"][0]["criterion"]["statement"] + answered = opened for _ in range(3): - answered = (await _answer(client, session_id, statement)).json() + answered = ( + await _answer(client, session_id, statement, seq=answered["turns"][-1]["seq"]) + ).json() if answered["status"] != "active": break @@ -317,6 +342,33 @@ async def test_what_a_learner_demonstrated_outlives_the_session(client: httpx.As assert reopened["turns"][0]["move"]["nodeId"] != first_concept +async def test_a_session_out_of_time_says_goodbye_rather_than_going_quiet( + client_with_no_time_left: httpx.AsyncClient, +) -> None: + """The clock is wall time measured from the row (plan §6, AD9), so it is real across requests + and survives a reload — before T6 it was hardcoded to zero and the budget was a setting nothing + could reach. And the ending is a turn: ``status`` is a field, but the transcript is what the + learner reads, and a session that stopped talking is indistinguishable from one that crashed.""" + # Arrange + graph = await _graph(client_with_no_time_left) + opened = ( + await client_with_no_time_left.post( + "/api/live/sessions", json={"graphId": graph["graphId"]} + ) + ).json() + + # Act + session = (await _answer(client_with_no_time_left, opened["sessionId"], "Anything.")).json() + + # Assert — closed, with a goodbye that says why, and the answered turn still behind it. + assert session["status"] == "closed" + closing = session["turns"][-1] + assert closing["move"]["kind"] == "close" + assert closing["tutor"].strip() + assert "minutes are up" in closing["tutor"] + assert session["turns"][-2]["answer"] == "Anything." + + async def test_a_session_that_has_closed_does_not_take_another_answer( client: httpx.AsyncClient, ) -> None: @@ -328,7 +380,8 @@ async def test_a_session_that_has_closed_does_not_take_another_answer( session_id = opened["sessionId"] session = opened for _ in range(12): - session = (await _answer(client, session_id, session["turns"][-1]["tutor"])).json() + head = session["turns"][-1] + session = (await _answer(client, session_id, head["tutor"], seq=head["seq"])).json() if session["status"] == "closed": break assert session["status"] == "closed", "the director never ran out of material" @@ -340,6 +393,40 @@ async def test_a_session_that_has_closed_does_not_take_another_answer( assert response.status_code == 409, response.text +async def test_an_answer_to_a_question_that_has_moved_on_is_refused( + client: httpx.AsyncClient, +) -> None: + """A learner pressing send twice, or a second tab. Without the seq the duplicate is graded + against the question that replaced the one they were answering — the words land in the record + under a criterion they were never written for, and the belief that moves is the wrong one.""" + # Arrange — one answer given, so the session is on turn 2. + graph = await _graph(client) + opened = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() + await _answer(client, opened["sessionId"], "First attempt.", seq=1) + + # Act — the same submit arriving again. + response = await _answer(client, opened["sessionId"], "First attempt.", seq=1) + + # Assert — a conflict with the session's state, not with the learner: reloading is the recovery. + assert response.status_code == 409, response.text + resumed = (await client.get(f"/api/live/sessions/{opened['sessionId']}")).json() + assert len(resumed["turns"]) == 2, "the refused answer must not have taken a turn" + + +async def test_an_answer_that_names_no_turn_is_refused(client: httpx.AsyncClient) -> None: + """The seq is part of the contract, not an optimisation a client may skip: omitted, the server + would have to guess which question was being answered, which is the guess this prevents.""" + # Arrange + graph = await _graph(client) + opened = (await client.post("/api/live/sessions", json={"graphId": graph["graphId"]})).json() + + # Act / Assert + response = await client.post( + f"/api/live/sessions/{opened['sessionId']}/turns", json={"answer": "Something."} + ) + assert response.status_code == 422 + + async def test_an_answer_with_nothing_in_it_is_refused_at_the_door( client: httpx.AsyncClient, ) -> None: diff --git a/apps/web/src/lib/liveSession.test.ts b/apps/web/src/lib/liveSession.test.ts index 6bdf2127..1a8a0cf5 100644 --- a/apps/web/src/lib/liveSession.test.ts +++ b/apps/web/src/lib/liveSession.test.ts @@ -6,6 +6,7 @@ const SESSION = { sessionId: "s1", graphId: "g1", status: "active", + startedAt: "2026-08-09T21:00:00Z", turns: [ { seq: 1, @@ -70,7 +71,7 @@ describe("liveSession — opening and resuming a session", () => { }; const session = await withFetch(json(answered), () => - answerTurn("", "s1", "It pulls things together."), + answerTurn("", "s1", "It pulls things together.", 1), ); expect(session.turns).toHaveLength(2); diff --git a/apps/web/src/lib/liveSession.ts b/apps/web/src/lib/liveSession.ts index 25bcf10b..5ab3e6e3 100644 --- a/apps/web/src/lib/liveSession.ts +++ b/apps/web/src/lib/liveSession.ts @@ -52,6 +52,10 @@ export interface LiveSession { sessionId: string; graphId: string; status: "active" | "closed"; + /** When the session opened (ISO 8601, UTC). The session is bounded by wall time, so this is what + * a surface needs to show how much of it is left — and it survives a reload because it is on the + * row rather than in whichever process happened to serve the request. */ + startedAt: string; turns: SessionTurn[]; } @@ -100,11 +104,15 @@ export async function loadSession( /** Answer the criterion the last turn staged. Resolves with the WHOLE session, not just the new * turn: the answered turn changes too — it gains the learner's words and the verdict on them — and - * a surface stitching two shapes together is one that can disagree with the row behind it. */ + * a surface stitching two shapes together is one that can disagree with the row behind it. + * + * `answeringSeq` names the turn being answered, so a double-submit is refused (409) rather than + * graded against the question that replaced it. */ export async function answerTurn( apiBaseUrl: string, sessionId: string, answer: string, + answeringSeq: number, signal?: AbortSignal, ): Promise { return request( @@ -113,7 +121,7 @@ export async function answerTurn( { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ answer }), + body: JSON.stringify({ answer, answeringSeq }), ...(signal ? { signal } : {}), }, "Couldn't send that answer.", diff --git a/packages/live/src/lunaris_live/session/__init__.py b/packages/live/src/lunaris_live/session/__init__.py index f85c93d1..fa3025e0 100644 --- a/packages/live/src/lunaris_live/session/__init__.py +++ b/packages/live/src/lunaris_live/session/__init__.py @@ -36,6 +36,7 @@ from .session_closed_error import SessionClosedError from .session_format_error import SessionFormatError from .stage_criterion import stage_criterion +from .stale_answer_error import StaleAnswerError from .stub_grader import StubGrader from .stub_tutor import StubTutor from .supabase_knowledge_store import SupabaseKnowledgeStore @@ -66,6 +67,7 @@ "SessionFormatError", "SessionStatus", "SessionTurn", + "StaleAnswerError", "StubGrader", "StubTutor", "SupabaseKnowledgeStore", diff --git a/packages/live/src/lunaris_live/session/decide_move.py b/packages/live/src/lunaris_live/session/decide_move.py index ef3bd016..6951e567 100644 --- a/packages/live/src/lunaris_live/session/decide_move.py +++ b/packages/live/src/lunaris_live/session/decide_move.py @@ -41,8 +41,8 @@ def decide_move(graph: ConceptGraph, model: LearnerModel, clock: SessionClock) - return DirectorMove( kind=MoveKind.CLOSE, reason=( - f"The session's {round(clock.budget_s / 60)} minutes are up. Stopping here so it " - "ends on a recap rather than mid-explanation." + f"The session's {round(clock.budget_s / 60)} minutes are up — better to end on a " + "recap than mid-explanation." ), ) diff --git a/packages/live/src/lunaris_live/session/memory_session_store.py b/packages/live/src/lunaris_live/session/memory_session_store.py index 1afe2598..b2bf227c 100644 --- a/packages/live/src/lunaris_live/session/memory_session_store.py +++ b/packages/live/src/lunaris_live/session/memory_session_store.py @@ -1,4 +1,7 @@ +import threading + from .schema import Session +from .stale_answer_error import StaleAnswerError class MemorySessionStore: @@ -13,10 +16,23 @@ def __init__(self) -> None: # Kept parallel to the sessions so ``Session`` stays a clean wire contract with no owner # field on it — the same split the in-memory cost store uses. self._owners: dict[str, str | None] = {} + # The service hops these calls onto worker threads, so two answers in flight really are two + # threads. The lock makes the check and the write one step; without it the compare-and-set + # below is a race with a smaller window rather than a guarantee. + self._lock = threading.Lock() - def save(self, session: Session, *, owner_id: str | None = None) -> None: - self._sessions[session.session_id] = session - self._owners[session.session_id] = owner_id + def save( + self, session: Session, *, owner_id: str | None = None, expect_turns: int | None = None + ) -> None: + with self._lock: + if expect_turns is not None: + stored = self._sessions.get(session.session_id) + if stored is not None and len(stored.turns) != expect_turns: + raise StaleAnswerError( + f"session {session.session_id} has moved on to {len(stored.turns)} turns" + ) + self._sessions[session.session_id] = session + self._owners[session.session_id] = owner_id def load(self, session_id: str, *, owner_id: str | None = None) -> Session: session = self._sessions.get(session_id) diff --git a/packages/live/src/lunaris_live/session/open_session.py b/packages/live/src/lunaris_live/session/open_session.py index 897a9566..0dc0669d 100644 --- a/packages/live/src/lunaris_live/session/open_session.py +++ b/packages/live/src/lunaris_live/session/open_session.py @@ -1,3 +1,5 @@ +from datetime import UTC, datetime + from ..graph import ConceptGraph from .decide_move import decide_move from .protocols import ITutor @@ -40,6 +42,8 @@ async def open_session( return Session( session_id=session_id, graph_id=graph.graph_id, + # Stamped once, here, at the only moment a session is born. Every later read carries it. + started_at=datetime.now(UTC), turns=[ SessionTurn( seq=clock.turn, diff --git a/packages/live/src/lunaris_live/session/protocols/session_store.py b/packages/live/src/lunaris_live/session/protocols/session_store.py index 3c313527..4c2f240b 100644 --- a/packages/live/src/lunaris_live/session/protocols/session_store.py +++ b/packages/live/src/lunaris_live/session/protocols/session_store.py @@ -14,8 +14,17 @@ class ISessionStore(Protocol): ``owner_id`` is the authenticated learner. ``load`` raises ``FileNotFoundError`` when there is no such session **for that owner** — another learner's session is not-found rather than forbidden, because its existence is itself owner-scoped information. + + ``expect_turns`` makes a save a compare-and-set: the write lands only if the stored session is + still the length the caller read. It is what stops two answers submitted at once from silently + losing one — both would read the same head, both would pass an in-memory staleness check, and + the second write would overwrite the first along with the model calls that produced it. ``None`` + means an unconditional write, which is right exactly once: when the session is created. + Raises ``StaleAnswerError`` when the row has moved on. """ - def save(self, session: Session, *, owner_id: str | None = None) -> None: ... + def save( + self, session: Session, *, owner_id: str | None = None, expect_turns: int | None = None + ) -> None: ... def load(self, session_id: str, *, owner_id: str | None = None) -> Session: ... diff --git a/packages/live/src/lunaris_live/session/schema/session.py b/packages/live/src/lunaris_live/session/schema/session.py index afba0ead..daca3c06 100644 --- a/packages/live/src/lunaris_live/session/schema/session.py +++ b/packages/live/src/lunaris_live/session/schema/session.py @@ -1,3 +1,5 @@ +from datetime import datetime + from pydantic import Field from ...graph.schema.base import LiveModel @@ -19,4 +21,14 @@ class Session(LiveModel): session_id: str = Field(min_length=1, max_length=100) graph_id: str = Field(min_length=1, max_length=100) status: SessionStatus = SessionStatus.ACTIVE + #: When the session opened, in UTC. The budget is wall time (plan §6, AD9), and wall time is the + #: one thing a resumed session cannot recover from its own turns — so it is stamped here and + #: carried in the row rather than held in the process, which a reload would reset. + #: + #: Required, with no default, and that is the load-bearing part. A default would run afresh on + #: every parse, so a row stored before this field existed would be re-stamped "now" on each + #: read — handing a session that was one turn from its budget a whole new one, every time it + #: was reloaded. Unreadable is the honest answer for a session nobody can date: it surfaces as + #: ``SessionFormatError`` (a 500 that says so) rather than as an unbounded session. + started_at: datetime turns: list[SessionTurn] = Field(default_factory=list) diff --git a/packages/live/src/lunaris_live/session/stale_answer_error.py b/packages/live/src/lunaris_live/session/stale_answer_error.py new file mode 100644 index 00000000..1b6e4eca --- /dev/null +++ b/packages/live/src/lunaris_live/session/stale_answer_error.py @@ -0,0 +1,12 @@ +class StaleAnswerError(RuntimeError): + """An answer arrived for a turn the session has already moved past. + + A learner pressing send twice, or a second tab answering while the first was still open. + Left unguarded the duplicate is graded against the *next* question: the words are recorded + under a criterion they were never written for, and the belief that moves is about the wrong + concept — which the transcript then reports as though the learner had said it in reply to + that question. + + Not the learner doing anything wrong: they answered the thing they were shown. The session + simply is not there any more. + """ diff --git a/packages/live/src/lunaris_live/session/supabase_session_store.py b/packages/live/src/lunaris_live/session/supabase_session_store.py index 9c84a837..a996018b 100644 --- a/packages/live/src/lunaris_live/session/supabase_session_store.py +++ b/packages/live/src/lunaris_live/session/supabase_session_store.py @@ -5,6 +5,7 @@ from .schema import Session from .session_format_error import SessionFormatError +from .stale_answer_error import StaleAnswerError _URL_ENV = "SUPABASE_URL" _SERVICE_KEY_ENV = "SUPABASE_SERVICE_ROLE_KEY" @@ -50,7 +51,17 @@ def _ensure_client(self) -> object: return self._client @guard("live_sessions upsert") - def save(self, session: Session, *, owner_id: str | None = None) -> None: + def save( + self, session: Session, *, owner_id: str | None = None, expect_turns: int | None = None + ) -> None: + """Write the session's head, optionally only if it is still the one the caller read. + + ``expect_turns`` turns this into a compare-and-set against ``turn_count``, which is why that + column was lifted out of the payload in the first place. Two answers submitted at once both + read the same head and both pass any in-memory staleness check; without the condition here + the second write silently overwrites the first, losing a graded answer and the model calls + that produced it. Postgres settles it: the second UPDATE matches no row. + """ client = self._ensure_client() row: dict[str, object] = { "id": session.session_id, @@ -63,7 +74,19 @@ def save(self, session: Session, *, owner_id: str | None = None) -> None: } if owner_id is not None: row["user_id"] = owner_id - client.table(_TABLE).upsert(row, on_conflict="id").execute() # type: ignore[attr-defined] + + if expect_turns is None: + client.table(_TABLE).upsert(row, on_conflict="id").execute() # type: ignore[attr-defined] + return + + query = client.table(_TABLE).update(row).eq("id", session.session_id) # type: ignore[attr-defined] + # Scoped both ways, like the read: the service-role client bypasses RLS, so an update that + # only matched on the id could rewrite another learner's session. + query = query.is_("user_id", None) if owner_id is None else query.eq("user_id", owner_id) + if not query.eq("turn_count", expect_turns).execute().data: + raise StaleAnswerError( + f"session {session.session_id} is no longer at {expect_turns} turns" + ) @guard("live_sessions load") def load(self, session_id: str, *, owner_id: str | None = None) -> Session: diff --git a/packages/live/src/lunaris_live/session/take_turn.py b/packages/live/src/lunaris_live/session/take_turn.py index ddd1de57..67b83cf0 100644 --- a/packages/live/src/lunaris_live/session/take_turn.py +++ b/packages/live/src/lunaris_live/session/take_turn.py @@ -18,10 +18,16 @@ ) from .session_closed_error import SessionClosedError from .stage_criterion import stage_criterion +from .stale_answer_error import StaleAnswerError from .turn_outcome import TurnOutcome logger = structlog.get_logger() +#: How a session signs off. Deliberately plain and deliberately not generated: a goodbye is the one +#: turn with nothing to teach, and P2c replaces it with the real ceremony (recap, mastery delta, +#: what to come back to) rather than with better prose. +_CLOSING = "That's where we'll stop for today." + async def take_turn( session: Session, @@ -29,9 +35,11 @@ async def take_turn( model: LearnerModel, *, answer: str, + answering_seq: int, grader: IGrader, tutor: ITutor, run_id: str, + elapsed_s: float, budget_s: float, ) -> TurnOutcome: """The loop, once: score what the learner just said, move the belief, decide what happens next. @@ -46,7 +54,12 @@ async def take_turn( and the session still advances, because stranding somebody on a concept the map cannot yet check would be the map's problem made into the learner's. - Raises ``SessionClosedError`` on a session the director has already ended, and + ``answering_seq`` is the turn the learner was looking at when they answered. It is named rather + than assumed, because a duplicate submit would otherwise be graded against the question that + replaced it — recorded under a criterion it was never written for. + + Raises ``StaleAnswerError`` when the named turn is not the one in front of the learner, + ``SessionClosedError`` on a session the director has already ended, and ``GraderUnavailableError`` / ``TutorUnavailableError`` when a turn could not be taken at all — in which case nothing has moved and the caller can offer the learner a retry that means something. @@ -57,6 +70,11 @@ async def take_turn( raise ValueError(f"session {session.session_id} has no turn to answer") asked = session.turns[-1] + if answering_seq != asked.seq: + raise StaleAnswerError( + f"answer names turn {answering_seq}; {session.session_id} is on turn {asked.seq}" + ) + said = answer.strip()[:MAX_ANSWER_CHARS] graded = await _grade(asked, graph, said=said, grader=grader, run_id=run_id) @@ -65,7 +83,7 @@ async def take_turn( model = _moved_by(model, asked, graded) turns = [*session.turns[:-1], asked.model_copy(update={"answer": said, "grade": graded})] - clock = SessionClock(turn=len(turns) + 1, elapsed_s=0.0, budget_s=budget_s) + clock = SessionClock(turn=len(turns) + 1, elapsed_s=elapsed_s, budget_s=budget_s) move = decide_move(graph, model, clock) if move.kind is MoveKind.CLOSE: return TurnOutcome(session=_closed(session, turns, move, run_id=run_id), model=model) @@ -95,9 +113,17 @@ def _moved_by(model: LearnerModel, asked: SessionTurn, graded: TurnGrade | None) def _closed( session: Session, turns: list[SessionTurn], move: DirectorMove, *, run_id: str ) -> Session: - """The session, ended. The closing words are P2c's ceremony; what T5 owes is that a session - which has run out of material stops rather than looping, and that the last thing left in the - transcript is still the turn the learner answered.""" + """The session, ended — and said out loud. + + The goodbye is a turn rather than only a status, because ``status`` is a field and the + transcript is what the learner reads: a session that ended by going quiet is indistinguishable + from one that crashed. It is written deterministically rather than by the tutor, which teaches + concepts and is deliberately refused a CLOSE — a close is about the session, not a concept. + + The recap, the mastery delta and the spaced-retrieval schedule are P2c's ceremony. What is owed + here is that a session which has run out of material or out of time stops rather than looping, + ends visibly, and says why. + """ logger.info( "live.session.closed", run_id=run_id, @@ -105,7 +131,15 @@ def _closed( reason=move.reason, turn_count=len(turns), ) - return session.model_copy(update={"turns": turns, "status": SessionStatus.CLOSED}) + goodbye = SessionTurn( + seq=len(turns) + 1, + move=move, + # The director's own reason, verbatim: the learner is owed the same explanation the trace + # gets, and two wordings of one decision is how the two come to disagree. + tutor=f"{_CLOSING} {move.reason}", + run_id=run_id, + ) + return session.model_copy(update={"turns": [*turns, goodbye], "status": SessionStatus.CLOSED}) async def _teach( diff --git a/packages/live/tests/test_session_lifecycle.py b/packages/live/tests/test_session_lifecycle.py new file mode 100644 index 00000000..a15b8a57 --- /dev/null +++ b/packages/live/tests/test_session_lifecycle.py @@ -0,0 +1,207 @@ +"""A session's life: its clock, its ending, and answering the turn you were actually shown (T6). + +T5 made the loop turn; this makes it a *session* — something with a beginning, a bounded middle and +a deliberate end that a learner can read. Three claims live here: + +- The clock is wall time measured from when the session opened, so it survives a reload. Measured in + turns it would let a session of long, slow turns run for hours (AD9); held in the process it would + reset every time the tab did. +- The close is a turn, not just a status. A session that ended by going silent is indistinguishable + from one that crashed, and `status` is not something a transcript shows. +- An answer names the turn it is answering. Otherwise a double-submit answers the *next* question + with the previous question's words, and neither the learner nor the trace can tell. +""" + +import pytest +from lunaris_live.graph import ( + ConceptGraph, + ConceptNode, + MasteryCriterion, + MasteryCriterionKind, + TeachingSpec, +) +from lunaris_live.session import ( + LearnerModel, + MoveKind, + SessionClock, + SessionStatus, + StaleAnswerError, + StubGrader, + StubTutor, + open_session, + take_turn, +) + +_BUDGET_S = 1800.0 + + +def _node(node_id: str, name: str, *, requires: list[str] | None = None) -> ConceptNode: + return ConceptNode( + id=node_id, + name=name, + definition=f"What {name} is.", + requires=requires or [], + teaching_spec=TeachingSpec(objective=f"Use {name}.", misconceptions=[f"{name} is magic."]), + mastery_criteria=[ + MasteryCriterion( + kind=MasteryCriterionKind.EXPLAIN, statement=f"Explain {name} in your own words." + ) + ], + ) + + +def _graph() -> ConceptGraph: + return ConceptGraph( + graph_id="g1", + topic="A subject", + nodes=[_node("a", "Alpha"), _node("b", "Beta", requires=["a"])], + topo_order=["a", "b"], + is_acyclic=True, + ) + + +async def _opened(): + return await open_session( + _graph(), + LearnerModel(graph_id="g1"), + SessionClock(turn=1, elapsed_s=0.0, budget_s=_BUDGET_S), + session_id="s1", + run_id="r1", + tutor=StubTutor(), + ) + + +async def _answer( + session, *, answer: str = "No idea.", elapsed_s: float = 10.0, seq: int | None = None +): + return await take_turn( + session, + _graph(), + LearnerModel(graph_id="g1"), + answer=answer, + answering_seq=session.turns[-1].seq if seq is None else seq, + grader=StubGrader(), + tutor=StubTutor(), + run_id="r2", + elapsed_s=elapsed_s, + budget_s=_BUDGET_S, + ) + + +# ── the clock ────────────────────────────────────────────────────────────────────────────────── + + +async def test_a_session_records_when_it_began() -> None: + """The budget is wall time (plan §6, AD9), and wall time cannot be recovered from the turns — + so the one thing a resumed session needs in order to know how long it has left is stamped when + it opens and carried in the row.""" + # Act + session = await _opened() + + # Assert + assert session.started_at is not None + assert session.started_at.tzinfo is not None, ( + "a naive timestamp cannot be compared across hosts" + ) + + +async def test_a_session_past_its_budget_closes_on_the_next_answer() -> None: + """The clock rule the director has always had, now reachable: before this, ``elapsed_s`` was + hardcoded to zero, so the budget was a setting nothing could ever hit.""" + # Act — the learner answers well past the session's whole budget. + outcome = await _answer(await _opened(), elapsed_s=_BUDGET_S + 1) + + # Assert + assert outcome.session.status is SessionStatus.CLOSED + assert outcome.session.turns[-1].move.kind is MoveKind.CLOSE + + +async def test_a_session_inside_its_budget_keeps_going() -> None: + """The boundary in the other direction, so the rule above is a rule and not a constant.""" + # Act + outcome = await _answer(await _opened(), elapsed_s=1.0) + + # Assert + assert outcome.session.status is SessionStatus.ACTIVE + + +# ── the ending ───────────────────────────────────────────────────────────────────────────────── + + +async def test_a_closed_session_ends_on_something_the_learner_can_read() -> None: + """A session that ends by going quiet is indistinguishable from one that crashed. ``status`` is + a field; the transcript is what the learner sees, so the ending has to be in the transcript. + + The recap, the mastery delta and the spaced-retrieval schedule are P2c's ceremony. What T6 owes + is that the session says goodbye at all, and says why it is ending. + """ + # Act + outcome = await _answer(await _opened(), elapsed_s=_BUDGET_S + 1) + + # Assert + closing = outcome.session.turns[-1] + assert closing.move.kind is MoveKind.CLOSE + assert closing.tutor.strip() + assert closing.criterion is None, "a closing turn asks for nothing — nothing follows it" + assert closing.run_id == "r2" + + +async def test_the_answered_turn_survives_the_ending() -> None: + """The last thing the learner did still has to be in the record they scrolled back through.""" + # Act + outcome = await _answer(await _opened(), answer="No idea.", elapsed_s=_BUDGET_S + 1) + + # Assert — the answered turn, then the goodbye. + answered = outcome.session.turns[-2] + assert answered.answer == "No idea." + assert answered.grade is not None + + +async def test_a_close_is_the_last_word() -> None: + """Nothing may follow it: the session is closed, and ``take_turn`` refuses a closed session.""" + # Arrange + closed = (await _answer(await _opened(), elapsed_s=_BUDGET_S + 1)).session + + # Act / Assert + with pytest.raises(Exception) as refused: + await _answer(closed) + assert "closed" in str(refused.value) + + +# ── answering the turn you were shown ────────────────────────────────────────────────────────── + + +async def test_an_answer_names_the_turn_it_is_answering() -> None: + """The happy path, so the guard below is a guard and not a wall.""" + # Act + outcome = await _answer(await _opened(), seq=1) + + # Assert + assert outcome.session.turns[0].answer == "No idea." + + +async def test_an_answer_to_a_question_that_has_moved_on_is_refused() -> None: + """A learner pressing send twice, or a tab left open while another one answered. Without this + the second copy is graded against the *next* question — the words are recorded under a criterion + they were never written for, and the belief that moves is about the wrong concept.""" + # Arrange — one answer has already been given, so the session is on turn 2. + session = (await _answer(await _opened(), seq=1)).session + + # Act / Assert — the same submit arriving again, still naming turn 1. + with pytest.raises(StaleAnswerError): + await _answer(session, seq=1) + + +async def test_a_refused_answer_changes_nothing() -> None: + """The point of refusing is that the learner can be told and can retry; a partial application + would leave a belief moved by an answer the session never accepted.""" + # Arrange + session = (await _answer(await _opened(), seq=1)).session + before = session.model_dump(mode="json") + + # Act + with pytest.raises(StaleAnswerError): + await _answer(session, seq=1) + + # Assert + assert session.model_dump(mode="json") == before diff --git a/packages/live/tests/test_session_stores.py b/packages/live/tests/test_session_stores.py index edcffd8e..d74ecede 100644 --- a/packages/live/tests/test_session_stores.py +++ b/packages/live/tests/test_session_stores.py @@ -9,6 +9,7 @@ the loop writes through the service-role client, which bypasses RLS. """ +from datetime import UTC, datetime from typing import Any import pytest @@ -19,6 +20,7 @@ Session, SessionFormatError, SessionTurn, + StaleAnswerError, SupabaseSessionStore, ) from lunaris_runtime.persistence import PersistenceError @@ -28,6 +30,7 @@ def _session(session_id: str = "s1") -> Session: return Session( session_id=session_id, graph_id="g1", + started_at=datetime(2026, 8, 9, 21, 0, tzinfo=UTC), turns=[ SessionTurn( seq=1, @@ -179,6 +182,7 @@ def test_a_session_this_build_cannot_parse_is_not_an_outage() -> None: "sessionId": "s1", "graphId": "g1", "status": "active", + "startedAt": "2026-08-09T21:00:00Z", "turns": [ { "seq": 1, @@ -194,6 +198,35 @@ def test_a_session_this_build_cannot_parse_is_not_an_outage() -> None: store.load("s1", owner_id=None) +def test_a_session_nobody_can_date_is_unreadable_rather_than_re_clocked() -> None: + """``started_at`` is required and has no default, and this is why. + + A default would run afresh on every parse, so a row stored before the field existed would be + stamped "now" on each read — handing a session one turn from its budget a whole new one, every + time it was reloaded. That is the unbounded session the budget exists to prevent, arriving + silently. Unreadable is the honest answer for a session nobody can date. + """ + # Arrange — a session written before sessions were dated. + undated = { + "sessionId": "s1", + "graphId": "g1", + "status": "active", + "turns": [ + { + "seq": 1, + "move": {"kind": "introduce", "nodeId": "a", "reason": "Opening concept."}, + "tutor": "Let's start with A.", + "runId": "r1", + } + ], + } + store = SupabaseSessionStore(client=FakeSupabase({"payload": undated})) + + # Act / Assert + with pytest.raises(SessionFormatError): + store.load("s1", owner_id=None) + + def test_a_readable_row_still_loads_through_the_same_path() -> None: """The guard above must not be a wall: the ordinary row goes through untouched.""" # Arrange @@ -216,3 +249,52 @@ def test_a_format_failure_is_still_a_persistence_failure_to_anyone_not_looking_f # Act / Assert with pytest.raises(PersistenceError): store.load("s1", owner_id=None) + + +# ── two answers at once ──────────────────────────────────────────────────────────────────────── + + +def test_a_write_conditioned_on_a_head_that_has_moved_is_refused() -> None: + """The concurrent double-submit, settled where it can actually be settled. + + Two answers sent at the same moment both read a one-turn session, so both pass every check made + against the snapshot they hold — the staleness guard in ``take_turn`` sees identical, correct + values in each request. Only the store knows which one arrived second, and without this it + silently overwrites the first: a graded answer and the model calls behind it vanish from the + transcript, with nobody told. + """ + # Arrange — the first answer has landed, so the stored session is two turns long. + store = MemorySessionStore() + store.save(_session(), owner_id="learner-1") + grown = _session().model_copy( + update={ + "turns": [ + *_session().turns, + SessionTurn( + seq=2, + move=DirectorMove(kind=MoveKind.RETRIEVE, node_id="a", reason="Coming back."), + tutor="What happens when it doubles?", + run_id="r2", + ), + ] + } + ) + store.save(grown, owner_id="learner-1", expect_turns=1) + + # Act / Assert — the second request still believes the session is one turn long. + with pytest.raises(StaleAnswerError): + store.save(grown, owner_id="learner-1", expect_turns=1) + + # And the winner is untouched. + assert len(store.load("s1", owner_id="learner-1").turns) == 2 + + +def test_creating_a_session_is_the_one_unconditional_write() -> None: + """``expect_turns=None`` is not a loophole — it is the only moment there is nothing to compare + against, because the session did not exist a moment ago.""" + # Arrange / Act + store = MemorySessionStore() + store.save(_session(), owner_id="learner-1") + + # Assert + assert store.load("s1", owner_id="learner-1").session_id == "s1" diff --git a/packages/live/tests/test_take_turn.py b/packages/live/tests/test_take_turn.py index 4c22b612..4e4bf5f2 100644 --- a/packages/live/tests/test_take_turn.py +++ b/packages/live/tests/test_take_turn.py @@ -80,9 +80,11 @@ async def _answered(answer: str, *, model: LearnerModel | None = None): _graph(), model or LearnerModel(graph_id="g1"), answer=answer, + answering_seq=1, grader=StubGrader(), tutor=StubTutor(), run_id="r2", + elapsed_s=0.0, budget_s=_BUDGET_S, ) @@ -182,9 +184,11 @@ async def test_a_learner_who_keeps_missing_is_not_marched_onward() -> None: _graph(), first.model, answer="Still no idea.", + answering_seq=first.session.turns[-1].seq, grader=StubGrader(), tutor=StubTutor(), run_id="r3", + elapsed_s=0.0, budget_s=_BUDGET_S, ) @@ -220,18 +224,22 @@ async def test_a_map_with_nothing_left_to_teach_closes_rather_than_looping() -> single, model, answer="I can explain Alpha: it is what Alpha is.", + answering_seq=session.turns[-1].seq, grader=StubGrader(), tutor=StubTutor(), run_id=f"r{index + 2}", + elapsed_s=0.0, budget_s=_BUDGET_S, ) session, model = outcome.session, outcome.model if session.status is SessionStatus.CLOSED: break - # Assert — closed, and the last thing in the transcript is still the answered turn. + # Assert — closed, ending on the goodbye, with the answered turn still behind it (T6 makes the + # ending a turn the learner can read rather than a status change they cannot see). assert session.status is SessionStatus.CLOSED - assert session.turns[-1].answer is not None + assert session.turns[-1].move.kind is MoveKind.CLOSE + assert session.turns[-2].answer is not None # ── the tutor is told what it has already said ───────────────────────────────────────────────── @@ -267,9 +275,11 @@ async def teach(self, move, node, *, topic, criterion=None, already_said=(), run _graph(), LearnerModel(graph_id="g1"), answer="No idea, sorry.", + answering_seq=1, grader=StubGrader(), tutor=tutor, run_id="r2", + elapsed_s=0.0, budget_s=_BUDGET_S, ) @@ -280,9 +290,11 @@ async def teach(self, move, node, *, topic, criterion=None, already_said=(), run _graph(), outcome.model, answer=reply, + answering_seq=outcome.session.turns[-1].seq, grader=StubGrader(), tutor=tutor, run_id=f"r{index + 3}", + elapsed_s=0.0, budget_s=_BUDGET_S, ) @@ -318,9 +330,11 @@ async def grade(self, answer, *, criterion, node, run_id): _graph(), model, answer="A real attempt at an answer.", + answering_seq=session.turns[-1].seq, grader=BrokenGrader(), tutor=StubTutor(), run_id="r2", + elapsed_s=0.0, budget_s=_BUDGET_S, ) @@ -350,9 +364,11 @@ async def teach(self, move, node, *, topic, criterion=None, already_said=(), run _graph(), model, answer="No idea, sorry.", + answering_seq=session.turns[-1].seq, grader=StubGrader(), tutor=SilentTutor(), run_id="r2", + elapsed_s=0.0, budget_s=_BUDGET_S, ) assert model.nodes == {} @@ -375,9 +391,11 @@ async def test_a_closed_session_does_not_take_another_turn() -> None: _graph(), LearnerModel(graph_id="g1"), answer="Anything.", + answering_seq=closed.turns[-1].seq, grader=StubGrader(), tutor=StubTutor(), run_id="r2", + elapsed_s=0.0, budget_s=_BUDGET_S, ) @@ -423,9 +441,11 @@ async def test_a_turn_that_asked_nothing_gradeable_still_moves_the_session_on() sim_only, LearnerModel(graph_id="g1"), answer="I think it blows up.", + answering_seq=1, grader=StubGrader(), tutor=StubTutor(), run_id="r2", + elapsed_s=0.0, budget_s=_BUDGET_S, ) From 8252e7e2e9ffe9d8962cb0559126e19fa3bcbac0 Mon Sep 17 00:00:00 2001 From: Pouyan Jahangiri Date: Sun, 9 Aug 2026 23:48:11 -0700 Subject: [PATCH 7/9] feat(live): a surface a session can actually be held on (Phase 2a, T7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/live/AnswerForm.module.css | 76 +++++ apps/web/src/components/live/AnswerForm.tsx | 92 +++++ .../src/components/live/LiveShell.module.css | 81 +++++ .../src/components/live/LiveShell.test.tsx | 60 ++++ apps/web/src/components/live/LiveShell.tsx | 51 ++- .../live/SessionTranscript.module.css | 114 +++++++ .../src/components/live/SessionTranscript.tsx | 82 +++++ .../components/live/SessionView.module.css | 137 ++++++++ .../src/components/live/SessionView.test.tsx | 314 ++++++++++++++++++ apps/web/src/components/live/SessionView.tsx | 116 +++++++ apps/web/src/hooks/useLiveSession.test.ts | 144 ++++++++ apps/web/src/hooks/useLiveSession.ts | 85 +++++ apps/web/src/lib/liveSession.ts | 4 + apps/web/src/test/fixtures.ts | 4 +- 14 files changed, 1345 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/live/AnswerForm.module.css create mode 100644 apps/web/src/components/live/AnswerForm.tsx create mode 100644 apps/web/src/components/live/SessionTranscript.module.css create mode 100644 apps/web/src/components/live/SessionTranscript.tsx create mode 100644 apps/web/src/components/live/SessionView.module.css create mode 100644 apps/web/src/components/live/SessionView.test.tsx create mode 100644 apps/web/src/components/live/SessionView.tsx create mode 100644 apps/web/src/hooks/useLiveSession.test.ts create mode 100644 apps/web/src/hooks/useLiveSession.ts diff --git a/apps/web/src/components/live/AnswerForm.module.css b/apps/web/src/components/live/AnswerForm.module.css new file mode 100644 index 00000000..47ff3711 --- /dev/null +++ b/apps/web/src/components/live/AnswerForm.module.css @@ -0,0 +1,76 @@ +/* Welded to the transcript above it rather than floating below: the answer is part of the record, + not a separate widget. */ +.form { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-4) var(--space-5); + background: var(--surface); + border: var(--border-width) solid var(--border); + border-top: none; + border-radius: 0 0 var(--radius-md) var(--radius-md); +} + +.label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-muted); +} + +.box { + width: 100%; + padding: var(--space-3); + font-family: inherit; + font-size: var(--text-sm); + line-height: var(--lh-sm); + color: var(--text); + background: var(--bg); + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius-sm); + resize: vertical; +} + +.box:focus-visible { + outline: none; + /* A ring rather than an outline, so it follows the radius. */ + box-shadow: 0 0 0 2px var(--focus-ring); + border-color: var(--focus-ring); +} + +.box:disabled { + color: var(--text-muted); + background: var(--bg-muted); + cursor: not-allowed; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.hint { + display: flex; + align-items: center; + gap: var(--space-1); + margin: 0; + font-size: var(--text-xs); + color: var(--text-muted); +} + +.hint[role="alert"] { + color: var(--danger); +} + +.kbd { + padding: 1px var(--space-1); + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + background: var(--bg-muted); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); +} diff --git a/apps/web/src/components/live/AnswerForm.tsx b/apps/web/src/components/live/AnswerForm.tsx new file mode 100644 index 00000000..c56c53d2 --- /dev/null +++ b/apps/web/src/components/live/AnswerForm.tsx @@ -0,0 +1,92 @@ +import { useId, useRef, useState, type KeyboardEvent } from "react"; + +import { MAX_ANSWER_CHARS } from "../../lib/liveSession"; +import { Button } from "../primitives/Button"; +import styles from "./AnswerForm.module.css"; + +interface AnswerFormProps { + /** What the learner is being asked to demonstrate, or null when the turn stages nothing. */ + criterion: string | null; + /** True while an answer is being marked — the box locks so one answer cannot be sent twice. */ + busy: boolean; + onAnswer: (text: string) => void; +} + +/** Where the learner replies. A textarea, because an answer is prose and the whole loop rests on + * them being able to say it in their own words. + * + * Enter stays a newline and ⌘/Ctrl+Enter sends: a stray Return mid-thought that submitted the + * answer would be the surface answering for them. Submit is never pre-disabled — a form that + * greys out its own button hides the reason it is not ready — so an empty send explains itself + * instead. */ +export function AnswerForm({ criterion, busy, onAnswer }: AnswerFormProps) { + const [text, setText] = useState(""); + const [error, setError] = useState(null); + const boxId = useId(); + const errorId = useId(); + const box = useRef(null); + + const submit = () => { + if (busy) return; + if (!text.trim()) { + setError("Write something first — even a guess is worth marking."); + box.current?.focus(); + return; + } + setError(null); + onAnswer(text.trim()); + setText(""); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + submit(); + } + }; + + return ( +
{ + event.preventDefault(); + submit(); + }} + > + +