practice — discretized parameter axes: named levels, never a number (owner-approved 2026-09-29)

Contracts: LadderRung 0-4, CueDistance 0-2, Presentation theory|
application; PracticeParams carries the three named levels. Doc
comments hold the three definition tables verbatim (rung names +
definitions from the ladder doctrine; cue levels; presentation
levels).

Core: LADDER_RUNGS/CUE_LEVELS/PRESENTATIONS tables in src/core/
practice.ts (definitions verbatim) — ONE source spoken by the agents'
prompts, the dialog's stepped controls, and the exam header; core
barrel re-exports practice.ts (pure domain, browser-safe).

Agents: Part I's ladder intro now 'five rungs, numbered; the request
arrives as a rung' with bullets numbered 0-4 (definitions untouched);
Part III's parameters block replaced with the named-level doctrine
(rung = exact class, no continuum; cue counts lesson examples AND
previous problems' situations; presentation dresses, never changes);
partitioner's RESPECT THE PARAMETERS replaced (low rungs = many small
assignments, high = larger; far cue makes confusables-together MORE
important; presentation does not shape the partition). renderParams
outputs the exact named-level block. Anchor/0..1 language swept dead.

Service + store: strict union validation — floats between levels,
out-of-range, wrong strings, legacy dial shapes all 400 at the API
and fail isProblemSet (legacy sets skipped from lists; no migration —
data/practice is disposable scratch, owner wipes before testing).

UI: dialog = 5-detent ladder slider + 3-detent cue slider (each shows
the level NAME + one-line definition) + Theory|Application segmented
toggle; defaults rung 2 / cue 1 / theory; Create posts the new shape.
Exam header gains the levels line ('Direct exercise · Adjacent
context · Theory').

Tests: renderParams exact-block pin; strict-validation pins (new
tests/practice-service.test.ts — bad unions 400, legacy shape 400);
store pins (legacy floats + 2.5 rung fail); render pins (detents +
names + definitions, defaults, detent-swap, toggle active segment,
levels line); every params fixture moved off floats. 1138/1138, tsc
0, lint 0, build 0.

Docs: architecture.md — discretization paragraph (compact tables,
named-level invocation, defaults, one-table source), UI mentions;
README axes sentence.
This commit is contained in:
amosblomqvist
2026-09-29 20:06:35 +03:00
parent a53127a764
commit e92679254d
15 changed files with 319 additions and 106 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ data/ chats/ · graph/ (six ledgers + positions/source
**The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees only the session, rendered as readable markdown by the shared `core/session-markdown.ts` engine — propositions as true statements, `follows`/`supports` edges with verbatim evidence; fully trusted, graph-blind) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools, stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create — and may draw bridging edges between existing nodes and the new batch) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes edges, checkpoints coverage) → layout settle. No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 4096, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read; the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection.
**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one grader per answer, verdict (`solid`/`partial`/`missed`) + feedback, post-grade reveal, nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one grader per answer, verdict (`solid`/`partial`/`missed`) + feedback, post-grade reveal, nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server.
+91 -44
View File
@@ -2,10 +2,12 @@
/**
* The problem-set creation dialog (practice slice 5, 2026-09-29;
* cluster-picker fix same evening): the three parameter dials + the
* CLUSTER picker → POST /api/practice/sets → router.push to the new
* exam page. In the delete-session dialog's shape (portaled z-50, pure
* body exported for SSR pins).
* cluster-picker + discretized-axes fixes same evening): the three
* NAMED-LEVEL parameters — a 5-detent ladder slider, a 3-detent cue
* slider, a Theory|Application toggle — + the CLUSTER picker → POST
* /api/practice/sets → router.push to the new exam page. In the
* delete-session dialog's shape (portaled z-50, pure body exported for
* SSR pins).
*
* GRAPH-DOMAIN, NOT CHAT-DOMAIN (owner ruling 2026-09-29): problem sets
* take NODES. The picker lists the birth-session clusters the graph
@@ -25,15 +27,16 @@ import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { useSettingsDialog } from "@/components/settings/settings-dialog";
import type { GraphClusterGroup } from "@/src/core";
import { CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "@/src/core";
import type { GraphClusterGroup, PracticeParams } from "@/src/core";
/** The three dials — key order matches PracticeParams; anchors are the
* honest display (a raw 0..1 number tells the learner nothing). */
export const PRACTICE_DIALS = [
{ key: "searchSolutionRatio", label: "Search ↔ Solve", anchors: "flashcard · competition" },
{ key: "cueDistance", label: "Cue distance", anchors: "near the lesson · far transfer" },
{ key: "presentation", label: "Presentation", anchors: "theory · application" },
] as const;
/** The creation defaults (owner, 2026-09-29): rung 2 Direct exercise,
* cue 1 Adjacent context, Theory. */
export const DEFAULT_PRACTICE_PARAMS: PracticeParams = {
ladderRung: 2,
cueDistance: 1,
presentation: "theory",
};
export interface PracticeFailure {
status: number;
@@ -44,8 +47,8 @@ export interface PracticeFailure {
/** The dialog's pure body — exported for SSR pins (the delete-session /
* settings body pattern). The wrapper owns state + fetch + portal. */
export function PracticeDialogBody({
values,
onDial,
params,
onParam,
clusters,
selectedKey,
onSelect,
@@ -55,8 +58,8 @@ export function PracticeDialogBody({
onClose,
onRun,
}: {
values: Record<(typeof PRACTICE_DIALS)[number]["key"], number>;
onDial: (key: (typeof PRACTICE_DIALS)[number]["key"], v: number) => void;
params: PracticeParams;
onParam: (patch: Partial<PracticeParams>) => void;
clusters: GraphClusterGroup[];
selectedKey: string | null;
onSelect: (key: string) => void;
@@ -110,26 +113,74 @@ export function PracticeDialogBody({
</div>
</div>
{/* The three dials (SettingSlider's markup, anchors as display). */}
{/* The three parameters — named levels, never a number to
interpolate (owner, 2026-09-29). Stepped sliders snap to the
detents; each shows the level's NAME + its one-line
definition; presentation is a two-segment toggle. */}
<div className="mt-4 flex flex-col gap-3">
{PRACTICE_DIALS.map((dial) => (
<label key={dial.key} className="flex flex-col gap-1">
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
{dial.label}
<span className="text-muted-foreground/80">{dial.anchors}</span>
</span>
<input
type="range"
min={0}
max={1}
step={0.1}
value={values[dial.key]}
data-testid={`dial-${dial.key}`}
onChange={(e) => onDial(dial.key, Number(e.target.value))}
className="h-1.5 w-full accent-primary"
/>
</label>
))}
<div className="flex flex-col gap-1">
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
Ladder
<span className="text-muted-foreground/80">0 · 1 · 2 · 3 · 4</span>
</span>
<input
type="range"
min={0}
max={4}
step={1}
value={params.ladderRung}
data-testid="param-ladder"
onChange={(e) => onParam({ ladderRung: Number(e.target.value) as PracticeParams["ladderRung"] })}
className="h-1.5 w-full accent-primary"
/>
<span data-testid="param-ladder-level" className="text-xs">
<span className="font-medium">{LADDER_RUNGS[params.ladderRung].name}</span>
<span className="text-muted-foreground"> — {LADDER_RUNGS[params.ladderRung].definition}</span>
</span>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
Cue distance
<span className="text-muted-foreground/80">0 · 1 · 2</span>
</span>
<input
type="range"
min={0}
max={2}
step={1}
value={params.cueDistance}
data-testid="param-cue"
onChange={(e) => onParam({ cueDistance: Number(e.target.value) as PracticeParams["cueDistance"] })}
className="h-1.5 w-full accent-primary"
/>
<span data-testid="param-cue-level" className="text-xs">
<span className="font-medium">{CUE_LEVELS[params.cueDistance].name}</span>
<span className="text-muted-foreground"> — {CUE_LEVELS[params.cueDistance].definition}</span>
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] text-muted-foreground">Presentation</span>
<div className="flex gap-1" data-testid="param-presentation">
{(["theory", "application"] as const).map((pres) => (
<button
type="button"
key={pres}
data-testid={`param-presentation-${pres}`}
onClick={() => onParam({ presentation: pres })}
className={`flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors ${
params.presentation === pres
? "border-primary bg-primary/10 font-medium text-foreground"
: "text-muted-foreground hover:bg-accent"
}`}
>
{PRESENTATIONS[pres].name}
</button>
))}
</div>
<span data-testid="param-presentation-level" className="text-xs text-muted-foreground">
{PRESENTATIONS[params.presentation].definition}
</span>
</div>
</div>
{failure && (
@@ -176,11 +227,7 @@ export function PracticeDialogBody({
export function PracticeDialog({ onClose }: { onClose: () => void }) {
const router = useRouter();
const { open: openSettings } = useSettingsDialog();
const [values, setValues] = useState<Record<(typeof PRACTICE_DIALS)[number]["key"], number>>({
searchSolutionRatio: 0.5,
cueDistance: 0.5,
presentation: 0.5,
});
const [params, setParams] = useState<PracticeParams>({ ...DEFAULT_PRACTICE_PARAMS });
const [clusters, setClusters] = useState<GraphClusterGroup[] | null>(null);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -225,7 +272,7 @@ export function PracticeDialog({ onClose }: { onClose: () => void }) {
headers: { "content-type": "application/json" },
body: JSON.stringify({
nodeIds: picked.nodeIds,
params: values,
params,
title: picked.label ?? undefined,
}),
});
@@ -240,7 +287,7 @@ export function PracticeDialog({ onClose }: { onClose: () => void }) {
setFailure({ status: 0, error: "Create failed — network error. Try again." });
setBusy(false);
}
}, [clusters, selectedKey, busy, values, router]);
}, [clusters, selectedKey, busy, params, router]);
return createPortal(
<div
@@ -250,8 +297,8 @@ export function PracticeDialog({ onClose }: { onClose: () => void }) {
}}
>
<PracticeDialogBody
values={values}
onDial={(key, v) => setValues((prev) => ({ ...prev, [key]: v }))}
params={params}
onParam={(patch) => setParams((prev) => ({ ...prev, ...patch }))}
clusters={clusters ?? []}
selectedKey={selectedKey}
onSelect={setSelectedKey}
+5
View File
@@ -19,6 +19,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { GradeVerdict, Problem, ProblemSet } from "@/src/core";
import { CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "@/src/core";
import { restoreMathEscapes } from "@/src/core";
import { MessageResponse } from "@/components/ai-elements/message";
import { BrailleSpinner } from "@/components/braille-spinner";
@@ -87,6 +88,10 @@ export function PracticeExamBody({
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-6" data-testid="practice-exam">
<div className="flex flex-col gap-1">
<h1 className="font-title text-3xl tracking-tight">{set.title}</h1>
<p className="text-xs text-muted-foreground/80" data-testid="practice-exam-levels">
{LADDER_RUNGS[set.params.ladderRung].name} · {CUE_LEVELS[set.params.cueDistance].name} ·{" "}
{PRESENTATIONS[set.params.presentation].name}
</p>
<p className="text-sm text-muted-foreground" data-testid="practice-exam-status">
{set.status === "partitioning" && "Partitioning the chains…"}
{set.status === "generating" && `Generating — ${set.problems.filter((p) => p.status === "ready").length}/${set.problems.length} problems ready`}
+4 -2
View File
@@ -374,13 +374,15 @@ Practice turns the graph into problem sets the learner reasons through out loud.
**Self-contained sets.** A `ProblemSet` freezes its world at creation: the selected node ids, a **propositions snapshot** (`Record<nodeId, {name, statement}>`), and the derived chains all persist into the set (`data/practice/*.json`, one file per set, `PracticeStore` port). Generation, grading, coverage, and the UI read ONLY the set — later extractions can never shift the ground under a live or historical set.
**Two-stage generation.** One **partitioner** agent reads the chains plus the three creator-set dials (`searchSolutionRatio`, `cueDistance`, `presentation` — each 0..1, shown as anchor text in the UI, never raw numbers) and submits the true partition; each partition becomes one problem's target. Then **one generator agent per problem**: its prompt carries the problem-engineering framework verbatim (the owner's doctrine — the three distances, the search-to-solution axis, the rung ladder, reverse/discriminate/probe-the-premises, the standing prohibitions), **YOUR TARGET** (the assigned chains echoed verbatim — the echo law: the submit's chain set must equal the assignment exactly), and **THE FULL SET** (the whole selection: outside propositions are supporting material the generator may draw on with minimal use and maximal focus; the never-announce law covers ALL propositions). The submit is commit-before-pose — statement, solution, and rubric arrive together (the same sycophancy mitigation the quiz/explain mechanic uses); a problem never exists without its pre-written answer. Generators run in parallel (a process-wide 3-permit semaphore shared with grading) with per-problem failure isolation: one bad problem errors alone; the set still reaches `ready`. **Dedup across sets**: each generator also sees the problems of the ≤5 most recent sets sharing a node, so situations don't repeat between sets; within a set, parallel generators can't see each other — accepted for V1 (owner ruling).
**The three axes are discretized named levels** (owner, 2026-09-29 — never a number to interpolate; there is no continuum between levels): the **ladder rung** (the main axis — 0 Worked example, 1 Completion, 2 Direct exercise, 3 Composition, 4 Path-finding; the invocation names the rung and the generator builds exactly that CLASS of problem), the **cue distance** (0 Lesson context — the textbook/toy-example shape in the teaching's own framing; 1 Adjacent context — a genuinely new situation built from the teaching's own world; 2 Far context — the edge of the recognizable band, where the proposition must still genuinely apply and be findable by looking), and the **presentation** (Theory — the subject's own terms, or Application — a concrete worldly affair in which the proposition's structure appears in the wild; it dresses the situation, never changes the rung or the cue distance). The names + one-line definitions live in one place (`src/core/practice.ts`'s level tables) and are spoken by everything: the agents' prompts (`renderParams`), the creation dialog's stepped controls, the exam header's levels line. Creation defaults: rung 2 Direct exercise · cue 1 Adjacent context · Theory. The API validates the exact unions — a float between rungs is a 400; legacy float-params sets fail the store validator and are skipped (no migration — `data/practice` is disposable scratch).
**Two-stage generation.** One **partitioner** agent reads the chains plus the three NAMED-LEVEL parameters and submits the true partition; each partition becomes one problem's target. Then **one generator agent per problem**: its prompt carries the problem-engineering framework verbatim (the owner's doctrine — the three distances, the search-to-solution axis, the rung ladder, reverse/discriminate/probe-the-premises, the standing prohibitions), **YOUR TARGET** (the assigned chains echoed verbatim — the echo law: the submit's chain set must equal the assignment exactly), and **THE FULL SET** (the whole selection: outside propositions are supporting material the generator may draw on with minimal use and maximal focus; the never-announce law covers ALL propositions). The submit is commit-before-pose — statement, solution, and rubric arrive together (the same sycophancy mitigation the quiz/explain mechanic uses); a problem never exists without its pre-written answer. Generators run in parallel (a process-wide 3-permit semaphore shared with grading) with per-problem failure isolation: one bad problem errors alone; the set still reaches `ready`. **Dedup across sets**: each generator also sees the problems of the ≤5 most recent sets sharing a node, so situations don't repeat between sets; within a set, parallel generators can't see each other — accepted for V1 (owner ruling).
**Grading is feedback-only.** Submitting answers fires one **grader** job per answered problem: the grader sees the problem, the propositions its chains touch, the pre-written solution and rubric, and the learner's answer — never the full set (minimal, by ruling). It returns a verdict (`solid`/`partial`/`missed`) and feedback that names the failed recall and teaches the boundary. The verdict lands on the problem and the UI reveals it with the solution and rubric; the answer locks. Nothing reaches the graph, no level, no count, anywhere. A grader failure leaves the answer gradeless and re-submittable.
**The jobs pattern — registry, not JobRecords.** Practice runs are NOT Workbench jobs: no `JobStore` records, no steer messages, nothing ever wakes the teacher — a practice run isn't chat-scoped (a set outlives its birth session and has no lesson to re-enter; its result is itself, persisted in its own store and read by its own page). What it shares with the Workbench is the observability surface: a `globalThis` run registry (the module-instance idiom — `__lhPracticeRuns`), one row per set-phase (`setId:generation`, `setId:grading`), feeding the activity dock (GraduationCap rows; abort is set-scoped and idempotent; a boot sweep marks orphaned `partitioning`/`generating` sets as `error "Interrupted (server restarted)"` — the job runner's crash-honesty at set grain). Store writes serialize behind a per-set mutex; the pipeline is the sole writer of `data/practice`.
**API + UI.** Creation is GRAPH-DOMAIN, not chat-domain (owner ruling 2026-09-29: "problem sets always take in nodes — graph objects"): `POST /api/practice/sets` takes `{nodeIds, params, title?}` ONLY — no chatId, no clusterId; the UI resolves a picked cluster to its member node ids and passes the cluster's label as the title. `GET /api/graph/clusters` serves the picker: the birth-session partition itself (`birthSessionPartition`, same ordering — size desc, ties by id) — EVERY group regardless of size (owner ruling, same evening: MIN_CLUSTER_SIZE is a hull-drawing rule; practice availability must not inherit a visual threshold), each as `{key, kind: "chat"|"notes", label, nodeIds, nodeCount}`; a cluster whose naming record is gone keeps `label: null` — honest, never guessed. 202 `{id}` with generation fire-and-forget; 400 empty selection (empty intersection with live nodes — the vanished-nodes edge) / bad params; 503 model/key refusal. `GET /api/practice/sets` · `GET/DELETE /api/practice/sets/[id]` (delete aborts live runs first) · `POST /api/practice/sets/[id]/answers` (202; 409 when not ready). Surfaces: `/practice` (the list — status/counts rows, delete confirm, the new-set dialog with the three dials and the cluster picker — the ONLY creation entry for now; the graph-view cluster entry comes later with the free-form selection UI) and `/practice/[id]` (the exam page — numbered problems, markdown statements with math healing at the display seam, a textarea + focus-gated dictation bar per problem, one Submit per set, the post-grade reveal). Entry: the sidebar's Practice nav row.
**API + UI.** Creation is GRAPH-DOMAIN, not chat-domain (owner ruling 2026-09-29: "problem sets always take in nodes — graph objects"): `POST /api/practice/sets` takes `{nodeIds, params, title?}` ONLY — no chatId, no clusterId; the UI resolves a picked cluster to its member node ids and passes the cluster's label as the title. `GET /api/graph/clusters` serves the picker: the birth-session partition itself (`birthSessionPartition`, same ordering — size desc, ties by id) — EVERY group regardless of size (owner ruling, same evening: MIN_CLUSTER_SIZE is a hull-drawing rule; practice availability must not inherit a visual threshold), each as `{key, kind: "chat"|"notes", label, nodeIds, nodeCount}`; a cluster whose naming record is gone keeps `label: null` — honest, never guessed. 202 `{id}` with generation fire-and-forget; 400 empty selection (empty intersection with live nodes — the vanished-nodes edge) / bad params; 503 model/key refusal. `GET /api/practice/sets` · `GET/DELETE /api/practice/sets/[id]` (delete aborts live runs first) · `POST /api/practice/sets/[id]/answers` (202; 409 when not ready). Surfaces: `/practice` (the list — status/counts rows, delete confirm, the new-set dialog with the named-level controls (a 5-detent ladder slider, a 3-detent cue slider, a Theory|Application toggle — each showing the level's name + definition) and the cluster picker — the ONLY creation entry for now; the graph-view cluster entry comes later with the free-form selection UI) and `/practice/[id]` (the exam page — a levels line in the header, numbered problems, markdown statements with math healing at the display seam, a textarea + focus-gated dictation bar per problem, one Submit per set, the post-grade reveal). Entry: the sidebar's Practice nav row.
**Model wiring.** Three roles — `partitioner`, `problemGenerator`, `grader` — all pinned to the **strong** slot (quality-critical, one settings edit from cheap) and in `BACKGROUND_MODEL_ROLES` (per-role overrides). The stage agents are single-submit loops on the shared `runSubmitAgent` seam (`submit-agent.ts`, extracted from extraction for exactly this): a `validate` hook turns a bad submit into a tool-input error the model retries against, within `maxTurns 4`; exhaustion fails the stage honestly (the set says so, the chains re-run cleanly on a new set).
+20 -12
View File
@@ -36,7 +36,7 @@ import type { PiEngine } from "../models.ts";
import { runSubmitAgent } from "./submit-agent.ts";
import { callUsage } from "./extraction.ts";
import type { CallUsage, PracticeParams, ReasoningChain } from "../../core/contracts.ts";
import { chainCoverage } from "../../core/practice.ts";
import { chainCoverage, CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "../../core/practice.ts";
// ─── doctrines ───────────────────────────────────────────────────────────────
@@ -109,13 +109,13 @@ Three operations modify a problem at any position on the axes.
### The ladder
Canonical points on the main axis, bottom to top:
The main axis, bottom to top — five rungs, numbered; the request arrives as a rung:
- **Worked example** — the solution is shown; nothing is demanded. An installation object; nothing yet to recall.
- **Completion** — the path is shown with a gap; the learner supplies one step. What to recall is nearly announced.
- **Direct exercise** — one step, no hint, but the situation makes the applicable proposition unmistakable. Trains execution, not choosing.
- **Composition** — several steps, chained; the path is narrow but must be held and run.
- **Path-finding** — several steps, the path concealed, distractors present, the statement suggesting wrong methods. The competition shape.
- **0 Worked example** — the solution is shown; nothing is demanded. An installation object; nothing yet to recall.
- **1 Completion** — the path is shown with a gap; the learner supplies one step. What to recall is nearly announced.
- **2 Direct exercise** — one step, no hint, but the situation makes the applicable proposition unmistakable. Trains execution, not choosing.
- **3 Composition** — several steps, chained; the path is narrow but must be held and run.
- **4 Path-finding** — several steps, the path concealed, distractors present, the statement suggesting wrong methods. The competition shape.
The ladder is the main axis only. Cue distance and the three operations apply at every point.
@@ -183,7 +183,11 @@ You are one generator in a two-stage pipeline: a partitioner has already divided
INPUTS (in the user message):
- YOUR TARGET: the chains this problem is built to strengthen — the partitioner's assignment.
- THE FULL SET: the proposition legend and chain list of the ENTIRE selection, not just the target. A coherent problem often cannot be built from the target alone — you may draw on other propositions of the full set as supporting material when the problem needs it. DOCTRINE: minimal use of outside propositions, maximal focus on the target — the target is what the learner must recall; outside propositions are material the solution may pass through. The never-announce rule covers every proposition, target or not.
- THE PARAMETERS: search-to-solution ratio, cue distance, presentation, each 0..1 (0 = flashcard end / lesson-close / pure theory; 1 = competition end / far transfer / real-world application). Apply Part II step 2: degrade honestly if the target cannot support them — the degradation shows in the problem's own shape, never in meta-commentary.
- THE PARAMETERS: three settings, each a named level — never a number to interpolate.
THE LADDER RUNG (the main axis): 0 Worked example · 1 Completion · 2 Direct exercise · 3 Composition · 4 Path-finding (Part I's ladder). The invocation names your rung; build exactly that CLASS of problem. There is no continuum between rungs — a rung-3 problem is composition, not "composition with a little path-finding."
THE CUE DISTANCE: 0 Lesson context · 1 Adjacent context · 2 Far context — how far the situation sits from the cues the learner has already met: the lesson's own examples AND every previous problem's situation. Far context is the edge of the recognizable band — the proposition must still genuinely apply, and the learner must be able to find it by looking.
THE PRESENTATION: Theory (the subject's own terms — symbols, formal objects, native vocabulary) or Application (a concrete worldly affair in which the proposition's structure appears in the wild). It dresses the situation; it never changes the rung or the cue distance.
Apply Part II step 2: degrade honestly if the target cannot support them — the degradation shows in the problem's own shape, never in meta-commentary.
- PREVIOUS PROBLEMS: problems the learner has already answered, with the chains they exercised. Do not rebuild one; keep your situation distant from theirs — cue distance counts previous problems' situations (Part I, "Cue distance").
OUTPUT — your entire deliverable is ONE submit_problem call:
@@ -201,7 +205,7 @@ PARTITION DOCTRINE:
- SMALLEST SOUND SET: prefer fewer, well-loaded problems. But never force unrelated material together: a problem's chains must share a real target relationship (confusable with each other, feeding the same conclusion, forming a path, or clustered around one idea). If the only thing two chains share is the graph, they belong in different problems.
- GROUP CONFUSABLES TOGETHER: propositions that apply to almost the same situations must land in the same problem — discrimination (choosing between near-identical situations) is a problem-engineering operation, and it is only possible when both confusables are in the room.
- READ THE MIX OFF THE SHAPE: many singletons and few multi-premise chains suggest recall + discrimination problems; long premise chains suggest composition and path-finding. Your partition is where that judgment lives.
- RESPECT THE PARAMETERS: the request carries the three dials (search-to-solution ratio, cue distance, presentation). Higher ratios support — and favor — larger assignments (more material to conceal a path in); at the bottom of the axis, one-step problems dominate and single chains stand alone.
- RESPECT THE PARAMETERS: the request names the ladder rung, the cue distance, and the presentation. Low rungs (0–2) mean many small assignments — single chains standing alone; high rungs (3–4) mean larger assignments — composition needs material to chain, path-finding needs distractor material to conceal the path among. Far cue distance makes grouping confusables together MORE important: the discrimination must travel into the far situation. Presentation does not shape your partition.
- DO NOT DUPLICATE: previous problems the learner has already seen are listed with their chains. If an existing problem already covers a group of chains, re-partition so the new set differs in what it groups and how.
OUTPUT — your entire deliverable is ONE submit_partition call: "partitions", a list of { chains: [ids] }, one entry per problem, together containing every input chain exactly once. No empty entries; no unknown ids; no problem without a real target relationship.`;
@@ -277,10 +281,14 @@ export function renderChains(chains: ReasoningChain[], propositions: Proposition
}
export function renderParams(params: PracticeParams): string {
const rung = LADDER_RUNGS[params.ladderRung];
const cue = CUE_LEVELS[params.cueDistance];
const pres = PRESENTATIONS[params.presentation];
return [
`search-to-solution ratio: ${params.searchSolutionRatio.toFixed(1)} (0 = announced recall, 1 = competition problem)`,
`cue distance: ${params.cueDistance.toFixed(1)} (0 = lesson-close, 1 = far transfer)`,
`presentation: ${params.presentation.toFixed(1)} (0 = pure theory, 1 = real-world application)`,
"THE PARAMETERS",
`Ladder rung ${params.ladderRung} — ${rung.name}: ${rung.definition}`,
`Cue distance ${params.cueDistance} — ${cue.name}: ${cue.definition}`,
`Presentation: ${pres.name} — ${pres.definition}`,
].join("\n");
}
+52 -12
View File
@@ -49,17 +49,57 @@ export interface ReasoningChain {
conclusion: string;
}
/** The three dials every problem set is configured with (owner, 2026-09-29)
* — all 0..1, interpreted by the agents against the framework's axes. */
/** The main axis, discretized (owner, 2026-09-29 — named levels, never
* a number to interpolate; definitions verbatim from the ladder
* doctrine): */
export type LadderRung = 0 | 1 | 2 | 3 | 4;
/** Cue distance from every situation the learner has already met — the
* lesson's own examples AND every previous problem's situation
* (definitions owner-approved 2026-09-29): */
export type CueDistance = 0 | 1 | 2;
/** How the situation is dressed — never changes the rung or the cue
* distance: */
export type Presentation = "theory" | "application";
/** The three settings every problem set is configured with (owner,
* 2026-09-29) — each a named level, invoked as that level exactly:
*
* THE LADDER (the main axis, bottom to top):
* 0 Worked example — the solution is shown; nothing is demanded. An
* installation object; nothing yet to recall.
* 1 Completion — the path is shown with a gap; the learner supplies
* one step. What to recall is nearly announced.
* 2 Direct exercise — one step, no hint, but the situation makes the
* applicable proposition unmistakable. Trains execution, not choosing.
* 3 Composition — several steps, chained; the path is narrow but must
* be held and run.
* 4 Path-finding — several steps, the path concealed, distractors
* present, the statement suggesting wrong methods. The competition
* shape.
*
* THE CUE DISTANCE:
* 0 Lesson context — the textbook/toy-example shape in the teaching's
* own framing.
* 1 Adjacent context — a genuinely new situation built from the
* teaching's own world — same domain, different setup/packaging.
* 2 Far context — as far from the typical cue as recognition allows,
* the edge of the recognizable band; the proposition must still
* genuinely apply, and the learner must be able to find it by looking.
*
* THE PRESENTATION:
* theory — the subject's own terms — symbols, formal objects, native
* vocabulary.
* application — a concrete worldly affair — a case, episode, system,
* or story — in which the proposition's structure appears in the wild. */
export interface PracticeParams {
/** Search-to-solution ratio — the main axis: flashcard recall (0) to
* competition problem (1). */
searchSolutionRatio: number;
/** Cue distance from every situation already used (0 = near the
* lesson, 1 = far transfer — still inside the recognizable band). */
cueDistance: number;
/** Presentation axis: pure theory (0) to real-world application (1). */
presentation: number;
/** Which class of problem to build — exactly that rung. */
ladderRung: LadderRung;
/** How far the situation sits from the cues already met. */
cueDistance: CueDistance;
/** How the situation is dressed. */
presentation: Presentation;
}
/** Where a set stands in the two-stage run. The set is PERSISTED at
@@ -119,8 +159,8 @@ export interface Problem {
export interface ProblemSet {
/** "ps_" + ts36 + rand — store-assigned. */
id: string;
/** Display label — auto-generated (the session's title, or
* date-based). */
/** Display label — the picked cluster's label, or the dated
* default. */
title: string;
createdAt: string;
params: PracticeParams;
+1
View File
@@ -31,6 +31,7 @@ export * from "./phases.ts";
export * from "./pending.ts";
export * from "./prompt-segments.ts";
export * from "./math-delims.ts";
export * from "./practice.ts";
// Wave B (owner-approved 2026-09-26): the native record (LhMessage), the
// and the input-events contract — all
// pure logic, browser-safe.
+24 -1
View File
@@ -11,7 +11,30 @@
* the union of the problems' chains must equal the set's chains; the
* partitioner's submit is validated against it.
*/
import type { GraphEdge, GraphNode, Problem, ReasoningChain } from "./contracts.ts";
import type { CueDistance, GraphEdge, GraphNode, LadderRung, Presentation, Problem, ReasoningChain } from "./contracts.ts";
/** The named levels (owner-approved 2026-09-29) — definitions verbatim
* from the ladder doctrine / the contract's tables. The UI's stepped
* controls, the exam header's levels line, and the agents' prompt
* rendering all speak these names — never a bare number. */
export const LADDER_RUNGS: Record<LadderRung, { name: string; definition: string }> = {
0: { name: "Worked example", definition: "the solution is shown; nothing is demanded. An installation object; nothing yet to recall." },
1: { name: "Completion", definition: "the path is shown with a gap; the learner supplies one step. What to recall is nearly announced." },
2: { name: "Direct exercise", definition: "one step, no hint, but the situation makes the applicable proposition unmistakable. Trains execution, not choosing." },
3: { name: "Composition", definition: "several steps, chained; the path is narrow but must be held and run." },
4: { name: "Path-finding", definition: "several steps, the path concealed, distractors present, the statement suggesting wrong methods. The competition shape." },
};
export const CUE_LEVELS: Record<CueDistance, { name: string; definition: string }> = {
0: { name: "Lesson context", definition: "the textbook/toy-example shape in the teaching's own framing." },
1: { name: "Adjacent context", definition: "a genuinely new situation built from the teaching's own world — same domain, different setup/packaging." },
2: { name: "Far context", definition: "as far from the typical cue as recognition allows, the edge of the recognizable band — the proposition must still genuinely apply, and the learner must be able to find it by looking." },
};
export const PRESENTATIONS: Record<Presentation, { name: string; definition: string }> = {
theory: { name: "Theory", definition: "the subject's own terms — symbols, formal objects, native vocabulary." },
application: { name: "Application", definition: "a concrete worldly affair — a case, episode, system, or story — in which the proposition's structure appears in the wild." },
};
/** The selection's follows-edges as chains: every edge into a node joins
* that node's (one) chain; a node with no in- AND no out-follows-edge in
+7 -4
View File
@@ -41,10 +41,13 @@ export function getPracticeSet(id: string): ProblemSet | null {
function parseParams(raw: unknown): PracticeParams | null {
if (typeof raw !== "object" || raw === null) return null;
const p = raw as Record<string, unknown>;
const dial = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1;
if (!dial(p.searchSolutionRatio) || !dial(p.cueDistance) || !dial(p.presentation)) return null;
// Named levels ONLY (owner, 2026-09-29): exact unions, nothing between —
// a rung is a rung; there is no float to interpolate.
if (p.ladderRung !== 0 && p.ladderRung !== 1 && p.ladderRung !== 2 && p.ladderRung !== 3 && p.ladderRung !== 4) return null;
if (p.cueDistance !== 0 && p.cueDistance !== 1 && p.cueDistance !== 2) return null;
if (p.presentation !== "theory" && p.presentation !== "application") return null;
return {
searchSolutionRatio: p.searchSolutionRatio,
ladderRung: p.ladderRung,
cueDistance: p.cueDistance,
presentation: p.presentation,
};
@@ -88,7 +91,7 @@ export async function createPracticeSetResponse(body: unknown): Promise<Response
const params = parseParams(b.params);
if (!params) {
return Response.json(
{ error: "params must carry searchSolutionRatio, cueDistance, presentation as numbers in [0,1]" },
{ error: "params must carry ladderRung (0-4), cueDistance (0-2), presentation (theory|application) as exact levels" },
{ status: 400 },
);
}
+10 -6
View File
@@ -35,13 +35,17 @@ const isStringArray = (v: unknown): v is string[] =>
const isParams = (v: unknown): boolean => {
if (typeof v !== "object" || v === null) return false;
const p = v as Record<string, unknown>;
// Named levels ONLY (owner, 2026-09-29): legacy float-params sets fail
// here and are skipped from lists — no migration (owner wipes
// data/practice before testing).
return (
typeof p.searchSolutionRatio === "number" &&
Number.isFinite(p.searchSolutionRatio) &&
typeof p.cueDistance === "number" &&
Number.isFinite(p.cueDistance) &&
typeof p.presentation === "number" &&
Number.isFinite(p.presentation)
(p.ladderRung === 0 ||
p.ladderRung === 1 ||
p.ladderRung === 2 ||
p.ladderRung === 3 ||
p.ladderRung === 4) &&
(p.cueDistance === 0 || p.cueDistance === 1 || p.cueDistance === 2) &&
(p.presentation === "theory" || p.presentation === "application")
);
};
+17 -2
View File
@@ -16,7 +16,7 @@ import {
type ToolCall,
} from "@earendil-works/pi-ai";
import type { PiEngine } from "../src/agent/models.ts";
import { createPracticeAgents, renderChains, renderPreviousProblems } from "../src/agent/subagents/practice.ts";
import { createPracticeAgents, renderChains, renderParams, renderPreviousProblems } from "../src/agent/subagents/practice.ts";
import { ModelsNotConfiguredError, ProviderKeyMissingError, type ProviderKeyMissing } from "../src/core/settings.ts";
import type { PracticeParams, ReasoningChain } from "../src/core/contracts.ts";
@@ -31,7 +31,7 @@ const chains: ReasoningChain[] = [
{ id: "n3", premises: ["n1"], conclusion: "n3" },
{ id: "n2", premises: [], conclusion: "n2" },
];
const params: PracticeParams = { searchSolutionRatio: 0.7, cueDistance: 0.4, presentation: 0.6 };
const params: PracticeParams = { ladderRung: 3, cueDistance: 2, presentation: "application" };
function fauxEngine(steps: FauxResponseStep[]): PiEngine {
const faux = createFauxCore({});
@@ -70,6 +70,21 @@ function silentModel(): PiEngine {
// ─── rendering ───────────────────────────────────────────────────────────────
describe("renderParams — named levels, never a number", () => {
it("renders the exact named-level block", () => {
const out = renderParams(params);
assert.equal(
out,
[
"THE PARAMETERS",
"Ladder rung 3 — Composition: several steps, chained; the path is narrow but must be held and run.",
"Cue distance 2 — Far context: as far from the typical cue as recognition allows, the edge of the recognizable band — the proposition must still genuinely apply, and the learner must be able to find it by looking.",
'Presentation: Application — a concrete worldly affair — a case, episode, system, or story — in which the proposition\'s structure appears in the wild.',
].join("\n"),
);
});
});
describe("rendering (the agent-facing text)", () => {
it("renderChains: legend with names + statements, premises → conclusion, singletons marked", () => {
const text = renderChains(chains, propositions);
+1 -1
View File
@@ -222,7 +222,7 @@ function diamondGraph(): GraphStore {
);
}
const PARAMS = { searchSolutionRatio: 0.6, cueDistance: 0.5, presentation: 0.4 };
const PARAMS = { ladderRung: 4, cueDistance: 1, presentation: "theory" } as const;
function fixture(partial: Partial<Parameters<typeof createPracticePipeline>[0]> = {}) {
const practice = memoryPracticeStore();
+43
View File
@@ -0,0 +1,43 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createPracticeSetResponse } from "../src/server/practice.ts";
/**
* The practice create-route's parameter validation (owner, 2026-09-29):
* the three axes are NAMED LEVELS — exact unions, nothing between. A
* rung is a rung; a float between rungs, a wrong string, or a missing
* field is a 400 BEFORE the pipeline is ever reached (these pins only
* exercise the rejecting paths — the accepting path runs the real
* pipeline and belongs to the pipeline suite).
*/
const NODE_IDS = { nodeIds: ["n1", "n2", "n3"], title: "Ethics cluster" };
async function statusOf(params: unknown): Promise<number> {
const res = await createPracticeSetResponse({ ...NODE_IDS, params });
return res.status;
}
describe("createPracticeSetResponse — strict named-level params", () => {
it("floats between levels → 400", async () => {
assert.equal(await statusOf({ ladderRung: 2.5, cueDistance: 1, presentation: "theory" }), 400);
assert.equal(await statusOf({ ladderRung: 2, cueDistance: 1.5, presentation: "theory" }), 400);
});
it("out-of-range integers → 400", async () => {
assert.equal(await statusOf({ ladderRung: 5, cueDistance: 1, presentation: "theory" }), 400);
assert.equal(await statusOf({ ladderRung: -1, cueDistance: 1, presentation: "theory" }), 400);
assert.equal(await statusOf({ ladderRung: 2, cueDistance: 3, presentation: "theory" }), 400);
});
it("wrong presentation → 400", async () => {
assert.equal(await statusOf({ ladderRung: 2, cueDistance: 1, presentation: "real-world" }), 400);
assert.equal(await statusOf({ ladderRung: 2, cueDistance: 1, presentation: 1 }), 400);
});
it("missing / legacy float fields → 400", async () => {
assert.equal(await statusOf({ ladderRung: 2, cueDistance: 1 }), 400);
assert.equal(await statusOf({ searchSolutionRatio: 0.7, cueDistance: 0.4, presentation: 0.6 }), 400, "legacy dials are dead");
assert.equal(await statusOf("high"), 400);
});
});
+6 -1
View File
@@ -24,7 +24,7 @@ afterEach(() => {
const init = (over: Partial<Omit<ProblemSet, "id">> = {}): Omit<ProblemSet, "id"> => ({
title: "Libertarianism — session practice",
createdAt: "2026-09-29T12:00:00.000Z",
params: { searchSolutionRatio: 0.7, cueDistance: 0.4, presentation: 0.6 },
params: { ladderRung: 2, cueDistance: 1, presentation: "theory" },
nodeIds: ["n_a", "n_b", "n_c"],
propositions: {
n_a: { name: "Self-ownership", statement: "Each person owns themself." },
@@ -122,6 +122,11 @@ describe("isProblemSet", () => {
assert.ok(!isProblemSet({}));
assert.ok(!isProblemSet({ ...init(), id: "xx_1" }));
assert.ok(!isProblemSet({ ...init(), params: { searchSolutionRatio: "high" } }));
// Legacy float-params sets fail the named-level unions and are
// skipped from lists (owner, 2026-09-29: no migration — data/practice
// is wiped before testing).
assert.ok(!isProblemSet({ ...init(), id: "ps_ok", params: { searchSolutionRatio: 0.7, cueDistance: 0.4, presentation: 0.6 } }));
assert.ok(!isProblemSet({ ...init(), id: "ps_ok2", params: { ladderRung: 2.5, cueDistance: 1, presentation: "theory" } }), "no floats between rungs");
assert.ok(!isProblemSet({ ...init(), chains: [{ id: "n_c", premises: "n_a", conclusion: "n_c" }] }));
assert.ok(!isProblemSet({ ...init(), propositions: { n_a: { name: "x" } } }));
assert.ok(!isProblemSet({ ...init(), status: "cooked" }));
+37 -20
View File
@@ -9,11 +9,7 @@ import {
PracticeListBody,
setStatusWord,
} from "../components/practice-list.tsx";
import {
PRACTICE_DIALS,
PracticeDialogBody,
type PracticeFailure,
} from "../components/practice-dialog.tsx";
import { DEFAULT_PRACTICE_PARAMS, PracticeDialogBody, type PracticeFailure } from "../components/practice-dialog.tsx";
import type { GraphClusterGroup } from "../src/core";
import { PracticeExamBody } from "../components/practice-exam.tsx";
import type { Problem, ProblemSet } from "../src/core";
@@ -30,13 +26,7 @@ import type { Problem, ProblemSet } from "../src/core";
// ─── fixtures ────────────────────────────────────────────────────────────────
const PARAMS = { searchSolutionRatio: 0.5, cueDistance: 0.5, presentation: 0.5 };
const dialValues = {
searchSolutionRatio: 0.7,
cueDistance: 0.5,
presentation: 0.3,
};
const PARAMS = { ladderRung: 2, cueDistance: 1, presentation: "theory" } as const;
function clusters(): GraphClusterGroup[] {
return [
@@ -95,8 +85,8 @@ function set(over: Partial<ProblemSet> = {}): ProblemSet {
describe("PracticeDialogBody — creation (cluster picker)", () => {
const base = {
values: dialValues,
onDial: () => {},
params: { ...DEFAULT_PRACTICE_PARAMS },
onParam: () => {},
clusters: clusters(),
selectedKey: null,
onSelect: () => {},
@@ -107,13 +97,33 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
onRun: () => {},
};
it("renders the three dials with anchor labels", () => {
it("stepped controls: 5-detent ladder + 3-detent cue + Theory|Application toggle, defaults rung 2 / cue 1 / theory", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
for (const dial of PRACTICE_DIALS) {
assert.ok(html.includes(`data-testid="dial-${dial.key}"`), `the ${dial.key} dial`);
assert.ok(html.includes(dial.anchors), `the ${dial.key} anchors`);
assert.ok(html.includes(dial.label));
}
assert.ok(html.includes('data-testid="param-ladder"'));
assert.match(html, /min="0" max="4" step="1"/);
assert.ok(html.includes('data-testid="param-cue"'));
assert.match(html, /min="0" max="2" step="1"/);
assert.ok(html.includes('data-testid="param-presentation-theory"'));
assert.ok(html.includes('data-testid="param-presentation-application"'));
// The DEFAULTS: rung 2 / cue 1 / theory, with names + definitions.
assert.match(html, /value="2"/); // ladder detent
assert.match(html, /value="1"/); // cue detent
assert.ok(html.includes("Direct exercise"), "default rung name");
assert.ok(html.includes("Adjacent context"), "default cue name");
assert.ok(html.includes("one step, no hint"), "current rung definition shown");
assert.ok(html.includes("same domain, different setup/packaging"), "current cue definition shown");
});
it("moving a detent swaps the shown name + definition; the toggle switches", () => {
const moved = renderToStaticMarkup(
<PracticeDialogBody {...base} params={{ ladderRung: 4, cueDistance: 0, presentation: "application" }} />,
);
assert.ok(moved.includes("Path-finding"));
assert.ok(moved.includes("the statement suggesting wrong methods"), "rung-4 definition");
assert.ok(moved.includes("Lesson context"));
assert.ok(moved.includes("textbook/toy-example shape"), "cue-0 definition");
assert.ok(moved.includes("a concrete worldly affair"), "application definition shown");
assert.match(moved, /param-presentation-application[^]*border-primary/, "application segment active");
});
it("cluster picker: label + proposition-count rows, notes kind marked, Create gated until picked", () => {
@@ -264,6 +274,13 @@ describe("PracticeExamBody — exam states", () => {
assert.ok(!html.includes('data-testid="practice-submit"'));
});
it("the header carries the levels line (named levels, owner 2026-09-29)", () => {
const s = set({ problems: [readyProblem("pp_1", ["n3"])] });
const html = renderToStaticMarkup(<PracticeExamBody set={s} {...examBase} />);
assert.ok(html.includes('data-testid="practice-exam-levels"'));
assert.ok(html.includes("Direct exercise · Adjacent context · Theory"), "the fixture's rung 2 / cue 1 / theory");
});
it("ready: the statement renders as markdown, the box is answerable, submit gated on a draft", () => {
const s = set({ problems: [readyProblem("pp_1", ["n3"])] });
const gated = renderToStaticMarkup(<PracticeExamBody set={s} {...examBase} />);