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
+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
// 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 },
];
@@ -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.
+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.
- 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"]`).
+43 -3
View File
@@ -106,12 +106,42 @@ export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
// 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." : "")
);
}
+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 `\[...\]`;
* 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("");
}
+24 -15
View File
@@ -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[] = [];