math: dollar delimiters end-to-end (exact answers, escaping examples, context rendering)

- switch the math authoring/rendering contract from \(...\)/\[...\] to the
  standard $...$ inline / $$...$$ display: prompts (20-question-tools,
  40-formatting), singleDollarTextMath on the renderer, legacy reader kept,
  and escape-debris recovery extended to dollar spans (mirroring micromark
  delimiter-run rules)
- exact answer membership now precedes debris cleanup in
  coerceCorrectAnswer (calculus incident: an exact sentence-final-period
  label was stripped as model debris, then rejected); option keys are
  passed at the boundary/grading seams, the dock no longer pre-coerces
- a complete, phase-correct JSON-escaping example rides the probe/quiz
  tool descriptions (serialized from one canonical body; quiz leads with
  type+nodeId), plus the one-sentence JSON-escaping rule in 40-formatting
- ask option descriptions render markdown/LaTeX in the pending dock;
  settled cards keep the details line and option descriptions
  retroactively, read from the persisted call arguments (chat passes
  part.input to AnsweredCard)

1566/1566 tests on both navigator configs; typecheck, lint, production
build, standalone packaging, and Electron-runtime smoke all green.
This commit is contained in:
amosblomqvist
2026-10-13 06:24:33 +03:00
parent a395f37727
commit bf29a2070f
19 changed files with 628 additions and 93 deletions
+8 -10
View File
@@ -331,10 +331,10 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown>;
// streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its // streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its
// own highlighter); the cast papers over the .d.ts mismatch. // own highlighter); the cast papers over the .d.ts mismatch.
// //
// singleDollarTextMath: false — a bare $ is ALWAYS literal currency (the // Standard dollar authoring (owner, 2026-10-12): $...$ inline, $$ fenced
// teacher-prompt contract). Inline math arrives as \(...\) and display math // on separate lines for display. transformMathDelimiters stays as the
// as \[...\]; transformMathDelimiters (below) rewrites both to the // compatibility reader for old \(...\)/\[...\] content; its two-dollar
// two-dollar forms remark-math still parses. $ can never start math. // output remains valid alongside native single-dollar math.
// //
// remark-breaks rides in the cjk plugin's remarkPluginsAfter slot (public // remark-breaks rides in the cjk plugin's remarkPluginsAfter slot (public
// PluginConfig API): Streamdown's bare `remarkPlugins` PROP would REPLACE the // PluginConfig API): Streamdown's bare `remarkPlugins` PROP would REPLACE the
@@ -352,16 +352,14 @@ export const streamdownPlugins = {
remarkPluginsAfter: [...cjk.remarkPluginsAfter, remarkBreaks], remarkPluginsAfter: [...cjk.remarkPluginsAfter, remarkBreaks],
}, },
code, code,
math: createMathPlugin({ singleDollarTextMath: false }), math: createMathPlugin({ singleDollarTextMath: true }),
} as unknown as PluginConfig; } as unknown as PluginConfig;
export const MessageResponse = memo( export const MessageResponse = memo(
({ className, children, ...props }: MessageResponseProps) => { ({ className, children, ...props }: MessageResponseProps) => {
// Delimiter preprocessing is a single linear scan (no markdown parsing) // Legacy backslash delimiters are converted in a single linear scan;
// — cheap enough for the render path of this children-memoized // native dollar math passes through to Streamdown unchanged. Incomplete
// component. Unbalanced delimiters pass through untouched, so mid-stream // legacy spans are left for a later render when their closer arrives.
// chunks render literally until the closing delimiter arrives and the
// full text is re-transformed.
const content = const content =
typeof children === "string" typeof children === "string"
? transformMathDelimiters(children) ? transformMathDelimiters(children)
+26 -1
View File
@@ -7,6 +7,7 @@ import { ThinkingBlock } from "@/components/thinking-block";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MessageResponse } from "@/components/ai-elements/message"; import { MessageResponse } from "@/components/ai-elements/message";
import type { QuestionAnswerData } from "@/src/core"; import type { QuestionAnswerData } from "@/src/core";
import { parseAskInput, parseQuizInput } from "@/src/core";
/** /**
* Static rendering of an answered question (tool part in `output-available`). * Static rendering of an answered question (tool part in `output-available`).
@@ -18,7 +19,7 @@ import type { QuestionAnswerData } from "@/src/core";
* client — the card reveals his answer, the committed correct answer, and * client — the card reveals his answer, the committed correct answer, and
* the explanation, with NO verdict chips (owner ruling 2026-09-27). * the explanation, with NO verdict chips (owner ruling 2026-09-27).
*/ */
export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; input?: unknown }) {
const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03) const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
if (answer.dismissed) { if (answer.dismissed) {
return ( return (
@@ -32,6 +33,15 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
const isExplain = answer.kind === "explain"; const isExplain = answer.kind === "explain";
const selectedIdx = new Set(answer.answers.map((a) => a.index)); const selectedIdx = new Set(answer.answers.map((a) => a.index));
const correctIdx = new Set(answer.correctIndices ?? []); const correctIdx = new Set(answer.correctIndices ?? []);
// The question's own context rides the ORIGINAL CALL, not the answer
// snapshot (owner, 2026-10-12): the details line — and, for ask
// questions, option descriptions — re-render from the persisted
// arguments, so every existing transcript shows them too. The lean
// snapshot (what the model sees, compactAnswerForModel) is untouched;
// any parse failure or absent input renders exactly the older card.
const askInput = answer.kind === "ask" ? parseAskInput(input) : undefined;
const parsedDetails = askInput?.details ?? (answer.kind === "quiz" ? parseQuizInput(input)?.details : undefined);
const descByIndex = new Map((askInput?.options ?? []).map((o, i) => [i + 1, o.description]));
return ( return (
<> <>
@@ -78,6 +88,13 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
<div className="mb-3 font-medium"> <div className="mb-3 font-medium">
<MessageResponse>{answer.questionText}</MessageResponse> <MessageResponse>{answer.questionText}</MessageResponse>
</div> </div>
{parsedDetails && (
// The details line, same paint as the pending dock (owner,
// 2026-10-12): full markdown, LaTeX included.
<div className="mb-3 text-sm text-muted-foreground">
<MessageResponse>{parsedDetails}</MessageResponse>
</div>
)}
{((answer.options?.length ?? 0) > 0 || answer.other) && ( {((answer.options?.length ?? 0) > 0 || answer.other) && (
<div className="flex flex-col gap-1.5 text-sm"> <div className="flex flex-col gap-1.5 text-sm">
@@ -115,6 +132,14 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
)} )}
<span className={cn(!sel && "text-muted-foreground")}> <span className={cn(!sel && "text-muted-foreground")}>
<MessageResponse>{opt.label}</MessageResponse> <MessageResponse>{opt.label}</MessageResponse>
{descByIndex.get(opt.index) && (
// Ask-option subtext (owner, 2026-10-12): same markdown path
// as the pending dock — LaTeX included. Quiz options carry
// none (owner ruling 2026-09-26); the map stays empty for them.
<MessageResponse className="lh-prose block text-xs text-muted-foreground">
{descByIndex.get(opt.index)}
</MessageResponse>
)}
</span> </span>
</div> </div>
); );
+3 -1
View File
@@ -1487,7 +1487,9 @@ export function Chat({
// Pending/streaming questions live in the dock, not the // Pending/streaming questions live in the dock, not the
// transcript; answered ones render as graded records. // transcript; answered ones render as graded records.
if (part.state === "output-available") { if (part.state === "output-available") {
return <AnsweredCard key={pi} answer={part.output as QuestionAnswerData} />; return (
<AnsweredCard key={pi} answer={part.output as QuestionAnswerData} input={part.input} />
);
} }
return null; return null;
} }
+8 -5
View File
@@ -9,7 +9,6 @@ import { MessageResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
buildAnswer, buildAnswer,
coerceCorrectAnswer,
normalizeQuizType, normalizeQuizType,
CONFIDENCE_STOPS, CONFIDENCE_STOPS,
DEFAULT_CONFIDENCE_INDEX, DEFAULT_CONFIDENCE_INDEX,
@@ -170,7 +169,7 @@ function teaserText(q: string): string {
return q return q
.replace(/\\\(([\s\S]*?)\\\)/g, "$1") // \(...\) inline math .replace(/\\\(([\s\S]*?)\\\)/g, "$1") // \(...\) inline math
.replace(/\\\[([\s\S]*?)\\\]/g, "$1") // \[...\] display math .replace(/\\\[([\s\S]*?)\\\]/g, "$1") // \[...\] display math
.replace(/\$\$?([^$]*)\$\$?/g, "$1") // legacy $...$ (pre-switch sessions) .replace(/\$\$?([^$]*)\$\$?/g, "$1") // standard $...$/$$...$$ math
.replace(/\\frac\{([^{}]*)\}\{([^{}]*)\}/g, "$1/$2") .replace(/\\frac\{([^{}]*)\}\{([^{}]*)\}/g, "$1/$2")
.replace(/\\([a-zA-Z]+)/g, "$1") .replace(/\\([a-zA-Z]+)/g, "$1")
.replace(/[{}]/g, "") .replace(/[{}]/g, "")
@@ -293,7 +292,7 @@ function QuestionDockForm({
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
if (quizInput) { if (quizInput) {
const resolved = resolveCorrect(coerceCorrectAnswer(quizInput.correctAnswer), ordered); const resolved = resolveCorrect(quizInput.correctAnswer, ordered);
if (resolved.error) console.error("[quiz] correctAnswer did not resolve:", resolved.error); if (resolved.error) console.error("[quiz] correctAnswer did not resolve:", resolved.error);
q.correctIndices = resolved.indices; q.correctIndices = resolved.indices;
q.explanation = quizInput.explanation; q.explanation = quizInput.explanation;
@@ -607,8 +606,12 @@ function QuestionDockForm({
// the "I don't know" copy stays JetBrains Mono. QUIZ options render // the "I don't know" copy stays JetBrains Mono. QUIZ options render
// NO description (owner ruling 2026-09-26): the model used them to // NO description (owner ruling 2026-09-26): the model used them to
// telegraph correctness — the schema dropped the field and old // telegraph correctness — the schema dropped the field and old
// records must not leak either. // records must not leak either. Same MessageResponse path as the
<span className="lh-prose block text-xs text-muted-foreground">{opt.description}</span> // label (owner, 2026-10-12): descriptions are full markdown —
// LaTeX included — not plain text.
<MessageResponse className="lh-prose block text-xs text-muted-foreground">
{opt.description}
</MessageResponse>
)} )}
</span> </span>
</button> </button>
+2
View File
@@ -231,6 +231,8 @@ The teacher's context window and attention are precious — it has the lesson to
## 3. Presentation layer (noted, not yet scheduled) ## 3. Presentation layer (noted, not yet scheduled)
**Current math authoring contract (2026-10-12):** standard `$...$` inline and `$$...$$` on separate lines for display. This supersedes the currency-only dollar rule below; correctly formed backslash-delimited content remains readable. Existing JSON-escape restoration also recognizes the new delimiters, with its recovery rules unchanged. Additional examples, math validation, over-escaping repairs, and transport redesign are deferred. The original W3 decisions below are historical.
- **LaTeX delimiters: switch to the ChatGPT convention.** Inline math `\(...\)`, display math `\[...\]`. The single `$` is retired from math duty and reserved for currency (ending the dollar-sign hazard for good, on both teacher output and eventually user input). Technical path: preprocess `\(...\)`/`\[...\]` → remark-math-compatible form (or a dedicated micromark extension), plus a system-prompt update. The old `$`/`$$` contract dies with this change. - **LaTeX delimiters: switch to the ChatGPT convention.** Inline math `\(...\)`, display math `\[...\]`. The single `$` is retired from math duty and reserved for currency (ending the dollar-sign hazard for good, on both teacher output and eventually user input). Technical path: preprocess `\(...\)`/`\[...\]` → remark-math-compatible form (or a dedicated micromark extension), plus a system-prompt update. The old `$`/`$$` contract dies with this change.
- **Line breaks bug:** lesson text loses single line breaks (markdown semantics collapse them — likely fix: `remark-breaks`, or prompt-level blank-line discipline; verify and fix). - **Line breaks bug:** lesson text loses single line breaks (markdown semantics collapse them — likely fix: `remark-breaks`, or prompt-level blank-line discipline; verify and fix).
- **Lesson-content formatting:** richer, more structured presentation of teaching blocks (headings, callouts, worked examples) — direction noted, specifics TBD. - **Lesson-content formatting:** richer, more structured presentation of teaching blocks (headings, callouts, worked examples) — direction noted, specifics TBD.
+1 -1
View File
@@ -70,7 +70,7 @@ export function listSourceSegments(agent: PromptAgent): Array<{ path: string; sh
// file mechanics stay out (the doctrine contradicts them; 00-base.md // file mechanics stay out (the doctrine contradicts them; 00-base.md
// plays the base role), but the FORMATTING guide composes for every // plays the base role), but the FORMATTING guide composes for every
// agent (owner, 2026-10-04 late): LaTeX delimiters and markdown apply // agent (owner, 2026-10-04 late): LaTeX delimiters and markdown apply
// in all settings — practice must write \(...\)/\[...\] too. // in all settings — practice shares the $...$/$$...$$ authoring contract.
const segments: Array<{ path: string; shared: boolean; file: string }> = agent === "practice" ? [] : [ const segments: Array<{ path: string; shared: boolean; file: string }> = agent === "practice" ? [] : [
{ path: "system-prompt.md", shared: true, file: PATHS.teacherPrompt }, { path: "system-prompt.md", shared: true, file: PATHS.teacherPrompt },
]; ];
@@ -7,7 +7,7 @@ Two instruments. The quiz tool (its exact name depends on the quizzing mechanic
Applies to every graded question you ask, whatever its role: Applies to every graded question you ask, whatever its role:
- Multiple choice carries a separate `explanation` (why the correct option is correct, written BEFORE `correctAnswer`: reason it out first, then commit). Free recall has ONE field — `correctAnswer`, the committed answer with its reasoning inside. - Multiple choice carries a separate `explanation` (why the correct option is correct, written BEFORE `correctAnswer`: reason it out first, then commit). Free recall has ONE field — `correctAnswer`, the committed answer with its reasoning inside.
- **LaTeX everywhere.** Every question field renders markdown and LaTeX — the question text, every option, every explanation. Anything expressible as math goes in math: formulas, symbols, even single variables (`\(x\)`, `\(\alpha\)`). Plain-text math in a question is a craft failure; `\(...\)` and `\[...\]` are the only delimiters (never `$`). - **LaTeX everywhere.** Every question field renders markdown and LaTeX — the question text, every option, every explanation. Anything expressible as math goes in math: formulas, symbols, even single variables (`$x$`, `$\alpha$`). Plain-text math in a question is a craft failure; use `$...$` for inline math and `$$...$$` on separate lines for display math.
- **The option craft.** The giveaway is never the distractors' wrongness, it's asymmetry: the correct option carrying its reasoning while the others are bare, or key-term bolding that flags it. Build evenness in: (1) every option is a bare claim — zero "why" in any of them; all reasoning goes in the explanation, shown only after he answers; (2) write the correct claim first, then mutate it into each distractor — what would someone holding a specific misconception claim? Same skeleton, same grain, same register; (3) each distractor is a real, BELIEVABLE error — the answer he would actually give under a specific wrong model, wrong on knowledge, never obviously wrong on sight. An option a cold reader eliminates instantly is a wasted option: his pick is diagnostic only when the wrong model is what selects it; (4) no asymmetric bolding. If you can tell the right one cold without knowing the material, regenerate — don't patch. - **The option craft.** The giveaway is never the distractors' wrongness, it's asymmetry: the correct option carrying its reasoning while the others are bare, or key-term bolding that flags it. Build evenness in: (1) every option is a bare claim — zero "why" in any of them; all reasoning goes in the explanation, shown only after he answers; (2) write the correct claim first, then mutate it into each distractor — what would someone holding a specific misconception claim? Same skeleton, same grain, same register; (3) each distractor is a real, BELIEVABLE error — the answer he would actually give under a specific wrong model, wrong on knowledge, never obviously wrong on sight. An option a cold reader eliminates instantly is a wasted option: his pick is diagnostic only when the wrong model is what selects it; (4) no asymmetric bolding. If you can tell the right one cold without knowing the material, regenerate — don't patch.
- **Free-recall grading.** Grade his words strictly against the committed answer; never mark a wrong answer right to be kind — a near-miss is partial understanding: name exactly what he got and what he missed. If he genuinely doesn't know, he says so in his own words — that is a genuine gap, not a wrong answer. - **Free-recall grading.** Grade his words strictly against the committed answer; never mark a wrong answer right to be kind — a near-miss is partial understanding: name exactly what he got and what he missed. If he genuinely doesn't know, he says so in his own words — that is a genuine gap, not a wrong answer.
+1 -1
View File
@@ -2,6 +2,6 @@
Everything you write — every message, every quiz question, every quiz option and explanation — renders as markdown to him, and the full set is available: headings, lists, tables, fenced code, inline code, bold, mermaid diagrams. Use it maximally when it fits — the default is a structured document, not a wall of prose. Headings for the sections a message naturally has; lists and tables for anything enumerable or comparative; code blocks for anything literal. Structure is not decoration — it makes the content readable at a glance — and when in doubt, structure it. Everything you write — every message, every quiz question, every quiz option and explanation — renders as markdown to him, and the full set is available: headings, lists, tables, fenced code, inline code, bold, mermaid diagrams. Use it maximally when it fits — the default is a structured document, not a wall of prose. Headings for the sections a message naturally has; lists and tables for anything enumerable or comparative; code blocks for anything literal. Structure is not decoration — it makes the content readable at a glance — and when in doubt, structure it.
- Math: anything expressible in LaTeX goes in LaTeX — formulas, symbols, even single variables — everywhere text renders, and that means EVERY field: prose, quiz questions, quiz options, explanations, problem statements, answers. Inline `\(f(x)\)`, display `\[` fenced on its own lines. NEVER `$` or `$$` — a bare `$` is literal currency here; `\(...\)` and `\[...\]` are the only delimiters that render. - Math: anything expressible in LaTeX goes in LaTeX — formulas, symbols, even single variables — everywhere text renders, and that means EVERY field: prose, quiz questions, quiz options, explanations, problem statements, answers. Inline `$f(x)$`, display `$$` fenced on its own lines. Use `$...$` for inline math and `$$...$$` on separate lines for display math. In JSON strings (tool arguments), use `\\` for LaTeX command backslashes, `\\\\` for TeX row breaks, and `\n` for newlines.
- Key ideas: a GitHub-style `> [!IMPORTANT]` callout renders as a distinct outlined block — the one emphasis device for load-bearing ideas. Use it sparingly: an unconditional truth as it's laid down, a payoff when it lands, a definition the whole chain hinges on. Everything else stays in prose — if everything is boxed, nothing is. - Key ideas: a GitHub-style `> [!IMPORTANT]` callout renders as a distinct outlined block — the one emphasis device for load-bearing ideas. Use it sparingly: an unconditional truth as it's laid down, a payoff when it lands, a definition the whole chain hinges on. Everything else stays in prose — if everything is boxed, nothing is.
- Diagrams: ```mermaid fenced blocks render as real diagrams — reach for one whenever structure, flow, or relationships are the point; a good diagram beats three paragraphs. Node labels with spaces or symbols must use quoted bracket form (`B["u = x^3"]`). - Diagrams: ```mermaid fenced blocks render as real diagrams — reach for one whenever structure, flow, or relationships are the point; a good diagram beats three paragraphs. Node labels with spaces or symbols must use quoted bracket form (`B["u = x^3"]`).
+43 -3
View File
@@ -106,12 +106,42 @@ export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
// quiz variants): duplicates stay honest by sharing SCHEMA ATOMS // quiz variants): duplicates stay honest by sharing SCHEMA ATOMS
// (question-core) + PROMPT CHUNKS, and each tool declares only its // (question-core) + PROMPT CHUNKS, and each tool declares only its
// mechanical delta. // mechanical delta.
//
// ONE sanctioned exception (owner, 2026-10-12): each quiz tool's
// description ends with a COMPLETE example call — compact, phase-correct
// (probe carries no phase metadata; quiz leads with type+nodeId), doubling
// as the JSON-escaping exemplar (LaTeX backslashes double inside JSON
// strings). Placement ruling: WITH THE TOOL, beside its exact schema — a
// shared-prompt example is phase-ambiguous (the models fumbled exactly
// these metadata fields), per-agent prompt copies would drift. Serialized
// from ONE canonical body so the variants cannot disagree; craft doctrine
// stays in the prompts.
/** The canonical example body (owner-approved content, 2026-10-12): a
* compact applied question whose fields exercise the escaping hazards —
* braced commands (\frac, \cos), inline and display math, newlines —
* while doubling as an exemplar of good craft: uniform options, stable
* values, reasoning (explanation) committed before correctAnswer.
* JSON.stringify renders the wire text, so the escaping the model sees
* is by construction the correct one. Exported for the description pins. */
export const quizExampleBody = {
question: "A signal is $y=\\sin(2x)$ and $\\frac{dx}{dt}=3$. What is $\\frac{dy}{dt}$?",
options: [
{ label: "$6\\cos(2x)$", value: "a" },
{ label: "$2\\cos(2x)$", value: "b" },
{ label: "$3\\cos(2x)$", value: "c" },
],
explanation:
"The phase changes twice as fast as $x$; multiply by the sine's slope:\n$$\n\\frac{dy}{dt}=\\cos(2x)\\cdot2\\cdot3=6\\cos(2x)\n$$",
correctAnswer: "a",
};
/** The quiz mechanics every variant shares; `delta` appends the variant's /** The quiz mechanics every variant shares; `delta` appends the variant's
* own fields (its NAME among them — the probe-phase tools self-reference * own fields (its NAME among them — the probe-phase tools self-reference
* as probe/recall, owner 2026-10-09). All mechanics, zero doctrine. */ * as probe/recall, owner 2026-10-09). All mechanics, zero doctrine — plus
* the one sanctioned example (above). */
function quizDescription(delta: { name: "quiz" | "probe"; nodeId: boolean; types: boolean }): string { function quizDescription(delta: { name: "quiz" | "probe"; nodeId: boolean; types: boolean }): string {
return ( const base =
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " + "Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
"Unlike ask_user_question (which collects preferences/decisions with no right answer), " + "Unlike ask_user_question (which collects preferences/decisions with no right answer), " +
delta.name + delta.name +
@@ -129,7 +159,17 @@ function quizDescription(delta: { name: "quiz" | "probe"; nodeId: boolean; types
: "") + : "") +
"ONE question per turn: call a single " + "ONE question per turn: call a single " +
delta.name + delta.name +
", then end your turn — his answer determines the next." ", then end your turn — his answer determines the next.";
// Phase-correct shape: the teach example LEADS with the required pair
// (type, nodeId — the 2026-10-09 drop-order ruling); the probe example
// carries no phase metadata at all, exactly like the probe schema.
const example = delta.nodeId ? { type: "check" as const, nodeId: "chain-rule", ...quizExampleBody } : quizExampleBody;
return (
base +
"\nExample arguments — copy the escaping exactly: inside JSON strings a LaTeX backslash is \\\\ and a newline is \\n.\n```json\n" +
JSON.stringify(example, null, 2) +
"\n```" +
(delta.nodeId ? " The example's nodeId is illustrative — copy an exact id from this session's plan_init." : "")
); );
} }
+129 -15
View File
@@ -1,17 +1,15 @@
/** /**
* Math delimiter preprocessing — the \(...\)/\[...\] contract. * Legacy math delimiter preprocessing — the \(...\)/\[...\] reader.
* *
* The teacher writes inline math as `\(...\)` and display math as `\[...\]`; * Current authoring uses `$...$` inline and `$$...$$` on separate lines for
* a bare `$` is ALWAYS literal currency (system-prompt contract). remark-math * display (owner, 2026-10-12). This pure transform keeps correctly formed
* expects `$...$`/`$$...$$`, so this pure transform rewrites balanced * old backslash-delimited content readable; dollar math passes through to
* delimiters before the markdown hits Streamdown. Combined with * Streamdown's native parser. Escape restoration below recognizes both
* `singleDollarTextMath: false`, the dollar sign can never start math — the * syntaxes while retaining the existing recovery rules.
* currency hazard dies structurally.
* *
* Rules: * Rules:
* - `\(...\)` → `$$...$$` (two-dollar inline: with * - `\(...\)` → `$$...$$` (two-dollar inline, still accepted by micromark),
* `singleDollarTextMath: false` this is the only inline math form micromark * `\[...\]` → `$$\n...\n$$` (block form: flow math).
* still parses), `\[...\]` → `$$\n...\n$$` (block form: flow math).
* - An escaped delimiter (`\\(` = literal backslash + paren) is left alone. * - An escaped delimiter (`\\(` = literal backslash + paren) is left alone.
* - Nothing inside fenced code blocks or inline code spans is touched. * - Nothing inside fenced code blocks or inline code spans is touched.
* - Unbalanced delimiters are left untouched: Streamdown streams chunks, and * - Unbalanced delimiters are left untouched: Streamdown streams chunks, and
@@ -196,8 +194,8 @@ export function transformMathDelimiters(markdown: string): string {
closeEndsLine(markdown, afterClose) ? "" : "\n\n"; closeEndsLine(markdown, afterClose) ? "" : "\n\n";
out.push(`${padBefore}$$\n${content}\n$$${padAfter}`); out.push(`${padBefore}$$\n${content}\n$$${padAfter}`);
} else { } else {
// Two-dollar inline: the only inline math form left with // Preserve the legacy reader's two-dollar inline output
// singleDollarTextMath: false (renders inline, not display). // (renders inline, not display).
out.push(`$$${content}$$`); out.push(`$$${content}$$`);
} }
i = closeAt + close.length; i = closeAt + close.length;
@@ -226,9 +224,9 @@ export function transformMathDelimiters(markdown: string): string {
* control chars can never be legitimate content, so restoring the two-char * control chars can never be legitimate content, so restoring the two-char
* escape reconstructs the model's intent byte-perfectly. Outside spans the * escape reconstructs the model's intent byte-perfectly. Outside spans the
* same chars might be real prose (pasted tables, formatted text), so only * same chars might be real prose (pasted tables, formatted text), so only
* balanced `\(…\)` / `\[…\]` spans are touched — the same scanner rules as * balanced math spans are touched. The legacy `\(…\)` / `\[…\]` pass stays
* transformMathDelimiters (escaped delimiters are literal, unbalanced * unchanged; the dollar reader adds `$…$` and fenced `$$…$$` recognition,
* delimiters pass through untouched). * excluding code and escaped opening dollars. Unbalanced spans pass through.
* *
* ASYMMETRIC NEWLINE RULE (owner ruling 2026-09-27, Option A): inline spans * ASYMMETRIC NEWLINE RULE (owner ruling 2026-09-27, Option A): inline spans
* restore all six debris chars — real newlines/tabs inside INLINE math * restore all six debris chars — real newlines/tabs inside INLINE math
@@ -257,6 +255,12 @@ const DEBRIS_RE = /[\b\t\n\v\f\r]/;
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/; const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
export function restoreMathEscapes(text: string): string { export function restoreMathEscapes(text: string): string {
if (!DEBRIS_RE.test(text)) return text;
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
}
/** Keep legacy recovery byte-for-byte; only the recognized syntax expands. */
function restoreLegacyMathEscapes(text: string): string {
if (!DEBRIS_RE.test(text)) return text; if (!DEBRIS_RE.test(text)) return text;
const out: string[] = []; const out: string[] = [];
let i = 0; let i = 0;
@@ -283,3 +287,113 @@ export function restoreMathEscapes(text: string): string {
} }
return out.join(""); return out.join("");
} }
function dollarRun(text: string, i: number): number {
let count = 0;
while (text[i + count] === "$") count += 1;
return count;
}
/** Native math fences start at a line's block position; one-line $$…$$
* remains inline. A flow opener may have metadata, but never another $. */
function dollarIsDisplay(text: string, i: number, size: number): boolean {
if (size < 2 || !displayIsBlockPositioned(text, i)) return false;
const end = text.indexOf("\n", i + size);
return !text.slice(i + size, end === -1 ? text.length : end).includes("$");
}
/** Match micromark's delimiter runs: exact size inline, at least the
* opener's size on an otherwise-empty line for display. Inside math,
* backslashes are TeX data, not Markdown escapes for closing dollars. */
function findDollarClose(
text: string,
from: number,
size: number,
isDisplay: boolean,
): { at: number; after: number } | undefined {
if (isDisplay) {
let lineEnd = text.indexOf("\n", from);
while (lineEnd !== -1) {
const start = lineEnd + 1;
lineEnd = text.indexOf("\n", start);
const line = text.slice(start, lineEnd === -1 ? text.length : lineEnd);
const close = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(line);
if (close && close[2]!.length >= size) {
const at = start + close[1]!.length;
return { at, after: at + close[2]!.length };
}
}
return undefined;
}
for (let j = from; j < text.length;) {
if (text[j] !== "$") {
j += 1;
continue;
}
const count = dollarRun(text, j);
if (count === size) return { at: j, after: j + count };
j += count;
}
return undefined;
}
/** Dollar recognition only — the same six/five-character repair maps,
* with no over-escaping normalization or mathematical validation. */
function restoreDollarMathEscapes(text: string): string {
if (!DEBRIS_RE.test(text) || !text.includes("$")) return text;
const out: string[] = [];
let i = 0;
while (i < text.length) {
if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) {
i = copyFencedBlock(text, i, out);
continue;
}
if (text[i] === "`") {
const run = backtickRun(text, i);
let j = i + run;
while (j < text.length) {
const nextRun = backtickRun(text, j);
if (nextRun === run) break;
j += Math.max(nextRun, 1);
}
const after = j < text.length ? j + run : i + run;
out.push(text.slice(i, after));
i = after;
continue;
}
// Legacy spans have already received their own inline/display policy.
// Do not reinterpret dollars inside them in this second pass.
if (text[i] === "\\" && !isEscaped(text, i)) {
const next = text[i + 1];
if (next === "(" || next === "[") {
const closeAt = findClose(text, i + 2, next === "[" ? DISPLAY_CLOSE : INLINE_CLOSE);
if (closeAt !== -1) {
out.push(text.slice(i, closeAt + 2));
i = closeAt + 2;
continue;
}
}
}
if (text[i] === "$" && !isEscaped(text, i)) {
const size = dollarRun(text, i);
const isDisplay = dollarIsDisplay(text, i, size);
const close = findDollarClose(text, i + size, size, isDisplay);
if (close) {
const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
out.push(
text.slice(i, i + size) +
text.slice(i + size, close.at).replace(debris, (c) => ESCAPE_DEBRIS[c]!) +
text.slice(close.at, close.after),
);
i = close.after;
continue;
}
out.push(text.slice(i, i + size));
i += size;
continue;
}
out.push(text[i]!);
i += 1;
}
return out.join("");
}
+24 -15
View File
@@ -116,9 +116,9 @@ export function quizInputProblems(input: {
}): string[] { }): string[] {
const { problems, keys } = optionProblems(input.options); const { problems, keys } = optionProblems(input.options);
// Debris (colons/quotes/whitespace) strips inside coerceCorrectAnswer — // Preserve exact option keys before trying copy-syntax cleanup — the
// the shared membership seam (boundary, display, grading). // same option-aware coercion runs at the display and grading seams.
const entries = coerceCorrectAnswer(input.correctAnswer); const entries = coerceCorrectAnswer(input.correctAnswer, keys);
const unique = [...new Set(entries)]; const unique = [...new Set(entries)];
if (unique.length === 0 || unique.some((v) => v.length === 0)) { if (unique.length === 0 || unique.some((v) => v.length === 0)) {
problems.push("correctAnswer is required — provide the value (or label) of the correct option(s)."); problems.push("correctAnswer is required — provide the value (or label) of the correct option(s).");
@@ -606,25 +606,34 @@ export function shuffleOptions(options: QuizOption[], rand: () => number = Math.
// ─── quiz grading ─────────────────────────────────────────────────────────── // ─── quiz grading ───────────────────────────────────────────────────────────
/** The harness sometimes delivers a multi-select correctAnswer array as a /** Preserve an exact option key before trying copy-syntax cleanup: a
* JSON-stringified string because the schema union lists String first. * label's final period, quotes, or backticks can be CONTENT (calculus
* Every entry carries the MODEL-VALUE DEBRIS STRIP (whitehead, * incident, 2026-10-12), not model debris. The actual option values are
* 2026-10-09): glm-5.3 copied option values with the JSON key-value * required so no caller can strip a valid answer before membership.
* colon glued on (`[":parmenides"]`); membership comparisons on ALL * Non-matches keep the existing whitehead heal (`[":parmenides"]`) and
* three seams (boundary check, display parse, grading) route through * JSON-stringified multi-select coercion. Shared by boundary, display,
* here, so the strip heals new calls AND already-poisoned records. */ * and grading; canonical option keys are never rewritten. */
export function coerceCorrectAnswer(correctAnswer: string | string[]): string[] { export function coerceCorrectAnswer(
if (Array.isArray(correctAnswer)) return correctAnswer.map(stripModelValueDebris); correctAnswer: string | string[],
optionValues: readonly string[],
): string[] {
const clean = (value: string): string => {
const trimmed = value.trim();
return optionValues.includes(trimmed) ? trimmed : stripModelValueDebris(value);
};
if (Array.isArray(correctAnswer)) return correctAnswer.map(clean);
const trimmed = correctAnswer.trim(); const trimmed = correctAnswer.trim();
// An exact string key can itself look like a JSON array.
if (optionValues.includes(trimmed)) return [trimmed];
if (trimmed.startsWith("[") && trimmed.endsWith("]")) { if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
try { try {
const parsed = JSON.parse(trimmed); const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) return parsed.map((v) => stripModelValueDebris(String(v))); if (Array.isArray(parsed)) return parsed.map((v) => clean(String(v)));
} catch { } catch {
// not valid JSON — treat as a single literal value // not valid JSON — treat as a single literal value
} }
} }
return [stripModelValueDebris(correctAnswer)]; return [clean(correctAnswer)];
} }
/** Resolve author-supplied value(s) to 1-based display indices. Keying by /** Resolve author-supplied value(s) to 1-based display indices. Keying by
@@ -635,7 +644,7 @@ export function resolveCorrect(
options: QuizOption[], options: QuizOption[],
): { indices: number[]; error?: string } { ): { indices: number[]; error?: string } {
if (correctAnswer === undefined) return { indices: [], error: "correctAnswer is required" }; if (correctAnswer === undefined) return { indices: [], error: "correctAnswer is required" };
const arr = coerceCorrectAnswer(correctAnswer); const arr = coerceCorrectAnswer(correctAnswer, options.map((o) => o.value));
if (arr.length === 0) return { indices: [], error: "correctAnswer is required" }; if (arr.length === 0) return { indices: [], error: "correctAnswer is required" };
const byValue = new Map(options.map((o, i) => [o.value, i + 1])); const byValue = new Map(options.map((o, i) => [o.value, i + 1]));
const indices: number[] = []; const indices: number[] = [];
+2 -2
View File
@@ -35,7 +35,7 @@ import { buildSubagentSteer } from "../src/core/contracts.ts";
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts"; import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
import { chatStore } from "../src/store/chats.ts"; import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts"; import { jobStore } from "../src/store/jobs.ts";
import { buildAnswer, coerceCorrectAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts"; import { buildAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult } from "../src/core/native-messages.ts"; import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult } from "../src/core/native-messages.ts";
import { projectView } from "../src/core/view.ts"; import { projectView } from "../src/core/view.ts";
import type { PiEngine } from "../src/agent/models.ts"; import type { PiEngine } from "../src/agent/models.ts";
@@ -113,7 +113,7 @@ function gradedAnswer(input: typeof QUIZ_INPUT, values: string[]) {
question: input.question, question: input.question,
mode: "single-select", mode: "single-select",
options, options,
correctIndices: resolveCorrect(coerceCorrectAnswer(input.correctAnswer), options).indices, correctIndices: resolveCorrect(input.correctAnswer, options).indices,
explanation: input.explanation, explanation: input.explanation,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
+6 -2
View File
@@ -295,8 +295,12 @@ describe("practice prompt composition (own directory + the formatting appendix)"
it("the SHARED formatting guide composes (owner, 2026-10-04 late): LaTeX delimiters in every setting", () => { it("the SHARED formatting guide composes (owner, 2026-10-04 late): LaTeX delimiters in every setting", () => {
const prompt = composeInstructions("practice"); const prompt = composeInstructions("practice");
// The delimiter law — practice must write \(...\)/\[...\] too. // The standard delimiter law is shared by all three authoring agents.
assert.match(prompt, /NEVER `\$` or `\$\$`/); for (const agent of ["practice", "prober", "teacher"] as const) {
const instructions = composeInstructions(agent);
assert.ok(instructions.includes("Inline `$f(x)$`, display `$$` fenced on its own lines."));
assert.ok(!instructions.includes("NEVER `$` or `$$`"));
}
assert.ok(prompt.indexOf("# The practice session") < prompt.indexOf("## Formatting"), "formatting rides as the appendix"); assert.ok(prompt.indexOf("# The practice session") < prompt.indexOf("## Formatting"), "formatting rides as the appendix");
}); });
+99 -5
View File
@@ -36,6 +36,7 @@ import {
toClientQuestion, toClientQuestion,
} from "../src/core/question-core.ts"; } from "../src/core/question-core.ts";
import { toolQuestionKind } from "../src/core/pending.ts"; import { toolQuestionKind } from "../src/core/pending.ts";
import { parseQuizInput } from "../src/core/tool-parts.ts";
// ─── the authoring contract (TypeBox schemas + execute-time refinements) ── // ─── the authoring contract (TypeBox schemas + execute-time refinements) ──
// Schema-valid ⇔ gradeable: a quiz whose correctAnswer can't be resolved // Schema-valid ⇔ gradeable: a quiz whose correctAnswer can't be resolved
@@ -249,20 +250,28 @@ describe("shuffleOptions", () => {
// ─── coerceCorrectAnswer ──────────────────────────────────────────────────── // ─── coerceCorrectAnswer ────────────────────────────────────────────────────
describe("coerceCorrectAnswer", () => { describe("coerceCorrectAnswer", () => {
const values = ["mercury", "a", "b"];
it("wraps a single string", () => { it("wraps a single string", () => {
assert.deepEqual(coerceCorrectAnswer("mercury"), ["mercury"]); assert.deepEqual(coerceCorrectAnswer("mercury", values), ["mercury"]);
}); });
it("passes arrays through", () => { it("passes arrays through", () => {
assert.deepEqual(coerceCorrectAnswer(["a", "b"]), ["a", "b"]); assert.deepEqual(coerceCorrectAnswer(["a", "b"], values), ["a", "b"]);
}); });
it("parses a JSON-stringified multi-select array", () => { it("parses a JSON-stringified multi-select array", () => {
assert.deepEqual(coerceCorrectAnswer('["a", "b"]'), ["a", "b"]); assert.deepEqual(coerceCorrectAnswer('["a", "b"]', values), ["a", "b"]);
}); });
it("leaves a non-JSON bracketed string alone", () => { it("leaves a non-JSON bracketed string alone", () => {
assert.deepEqual(coerceCorrectAnswer("[not json"), ["[not json"]); assert.deepEqual(coerceCorrectAnswer("[not json", values), ["[not json"]);
});
it("preserves an exact key even when it looks like a JSON array", () => {
const key = '["a", "b"]';
assert.deepEqual(coerceCorrectAnswer(key, [...values, key]), [key]);
assert.deepEqual(coerceCorrectAnswer("[]", ["[]"]), ["[]"]);
}); });
}); });
@@ -302,6 +311,91 @@ describe("resolveCorrect", () => {
}); });
}); });
describe("exact answer membership precedes debris cleanup", () => {
it("the calculus label's final period survives boundary, display, shuffle, and grading", () => {
const correct = "$f$ need not be differentiable at $a$.";
const input = {
question: "All partial derivatives of $f$ exist at $a$. Must $f$ be differentiable there?",
options: [{ label: "$f$ is differentiable at $a$." }, { label: correct }],
explanation: "Existing partial derivatives need not give a linear approximation.",
correctAnswer: correct,
};
const before = JSON.stringify(input);
assert.ok(boundaryOk(probeQuizInputSchema, input, quizInputProblems(input)));
const parsed = parseQuizInput(input);
assert.ok(parsed, "the accepted raw call must also render");
assert.equal(parsed.correctAnswer, correct);
const options = normalizeOptions(parsed.options).reverse();
const resolved = resolveCorrect(parsed.correctAnswer, options);
assert.deepEqual(resolved, { indices: [1] });
const qd: QuestionData = {
id: "calculus-period",
kind: "quiz",
question: parsed.question,
mode: "single-select",
options,
correctIndices: resolved.indices,
explanation: parsed.explanation,
createdAt: "2026-10-12T00:00:00Z",
};
assert.equal(buildAnswer(qd, { values: [correct] }).correct, true);
assert.equal(buildAnswer(qd, { values: [input.options[0]!.label] }).correct, false);
assert.equal(JSON.stringify(input), before, "raw arguments are never rewritten");
});
it("retains punctuation, quotes, colons, and backticks in exact labels and explicit keys", () => {
for (const key of ["value.", "value:", '"value"', "'value'", "`value`", ":value"]) {
for (const option of [{ label: key }, { label: "A claim", value: key }]) {
const input = {
question: "Which claim?",
options: [option, { label: "Other", value: "other" }],
explanation: "The first claim follows.",
correctAnswer: key,
};
assert.deepEqual(quizInputProblems(input), [], key);
assert.ok(parseQuizInput(input), key);
assert.deepEqual(resolveCorrect(key, normalizeOptions(input.options)), { indices: [1] }, key);
}
}
});
it("never redirects an exact punctuated key to a different debris-free option", () => {
const keys = ["value", "value.", '"value"', "`value`", ":value"];
const options = normalizeOptions(keys.map((value, i) => ({ label: `Claim ${i}`, value })));
for (const [i, key] of keys.entries()) {
assert.deepEqual(resolveCorrect(key, options), { indices: [i + 1] }, key);
}
assert.deepEqual(options.map((o) => o.value), keys, "canonical keys stay distinct and untouched");
});
it("preserves exact multi-select entries while still healing non-matching copy debris", () => {
const input = {
question: "Which claims?",
options: [{ label: "First." }, { label: "Second", value: "second" }],
explanation: "Both claims follow.",
multiSelect: true,
};
for (const correctAnswer of [["First.", ': "second"'], JSON.stringify(["First.", ': "second"'])]) {
assert.deepEqual(quizInputProblems({ ...input, correctAnswer }), []);
assert.ok(parseQuizInput({ ...input, correctAnswer }));
assert.deepEqual(resolveCorrect(correctAnswer, normalizeOptions(input.options)), { indices: [1, 2] });
}
});
it("retains an exact JSON-looking option key across validation and grading", () => {
const key = '["a", "b"]';
const input = {
question: "Which literal?",
options: [{ label: key }, { label: "Other" }],
explanation: "The first literal is intended.",
correctAnswer: key,
};
assert.deepEqual(quizInputProblems(input), []);
assert.ok(parseQuizInput(input));
assert.deepEqual(resolveCorrect(key, normalizeOptions(input.options)), { indices: [1] });
});
});
// ─── isCorrect (exact-set match) ──────────────────────────────────────────── // ─── isCorrect (exact-set match) ────────────────────────────────────────────
describe("isCorrect", () => { describe("isCorrect", () => {
@@ -692,7 +786,7 @@ describe("the quiz role field (predict | check)", () => {
question: valid.question, question: valid.question,
mode: "single-select", mode: "single-select",
options, options,
correctIndices: resolveCorrect(coerceCorrectAnswer(valid.correctAnswer), options).indices, correctIndices: resolveCorrect(valid.correctAnswer, options).indices,
explanation: valid.explanation, explanation: valid.explanation,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
+43 -1
View File
@@ -21,13 +21,14 @@ import {
createTeacherTools, createTeacherTools,
probeQuizTool, probeQuizTool,
probeExplainTool, probeExplainTool,
quizExampleBody,
teachExplainToolFor, teachExplainToolFor,
teachQuizToolFor, teachQuizToolFor,
type TeacherToolContext, type TeacherToolContext,
} from "../src/agent/tools.ts"; } from "../src/agent/tools.ts";
import { TOGGLEABLE_TOOLS } from "../src/core/pending.ts"; import { TOGGLEABLE_TOOLS } from "../src/core/pending.ts";
import { jobStore } from "../src/store/jobs.ts"; import { jobStore } from "../src/store/jobs.ts";
import { probeQuizInputSchema, quizInputObject } from "../src/core/question-core.ts"; import { probeQuizInputSchema, quizInputObject, quizInputProblems } from "../src/core/question-core.ts";
import { chatStore } from "../src/store/chats.ts"; import { chatStore } from "../src/store/chats.ts";
import { PATHS } from "../config.ts"; import { PATHS } from "../config.ts";
@@ -208,6 +209,47 @@ describe("the prober's quiz: no nodeId field at all", () => {
assert.equal(Value.Check(probe.parameters, { ...validQuiz, nodeId: "anything" }), true, "no session check to fail"); assert.equal(Value.Check(probe.parameters, { ...validQuiz, nodeId: "anything" }), true, "no session check to fail");
}); });
}); });
// ─── the description example (owner, 2026-10-12) ─────────────────────────
describe("quiz tool descriptions carry the complete, phase-correct example", () => {
it("the canonical body is schema-valid and authoring-clean (never ship a broken exemplar)", () => {
assert.deepEqual(quizInputProblems(quizExampleBody), []);
assert.equal(Value.Check(probeQuizInputSchema, quizExampleBody), true);
const teachExample = { type: "check", nodeId: "chain-rule", ...quizExampleBody };
assert.equal(Value.Check(quizInputObject, teachExample), true);
});
it("probe: the serialized body rides the description with correct JSON escaping, no phase metadata", () => {
const desc = probeQuizTool.description;
assert.ok(
desc.includes("```json\n" + JSON.stringify(quizExampleBody, null, 2) + "\n```"),
"the example is the exact JSON.stringify of the canonical body",
);
// The escaping exemplar must be VISIBLE as wire text — the runtime
// string carries literal double backslashes, exactly what the model
// must type inside JSON.
assert.ok(desc.includes("A signal is $y=\\\\sin(2x)$"), "LaTeX command backslash shows doubled");
assert.ok(desc.includes("a LaTeX backslash is \\\\ and a newline is \\n"), "the one-line escaping hint");
assert.ok(!desc.includes("nodeId"), "probe example carries no phase metadata");
});
it("quiz: the required pair leads the example, with the plan-id caveat", () => {
const chatId = newChat("Teacher: description example");
const desc = teachQuizToolFor({ chatId, chatStore }).description;
const teachExample = { type: "check", nodeId: "chain-rule", ...quizExampleBody };
assert.ok(desc.includes("```json\n" + JSON.stringify(teachExample, null, 2) + "\n```"));
// The 2026-10-09 drop-order ruling: type+nodeId LEAD the object —
// fields generated first get dropped least.
assert.ok(
/```json\n\{\n {2}"type": "check",\n {2}"nodeId": "chain-rule",/.test(desc),
"type and nodeId lead the serialized example",
);
assert.ok(
desc.includes("illustrative — copy an exact id from this session's plan_init"),
"the illustrative-nodeId caveat",
);
});
});
// ─── tool availability (2026-09 settings seam) ─────────────────────────────── // ─── tool availability (2026-09 settings seam) ───────────────────────────────
describe("tool toggles (core/pending.ts + factories)", () => { describe("tool toggles (core/pending.ts + factories)", () => {
+102 -19
View File
@@ -25,7 +25,6 @@ import type { JobRecord } from "../src/core/contracts.ts";
import { AnsweredCard } from "../components/answered-card.tsx"; import { AnsweredCard } from "../components/answered-card.tsx";
import { import {
buildAnswer, buildAnswer,
coerceCorrectAnswer,
normalizeOptions, normalizeOptions,
resolveCorrect, resolveCorrect,
questionNeedsReveal, questionNeedsReveal,
@@ -47,7 +46,7 @@ function quizQD(): QuestionData {
question: quizInput.question, question: quizInput.question,
mode: "single-select", mode: "single-select",
options, options,
correctIndices: resolveCorrect(coerceCorrectAnswer(quizInput.correctAnswer), options).indices, correctIndices: resolveCorrect(quizInput.correctAnswer, options).indices,
explanation: quizInput.explanation, explanation: quizInput.explanation,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
@@ -342,14 +341,13 @@ describe("render smoke tests", () => {
}); });
describe("math rendering (streamdown + KaTeX)", () => { describe("math rendering (streamdown + KaTeX)", () => {
// New delimiter contract (W3): the teacher writes \(...\) / \[...\]; a // Standard authoring: $...$ inline and $$ on separate lines for display.
// bare $ is ALWAYS literal currency (singleDollarTextMath: false kills it // The legacy backslash reader remains for existing content.
// structurally). transformMathDelimiters rewrites both to the two-dollar // Full coverage: render-markdown.test.tsx.
// forms remark-math still parses. Full coverage: render-markdown.test.tsx. it("renders standard $...$ as inline math", () => {
it("renders $...$ as LITERAL text — a $ is never math (the currency fix)", () => {
const html = renderToStaticMarkup(<MessageResponse>{"The map $f(x) = x^2$ is smooth."}</MessageResponse>); const html = renderToStaticMarkup(<MessageResponse>{"The map $f(x) = x^2$ is smooth."}</MessageResponse>);
assert.ok(!html.includes("katex"), `$-math must not render, got: ${html.slice(0, 300)}`); assert.ok(html.includes("katex"), `$-math must render, got: ${html.slice(0, 300)}`);
assert.match(html, /\$f\(x\) = x\^2\$/, "old-contract math renders verbatim"); assert.ok(!html.includes("katex-display"), "single-dollar math is inline");
}); });
it("renders display math ($$ fenced on own lines — the transform's target form)", () => { it("renders display math ($$ fenced on own lines — the transform's target form)", () => {
@@ -366,10 +364,8 @@ describe("math rendering (streamdown + KaTeX)", () => {
assert.ok(html.includes("katex") && !html.includes("katex-display"), "one-liner should render inline"); assert.ok(html.includes("katex") && !html.includes("katex-display"), "one-liner should render inline");
}); });
it("does NOT eat escaped currency (\\$5 stays text — belt and suspenders)", () => { it("does NOT eat escaped currency (\\$5 stays text)", () => {
// With singleDollarTextMath: false even a bare $5 can't parse as math; // With dollar math enabled, Markdown-escaped dollars remain literal.
// an escaped \$ is doubly safe. The prompt contract still asks the
// teacher to prefer plain "$5" (it renders literally now).
const html = renderToStaticMarkup(<MessageResponse>{"It costs \\$5 and \\$10 more."}</MessageResponse>); const html = renderToStaticMarkup(<MessageResponse>{"It costs \\$5 and \\$10 more."}</MessageResponse>);
assert.ok(!html.includes("katex"), "escaped currency must not parse as math"); assert.ok(!html.includes("katex"), "escaped currency must not parse as math");
assert.match(html, /\$5/); assert.match(html, /\$5/);
@@ -377,10 +373,10 @@ describe("math rendering (streamdown + KaTeX)", () => {
it("quiz cards render math in question, options, and explanation", () => { it("quiz cards render math in question, options, and explanation", () => {
const mathQuiz = { const mathQuiz = {
question: "What is \\(\\frac{dy}{dx}\\) of \\(y = x^2\\)?", question: "What is $\\frac{dy}{dx}$ of $y = x^2$?",
options: [{ label: "\\(2x\\)" }, { label: "\\(x^2\\)" }, { label: "\\(x\\)" }], options: [{ label: "$2x$" }, { label: "$x^2$" }, { label: "$x$" }],
correctAnswer: "\\(2x\\)", correctAnswer: "$2x$",
explanation: "The power rule gives \\(\\frac{dy}{dx} = 2x\\).", explanation: "The power rule gives $\\frac{dy}{dx} = 2x$.",
}; };
const pendingHtml = renderDock({ toolName: "quiz", toolCallId: "m1", input: mathQuiz as never }); const pendingHtml = renderDock({ toolName: "quiz", toolCallId: "m1", input: mathQuiz as never });
assert.ok(pendingHtml.includes("katex"), "pending dock: question/options math renders"); assert.ok(pendingHtml.includes("katex"), "pending dock: question/options math renders");
@@ -392,12 +388,12 @@ describe("math rendering (streamdown + KaTeX)", () => {
question: mathQuiz.question, question: mathQuiz.question,
mode: "single-select", mode: "single-select",
options, options,
correctIndices: resolveCorrect(coerceCorrectAnswer(mathQuiz.correctAnswer), options).indices, correctIndices: resolveCorrect(mathQuiz.correctAnswer, options).indices,
explanation: mathQuiz.explanation, explanation: mathQuiz.explanation,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
const answeredHtml = renderToStaticMarkup( const answeredHtml = renderToStaticMarkup(
<AnsweredCard answer={buildAnswer(qd, { values: ["\\(2x\\)"] })} />, <AnsweredCard answer={buildAnswer(qd, { values: ["$2x$"] })} />,
); );
assert.ok(answeredHtml.includes("katex"), "answered card: option/explanation math renders"); assert.ok(answeredHtml.includes("katex"), "answered card: option/explanation math renders");
}); });
@@ -692,3 +688,90 @@ describe("predict vs check rendering", () => {
assert.ok(legacyHtml.includes("a 2026-09 answer") || legacyHtml.includes('data-testid="your-thinking-trigger"'), "records from before the rename still render"); assert.ok(legacyHtml.includes("a 2026-09 answer") || legacyHtml.includes('data-testid="your-thinking-trigger"'), "records from before the rename still render");
}); });
}); });
// ─── details + option descriptions render as markdown (owner, 2026-10-12) ──
describe("question context renders as markdown everywhere (owner, 2026-10-12)", () => {
it("pending dock: ask option descriptions render LaTeX, not plain text", () => {
const html = renderDock({
toolName: "ask_user_question",
toolCallId: "d1",
input: {
question: "Which tool?",
options: [
{ label: "Pencil", description: "Wood around $\\text{Pb}_x\\text{C}$ graphite — the classic $x^2$ choice." },
{ label: "Pen" },
],
} as never,
});
// The description rides the same MessageResponse path as the label:
// KaTeX spans render inside the option subtext.
const katexCount = (html.match(/class="katex"/g) ?? []).length;
assert.ok(katexCount >= 2, `option description math renders (got ${katexCount})`);
assert.ok(!html.includes("$\\text"), "raw dollar delimiters do not leak as visible text");
});
it("settled ask card keeps the details line and option descriptions — retroactively from the persisted call", () => {
const input = {
question: "Which destination?",
details: "Inline $a^2+b^2=c^2$ and display:\n$$\nE = mc^2\n$$",
options: [
{ label: "Classical", description: "Entropy $H(X)$ first." },
{ label: "ML toolkit" },
],
};
const qd: QuestionData = {
id: "d2",
kind: "ask",
question: input.question,
mode: "single-select",
options: normalizeOptions(input.options),
createdAt: new Date().toISOString(),
};
const answer = buildAnswer(qd, { values: ["Classical"] });
const html = renderToStaticMarkup(<AnsweredCard answer={answer} input={input} />);
const katexCount = (html.match(/class="katex"/g) ?? []).length;
assert.ok(katexCount >= 3, `details (inline+display) + description math render (got ${katexCount})`);
assert.match(html, /Entropy/, "the option description text is present");
assert.ok(html.includes("katex-display"), "display math in the details line renders");
});
it("settled quiz card keeps the details line; quiz options stay description-less", () => {
const input = {
question: "Typical sets?",
details: "Recall $2^{nH(X)}$ — the asymptotic equipartition bound.",
options: [
{ label: "$\\approx 2^{nH(X)}$", value: "a" },
{ label: "$nH(X)$", value: "b" },
],
explanation: "AEP.",
correctAnswer: "a",
};
const options = normalizeOptions(input.options);
const qd: QuestionData = {
id: "d3",
kind: "quiz",
question: input.question,
mode: "single-select",
options,
correctIndices: resolveCorrect(input.correctAnswer, options).indices,
explanation: input.explanation,
createdAt: new Date().toISOString(),
};
const answer = buildAnswer(qd, { values: ["a"] });
const html = renderToStaticMarkup(<AnsweredCard answer={answer} input={input} />);
const katexCount = (html.match(/class="katex"/g) ?? []).length;
assert.ok(katexCount >= 3, `details + option-label math renders (got ${katexCount})`);
// Quiz options carry NO description field at all (owner ruling
// 2026-09-26) — the desc map is ask-only and stays empty here.
assert.ok(!html.includes("lh-prose block text-xs"), "no option-subtext slot on quiz rows");
});
it("legacy settled records (no input prop) render exactly as before", () => {
const answer = buildAnswer(askQD(), { values: ["Very"] });
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
assert.match(html, /Very/, "the card renders");
assert.equal((html.match(/class="katex"/g) ?? []).length, 0, "no context slot appears from nothing");
assert.ok(!html.includes("lh-prose block text-xs"), "no description slot without an ask input");
});
});
+20 -7
View File
@@ -94,7 +94,7 @@ describe("math delimiter transform (pure unit)", () => {
}); });
}); });
describe("render — new math contract (streamdown + KaTeX)", () => { describe("render — standard dollar math + legacy compatibility (streamdown + KaTeX)", () => {
it("renders \\(...\\) as inline KaTeX", () => { it("renders \\(...\\) as inline KaTeX", () => {
const html = render("The map \\(f(x) = x^2\\) is smooth."); const html = render("The map \\(f(x) = x^2\\) is smooth.");
assert.ok(html.includes("katex"), `inline math should render, got: ${html.slice(0, 300)}`); assert.ok(html.includes("katex"), `inline math should render, got: ${html.slice(0, 300)}`);
@@ -121,16 +121,29 @@ describe("render — new math contract (streamdown + KaTeX)", () => {
assert.equal(items.length, 2, "both list items render"); assert.equal(items.length, 2, "both list items render");
}); });
it("renders bare $ as literal currency — the hazard dies structurally", () => { it("keeps escaped currency literal", () => {
const html = render("It costs $5 and $10 more."); const html = render("It costs \\$5 and \\$10 more.");
assert.ok(!html.includes("katex"), "currency must never parse as math"); assert.ok(!html.includes("katex"), "escaped currency must not parse as math");
assert.match(html, /\$5 and \$10/); assert.match(html, /\$5 and \$10/);
}); });
it("renders old-contract $...$ math as literal text (singleDollarTextMath: false)", () => { it("renders standard $...$ math inline", () => {
const html = render("The map $f(x) = x^2$ is smooth."); const html = render("The map $f(x) = x^2$ is smooth.");
assert.ok(!html.includes("katex"), "$ can never start math"); assert.ok(html.includes("katex"), "single-dollar math renders");
assert.match(html, /\$f\(x\) = x\^2\$/, "the old form renders verbatim"); assert.ok(!html.includes("katex-display"), "single-dollar math is inline");
});
it("renders standard dollar commands and fenced display math", () => {
const html = render("The symbol $\\Omega$ appears here.\n\n$$\nS = k_B \\ln \\Omega\n$$");
assert.ok(html.includes("katex-display"), "standalone dollar fences render display math");
assert.ok(html.includes("Ω"), "the command renders as the Greek symbol");
assert.ok(!html.includes("katex-error"), "both formulas are valid");
});
it("keeps dollar examples in inline code and code fences literal", () => {
const html = render("Use `$x$` here.\n\n```text\n$$\ny = x^2\n$$\n```");
assert.ok(!html.includes("katex"), "code examples are not math");
assert.ok(html.includes("$x$"), "inline code retains its delimiters");
}); });
it("keeps \\(...\\) inside a code fence literal", () => { it("keeps \\(...\\) inside a code fence literal", () => {
+26 -4
View File
@@ -34,6 +34,7 @@ import {
import { parseQuizInput } from "../src/core/tool-parts.ts"; import { parseQuizInput } from "../src/core/tool-parts.ts";
import { resolveTargetId } from "../src/core/chains.ts"; import { resolveTargetId } from "../src/core/chains.ts";
import { bareQuestionTools } from "../src/agent/teacher-agent.ts"; import { bareQuestionTools } from "../src/agent/teacher-agent.ts";
import { probeQuizTool } from "../src/agent/tools.ts";
/** The incident's exact encoding: the value wrapped in literal quotes. */ /** The incident's exact encoding: the value wrapped in literal quotes. */
function QUOTED(value: string): string { function QUOTED(value: string): string {
@@ -126,9 +127,9 @@ describe("the colon shape — correctAnswer with copy-syntax debris", () => {
assert.equal(stripModelValueDebris('["x"]'), '["x"]'); // interior quotes are content (JSON-array strings) assert.equal(stripModelValueDebris('["x"]'), '["x"]'); // interior quotes are content (JSON-array strings)
}); });
it("coerceCorrectAnswer strips every entry (boundary check + grading seam)", () => { it("coerceCorrectAnswer cleans non-matching entries (boundary check + grading seam)", () => {
assert.deepEqual(coerceCorrectAnswer([":parmenides"]), ["parmenides"]); assert.deepEqual(coerceCorrectAnswer([":parmenides"], ["parmenides"]), ["parmenides"]);
assert.deepEqual(coerceCorrectAnswer(QUOTED("primary-external")), ["primary-external"]); assert.deepEqual(coerceCorrectAnswer(QUOTED("primary-external"), ["primary-external"]), ["primary-external"]);
}); });
it("the incident's exact call passes the semantic boundary (quizInputProblems)", () => { it("the incident's exact call passes the semantic boundary (quizInputProblems)", () => {
@@ -153,6 +154,27 @@ describe("the colon shape — correctAnswer with copy-syntax debris", () => {
}); });
}); });
describe("the calculus retry loop — an exact answer was incorrectly stripped", () => {
it("the real probe tool accepts a label with its sentence-final period", async () => {
const input = {
question: "Which claim?",
options: [{ label: "The claim holds." }, { label: "The claim need not hold." }],
explanation: "The premise does not imply the claim.",
correctAnswer: "The claim need not hold.",
};
const validated = validateToolArguments(probeQuizTool, {
type: "toolCall",
id: "calculus-period",
name: "probe",
arguments: probeQuizTool.prepareArguments!(input),
});
assert.equal(validated.correctAnswer, input.correctAnswer);
const result = await probeQuizTool.execute("calculus-period", validated);
assert.notEqual(result.isError, true, "an already-exact answer must not trigger a retry");
assert.equal(result.terminate, true, "the valid question is posed normally");
});
});
describe("the display mirror — raw records heal on read", () => { describe("the display mirror — raw records heal on read", () => {
it("parseQuizInput renders the incident's persisted raw record (no invisible quiz)", () => { it("parseQuizInput renders the incident's persisted raw record (no invisible quiz)", () => {
const raw = whiteheadQuiz(QUOTED("predict"), [":parmenides"]); const raw = whiteheadQuiz(QUOTED("predict"), [":parmenides"]);
@@ -163,6 +185,6 @@ describe("the display mirror — raw records heal on read", () => {
// which strips the recorded wobble to the intended role. // which strips the recorded wobble to the intended role.
assert.equal(normalizeQuizType(parsed?.type), "predict"); assert.equal(normalizeQuizType(parsed?.type), "predict");
// The graded answer resolves to the option despite the recorded colon. // The graded answer resolves to the option despite the recorded colon.
assert.deepEqual(parsed ? coerceCorrectAnswer(parsed.correctAnswer) : [], ["parmenides"]); assert.deepEqual(parsed ? coerceCorrectAnswer(parsed.correctAnswer, parsed.options.map((o) => o.value ?? o.label)) : [], ["parmenides"]);
}); });
}); });
+84
View File
@@ -12,6 +12,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { quizInputProblems } from "../src/core/question-core.ts"; import { quizInputProblems } from "../src/core/question-core.ts";
import { Value } from "typebox/value"; import { Value } from "typebox/value";
import { restoreMathEscapes } from "../src/core/math-delims.ts";
import { import {
parseAskInput, parseAskInput,
parseProbeDoneInput, parseProbeDoneInput,
@@ -279,6 +280,89 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () =>
}); });
}); });
describe("dollar-delimiter recognition — existing escape restoration rules", () => {
it("restores question, option, and explanation debris in dollar math", () => {
const parsed = parseQuizInput({
question: "What does $\x0corall x$ mean?",
options: [{ label: "$\x0barphi$ holds", value: "yes" }, { label: "No", value: "no" }],
correctAnswer: "yes",
explanation: "Here $p\to q$ is an implication.",
});
assert.ok(parsed);
assert.equal(parsed.question, "What does $\\forall x$ mean?");
assert.equal(parsed.options[0]!.label, "$\\varphi$ holds");
assert.equal(parsed.explanation, "Here $p\\to q$ is an implication.");
});
it("keeps the same six-character inline recovery map", () => {
for (const [debris, command] of [
["\beta", "\\beta"], ["\to", "\\to"], ["\neq", "\\neq"],
["\x0barphi", "\\varphi"], ["\x0corall", "\\forall"], ["\rho", "\\rho"],
]) {
assert.equal(restoreMathEscapes(`$${debris}$`), `$${command}$`);
}
});
it("preserves display newlines and real LaTeX row breaks", () => {
const math = "Compare:\n$$\n\\begin{aligned}\nx &= 1 \\\\\ny &= 2\n\\end{aligned}\n$$";
assert.equal(restoreMathEscapes(math), math);
});
it("restores five display debris characters but keeps newline ambiguity unchanged", () => {
for (const [debris, command] of [
["\beta", "\\beta"], ["\to", "\\to"], ["\x0barphi", "\\varphi"],
["\x0corall", "\\forall"], ["\\left(x \right)", "\\left(x \\right)"],
]) {
assert.equal(
restoreMathEscapes(`Compare:\n$$\n${debris}, p \neq q\n$$`),
`Compare:\n$$\n${command}, p \neq q\n$$`,
);
}
});
it("preserves legacy replacement frequency when a span has multiple debris characters", () => {
const body = "p\to q, \rho";
const legacy = restoreMathEscapes(`\\(${body}\\)`).slice(2, -2);
assert.equal(restoreMathEscapes(`$${body}$`), `$${legacy}$`);
assert.equal(legacy, "p\\to q, \rho", "the existing first-match-only behavior is unchanged");
});
it("shares dollar recovery with ask and free-recall fields", () => {
const ask = parseAskInput({ question: "Goal?", options: [{ label: "$\x0corall$ rigor" }, { label: "Intuition" }] });
assert.ok(ask);
assert.equal(ask.options![0]!.label, "$\\forall$ rigor");
const explain = parseExplainInput({ question: "Explain $p\to q$", correctAnswer: "$p\neq q$ is different." });
assert.ok(explain);
assert.equal(explain.question, "Explain $p\\to q$");
assert.equal(explain.correctAnswer, "$p\\neq q$ is different.");
});
it("leaves non-math debris, escaped opening dollars, and code examples untouched", () => {
const prose = "Pasted:\t\x0cb. \\$\x0corall x$. A good $x$.";
assert.equal(restoreMathEscapes(prose), prose);
const code = "Literal `$\x0corall x$`.\n\n```text\n$\x0barphi$\n```\n\nReal $\x0corall x$.";
assert.equal(restoreMathEscapes(code), code.replace("Real $\x0corall x$", "Real $\\forall x$"));
});
it("leaves unbalanced and mismatched delimiter runs untouched", () => {
for (const text of ["$\x0corall", "$$\n\x0corall x\n", "$\x0corall$$", "$$\x0corall$"]) {
assert.equal(restoreMathEscapes(text), text);
}
});
it("recognizes native inline runs and longer display closing fences", () => {
assert.equal(restoreMathEscapes("Inline $$p\neq q$$."), "Inline $$p\\neq q$$.");
assert.equal(restoreMathEscapes("$$\n\x0corall x\n$$$"), "$$\n\\forall x\n$$$");
});
it("does not add an over-escaping repair or reprocess legacy spans", () => {
const overescaped = "$$\n\\\\Omega\n$$";
assert.equal(restoreMathEscapes(overescaped), overescaped);
const legacy = "\\[\np $a\neq b$\n\\]";
assert.equal(restoreMathEscapes(legacy), legacy);
});
});
// ─── parseExplainInput (free-recall display parse, owner 2026-09-27) ─────── // ─── parseExplainInput (free-recall display parse, owner 2026-09-27) ───────
describe("parseExplainInput: the dock's display seam for explain calls", () => { describe("parseExplainInput: the dock's display seam for explain calls", () => {