diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index 96f47e1..e4b533b 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -331,10 +331,10 @@ export type MessageResponseProps = ComponentProps; // streamdown@2.5 expects shiki@4 types. Both work at runtime (each ships its // own highlighter); the cast papers over the .d.ts mismatch. // -// singleDollarTextMath: false — a bare $ is ALWAYS literal currency (the -// teacher-prompt contract). Inline math arrives as \(...\) and display math -// as \[...\]; transformMathDelimiters (below) rewrites both to the -// two-dollar forms remark-math still parses. $ can never start math. +// Standard dollar authoring (owner, 2026-10-12): $...$ inline, $$ fenced +// on separate lines for display. transformMathDelimiters stays as the +// compatibility reader for old \(...\)/\[...\] content; its two-dollar +// output remains valid alongside native single-dollar math. // // remark-breaks rides in the cjk plugin's remarkPluginsAfter slot (public // PluginConfig API): Streamdown's bare `remarkPlugins` PROP would REPLACE the @@ -352,16 +352,14 @@ export const streamdownPlugins = { remarkPluginsAfter: [...cjk.remarkPluginsAfter, remarkBreaks], }, code, - math: createMathPlugin({ singleDollarTextMath: false }), + math: createMathPlugin({ singleDollarTextMath: true }), } as unknown as PluginConfig; export const MessageResponse = memo( ({ className, children, ...props }: MessageResponseProps) => { - // Delimiter preprocessing is a single linear scan (no markdown parsing) - // — cheap enough for the render path of this children-memoized - // component. Unbalanced delimiters pass through untouched, so mid-stream - // chunks render literally until the closing delimiter arrives and the - // full text is re-transformed. + // Legacy backslash delimiters are converted in a single linear scan; + // native dollar math passes through to Streamdown unchanged. Incomplete + // legacy spans are left for a later render when their closer arrives. const content = typeof children === "string" ? transformMathDelimiters(children) diff --git a/components/answered-card.tsx b/components/answered-card.tsx index b3f2319..f281e33 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -7,6 +7,7 @@ import { ThinkingBlock } from "@/components/thinking-block"; import { cn } from "@/lib/utils"; import { MessageResponse } from "@/components/ai-elements/message"; import type { QuestionAnswerData } from "@/src/core"; +import { parseAskInput, parseQuizInput } from "@/src/core"; /** * 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 * 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) if (answer.dismissed) { return ( @@ -32,6 +33,15 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { const isExplain = answer.kind === "explain"; const selectedIdx = new Set(answer.answers.map((a) => a.index)); 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 ( <> @@ -78,6 +88,13 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
{answer.questionText}
+ {parsedDetails && ( + // The details line, same paint as the pending dock (owner, + // 2026-10-12): full markdown, LaTeX included. +
+ {parsedDetails} +
+ )} {((answer.options?.length ?? 0) > 0 || answer.other) && (
@@ -115,6 +132,14 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { )} {opt.label} + {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. + + {descByIndex.get(opt.index)} + + )}
); diff --git a/components/chat.tsx b/components/chat.tsx index dc677bc..7746ee1 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1487,7 +1487,9 @@ export function Chat({ // Pending/streaming questions live in the dock, not the // transcript; answered ones render as graded records. if (part.state === "output-available") { - return ; + return ( + + ); } return null; } diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 6981e17..661c18b 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -9,7 +9,6 @@ import { MessageResponse } from "@/components/ai-elements/message"; import { cn } from "@/lib/utils"; import { buildAnswer, - coerceCorrectAnswer, normalizeQuizType, CONFIDENCE_STOPS, DEFAULT_CONFIDENCE_INDEX, @@ -170,7 +169,7 @@ function teaserText(q: string): string { return q .replace(/\\\(([\s\S]*?)\\\)/g, "$1") // \(...\) inline 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(/\\([a-zA-Z]+)/g, "$1") .replace(/[{}]/g, "") @@ -293,7 +292,7 @@ function QuestionDockForm({ createdAt: new Date().toISOString(), }; 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); q.correctIndices = resolved.indices; q.explanation = quizInput.explanation; @@ -607,8 +606,12 @@ function QuestionDockForm({ // the "I don't know" copy stays JetBrains Mono. QUIZ options render // NO description (owner ruling 2026-09-26): the model used them to // telegraph correctness — the schema dropped the field and old - // records must not leak either. - {opt.description} + // records must not leak either. Same MessageResponse path as the + // label (owner, 2026-10-12): descriptions are full markdown — + // LaTeX included — not plain text. + + {opt.description} + )} diff --git a/docs/vision.md b/docs/vision.md index 7a98af9..0d9ee74 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -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) +**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. - **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. diff --git a/src/agent/prompt-segments.ts b/src/agent/prompt-segments.ts index 57f964a..5d8cc5a 100644 --- a/src/agent/prompt-segments.ts +++ b/src/agent/prompt-segments.ts @@ -70,7 +70,7 @@ export function listSourceSegments(agent: PromptAgent): Array<{ path: string; sh // file mechanics stay out (the doctrine contradicts them; 00-base.md // plays the base role), but the FORMATTING guide composes for every // 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" ? [] : [ { path: "system-prompt.md", shared: true, file: PATHS.teacherPrompt }, ]; diff --git a/src/agent/prompts/shared/20-question-tools.md b/src/agent/prompts/shared/20-question-tools.md index d5e6e75..df6888c 100644 --- a/src/agent/prompts/shared/20-question-tools.md +++ b/src/agent/prompts/shared/20-question-tools.md @@ -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: - 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. - **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. diff --git a/src/agent/prompts/shared/40-formatting.md b/src/agent/prompts/shared/40-formatting.md index c331e20..22499d4 100644 --- a/src/agent/prompts/shared/40-formatting.md +++ b/src/agent/prompts/shared/40-formatting.md @@ -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. -- 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. - 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"]`). diff --git a/src/agent/tools.ts b/src/agent/tools.ts index d1c8357..a9cc81b 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -106,12 +106,42 @@ export const askUserQuestionTool: AgentTool = { // quiz variants): duplicates stay honest by sharing SCHEMA ATOMS // (question-core) + PROMPT CHUNKS, and each tool declares only its // 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 * 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 { - return ( + const base = "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), " + delta.name + @@ -129,7 +159,17 @@ function quizDescription(delta: { name: "quiz" | "probe"; nodeId: boolean; types : "") + "ONE question per turn: call a single " + 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." : "") ); } diff --git a/src/core/math-delims.ts b/src/core/math-delims.ts index 56592ab..cb700c3 100644 --- a/src/core/math-delims.ts +++ b/src/core/math-delims.ts @@ -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 `\[...\]`; - * a bare `$` is ALWAYS literal currency (system-prompt contract). remark-math - * expects `$...$`/`$$...$$`, so this pure transform rewrites balanced - * delimiters before the markdown hits Streamdown. Combined with - * `singleDollarTextMath: false`, the dollar sign can never start math — the - * currency hazard dies structurally. + * Current authoring uses `$...$` inline and `$$...$$` on separate lines for + * display (owner, 2026-10-12). This pure transform keeps correctly formed + * old backslash-delimited content readable; dollar math passes through to + * Streamdown's native parser. Escape restoration below recognizes both + * syntaxes while retaining the existing recovery rules. * * Rules: - * - `\(...\)` → `$$...$$` (two-dollar inline: with - * `singleDollarTextMath: false` this is the only inline math form micromark - * still parses), `\[...\]` → `$$\n...\n$$` (block form: flow math). + * - `\(...\)` → `$$...$$` (two-dollar inline, still accepted by micromark), + * `\[...\]` → `$$\n...\n$$` (block form: flow math). * - An escaped delimiter (`\\(` = literal backslash + paren) is left alone. * - Nothing inside fenced code blocks or inline code spans is touched. * - Unbalanced delimiters are left untouched: Streamdown streams chunks, and @@ -196,8 +194,8 @@ export function transformMathDelimiters(markdown: string): string { closeEndsLine(markdown, afterClose) ? "" : "\n\n"; out.push(`${padBefore}$$\n${content}\n$$${padAfter}`); } else { - // Two-dollar inline: the only inline math form left with - // singleDollarTextMath: false (renders inline, not display). + // Preserve the legacy reader's two-dollar inline output + // (renders inline, not display). out.push(`$$${content}$$`); } 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 * escape reconstructs the model's intent byte-perfectly. Outside spans the * same chars might be real prose (pasted tables, formatted text), so only - * balanced `\(…\)` / `\[…\]` spans are touched — the same scanner rules as - * transformMathDelimiters (escaped delimiters are literal, unbalanced - * delimiters pass through untouched). + * balanced math spans are touched. The legacy `\(…\)` / `\[…\]` pass stays + * unchanged; the dollar reader adds `$…$` and fenced `$$…$$` recognition, + * excluding code and escaped opening dollars. Unbalanced spans pass through. * * ASYMMETRIC NEWLINE RULE (owner ruling 2026-09-27, Option A): inline spans * 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]/; 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; const out: string[] = []; let i = 0; @@ -283,3 +287,113 @@ export function restoreMathEscapes(text: string): string { } 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(""); +} diff --git a/src/core/question-core.ts b/src/core/question-core.ts index 33e9422..f0b3214 100644 --- a/src/core/question-core.ts +++ b/src/core/question-core.ts @@ -116,9 +116,9 @@ export function quizInputProblems(input: { }): string[] { const { problems, keys } = optionProblems(input.options); - // Debris (colons/quotes/whitespace) strips inside coerceCorrectAnswer — - // the shared membership seam (boundary, display, grading). - const entries = coerceCorrectAnswer(input.correctAnswer); + // Preserve exact option keys before trying copy-syntax cleanup — the + // same option-aware coercion runs at the display and grading seams. + const entries = coerceCorrectAnswer(input.correctAnswer, keys); const unique = [...new Set(entries)]; 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)."); @@ -606,25 +606,34 @@ export function shuffleOptions(options: QuizOption[], rand: () => number = Math. // ─── quiz grading ─────────────────────────────────────────────────────────── -/** The harness sometimes delivers a multi-select correctAnswer array as a - * JSON-stringified string because the schema union lists String first. - * Every entry carries the MODEL-VALUE DEBRIS STRIP (whitehead, - * 2026-10-09): glm-5.3 copied option values with the JSON key-value - * colon glued on (`[":parmenides"]`); membership comparisons on ALL - * three seams (boundary check, display parse, grading) route through - * here, so the strip heals new calls AND already-poisoned records. */ -export function coerceCorrectAnswer(correctAnswer: string | string[]): string[] { - if (Array.isArray(correctAnswer)) return correctAnswer.map(stripModelValueDebris); +/** Preserve an exact option key before trying copy-syntax cleanup: a + * label's final period, quotes, or backticks can be CONTENT (calculus + * incident, 2026-10-12), not model debris. The actual option values are + * required so no caller can strip a valid answer before membership. + * Non-matches keep the existing whitehead heal (`[":parmenides"]`) and + * JSON-stringified multi-select coercion. Shared by boundary, display, + * and grading; canonical option keys are never rewritten. */ +export function coerceCorrectAnswer( + 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(); + // An exact string key can itself look like a JSON array. + if (optionValues.includes(trimmed)) return [trimmed]; if (trimmed.startsWith("[") && trimmed.endsWith("]")) { try { 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 { // 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 @@ -635,7 +644,7 @@ export function resolveCorrect( options: QuizOption[], ): { indices: number[]; error?: string } { 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" }; const byValue = new Map(options.map((o, i) => [o.value, i + 1])); const indices: number[] = []; diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index 7837420..ed25fcc 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -35,7 +35,7 @@ import { buildSubagentSteer } from "../src/core/contracts.ts"; import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts"; import { chatStore } from "../src/store/chats.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 { projectView } from "../src/core/view.ts"; import type { PiEngine } from "../src/agent/models.ts"; @@ -113,7 +113,7 @@ function gradedAnswer(input: typeof QUIZ_INPUT, values: string[]) { question: input.question, mode: "single-select", options, - correctIndices: resolveCorrect(coerceCorrectAnswer(input.correctAnswer), options).indices, + correctIndices: resolveCorrect(input.correctAnswer, options).indices, explanation: input.explanation, createdAt: new Date().toISOString(), }; diff --git a/tests/practice-agents.test.ts b/tests/practice-agents.test.ts index 932b2fc..f36f857 100644 --- a/tests/practice-agents.test.ts +++ b/tests/practice-agents.test.ts @@ -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", () => { const prompt = composeInstructions("practice"); - // The delimiter law — practice must write \(...\)/\[...\] too. - assert.match(prompt, /NEVER `\$` or `\$\$`/); + // The standard delimiter law is shared by all three authoring agents. + 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"); }); diff --git a/tests/question-core.test.ts b/tests/question-core.test.ts index 221f1ea..9ebc3ba 100644 --- a/tests/question-core.test.ts +++ b/tests/question-core.test.ts @@ -36,6 +36,7 @@ import { toClientQuestion, } from "../src/core/question-core.ts"; import { toolQuestionKind } from "../src/core/pending.ts"; +import { parseQuizInput } from "../src/core/tool-parts.ts"; // ─── the authoring contract (TypeBox schemas + execute-time refinements) ── // Schema-valid ⇔ gradeable: a quiz whose correctAnswer can't be resolved @@ -249,20 +250,28 @@ describe("shuffleOptions", () => { // ─── coerceCorrectAnswer ──────────────────────────────────────────────────── describe("coerceCorrectAnswer", () => { + const values = ["mercury", "a", "b"]; + it("wraps a single string", () => { - assert.deepEqual(coerceCorrectAnswer("mercury"), ["mercury"]); + assert.deepEqual(coerceCorrectAnswer("mercury", values), ["mercury"]); }); 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", () => { - assert.deepEqual(coerceCorrectAnswer('["a", "b"]'), ["a", "b"]); + assert.deepEqual(coerceCorrectAnswer('["a", "b"]', values), ["a", "b"]); }); 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) ──────────────────────────────────────────── describe("isCorrect", () => { @@ -692,7 +786,7 @@ describe("the quiz role field (predict | check)", () => { question: valid.question, mode: "single-select", options, - correctIndices: resolveCorrect(coerceCorrectAnswer(valid.correctAnswer), options).indices, + correctIndices: resolveCorrect(valid.correctAnswer, options).indices, explanation: valid.explanation, createdAt: new Date().toISOString(), }; diff --git a/tests/question-tools.test.ts b/tests/question-tools.test.ts index 38cdadc..aaee97c 100644 --- a/tests/question-tools.test.ts +++ b/tests/question-tools.test.ts @@ -21,13 +21,14 @@ import { createTeacherTools, probeQuizTool, probeExplainTool, + quizExampleBody, teachExplainToolFor, teachQuizToolFor, type TeacherToolContext, } from "../src/agent/tools.ts"; import { TOGGLEABLE_TOOLS } from "../src/core/pending.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 { 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"); }); }); +// ─── 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) ─────────────────────────────── describe("tool toggles (core/pending.ts + factories)", () => { diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index 543adee..f473f42 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -25,7 +25,6 @@ import type { JobRecord } from "../src/core/contracts.ts"; import { AnsweredCard } from "../components/answered-card.tsx"; import { buildAnswer, - coerceCorrectAnswer, normalizeOptions, resolveCorrect, questionNeedsReveal, @@ -47,7 +46,7 @@ function quizQD(): QuestionData { question: quizInput.question, mode: "single-select", options, - correctIndices: resolveCorrect(coerceCorrectAnswer(quizInput.correctAnswer), options).indices, + correctIndices: resolveCorrect(quizInput.correctAnswer, options).indices, explanation: quizInput.explanation, createdAt: new Date().toISOString(), }; @@ -342,14 +341,13 @@ describe("render smoke tests", () => { }); describe("math rendering (streamdown + KaTeX)", () => { - // New delimiter contract (W3): the teacher writes \(...\) / \[...\]; a - // bare $ is ALWAYS literal currency (singleDollarTextMath: false kills it - // structurally). transformMathDelimiters rewrites both to the two-dollar - // forms remark-math still parses. Full coverage: render-markdown.test.tsx. - it("renders $...$ as LITERAL text — a $ is never math (the currency fix)", () => { + // Standard authoring: $...$ inline and $$ on separate lines for display. + // The legacy backslash reader remains for existing content. + // Full coverage: render-markdown.test.tsx. + it("renders standard $...$ as inline math", () => { const html = renderToStaticMarkup({"The map $f(x) = x^2$ is smooth."}); - assert.ok(!html.includes("katex"), `$-math must not render, got: ${html.slice(0, 300)}`); - assert.match(html, /\$f\(x\) = x\^2\$/, "old-contract math renders verbatim"); + assert.ok(html.includes("katex"), `$-math must render, got: ${html.slice(0, 300)}`); + assert.ok(!html.includes("katex-display"), "single-dollar math is inline"); }); 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"); }); - it("does NOT eat escaped currency (\\$5 stays text — belt and suspenders)", () => { - // With singleDollarTextMath: false even a bare $5 can't parse as math; - // an escaped \$ is doubly safe. The prompt contract still asks the - // teacher to prefer plain "$5" (it renders literally now). + it("does NOT eat escaped currency (\\$5 stays text)", () => { + // With dollar math enabled, Markdown-escaped dollars remain literal. const html = renderToStaticMarkup({"It costs \\$5 and \\$10 more."}); assert.ok(!html.includes("katex"), "escaped currency must not parse as math"); assert.match(html, /\$5/); @@ -377,10 +373,10 @@ describe("math rendering (streamdown + KaTeX)", () => { it("quiz cards render math in question, options, and explanation", () => { const mathQuiz = { - question: "What is \\(\\frac{dy}{dx}\\) of \\(y = x^2\\)?", - options: [{ label: "\\(2x\\)" }, { label: "\\(x^2\\)" }, { label: "\\(x\\)" }], - correctAnswer: "\\(2x\\)", - explanation: "The power rule gives \\(\\frac{dy}{dx} = 2x\\).", + question: "What is $\\frac{dy}{dx}$ of $y = x^2$?", + options: [{ label: "$2x$" }, { label: "$x^2$" }, { label: "$x$" }], + correctAnswer: "$2x$", + explanation: "The power rule gives $\\frac{dy}{dx} = 2x$.", }; const pendingHtml = renderDock({ toolName: "quiz", toolCallId: "m1", input: mathQuiz as never }); assert.ok(pendingHtml.includes("katex"), "pending dock: question/options math renders"); @@ -392,12 +388,12 @@ describe("math rendering (streamdown + KaTeX)", () => { question: mathQuiz.question, mode: "single-select", options, - correctIndices: resolveCorrect(coerceCorrectAnswer(mathQuiz.correctAnswer), options).indices, + correctIndices: resolveCorrect(mathQuiz.correctAnswer, options).indices, explanation: mathQuiz.explanation, createdAt: new Date().toISOString(), }; const answeredHtml = renderToStaticMarkup( - , + , ); 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"); }); }); + +// ─── 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(); + 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(); + 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(); + 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"); + }); +}); diff --git a/tests/render-markdown.test.tsx b/tests/render-markdown.test.tsx index 7359744..fdbd1d9 100644 --- a/tests/render-markdown.test.tsx +++ b/tests/render-markdown.test.tsx @@ -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", () => { 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)}`); @@ -121,16 +121,29 @@ describe("render — new math contract (streamdown + KaTeX)", () => { assert.equal(items.length, 2, "both list items render"); }); - it("renders bare $ as literal currency — the hazard dies structurally", () => { - const html = render("It costs $5 and $10 more."); - assert.ok(!html.includes("katex"), "currency must never parse as math"); + it("keeps escaped currency literal", () => { + const html = render("It costs \\$5 and \\$10 more."); + assert.ok(!html.includes("katex"), "escaped currency must not parse as math"); 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."); - assert.ok(!html.includes("katex"), "$ can never start math"); - assert.match(html, /\$f\(x\) = x\^2\$/, "the old form renders verbatim"); + assert.ok(html.includes("katex"), "single-dollar math renders"); + 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", () => { diff --git a/tests/tool-heal.test.ts b/tests/tool-heal.test.ts index 7428e85..e450607 100644 --- a/tests/tool-heal.test.ts +++ b/tests/tool-heal.test.ts @@ -34,6 +34,7 @@ import { import { parseQuizInput } from "../src/core/tool-parts.ts"; import { resolveTargetId } from "../src/core/chains.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. */ 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) }); - it("coerceCorrectAnswer strips every entry (boundary check + grading seam)", () => { - assert.deepEqual(coerceCorrectAnswer([":parmenides"]), ["parmenides"]); - assert.deepEqual(coerceCorrectAnswer(QUOTED("primary-external")), ["primary-external"]); + it("coerceCorrectAnswer cleans non-matching entries (boundary check + grading seam)", () => { + assert.deepEqual(coerceCorrectAnswer([":parmenides"], ["parmenides"]), ["parmenides"]); + assert.deepEqual(coerceCorrectAnswer(QUOTED("primary-external"), ["primary-external"]), ["primary-external"]); }); 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", () => { it("parseQuizInput renders the incident's persisted raw record (no invisible quiz)", () => { 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. assert.equal(normalizeQuizType(parsed?.type), "predict"); // 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"]); }); }); diff --git a/tests/tool-parts.test.ts b/tests/tool-parts.test.ts index 7b1d7a4..e1482d2 100644 --- a/tests/tool-parts.test.ts +++ b/tests/tool-parts.test.ts @@ -12,6 +12,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { quizInputProblems } from "../src/core/question-core.ts"; import { Value } from "typebox/value"; +import { restoreMathEscapes } from "../src/core/math-delims.ts"; import { parseAskInput, 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) ─────── describe("parseExplainInput: the dock's display seam for explain calls", () => {