Skip to content

Commit 53cef99

Browse files
Edwin ChanEdwin Chan
authored andcommitted
Grade exact LaTeX answers as expressions
1 parent 0daad1d commit 53cef99

5 files changed

Lines changed: 124 additions & 22 deletions

File tree

CENTRAL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Stack: Next.js, NextAuth (Google + dev bypass), Prisma, KaTeX for math, Lucide i
3939
- **Hand-drawn visual system** — `app/globals.css` now provides paper/ink design tokens, graph-paper backgrounds, asymmetric squircle cards and controls, marker-color accents, and irregular badges/tabs. Shared surfaces use native asymmetric borders instead of `border-image`, which created rigid grey rectangles outside rounded controls. Metric cards use the same neutral border on every edge instead of decorative colored strips. Dark structural borders are intentionally quieter, search inputs are borderless at rest with one cyan focus stroke, and selected problem-set tag counts use opaque ink/paper colors for readable contrast. Global wavy eyebrow/title underlines remain removed. A signed-in production Chrome audit of `/problem-sets/1991-ajhsme` also removed percentage shape paths from variable-height panels, fixed oversized panel-header geometry, and removed the blue statement band. Desktop navigation is a 64 px icon rail that expands to 240 px on hover/focus, while mobile retains the stable off-canvas sheet. The simplified Sigma in `public/dbsmo-mark.svg` is shared by favicon metadata and the landing brand. `app/layout.tsx` loads Shantell Sans for display/control text while Inter remains the body and math font. Route-specific coverage was visually audited across dashboard, problem catalog/detail, writeups, practice, classes, leaderboard, users/profiles, settings, and admin surfaces; FTW and Playground were intentionally excluded. Unsupported browsers retain ordinary border/radius fallbacks; details are in `docs/visual-system.md`.
4040
- **Mobile/classes cleanup** — fixed mobile `/problem-sets` hiding task rows by restoring the responsive card table, stabilized the mobile sidebar grid to avoid icon reflow on tap/focus, and made dashboard auth/actions render as a compact mobile account card. `/classes` now has a teacher/admin `Announcements` subtab that lists existing class messages and supports author/admin deletion. Display-name fallbacks now treat literal `"null"`/`"undefined"` strings as empty via `lib/display-name.ts`.
4141
- **Mobile sidebar focus fix** — mobile sidebar sheet geometry now remains stable under `:hover`, `:focus`, and `:focus-within`, preventing the nav grid from jumping upward when a mobile tap focuses one of the upper links (source: `app/globals.css`).
42-
- **Answer previews and LaTeX grading** — Practice and problem-set answer boxes now render a small KaTeX preview above the input using `mathInputToTex(...)`. `lib/grading.ts` normalizes math delimiters and common LaTeX forms through `lib/math-input.ts`, so expression answers and answer keys can use `$...$`, `\sqrt{...}`, coefficients before functions like `5\sqrt{2}-7`, `\frac{...}{...}`, braced powers, and `\pi`.
42+
- **Answer previews and LaTeX grading** — Practice and problem-set answer boxes now render a small KaTeX preview above the input using `mathInputToTex(...)`. `lib/grading.ts` normalizes math delimiters and common LaTeX forms through `lib/math-input.ts`, so expression answers and answer keys can use `$...$`, `\sqrt{...}`, coefficients before functions like `5\sqrt{2}-7`, `\frac{...}{...}`, braced powers, and `\pi`. `EXACT` answers retain literal text matching but automatically fall back to the same numeric/LaTeX expression comparison when literal matching fails.
4343
- **Expanded LaTeX statement support** — KaTeX is upgraded to `0.17.x`, the official `mhchem` extension enables `\ce{...}`, and `lib/latex-compat.ts` converts document wrappers plus `tabular`, `tabular*`, `tabularx`, and `longtable` into safe KaTeX-compatible arrays, including standard optional position arguments such as `[t]`. A production sweep of all published 1985-1998 AJHSME sets found and covered six affected expressions in 1987 Q8/Q23, 1989 Q14/Q22, 1990 Q9, and 1991 Q6. Safe MathLive notation aliases map to KaTeX primitives; editor/HTML commands remain unsupported. Rendering keeps `trust: false`, `maxSize: 50`, and `maxExpand: 1000`. See `docs/latex-support.md`.
4444
- **Legacy LaTeX currency compatibility** — imported contest statements using full-LaTeX `\textdollar` or invalid currency sequences such as `\54` now render through bounded aliases in `lib/latex-compat.ts`; escaped row breaks remain unchanged.
4545
- **Escape-aware LaTeX parsing and safe aliases**`LatexStatement` now tokenizes escaped dollar signs, multiline delimiters, image tokens, and matching bare environments without regex delimiter collisions. This fixes HTML imports such as 1996 AJHSME question 18 (`<math>\$2000</math>`). Conservative aliases cover common blackboard/bold, paired-delimiter, calculus, and basic SI-unit notation; legacy `eqnarray`/`flalign`/`multline` displays are downgraded safely. Security remains `trust: false`, `globalGroup: false`, fresh macros per expression, bounded size/expansion, and inert non-math HTML.

app/admin/create/page-client.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ const ANSWER_TYPE_OPTIONS: { value: AnswerType; label: string; hint: string }[]
5858
{ value: "INTEGER", label: "Integer", hint: "e.g. 42" },
5959
{ value: "DECIMAL", label: "Decimal", hint: "e.g. 3.14" },
6060
{ value: "FRACTION", label: "Fraction", hint: "e.g. 3/7" },
61-
{ value: "EXACT", label: "Exact match", hint: "e.g. triangle" },
61+
{
62+
value: "EXACT",
63+
label: "Exact match",
64+
hint: "Text, or equivalent numeric/LaTeX expressions",
65+
},
6266
{ value: "SET", label: "Set", hint: "e.g. 1,2,5" },
6367
{ value: "MULTIPLE", label: "Multiple accepted", hint: "Use semicolons (e.g. 3/7; 0.4286)" },
6468
{ value: "EXPRESSION", label: "Expression", hint: "e.g. sqrt(2), 2^0.5, pi/3" },

docs/import-format.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ Per-problem `topicTags` drive Practice mode.
117117

118118
## Answer Evaluation Rules
119119

120-
- `EXACT`: normalized string match
120+
- `EXACT`: normalized string match, with automatic numeric/LaTeX expression fallback
121121
- `INTEGER`: integer normalization
122122
- `DECIMAL`: decimal normalization
123123
- `FRACTION`: simplified fraction comparison
@@ -128,6 +128,8 @@ Per-problem `topicTags` drive Practice mode.
128128
### Notes
129129

130130
- Fraction answers like `2/4` are simplified to `1/2`.
131+
- `EXACT` preserves ordinary text matching, but numeric or LaTeX answers such as
132+
`\frac{5}{4}` also accept equivalent expression input such as `5/4` or `1.25`.
131133
- `FRACTION` does not automatically treat `1/2` and `0.5` as equal. Use `EXPRESSION` if decimal and fractional forms should both pass.
132134
- `EXPRESSION` supports inputs like `sqrt(2)`, `2^0.5`, `pi/3`, `2pi`, and `3(1+2)`.
133135
- `EXPRESSION` is numeric only. Symbolic algebra such as `x+1` is not supported.

lib/grading.ts

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -56,28 +56,32 @@ export function gradeAnswer(input: GradeInput): GradeResult {
5656
}
5757

5858
if (input.answerType === "expression") {
59-
const tolerance = input.decimalTolerance ?? 1e-9;
60-
const answerNumber = evaluateMathExpression(input.rawAnswer);
61-
const evaluatedCandidates = [input.answerKey, ...(input.acceptedAnswers ?? [])]
62-
.filter(Boolean)
63-
.map((answer) => evaluateMathExpression(answer));
64-
const normalizedCandidates = evaluatedCandidates.map((candidate) =>
65-
Number.isFinite(candidate) ? formatNumber(candidate) : "",
59+
return gradeExpressionCandidates(
60+
input.rawAnswer,
61+
rawCandidates,
62+
normalizedAnswer,
63+
input.decimalTolerance ?? 1e-9,
6664
);
67-
const isCorrect =
68-
Number.isFinite(answerNumber) &&
69-
evaluatedCandidates.some(
70-
(candidate) =>
71-
Number.isFinite(candidate) && numbersMatch(answerNumber, candidate, tolerance),
72-
);
65+
}
7366

74-
return {
75-
isCorrect,
76-
normalizedAnswer: Number.isFinite(answerNumber)
77-
? formatNumber(answerNumber)
78-
: normalizedAnswer,
79-
normalizedAcceptedAnswers: normalizedCandidates,
67+
if (input.answerType === "exact") {
68+
const literalResult = {
69+
isCorrect: candidates.includes(normalizedAnswer),
70+
normalizedAnswer,
71+
normalizedAcceptedAnswers: candidates,
8072
};
73+
if (literalResult.isCorrect) {
74+
return literalResult;
75+
}
76+
77+
const expressionResult = gradeExpressionCandidates(
78+
input.rawAnswer,
79+
rawCandidates,
80+
normalizedAnswer,
81+
input.decimalTolerance ?? 1e-9,
82+
true,
83+
);
84+
return expressionResult.isCorrect ? expressionResult : literalResult;
8185
}
8286

8387
return {
@@ -87,6 +91,49 @@ export function gradeAnswer(input: GradeInput): GradeResult {
8791
};
8892
}
8993

94+
function gradeExpressionCandidates(
95+
rawAnswer: string,
96+
rawCandidates: string[],
97+
fallbackNormalizedAnswer: string,
98+
tolerance: number,
99+
preserveExactNumericIdentity = false,
100+
): GradeResult {
101+
const answerNumber = evaluateMathExpression(rawAnswer);
102+
const evaluatedCandidates = rawCandidates.map((answer) => evaluateMathExpression(answer));
103+
const normalizedCandidates = evaluatedCandidates.map((candidate) =>
104+
Number.isFinite(candidate) ? formatNumber(candidate) : "",
105+
);
106+
const answerCanonical = preserveExactNumericIdentity
107+
? canonicalDecimal(normalizeText(rawAnswer, false))
108+
: null;
109+
const isCorrect =
110+
Number.isFinite(answerNumber) &&
111+
evaluatedCandidates.some((candidate, index) => {
112+
const candidateCanonical = preserveExactNumericIdentity
113+
? canonicalDecimal(normalizeText(rawCandidates[index], false))
114+
: null;
115+
if (answerCanonical && candidateCanonical) {
116+
return answerCanonical === candidateCanonical;
117+
}
118+
if (
119+
preserveExactNumericIdentity &&
120+
(Math.abs(answerNumber) > Number.MAX_SAFE_INTEGER ||
121+
Math.abs(candidate) > Number.MAX_SAFE_INTEGER)
122+
) {
123+
return false;
124+
}
125+
return Number.isFinite(candidate) && numbersMatch(answerNumber, candidate, tolerance);
126+
});
127+
128+
return {
129+
isCorrect,
130+
normalizedAnswer: Number.isFinite(answerNumber)
131+
? formatNumber(answerNumber)
132+
: fallbackNormalizedAnswer,
133+
normalizedAcceptedAnswers: normalizedCandidates,
134+
};
135+
}
136+
90137
export function normalizeAnswer(
91138
rawAnswer: string,
92139
answerType: AnswerType,

tests/grading.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,55 @@ describe("gradeAnswer", () => {
139139
expect(result.isCorrect).toBe(true);
140140
});
141141

142+
it("automatically evaluates LaTeX answer keys configured as exact", () => {
143+
expect(
144+
gradeAnswer({
145+
answerType: "exact",
146+
answerKey: "$\\frac{5}{4}$",
147+
rawAnswer: "5/4",
148+
}),
149+
).toMatchObject({
150+
isCorrect: true,
151+
normalizedAnswer: "1.25",
152+
});
153+
154+
expect(
155+
gradeAnswer({
156+
answerType: "exact",
157+
answerKey: "$\\frac{1}{6^{3/2}}$",
158+
rawAnswer: "1/(6*sqrt(6))",
159+
}).isCorrect,
160+
).toBe(true);
161+
});
162+
163+
it("preserves literal matching for non-mathematical exact answers", () => {
164+
expect(
165+
gradeAnswer({
166+
answerType: "exact",
167+
answerKey: "No",
168+
rawAnswer: " no ",
169+
}).isCorrect,
170+
).toBe(true);
171+
172+
expect(
173+
gradeAnswer({
174+
answerType: "exact",
175+
answerKey: "No",
176+
rawAnswer: "Yes",
177+
}).isCorrect,
178+
).toBe(false);
179+
});
180+
181+
it("does not collapse distinct unsafe integers during exact expression fallback", () => {
182+
expect(
183+
gradeAnswer({
184+
answerType: "exact",
185+
answerKey: "9007199254740992",
186+
rawAnswer: "9007199254740993",
187+
}).isCorrect,
188+
).toBe(false);
189+
});
190+
142191
it("evaluates coefficients before LaTeX functions", () => {
143192
const result = gradeAnswer({
144193
answerType: "expression",

0 commit comments

Comments
 (0)