Initial Commit

This commit is contained in:
2026-03-01 18:50:29 -04:00
parent 261c52d602
commit 364facd9f0
69 changed files with 7829 additions and 87 deletions

View File

@@ -0,0 +1,287 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt, randomChoice, shuffle } from "@/lib/utils";
import { roundToDecimalPlaces, roundToSignificantFigures, toStandardForm, standardFormToKatex } from "@/lib/math/decimals";
let counter = 0;
function nextId() {
return `dp-${++counter}-${Date.now()}`;
}
export function generateDecimalCompareOrder(difficulty: Difficulty): MathProblem {
const count = difficulty === 1 ? 3 : difficulty === 2 ? 4 : 5;
const values: number[] = [];
for (let i = 0; i < count; i++) {
const intPart = randomInt(0, difficulty === 1 ? 2 : 10);
const decPlaces = randomInt(1, difficulty === 1 ? 2 : 3);
const decPart = randomInt(1, Math.pow(10, decPlaces) - 1);
const val = intPart + decPart / Math.pow(10, decPlaces);
values.push(Math.round(val * 1000) / 1000);
}
const sorted = [...values].sort((a, b) => a - b);
const shuffled = shuffle(values);
return {
id: nextId(),
prompt: `\\text{Order from smallest to largest: } ${shuffled.join(", ")}`,
answer: { kind: "decimal", value: 0 },
hints: [
"Compare the whole number part first",
"If the whole parts are equal, compare tenths, then hundredths",
"Writing them with the same number of decimal places can help",
],
steps: sorted.map((v, i) => ({
explanation: `Position ${i + 1}: ${v}`,
})),
};
}
export function generateDecimalRounding(difficulty: Difficulty): MathProblem {
const value = randomInt(100, 99999) / (difficulty === 1 ? 100 : 1000);
const roundType = randomChoice(
difficulty === 1
? ["whole", "1dp"] as const
: difficulty === 2
? ["1dp", "2dp", "whole"] as const
: ["1sf", "2sf", "3sf"] as const,
);
let answer: number;
let instruction: string;
switch (roundType) {
case "whole":
answer = Math.round(value);
instruction = "the nearest whole number";
break;
case "1dp":
answer = roundToDecimalPlaces(value, 1);
instruction = "1 decimal place";
break;
case "2dp":
answer = roundToDecimalPlaces(value, 2);
instruction = "2 decimal places";
break;
case "1sf":
answer = roundToSignificantFigures(value, 1);
instruction = "1 significant figure";
break;
case "2sf":
answer = roundToSignificantFigures(value, 2);
instruction = "2 significant figures";
break;
case "3sf":
answer = roundToSignificantFigures(value, 3);
instruction = "3 significant figures";
break;
}
return {
id: nextId(),
prompt: `\\text{Round } ${value} \\text{ to ${instruction}}`,
answer: { kind: "decimal", value: answer },
hints: [
"Look at the digit to the right of where you're rounding",
"If it's 5 or more, round up. If it's less than 5, round down",
],
steps: [
{ explanation: `Rounding ${value} to ${instruction}` },
{ explanation: `Answer: ${answer}` },
],
};
}
export function generateStandardForm(difficulty: Difficulty): MathProblem {
let value: number;
if (difficulty === 1) {
value = randomChoice([7438, 1578, 62, 93000, 5200, 340]);
} else if (difficulty === 2) {
value = randomChoice([0.086, 0.748, 0.0055, 0.34, 0.007]);
} else {
value = randomChoice([12436.3, 0.00092, 504000, 0.0000067, 83100]);
}
const sf = toStandardForm(value);
return {
id: nextId(),
prompt: `\\text{Express } ${value} \\text{ in standard form}`,
answer: { kind: "standardForm", coefficient: sf.coefficient, exponent: sf.exponent },
hints: [
"Move the decimal point so there's one non-zero digit before it",
"Count how many places you moved the decimal",
value >= 1
? "Number is ≥ 1, so the power is positive"
: "Number is < 1, so the power is negative",
],
steps: [
{ explanation: `Move the decimal point to get a number between 1 and 10` },
{ explanation: `Coefficient: ${sf.coefficient}` },
{ explanation: `The decimal moved ${Math.abs(sf.exponent)} places, power = ${sf.exponent}` },
{ explanation: "Answer", math: standardFormToKatex(sf.coefficient, sf.exponent) },
],
};
}
export function generateDecimalAddSubtract(difficulty: Difficulty): MathProblem {
const operation = randomChoice(["add", "subtract"] as const);
const op = operation === "add" ? "+" : "-";
const places = difficulty === 1 ? 1 : difficulty === 2 ? 2 : 3;
const a = randomInt(100, 9999) / Math.pow(10, places);
const b = randomInt(100, 9999) / Math.pow(10, places);
const [first, second] = operation === "subtract" ? [Math.max(a, b), Math.min(a, b)] : [a, b];
const answer = roundToDecimalPlaces(
operation === "add" ? first + second : first - second,
places,
);
return {
id: nextId(),
prompt: `${first} ${op} ${second}`,
answer: { kind: "decimal", value: answer },
hints: [
"Line up the decimal points",
"Add zeros to fill empty places",
operation === "add" ? "Add column by column from right to left" : "Subtract column by column, borrow if needed",
],
steps: [
{ explanation: `Line up: ${first} ${op} ${second}` },
{ explanation: `= ${answer}` },
],
};
}
export function generateDecimalMultiplyDivide(difficulty: Difficulty): MathProblem {
const operation = randomChoice(["multiply", "divide"] as const);
if (difficulty === 1) {
// By powers of 10
const power = randomChoice([10, 100, 1000]);
const base = randomInt(1, 999) / 100;
if (operation === "multiply") {
const answer = roundToDecimalPlaces(base * power, 10);
return {
id: nextId(),
prompt: `${base} \\times ${power}`,
answer: { kind: "decimal", value: answer },
hints: [
`When multiplying by ${power}, move decimal ${Math.log10(power)} place(s) right`,
],
steps: [
{ explanation: `Move decimal point ${Math.log10(power)} place(s) to the right` },
{ explanation: `= ${answer}` },
],
};
} else {
const answer = roundToDecimalPlaces(base / power, 10);
return {
id: nextId(),
prompt: `${base} \\div ${power}`,
answer: { kind: "decimal", value: answer },
hints: [
`When dividing by ${power}, move decimal ${Math.log10(power)} place(s) left`,
],
steps: [
{ explanation: `Move decimal point ${Math.log10(power)} place(s) to the left` },
{ explanation: `= ${answer}` },
],
};
}
}
// By whole numbers or decimals
const a = randomInt(10, 999) / 10;
const b = difficulty === 2 ? randomInt(2, 12) : randomInt(10, 99) / 10;
if (operation === "multiply") {
const answer = roundToDecimalPlaces(a * b, 4);
return {
id: nextId(),
prompt: `${a} \\times ${b}`,
answer: { kind: "decimal", value: answer },
hints: [
"Multiply as if there are no decimal points",
"Count total decimal places in both numbers",
"Place the decimal point in your answer",
],
steps: [
{ explanation: `${a} × ${b}` },
{ explanation: `= ${answer}` },
],
};
} else {
const answer = roundToDecimalPlaces(a / b, 4);
return {
id: nextId(),
prompt: `${a} \\div ${b}`,
answer: { kind: "decimal", value: answer },
hints: [
"Place decimal point in answer directly above the one in the dividend",
"Divide as normal",
],
steps: [
{ explanation: `${a} ÷ ${b}` },
{ explanation: `= ${answer}` },
],
};
}
}
export function generateDecimalConversion(difficulty: Difficulty): MathProblem {
const direction = randomChoice(["toFraction", "toDecimal"] as const);
if (direction === "toFraction") {
const decimal = randomChoice(
difficulty === 1
? [0.5, 0.25, 0.75, 0.1, 0.2, 0.4]
: [0.125, 0.375, 0.625, 0.875, 0.15, 0.35, 0.45],
);
const den = decimal === 0.5 ? 2 : decimal === 0.25 || decimal === 0.75 ? 4 : [0.125, 0.375, 0.625, 0.875].includes(decimal) ? 8 : 100;
const num = Math.round(decimal * den);
const g = gcdSimple(num, den);
return {
id: nextId(),
prompt: `\\text{Convert } ${decimal} \\text{ to a fraction}`,
answer: { kind: "fraction", numerator: num / g, denominator: den / g },
hints: [
`Write as ${num}/${den}`,
"Simplify by dividing numerator and denominator by their GCD",
],
steps: [
{ explanation: `${decimal} = ${num}/${den}` },
{ explanation: `Simplified: ${num / g}/${den / g}` },
],
};
} else {
const den = randomChoice(difficulty === 1 ? [2, 4, 5, 10] : [3, 8, 20, 25, 40, 50]);
const num = randomInt(1, den - 1);
const answer = roundToDecimalPlaces(num / den, 6);
return {
id: nextId(),
prompt: `\\text{Convert } \\frac{${num}}{${den}} \\text{ to a decimal}`,
answer: { kind: "decimal", value: answer },
hints: [
`Divide ${num} by ${den}`,
"Or find an equivalent fraction with denominator 10, 100, etc.",
],
steps: [
{ explanation: `${num} ÷ ${den} = ${answer}` },
],
};
}
}
function gcdSimple(a: number, b: number): number {
a = Math.abs(a);
b = Math.abs(b);
while (b) {
[a, b] = [b, a % b];
}
return a;
}

View File

@@ -0,0 +1,163 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt, randomChoice } from "@/lib/utils";
import * as frac from "@/lib/math/fractions";
let counter = 0;
function nextId() {
return `fp-${++counter}-${Date.now()}`;
}
export function generateFractionAddSubtract(difficulty: Difficulty): MathProblem {
const operation = randomChoice(["add", "subtract"] as const);
const op = operation === "add" ? "+" : "-";
let n1: number, d1: number, n2: number, d2: number;
if (difficulty === 1) {
// Common denominators
d1 = randomChoice([2, 3, 4, 5, 6, 8, 10, 12]);
d2 = d1;
n1 = randomInt(1, d1 - 1);
n2 = operation === "subtract" ? randomInt(1, n1) : randomInt(1, d1 - n1);
} else if (difficulty === 2) {
// One denominator is a multiple of the other
d1 = randomChoice([2, 3, 4, 5, 6]);
const multiplier = randomChoice([2, 3]);
d2 = d1 * multiplier;
n1 = randomInt(1, d1 - 1);
n2 = randomInt(1, d2 - 1);
} else {
// Unlike denominators
d1 = randomChoice([3, 4, 5, 7, 8, 9]);
d2 = randomChoice([3, 4, 5, 7, 8, 9].filter((d) => d !== d1));
n1 = randomInt(1, d1 - 1);
n2 = randomInt(1, d2 - 1);
}
const [ansNum, ansDen] = operation === "add"
? frac.add(n1, d1, n2, d2)
: frac.subtract(n1, d1, n2, d2);
const prompt = `\\frac{${n1}}{${d1}} ${op} \\frac{${n2}}{${d2}}`;
const commonDen = frac.lcm(d1, d2);
const steps = d1 === d2
? [
{ explanation: "Denominators are the same, so just " + (operation === "add" ? "add" : "subtract") + " the numerators" },
{ explanation: `${n1} ${op} ${n2} = ${operation === "add" ? n1 + n2 : n1 - n2}`, math: `\\frac{${operation === "add" ? n1 + n2 : n1 - n2}}{${d1}}` },
]
: [
{ explanation: `Find the common denominator: LCM(${d1}, ${d2}) = ${commonDen}` },
{ explanation: `Convert: ${n1}×${commonDen / d1}/${d1}×${commonDen / d1} and ${n2}×${commonDen / d2}/${d2}×${commonDen / d2}` },
{ explanation: `${op === "+" ? "Add" : "Subtract"} numerators`, math: `\\frac{${ansNum}}{${ansDen}}` },
];
return {
id: nextId(),
prompt,
answer: { kind: "fraction", numerator: ansNum, denominator: ansDen },
hints: [
d1 === d2 ? "The denominators are already the same!" : "Find a common denominator first",
d1 !== d2 ? `Try using the Butterfly Method: cross multiply` : `Just ${operation} the numerators`,
],
steps,
};
}
export function generateFractionMultiply(difficulty: Difficulty): MathProblem {
const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [2, 3, 4, 5, 6, 7, 8] : [3, 5, 7, 9, 11, 12, 15];
const d1 = randomChoice(denChoices);
const d2 = randomChoice(denChoices);
const n1 = randomInt(1, d1 - 1);
const n2 = randomInt(1, d2 - 1);
const [ansNum, ansDen] = frac.multiply(n1, d1, n2, d2);
return {
id: nextId(),
prompt: `\\frac{${n1}}{${d1}} \\times \\frac{${n2}}{${d2}}`,
answer: { kind: "fraction", numerator: ansNum, denominator: ansDen },
hints: [
"Multiply numerator × numerator",
"Multiply denominator × denominator",
"Simplify if possible",
],
steps: [
{ explanation: `Multiply numerators: ${n1} × ${n2} = ${n1 * n2}` },
{ explanation: `Multiply denominators: ${d1} × ${d2} = ${d1 * d2}` },
{ explanation: "Simplify", math: `\\frac{${ansNum}}{${ansDen}}` },
],
};
}
export function generateFractionDivide(difficulty: Difficulty): MathProblem {
const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [2, 3, 4, 5, 6, 7, 8] : [3, 5, 7, 9, 11, 14, 15];
const d1 = randomChoice(denChoices);
const d2 = randomChoice(denChoices);
const n1 = randomInt(1, d1 - 1);
const n2 = randomInt(1, d2 - 1);
const [ansNum, ansDen] = frac.divide(n1, d1, n2, d2);
return {
id: nextId(),
prompt: `\\frac{${n1}}{${d1}} \\div \\frac{${n2}}{${d2}}`,
answer: { kind: "fraction", numerator: ansNum, denominator: ansDen },
hints: [
"Keep the first fraction the same",
"Flip the second fraction (reciprocal)",
"Then multiply",
],
steps: [
{ explanation: `Keep ${n1}/${d1}, flip ${n2}/${d2} to ${d2}/${n2}` },
{ explanation: `Multiply: ${n1}×${d2} / ${d1}×${n2}` },
{ explanation: "Simplify", math: `\\frac{${ansNum}}{${ansDen}}` },
],
};
}
export function generateFractionOfQuantity(difficulty: Difficulty): MathProblem {
const denChoices = difficulty === 1 ? [2, 4, 5, 10] : difficulty === 2 ? [3, 4, 5, 6, 8] : [7, 8, 9, 12, 15];
const den = randomChoice(denChoices);
const num = randomInt(1, den - 1);
const multiplier = randomChoice(difficulty === 1 ? [2, 4, 5, 10] : [3, 6, 7, 8, 9, 12]);
const quantity = den * multiplier;
const answer = num * multiplier;
return {
id: nextId(),
prompt: `\\frac{${num}}{${den}} \\text{ of } ${quantity}`,
answer: { kind: "integer", value: answer },
hints: [
`Divide ${quantity} by ${den} first`,
`Then multiply by ${num}`,
],
steps: [
{ explanation: `Divide the quantity by the denominator: ${quantity} ÷ ${den} = ${quantity / den}` },
{ explanation: `Multiply by the numerator: ${quantity / den} × ${num} = ${answer}` },
],
};
}
export function generateWholeFromFraction(difficulty: Difficulty): MathProblem {
const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [3, 5, 6, 8] : [7, 8, 9, 12];
const den = randomChoice(denChoices);
const num = randomInt(1, den - 1);
const whole = den * randomChoice(difficulty === 1 ? [2, 3, 4, 5] : [3, 5, 6, 7, 8, 10]);
const part = (num / den) * whole;
return {
id: nextId(),
prompt: `\\frac{${num}}{${den}} \\text{ of a number is } ${part}\\text{. Find the number.}`,
answer: { kind: "integer", value: whole },
hints: [
`If ${num}/${den} of the number is ${part}...`,
`Find 1/${den} first by dividing ${part} by ${num}`,
`Then multiply by ${den} to get the whole`,
],
steps: [
{ explanation: `1/${den} of the number = ${part} ÷ ${num} = ${part / num}` },
{ explanation: `The whole number = ${part / num} × ${den} = ${whole}` },
],
};
}

View File

@@ -0,0 +1,110 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt, randomChoice } from "@/lib/utils";
import { simplifyRatio, divideInRatio } from "@/lib/math/ratios";
let counter = 0;
function nextId() {
return `rp-${++counter}-${Date.now()}`;
}
export function generateSimplifyRatio(difficulty: Difficulty): MathProblem {
const gcd = randomChoice(difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [3, 4, 5, 6, 7] : [4, 6, 8, 9, 12]);
const a = randomInt(1, difficulty === 1 ? 5 : 10) * gcd;
const b = randomInt(1, difficulty === 1 ? 5 : 10) * gcd;
const simplified = simplifyRatio([a, b]);
return {
id: nextId(),
prompt: `\\text{Simplify the ratio } ${a}:${b}`,
answer: { kind: "ratio", parts: simplified },
hints: [
`Find the GCD of ${a} and ${b}`,
`The GCD is ${gcd}`,
`Divide both parts by ${gcd}`,
],
steps: [
{ explanation: `GCD of ${a} and ${b} is ${gcd}` },
{ explanation: `${a} ÷ ${gcd} = ${simplified[0]}, ${b} ÷ ${gcd} = ${simplified[1]}` },
{ explanation: `Simplified: ${simplified.join(":")}` },
],
};
}
export function generateDivideInRatio(difficulty: Difficulty): MathProblem {
const numParts = difficulty === 3 ? 3 : 2;
const parts: number[] = [];
for (let i = 0; i < numParts; i++) {
parts.push(randomInt(1, difficulty === 1 ? 5 : 9));
}
const sum = parts.reduce((a, b) => a + b, 0);
const multiplier = randomChoice(difficulty === 1 ? [2, 3, 4, 5] : [3, 4, 5, 6, 7, 8]);
const total = sum * multiplier;
const values = divideInRatio(total, parts);
const intValues = values.map(Math.round);
const names = ["first share", "second share", "third share"];
return {
id: nextId(),
prompt: `\\text{Share } ${total} \\text{ in the ratio } ${parts.join(":")}`,
answer: { kind: "ratio", parts: intValues },
hints: [
`Total parts: ${parts.join(" + ")} = ${sum}`,
`One part = ${total} ÷ ${sum} = ${multiplier}`,
`Multiply each ratio number by ${multiplier}`,
],
steps: [
{ explanation: `Total parts = ${parts.join(" + ")} = ${sum}` },
{ explanation: `One part = ${total} ÷ ${sum} = ${multiplier}` },
...parts.map((p, i) => ({
explanation: `${names[i]}: ${p} × ${multiplier} = ${intValues[i]}`,
})),
],
};
}
export function generateRatioWordProblem(difficulty: Difficulty): MathProblem {
const a = randomInt(2, 7);
const b = randomInt(2, 7);
const diff = Math.abs(a - b);
if (diff === 0) {
return generateDivideInRatio(difficulty);
}
const contexts = [
{ item: "sweets", nameA: "Josh", nameB: "Nathan" },
{ item: "marbles", nameA: "Amy", nameB: "Ben" },
{ item: "stickers", nameA: "Karen", nameB: "Natasha" },
];
const ctx = randomChoice(contexts);
const onePart = randomChoice(difficulty === 1 ? [5, 10, 12] : [6, 8, 9, 15, 20]);
const extraAmount = diff * onePart;
const totalParts = a + b;
const total = totalParts * onePart;
const bigger = a > b ? ctx.nameA : ctx.nameB;
const biggerRatio = Math.max(a, b);
const smallerRatio = Math.min(a, b);
return {
id: nextId(),
prompt: `\\text{${ctx.item.charAt(0).toUpperCase() + ctx.item.slice(1)} were shared between ${ctx.nameA} and ${ctx.nameB} in the ratio ${a}:${b}. If ${bigger} received ${extraAmount} more ${ctx.item}, find the total shared.}`,
answer: { kind: "integer", value: total },
hints: [
`The difference in ratio parts is ${biggerRatio} - ${smallerRatio} = ${diff}`,
`${diff} parts = ${extraAmount}, so 1 part = ${onePart}`,
`Total parts = ${a} + ${b} = ${totalParts}`,
],
steps: [
{ explanation: `Difference in parts: ${biggerRatio} - ${smallerRatio} = ${diff}` },
{ explanation: `${diff} parts = ${extraAmount}, so 1 part = ${extraAmount} ÷ ${diff} = ${onePart}` },
{ explanation: `Total parts: ${a} + ${b} = ${totalParts}` },
{ explanation: `Total ${ctx.item}: ${totalParts} × ${onePart} = ${total}` },
],
};
}

21
lib/problems/types.ts Normal file
View File

@@ -0,0 +1,21 @@
export type Difficulty = 1 | 2 | 3;
export type MathAnswer =
| { kind: "fraction"; numerator: number; denominator: number }
| { kind: "decimal"; value: number }
| { kind: "integer"; value: number }
| { kind: "ratio"; parts: number[] }
| { kind: "standardForm"; coefficient: number; exponent: number };
export interface SolutionStep {
explanation: string;
math?: string;
}
export interface MathProblem {
id: string;
prompt: string;
answer: MathAnswer;
hints: string[];
steps: SolutionStep[];
}