Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ export type SessionHistory = { sessions: SessionHistoryItem[] };

// Practice. Nothing but PracticeReveal carries the answer.

export type PracticeTopic = "addition_subtraction" | "multiplication" | "division" | "expanding_brackets" | "combining_like_terms" | "solving_equations" | "power_rule" | "sum_and_constant_rules" | "integration";
export type PracticeTopic = "addition_subtraction" | "multiplication" | "division" | "expanding_brackets" | "combining_like_terms" | "solving_equations" | "power_rule" | "sum_and_constant_rules" | "integration" | "chain_rule";
export type PracticeOutcome = "in_progress" | "incorrect" | "correct";

export type PracticeProblem = {
Expand Down
39 changes: 39 additions & 0 deletions apps/web/tests/e2e/learning.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,45 @@ test.beforeEach(async () => {
});

test.describe("the journeys", () => {
test("chain-rule correction leads to verified follow-up practice", async ({ page }) => {
test.setTimeout(180_000);
await signUp(page);
await script({ reading: reading(first("d/dx(sin(x^2)) = cos(x^2)")) });
await write(page, "d/dx(sin(x^2)) = cos(x^2)", 80, 80, 24);
await expectDuck(page, "alert");
await duck(page).click();
await expect(page.getByTestId("hint-panel")).toContainText("function inside another");
await expect(page.getByTestId("hint-panel")).not.toContainText("2x");
await script({ reading: reading(first("d/dx(sin(x^2)) = cos(x^2)"), first("d/dx(sin(x^2)) = 2x*cos(x^2)")) });
await write(page, "d/dx(sin(x^2)) = 2x*cos(x^2)", 80, 160, 24);
await expect(bubble(page)).toHaveText("That's it.");
await settle(page, 500);
await openMenu(page, "menu-mistakes");
await entries(page).click();
await expect(page.getByTestId("mistake-practise")).toBeVisible();
await page.getByTestId("mistakes-close").click();
await page.getByTestId("finish-session").click();
await expect(page.getByTestId("recommendation")).toContainText("Chain rule");
await page.getByTestId("practice-this").click();
const prompt = page.getByTestId("practice-prompt");
await expect(prompt).toContainText("d/dx(");
const match = (await prompt.innerText()).match(/d\/dx\((sin|cos|exp)\((\d+)x\)\)/);
expect(match).not.toBeNull();
const [, family, scale] = match!;
const outer = family === "sin" ? `cos(${scale}x)` : family === "cos" ? `-sin(${scale}x)` : `exp(${scale}x)`;
const answer = `${scale}*(${outer})`;
await script({ reading: reading(first("0")) });
await write(page, "0", 80, 80);
await expect(page.getByTestId("practice").getByTestId("bubble")).toContainText("Quack!");
await script({ reading: reading(first(answer)) });
await page.getByTestId("practice-clear").click();
await write(page, answer, 80, 80, 24);
await expect(page.getByTestId("practice-correct")).toBeVisible();
await page.getByTestId("practice-end").click();
await expect(page.getByTestId("summary-correct")).toHaveText("1");
await expect(page.getByTestId("summary-message")).toContainText("One question doesn't say much");
});

test("A: simplifying, a hint, a correction, My Mistakes, a recap, and practice", async ({ page }) => {
test.setTimeout(180_000);
await signUp(page);
Expand Down
7 changes: 7 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ Generated answer keys are verified before questions are shown. `learning.py` val

Independent completion earns one credit and assisted completion earns half. Distinct question types and skills contribute to mastery. Saved progress, My Mistakes and recaps support returning learners.

Standalone practice also supports chain-rule follow-ups from verified notebook mistakes. Explicit
integer-derived keys for sine, cosine and exponential compositions pass the existing bounded
verification/completion gate before display. Basic questions have a linear inner function;
intermediate ones have a power. This adds no model call during question generation and no new
mastery rule. A missing-factor pattern supports a useful hint without establishing the student's
mental cause. The offline episode scorecard in `evals/tutoring.py` exercises this flow and its guards.

## Accounts and storage

Notes remain in the browser's IndexedDB, keyed by account. Calibration samples, study sessions, mistake facts, learning events and progression are stored server-side, using SQLite locally or PostgreSQL through Psycopg. Notebook notes do not synchronise across devices; account learning records can.
Expand Down
26 changes: 26 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,32 @@ This starts `next start` and verifies that `/collect` returns HTTP 404, `?debug`

Synthetic fixtures under `evals/synthetic/` and `evals/dataset/synthetic/` support these tests. Evaluation source also supports methods tests, including calibration metrics. Generated results and collected handwriting belong outside the approved public file set.

### Offline tutoring episodes

From the repository root, using the backend development environment:

```bash
services/api/.venv/bin/python evals/tutoring.py --output /tmp/quack-tutoring.json
# Run the same evaluator against the earlier source version first to capture a baseline.
services/api/.venv/bin/python evals/tutoring.py --output /tmp/quack-after.json --baseline /tmp/quack-before.json
```

This reuses the existing HTTP test doubles with the real parser, isolated verifier, account-owned
state, hints, corrections, recap and practice routes. Four chain-rule episodes and a power-rule
control cover detection through follow-up practice. Five guard cases cover ambiguous readings,
disagreement, malformed output, timeout and zero budget. Results contain synthetic case IDs,
contract checks, synthetic practice questions and mock call counts. Shell model/database settings
are removed and unregistered HTTP calls are refused. No key or real model credits are used.
The comparison rejects changed fixtures, changed evaluator code and missing checks; a failed
episode returns a nonzero exit status. These episodes also run in ordinary backend regression tests.

Chain-rule practice now follows a verified missing-factor pattern from My Mistakes or a recap.
It uses small sine, cosine and exponential questions with linear or power inner functions;
all keys are verified before display. Numerical angle offsets and powers of brackets are outside
this initial question bank. Existing uncertainty, completion and budget rules still apply.
The evaluator checks deterministic contracts and hint spoiler/fidelity filters. Human hint review,
real recognition accuracy, learning gains and production latency/cost require separate evidence.

## What the tests establish

Synthetic ink and scripted readings can verify mathematics, state transitions and UI flow. They do not establish physical Apple Pencil behaviour, real handwriting accuracy or learning outcomes. Supabase stand-ins do not establish correct configuration of a hosted Auth project. Two model readings can agree on a misrecognition.
Expand Down
2 changes: 1 addition & 1 deletion evals/synthetic/glyphs.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"_comment": "A hand-authored single-stroke alphabet in a unit box (x right, y down). Used only to draw synthetic test input. It is not real handwriting.", "0": [[[0.5, 0], [0.15, 0.15], [0.05, 0.5], [0.15, 0.85], [0.5, 1], [0.85, 0.85], [0.95, 0.5], [0.85, 0.15], [0.5, 0]]], "1": [[[0.25, 0.2], [0.5, 0], [0.5, 1]]], "2": [[[0.1, 0.25], [0.25, 0.05], [0.55, 0], [0.85, 0.15], [0.85, 0.4], [0.6, 0.65], [0.1, 1], [0.95, 1]]], "3": [[[0.1, 0.1], [0.45, 0], [0.85, 0.12], [0.85, 0.35], [0.45, 0.48], [0.9, 0.62], [0.9, 0.88], [0.45, 1], [0.1, 0.9]]], "4": [[[0.7, 1], [0.7, 0], [0.05, 0.65], [0.95, 0.65]]], "5": [[[0.85, 0], [0.2, 0], [0.15, 0.45], [0.55, 0.4], [0.9, 0.6], [0.85, 0.88], [0.45, 1], [0.1, 0.9]]], "6": [[[0.8, 0.05], [0.4, 0.1], [0.12, 0.5], [0.12, 0.8], [0.45, 1], [0.85, 0.85], [0.85, 0.55], [0.5, 0.42], [0.14, 0.6]]], "7": [[[0.08, 0], [0.92, 0], [0.4, 1]]], "8": [[[0.5, 0.48], [0.18, 0.3], [0.2, 0.1], [0.5, 0], [0.8, 0.1], [0.82, 0.3], [0.5, 0.48], [0.12, 0.68], [0.15, 0.9], [0.5, 1], [0.85, 0.9], [0.88, 0.68], [0.5, 0.48]]], "9": [[[0.85, 0.35], [0.5, 0.5], [0.15, 0.35], [0.18, 0.12], [0.5, 0], [0.82, 0.12], [0.85, 0.35], [0.8, 0.7], [0.45, 1]]], "x": [[[0.1, 0.35], [0.9, 1]], [[0.9, 0.35], [0.1, 1]]], "y": [[[0.1, 0.35], [0.5, 0.85]], [[0.9, 0.35], [0.25, 1.25]]], "d": [[[0.75, 0.5], [0.4, 0.42], [0.1, 0.62], [0.12, 0.9], [0.4, 1], [0.75, 0.82]], [[0.78, 0], [0.78, 1]]], "+": [[[0.1, 0.55], [0.9, 0.55]], [[0.5, 0.2], [0.5, 0.9]]], "-": [[[0.12, 0.55], [0.88, 0.55]]], "=": [[[0.12, 0.42], [0.88, 0.42]], [[0.12, 0.7], [0.88, 0.7]]], "(": [[[0.7, 0], [0.32, 0.28], [0.28, 0.72], [0.7, 1]]], ")": [[[0.3, 0], [0.68, 0.28], [0.72, 0.72], [0.3, 1]]], "/": [[[0.8, 0], [0.2, 1]]], "s": [[[0.8, 0.42], [0.45, 0.35], [0.18, 0.48], [0.3, 0.65], [0.72, 0.75], [0.8, 0.9], [0.5, 1], [0.15, 0.92]]], "i": [[[0.5, 0.4], [0.5, 1]], [[0.5, 0.15], [0.5, 0.2]]], "n": [[[0.15, 0.38], [0.15, 1]], [[0.15, 0.55], [0.45, 0.38], [0.8, 0.5], [0.82, 1]]], "c": [[[0.82, 0.48], [0.45, 0.36], [0.15, 0.55], [0.15, 0.82], [0.45, 1], [0.82, 0.9]]], "o": [[[0.5, 0.36], [0.15, 0.5], [0.12, 0.8], [0.5, 1], [0.86, 0.82], [0.85, 0.52], [0.5, 0.36]]], "t": [[[0.45, 0.05], [0.45, 0.88], [0.8, 1]], [[0.12, 0.4], [0.8, 0.4]]], "a": [[[0.78, 0.42], [0.4, 0.36], [0.12, 0.6], [0.2, 0.92], [0.5, 1], [0.78, 0.8]], [[0.8, 0.38], [0.82, 1]]], "<": [[[0.85, 0.25], [0.15, 0.6], [0.85, 0.95]]], "f": [[[0.8, 0.08], [0.5, 0], [0.38, 0.2], [0.38, 1.1]], [[0.12, 0.42], [0.72, 0.42]]], "'": [[[0.5, 0], [0.42, 0.25]]], ",": [[[0.5, 0.9], [0.38, 1.1]]], ".": [[[0.48, 0.96], [0.5, 1]]], "∫": [[[0.8, 0.0], [0.55, 0.02], [0.45, 0.3], [0.42, 1.0], [0.3, 1.25], [0.1, 1.22]]], "√": [[[0.0, 0.62], [0.18, 0.55], [0.38, 1.0], [0.6, 0.0], [1.05, 0.0]]], "*": [[[0.38, 0.42], [0.62, 0.68]], [[0.62, 0.42], [0.38, 0.68]]], "÷": [[[0.3, 0.55], [0.7, 0.55]], [[0.5, 0.38], [0.5, 0.41]], [[0.5, 0.69], [0.5, 0.72]]], "×": [[[0.38, 0.42], [0.62, 0.68]], [[0.62, 0.42], [0.38, 0.68]]]}
{"_comment": "A hand-authored single-stroke alphabet in a unit box (x right, y down). Used only to draw synthetic test input. It is not real handwriting.", "0": [[[0.5, 0], [0.15, 0.15], [0.05, 0.5], [0.15, 0.85], [0.5, 1], [0.85, 0.85], [0.95, 0.5], [0.85, 0.15], [0.5, 0]]], "1": [[[0.25, 0.2], [0.5, 0], [0.5, 1]]], "2": [[[0.1, 0.25], [0.25, 0.05], [0.55, 0], [0.85, 0.15], [0.85, 0.4], [0.6, 0.65], [0.1, 1], [0.95, 1]]], "3": [[[0.1, 0.1], [0.45, 0], [0.85, 0.12], [0.85, 0.35], [0.45, 0.48], [0.9, 0.62], [0.9, 0.88], [0.45, 1], [0.1, 0.9]]], "4": [[[0.7, 1], [0.7, 0], [0.05, 0.65], [0.95, 0.65]]], "5": [[[0.85, 0], [0.2, 0], [0.15, 0.45], [0.55, 0.4], [0.9, 0.6], [0.85, 0.88], [0.45, 1], [0.1, 0.9]]], "6": [[[0.8, 0.05], [0.4, 0.1], [0.12, 0.5], [0.12, 0.8], [0.45, 1], [0.85, 0.85], [0.85, 0.55], [0.5, 0.42], [0.14, 0.6]]], "7": [[[0.08, 0], [0.92, 0], [0.4, 1]]], "8": [[[0.5, 0.48], [0.18, 0.3], [0.2, 0.1], [0.5, 0], [0.8, 0.1], [0.82, 0.3], [0.5, 0.48], [0.12, 0.68], [0.15, 0.9], [0.5, 1], [0.85, 0.9], [0.88, 0.68], [0.5, 0.48]]], "9": [[[0.85, 0.35], [0.5, 0.5], [0.15, 0.35], [0.18, 0.12], [0.5, 0], [0.82, 0.12], [0.85, 0.35], [0.8, 0.7], [0.45, 1]]], "x": [[[0.1, 0.35], [0.9, 1]], [[0.9, 0.35], [0.1, 1]]], "y": [[[0.1, 0.35], [0.5, 0.85]], [[0.9, 0.35], [0.25, 1.25]]], "d": [[[0.75, 0.5], [0.4, 0.42], [0.1, 0.62], [0.12, 0.9], [0.4, 1], [0.75, 0.82]], [[0.78, 0], [0.78, 1]]], "+": [[[0.1, 0.55], [0.9, 0.55]], [[0.5, 0.2], [0.5, 0.9]]], "-": [[[0.12, 0.55], [0.88, 0.55]]], "=": [[[0.12, 0.42], [0.88, 0.42]], [[0.12, 0.7], [0.88, 0.7]]], "(": [[[0.7, 0], [0.32, 0.28], [0.28, 0.72], [0.7, 1]]], ")": [[[0.3, 0], [0.68, 0.28], [0.72, 0.72], [0.3, 1]]], "/": [[[0.8, 0], [0.2, 1]]], "s": [[[0.8, 0.42], [0.45, 0.35], [0.18, 0.48], [0.3, 0.65], [0.72, 0.75], [0.8, 0.9], [0.5, 1], [0.15, 0.92]]], "i": [[[0.5, 0.4], [0.5, 1]], [[0.5, 0.15], [0.5, 0.2]]], "n": [[[0.15, 0.38], [0.15, 1]], [[0.15, 0.55], [0.45, 0.38], [0.8, 0.5], [0.82, 1]]], "c": [[[0.82, 0.48], [0.45, 0.36], [0.15, 0.55], [0.15, 0.82], [0.45, 1], [0.82, 0.9]]], "o": [[[0.5, 0.36], [0.15, 0.5], [0.12, 0.8], [0.5, 1], [0.86, 0.82], [0.85, 0.52], [0.5, 0.36]]], "t": [[[0.45, 0.05], [0.45, 0.88], [0.8, 1]], [[0.12, 0.4], [0.8, 0.4]]], "a": [[[0.78, 0.42], [0.4, 0.36], [0.12, 0.6], [0.2, 0.92], [0.5, 1], [0.78, 0.8]], [[0.8, 0.38], [0.82, 1]]], "<": [[[0.85, 0.25], [0.15, 0.6], [0.85, 0.95]]], "f": [[[0.8, 0.08], [0.5, 0], [0.38, 0.2], [0.38, 1.1]], [[0.12, 0.42], [0.72, 0.42]]], "'": [[[0.5, 0], [0.42, 0.25]]], ",": [[[0.5, 0.9], [0.38, 1.1]]], ".": [[[0.48, 0.96], [0.5, 1]]], "\u222b": [[[0.8, 0.0], [0.55, 0.02], [0.45, 0.3], [0.42, 1.0], [0.3, 1.25], [0.1, 1.22]]], "\u221a": [[[0.0, 0.62], [0.18, 0.55], [0.38, 1.0], [0.6, 0.0], [1.05, 0.0]]], "*": [[[0.38, 0.42], [0.62, 0.68]], [[0.62, 0.42], [0.38, 0.68]]], "\u00f7": [[[0.3, 0.55], [0.7, 0.55]], [[0.5, 0.38], [0.5, 0.41]], [[0.5, 0.69], [0.5, 0.72]]], "\u00d7": [[[0.38, 0.42], [0.62, 0.68]], [[0.62, 0.42], [0.38, 0.68]]], "e": [[[0.15, 0.66], [0.82, 0.66], [0.76, 0.46], [0.48, 0.36], [0.19, 0.48], [0.12, 0.76], [0.28, 0.94], [0.55, 1], [0.84, 0.89]]], "p": [[[0.16, 0.38], [0.16, 1.25]], [[0.16, 0.5], [0.46, 0.36], [0.8, 0.48], [0.84, 0.73], [0.64, 0.91], [0.35, 0.89], [0.16, 0.75]]]}
Loading
Loading