form 1 topics added

This commit is contained in:
2026-07-05 11:12:45 -04:00
parent a614532810
commit a23fa53772
58 changed files with 7089 additions and 475 deletions

View File

@@ -6,11 +6,11 @@ export interface Topic {
}
export interface Unit {
number: 1 | 2 | 3 | 4 | 5 | 6;
number: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
slug: string;
title: string;
description: string;
color: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6";
color: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-7" | "unit-8";
topics: Topic[];
}
@@ -160,6 +160,12 @@ export const curriculum: Unit[] = [
description: "Add, subtract, multiply, divide integers and apply to real-world problems",
color: "unit-5",
topics: [
{
slug: "order-compare",
title: "Order and Compare Integers",
shortTitle: "Order & Compare",
description: "Place integers on a number line, order them, and compare with < and >",
},
{
slug: "add-subtract",
title: "Add and Subtract Integers",
@@ -172,6 +178,12 @@ export const curriculum: Unit[] = [
shortTitle: "Multiply & Divide",
description: "Sign rules for multiplication and division with real-world thermometer problems",
},
{
slug: "negative-fractions",
title: "Negative Fractions",
shortTitle: "Negative Fractions",
description: "Apply the sign rules to add, subtract, multiply and divide fractions with negative signs",
},
],
},
{
@@ -181,6 +193,12 @@ export const curriculum: Unit[] = [
description: "Explore how numbers can be written in different bases, beyond the everyday denary system",
color: "unit-6",
topics: [
{
slug: "place-value",
title: "Denary Place Value (Base 10)",
shortTitle: "Place Value",
description: "Powers of 10 and writing numbers in expanded form",
},
{
slug: "binary",
title: "Binary Numbers",
@@ -195,6 +213,54 @@ export const curriculum: Unit[] = [
},
],
},
{
number: 7,
slug: "unit-7-coordinates",
title: "Coordinates",
description: "Plot points and read coordinates on the Cartesian plane",
color: "unit-7",
topics: [
{
slug: "plot-points",
title: "Plot Points on the Cartesian Plane",
shortTitle: "Plot Points",
description: "Use x and y coordinates to place points and reveal hidden shapes",
},
{
slug: "read-coordinates",
title: "State Coordinates of Points",
shortTitle: "Read Coordinates",
description: "Read off the (x, y) coordinates of points in all four quadrants",
},
],
},
{
number: 8,
slug: "unit-8-number-properties",
title: "Number Properties",
description: "Identity and inverse, the laws of arithmetic, and powers of numbers",
color: "unit-8",
topics: [
{
slug: "identity-inverse",
title: "Identity and Inverse",
shortTitle: "Identity & Inverse",
description: "Identity for addition and multiplication, inverses, and the zero facts",
},
{
slug: "number-laws",
title: "Commutative, Associative and Distributive Laws",
shortTitle: "Number Laws",
description: "Which operations can be swapped, regrouped, and expanded",
},
{
slug: "exponents",
title: "Powers and Exponents",
shortTitle: "Exponents",
description: "Base and power, expanding powers, and the index laws",
},
],
},
];
export function getUnit(slug: string): Unit | undefined {
@@ -206,7 +272,7 @@ export function getTopic(unitSlug: string, topicSlug: string): Topic | undefined
return unit?.topics.find((t) => t.slug === topicSlug);
}
export function getUnitColor(unitNumber: 1 | 2 | 3 | 4 | 5 | 6): string {
export function getUnitColor(unitNumber: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8): string {
const colors = {
1: "unit-1",
2: "unit-2",
@@ -214,6 +280,8 @@ export function getUnitColor(unitNumber: 1 | 2 | 3 | 4 | 5 | 6): string {
4: "unit-4",
5: "unit-5",
6: "unit-6",
7: "unit-7",
8: "unit-8",
};
return colors[unitNumber];
}

View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { gcd, lcm, Fraction } from "./fractions";
const f = (n: number, d: number) => new Fraction(n, d);
describe("gcd / lcm", () => {
it("computes gcd", () => {
expect(gcd(12, 8)).toBe(4);
expect(gcd(7, 3)).toBe(1);
expect(gcd(0, 5)).toBe(5);
expect(gcd(-12, 8)).toBe(4);
});
it("computes lcm", () => {
expect(lcm(4, 6)).toBe(12);
expect(lcm(3, 5)).toBe(15);
});
});
describe("Fraction sign + simplify", () => {
it("keeps the sign on the numerator", () => {
expect([f(2, -4).n, f(2, -4).d]).toEqual([-2, 4]);
expect([f(-2, -4).n, f(-2, -4).d]).toEqual([2, 4]);
});
it("reduces to lowest terms", () => {
const check = (n: number, d: number, en: number, ed: number) => {
const s = f(n, d).simplified();
expect([s.n, s.d]).toEqual([en, ed]);
};
check(5, 10, 1, 2);
check(8, 12, 2, 3);
check(3, 1, 3, 1);
check(2, -4, -1, 2);
check(-2, -4, 1, 2);
check(0, 5, 0, 1);
});
it("reports whether it is already simplified", () => {
expect(f(1, 2).isSimplified).toBe(true);
expect(f(5, 10).isSimplified).toBe(false);
});
});
describe("Fraction arithmetic (raw, then .simplified())", () => {
const s = (fr: Fraction) => [fr.simplified().n, fr.simplified().d];
it("adds", () => {
expect(s(f(1, 2).plus(f(1, 2)))).toEqual([1, 1]);
expect(s(f(2, 10).plus(f(3, 10)))).toEqual([1, 2]);
expect(s(f(1, 3).plus(f(1, 4)))).toEqual([7, 12]);
});
it("subtracts", () => {
expect(s(f(7, 8).minus(f(6, 8)))).toEqual([1, 8]);
expect(s(f(1, 2).minus(f(1, 2)))).toEqual([0, 1]);
});
it("multiplies", () => {
expect(s(f(2, 3).times(f(3, 4)))).toEqual([1, 2]);
expect(s(f(1, 2).times(f(1, 2)))).toEqual([1, 4]);
});
it("divides (invert and multiply)", () => {
expect(s(f(1, 2).dividedBy(f(1, 4)))).toEqual([2, 1]);
expect(s(f(3, 4).dividedBy(f(1, 2)))).toEqual([3, 2]);
});
it("handles negative fractions with the sign rules", () => {
expect(s(f(-1, 2).plus(f(3, 4)))).toEqual([1, 4]); // -1/2 + 3/4
expect(s(f(1, 2).minus(f(-3, 4)))).toEqual([5, 4]); // subtracting a negative adds
expect(s(f(-2, 3).times(f(1, 4)))).toEqual([-1, 6]); // different signs -> negative
expect(s(f(-2, 3).times(f(-3, 5)))).toEqual([2, 5]); // same signs -> positive
expect(s(f(-1, 2).dividedBy(f(1, 4)))).toEqual([-2, 1]); // -1/2 ÷ 1/4
});
});
describe("Fraction presentation + equality", () => {
it("renders KaTeX, collapsing whole numbers", () => {
expect(f(1, 2).toKatex()).toBe("\\frac{1}{2}");
expect(f(3, 1).toKatex()).toBe("3");
});
it("renders signed KaTeX with the sign in front", () => {
expect(f(-3, 4).toSignedKatex()).toBe("-\\frac{3}{4}");
expect(f(3, 4).toSignedKatex()).toBe("\\frac{3}{4}");
expect(f(-5, 1).toSignedKatex()).toBe("-5");
});
it("compares by value, not written form", () => {
expect(f(5, 10).equals(f(1, 2))).toBe(true);
expect(f(1, 2).equals(f(1, 3))).toBe(false);
});
});

View File

@@ -11,97 +11,100 @@ export function lcm(a: number, b: number): number {
return Math.abs(a * b) / gcd(a, b);
}
export function simplify(num: number, den: number): [number, number] {
if (den === 0) return [num, den];
const g = gcd(Math.abs(num), Math.abs(den));
const sign = den < 0 ? -1 : 1;
return [(num / g) * sign, (den / g) * sign];
/**
* An immutable fraction. Sign is kept on the numerator; arithmetic returns a new
* Fraction and does NOT auto-simplify — call `.simplified()` when you want lowest
* terms (this lets teaching views show the un-reduced working). Prefer this over
* passing `(num, den)` pairs around: positional pairs are easy to mis-order.
*/
export class Fraction {
readonly n: number;
readonly d: number;
constructor(n: number, d: number) {
// Canonical sign: keep it on the numerator. A zero denominator is tolerated
// (kept as-is) so divide-by-zero in a lesson renders rather than throwing.
if (d < 0) {
n = -n;
d = -d;
}
this.n = n;
this.d = d;
}
static of(n: number, d: number): Fraction {
return new Fraction(n, d);
}
get isZero(): boolean {
return this.n === 0;
}
plus(o: Fraction): Fraction {
return new Fraction(this.n * o.d + o.n * this.d, this.d * o.d);
}
minus(o: Fraction): Fraction {
return new Fraction(this.n * o.d - o.n * this.d, this.d * o.d);
}
times(o: Fraction): Fraction {
return new Fraction(this.n * o.n, this.d * o.d);
}
dividedBy(o: Fraction): Fraction {
return new Fraction(this.n * o.d, this.d * o.n);
}
simplified(): Fraction {
if (this.d === 0) return this;
if (this.n === 0) return new Fraction(0, 1);
const g = gcd(Math.abs(this.n), Math.abs(this.d));
return new Fraction(this.n / g, this.d / g);
}
get isSimplified(): boolean {
return gcd(Math.abs(this.n), Math.abs(this.d)) === 1;
}
/** Value equality (cross-multiplication), independent of the written form. */
equals(o: Fraction): boolean {
return this.n * o.d === o.n * this.d;
}
toDecimal(): number {
return this.n / this.d;
}
/** [whole, remainderNumerator, denominator] */
toMixed(): [number, number, number] {
const whole = Math.trunc(this.n / this.d);
const remainder = Math.abs(this.n % this.d);
return [whole, remainder, this.d];
}
toKatex(): string {
if (this.d === 1) return `${this.n}`;
return `\\frac{${this.n}}{${this.d}}`;
}
/** Like toKatex, but writes negatives with the sign in front: -\frac{3}{4} rather than \frac{-3}{4}. */
toSignedKatex(): string {
if (this.d === 1) return `${this.n}`;
const sign = this.n < 0 ? "-" : "";
return `${sign}\\frac{${Math.abs(this.n)}}{${this.d}}`;
}
toString(): string {
return this.d === 1 ? `${this.n}` : `${this.n}/${this.d}`;
}
}
export function add(
n1: number, d1: number,
n2: number, d2: number,
): [number, number] {
const num = n1 * d2 + n2 * d1;
const den = d1 * d2;
return simplify(num, den);
}
export function subtract(
n1: number, d1: number,
n2: number, d2: number,
): [number, number] {
const num = n1 * d2 - n2 * d1;
const den = d1 * d2;
return simplify(num, den);
}
export function multiply(
n1: number, d1: number,
n2: number, d2: number,
): [number, number] {
return simplify(n1 * n2, d1 * d2);
}
export function divide(
n1: number, d1: number,
n2: number, d2: number,
): [number, number] {
return simplify(n1 * d2, d1 * n2);
}
export function toDecimal(num: number, den: number): number {
return num / den;
}
export function fromDecimal(decimal: number, precision: number = 6): [number, number] {
const str = decimal.toFixed(precision);
const parts = str.split(".");
if (!parts[1]) return [parseInt(parts[0]), 1];
const den = Math.pow(10, parts[1].length);
const num = Math.round(decimal * den);
return simplify(num, den);
}
export function compare(
n1: number, d1: number,
n2: number, d2: number,
): -1 | 0 | 1 {
const diff = n1 * d2 - n2 * d1;
if (diff > 0) return 1;
if (diff < 0) return -1;
return 0;
}
export function fractionOfQuantity(num: number, den: number, quantity: number): number {
return (num / den) * quantity;
}
export function wholeFromFraction(num: number, den: number, part: number): number {
return (part * den) / num;
}
export function isProper(num: number, den: number): boolean {
return Math.abs(num) < Math.abs(den);
}
export function toMixed(num: number, den: number): [number, number, number] {
const whole = Math.floor(Math.abs(num) / Math.abs(den));
const remainder = Math.abs(num) % Math.abs(den);
const sign = (num < 0) !== (den < 0) ? -1 : 1;
return [whole * sign, remainder, Math.abs(den)];
}
export function fromMixed(whole: number, num: number, den: number): [number, number] {
const sign = whole < 0 ? -1 : 1;
return [sign * (Math.abs(whole) * den + num), den];
}
export function isSimplified(num: number, den: number): boolean {
return gcd(Math.abs(num), Math.abs(den)) === 1;
}
export function toKatex(num: number, den: number): string {
if (den === 1) return `${num}`;
return `\\frac{${num}}{${den}}`;
/**
* Convenience KaTeX formatter for raw (num, den) pairs. Handy in the step-by-step
* lesson views, which build up strings from loose numerators/denominators rather
* than Fraction objects. For arithmetic, use the Fraction class instead.
*/
export function toKatex(n: number, d: number): string {
return new Fraction(n, d).toKatex();
}

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import {
checkFractionAnswer,
checkIntegerAnswer,
checkDecimalAnswer,
checkRatioAnswer,
checkOrderingAnswer,
} from "./validation";
describe("checkIntegerAnswer", () => {
it("solves an exact match", () => {
expect(checkIntegerAnswer(6, 6).status).toBe("solved");
});
it("rejects a wrong value", () => {
expect(checkIntegerAnswer(5, 6).status).toBe("wrong");
});
});
describe("checkFractionAnswer", () => {
it("solves a fully simplified answer", () => {
expect(checkFractionAnswer(1, 2, 1, 2).status).toBe("solved");
});
// Regression: 5/10 is the right value but unsimplified — it must NOT be treated
// as fully solved (this is what let the score double-count before the fix).
it("flags a correct but unsimplified answer", () => {
expect(checkFractionAnswer(5, 10, 1, 2).status).toBe("unsimplified");
});
it("accepts any non-zero denominator for zero", () => {
expect(checkFractionAnswer(0, 5, 0, 1).status).toBe("solved");
});
it("rejects a wrong value", () => {
expect(checkFractionAnswer(3, 4, 1, 8).status).toBe("wrong");
});
it("rejects a zero denominator", () => {
const res = checkFractionAnswer(1, 0, 1, 2);
expect(res.status).toBe("wrong");
if (res.status === "wrong") expect(res.message).toMatch(/denominator/i);
});
});
describe("checkDecimalAnswer", () => {
it("solves within tolerance", () => {
expect(checkDecimalAnswer(3.14, 3.14).status).toBe("solved");
});
it("rejects outside tolerance", () => {
expect(checkDecimalAnswer(3.2, 3.14).status).toBe("wrong");
});
});
describe("checkRatioAnswer", () => {
it("solves an already-simplified ratio", () => {
expect(checkRatioAnswer([1, 2], [1, 2]).status).toBe("solved");
});
it("flags an equivalent but unsimplified ratio", () => {
expect(checkRatioAnswer([2, 4], [1, 2]).status).toBe("unsimplified");
});
it("rejects a non-equivalent ratio", () => {
expect(checkRatioAnswer([1, 3], [1, 2]).status).toBe("wrong");
});
});
describe("checkOrderingAnswer", () => {
it("solves a correct ascending order", () => {
expect(checkOrderingAnswer([-10, -8, -6, -4, -3, 5], [-10, -8, -6, -4, -3, 5]).status).toBe("solved");
});
it("rejects a wrong order", () => {
expect(checkOrderingAnswer([5, -10], [-10, 5]).status).toBe("wrong");
});
});

View File

@@ -1,4 +1,4 @@
import { gcd } from "./fractions";
import { Fraction } from "./fractions";
import { simplifyRatio } from "./ratios";
/** Parse a strict decimal string (digits and optional single dot). Rejects scientific notation, trailing letters, etc. */
@@ -29,9 +29,25 @@ export function parseStrictSignedDecimal(input: string): number | null {
return parseFloat(s);
}
/**
* The outcome of checking one submitted answer.
* - `solved` — correct and in the required form (fully done)
* - `unsimplified` — correct value, but still needs simplifying (a nudge, not done)
* - `wrong` — incorrect; `message` explains why
*
* A single `status` field means callers branch on one thing, instead of an
* error-prone `correct`/`simplified` boolean pair.
*/
export type AnswerResult =
| { correct: true; simplified: boolean }
| { correct: false; message: string };
| { status: "solved" }
| { status: "unsimplified" }
| { status: "wrong"; message: string };
export const SOLVED: AnswerResult = { status: "solved" };
export const UNSIMPLIFIED: AnswerResult = { status: "unsimplified" };
export function wrong(message: string): AnswerResult {
return { status: "wrong", message };
}
export function checkFractionAnswer(
userNum: number,
@@ -41,28 +57,26 @@ export function checkFractionAnswer(
requireSimplified: boolean = true,
): AnswerResult {
if (userDen === 0) {
return { correct: false, message: "Denominator cannot be zero" };
return wrong("Denominator cannot be zero");
}
// Check mathematical equivalence
const isEquivalent = userNum * expectedDen === expectedNum * userDen;
const user = new Fraction(userNum, userDen);
const expected = new Fraction(expectedNum, expectedDen);
if (!isEquivalent) {
return { correct: false, message: "That's not quite right. Try again!" };
if (!user.equals(expected)) {
return wrong("That's not quite right. Try again!");
}
// Any non-zero denominator gives the same value for zero, so don't force 0/1 in the UI.
if (userNum === 0) {
return { correct: true, simplified: true };
if (user.isZero) {
return SOLVED;
}
const isSimplified = gcd(Math.abs(userNum), Math.abs(userDen)) === 1;
if (requireSimplified && !isSimplified) {
return { correct: true, simplified: false };
if (requireSimplified && !user.isSimplified) {
return UNSIMPLIFIED;
}
return { correct: true, simplified: true };
return SOLVED;
}
export function checkDecimalAnswer(
@@ -71,9 +85,9 @@ export function checkDecimalAnswer(
tolerance: number = 0.001,
): AnswerResult {
if (Math.abs(userValue - expectedValue) <= tolerance) {
return { correct: true, simplified: true };
return SOLVED;
}
return { correct: false, message: "That's not quite right. Try again!" };
return wrong("That's not quite right. Try again!");
}
export function checkRatioAnswer(
@@ -82,34 +96,30 @@ export function checkRatioAnswer(
requireSimplified: boolean = true,
): AnswerResult {
if (userParts.length !== expectedParts.length) {
return { correct: false, message: "Check the number of parts in your ratio" };
return wrong("Check the number of parts in your ratio");
}
const userSimplified = simplifyRatio(userParts);
const expectedSimplified = simplifyRatio(expectedParts);
const isEquivalent = userSimplified.every((v, i) => v === expectedSimplified[i]);
if (!isEquivalent) {
return { correct: false, message: "That's not quite right. Try again!" };
return wrong("That's not quite right. Try again!");
}
if (requireSimplified) {
const isAlreadySimplified = userParts.every((v, i) => v === userSimplified[i]);
return { correct: true, simplified: isAlreadySimplified };
return isAlreadySimplified ? SOLVED : UNSIMPLIFIED;
}
return { correct: true, simplified: true };
return SOLVED;
}
export function checkIntegerAnswer(
userValue: number,
expectedValue: number,
): AnswerResult {
if (userValue === expectedValue) {
return { correct: true, simplified: true };
}
return { correct: false, message: "That's not quite right. Try again!" };
return userValue === expectedValue ? SOLVED : wrong("That's not quite right. Try again!");
}
export function checkOrderingAnswer(
@@ -117,11 +127,8 @@ export function checkOrderingAnswer(
expectedOrder: number[],
): AnswerResult {
if (userOrder.length !== expectedOrder.length) {
return { correct: false, message: "Make sure you've ordered all the numbers" };
return wrong("Make sure you've ordered all the numbers");
}
const isCorrect = userOrder.every((v, i) => v === expectedOrder[i]);
if (isCorrect) {
return { correct: true, simplified: true };
}
return { correct: false, message: "Check your ordering. Try again!" };
return isCorrect ? SOLVED : wrong("Check your ordering. Try again!");
}

View File

@@ -1,6 +1,6 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt, randomChoice } from "@/lib/utils";
import * as frac from "@/lib/math/fractions";
import { Fraction, lcm } from "@/lib/math/fractions";
let counter = 0;
function nextId() {
@@ -35,13 +35,13 @@ export function generateFractionAddSubtract(difficulty: Difficulty): MathProblem
}
const prompt = `\\frac{${n1}}{${d1}} ${op} \\frac{${n2}}{${d2}}`;
const commonDen = frac.lcm(d1, d2);
const commonDen = lcm(d1, d2);
const convertedN1 = n1 * (commonDen / d1);
const convertedN2 = n2 * (commonDen / d2);
const rawNum = operation === "add"
? convertedN1 + convertedN2
: convertedN1 - convertedN2;
const [ansNum, ansDen] = frac.simplify(rawNum, commonDen);
const { n: ansNum, d: ansDen } = new Fraction(rawNum, commonDen).simplified();
const steps = d1 === d2
? [
@@ -100,7 +100,7 @@ export function generateFractionMultiply(difficulty: Difficulty): MathProblem {
const n1 = randomInt(1, d1 - 1);
const n2 = randomInt(1, d2 - 1);
const [ansNum, ansDen] = frac.multiply(n1, d1, n2, d2);
const { n: ansNum, d: ansDen } = Fraction.of(n1, d1).times(Fraction.of(n2, d2)).simplified();
return {
id: nextId(),
@@ -126,7 +126,7 @@ export function generateFractionDivide(difficulty: Difficulty): MathProblem {
const n1 = randomInt(1, d1 - 1);
const n2 = randomInt(1, d2 - 1);
const [ansNum, ansDen] = frac.divide(n1, d1, n2, d2);
const { n: ansNum, d: ansDen } = Fraction.of(n1, d1).dividedBy(Fraction.of(n2, d2)).simplified();
return {
id: nextId(),
@@ -168,6 +168,150 @@ export function generateFractionOfQuantity(difficulty: Difficulty): MathProblem
};
}
/** A proper-fraction numerator with a random sign, magnitude 1..den-1. */
function signedProperNum(den: number): number {
const mag = randomInt(1, Math.max(1, den - 1));
return Math.random() < 0.5 ? -mag : mag;
}
/** Two proper fractions where at least one is negative (this is the negative-fractions topic). */
function signedFractionPair(d1: number, d2: number): [Fraction, Fraction] {
let n1 = signedProperNum(d1);
let n2 = signedProperNum(d2);
if (n1 > 0 && n2 > 0) {
if (Math.random() < 0.5) n1 = -n1;
else n2 = -n2;
}
return [new Fraction(n1, d1), new Fraction(n2, d2)];
}
function signedOperand(f: Fraction): string {
return f.n < 0 ? `\\left(${f.toSignedKatex()}\\right)` : f.toSignedKatex();
}
export function generateSignedFractionAddSubtract(difficulty: Difficulty): MathProblem {
const operation = randomChoice(["+", "-"] as const);
const dens = difficulty === 1 ? [2, 3, 4] : difficulty === 2 ? [2, 3, 4, 5, 6] : [3, 4, 5, 6, 8];
const d1 = randomChoice(dens);
const d2 = randomChoice(dens);
const [a, b] = signedFractionPair(d1, d2);
const result = (operation === "+" ? a.plus(b) : a.minus(b)).simplified();
return {
id: nextId(),
prompt: `${a.toSignedKatex()} ${operation} ${signedOperand(b)}`,
answer: { kind: "fraction", numerator: result.n, denominator: result.d },
hints: [
d1 === d2 ? "The denominators already match." : "Find a common denominator first.",
"Combine the numerators using the integer sign rules (subtracting a negative adds).",
"Simplify your answer.",
],
steps: [
{
explanation: d1 === d2 ? `Keep the denominator ${d1}.` : `Use a common denominator: LCM(${d1}, ${d2}) = ${lcm(d1, d2)}.`,
},
{ explanation: `${operation === "+" ? "Add" : "Subtract"} the numerators, watching the signs.` },
{ explanation: "Simplify to lowest terms.", math: result.toSignedKatex() },
],
};
}
export function generateSignedFractionMultiplyDivide(difficulty: Difficulty): MathProblem {
const operation = randomChoice(["\\times", "\\div"] as const);
const dens = difficulty === 1 ? [2, 3, 4] : difficulty === 2 ? [2, 3, 4, 5] : [3, 4, 5, 6, 7];
const d1 = randomChoice(dens);
const d2 = randomChoice(dens);
const [a, b] = signedFractionPair(d1, d2);
const result = (operation === "\\times" ? a.times(b) : a.dividedBy(b)).simplified();
const sameSign = a.n < 0 === b.n < 0;
return {
id: nextId(),
prompt: `${a.toSignedKatex()} ${operation} ${signedOperand(b)}`,
answer: { kind: "fraction", numerator: result.n, denominator: result.d },
hints: [
operation === "\\div" ? "Keep, change, flip: multiply by the reciprocal." : "Multiply numerators and denominators.",
`The signs are ${sameSign ? "the same, so the answer is positive" : "different, so the answer is negative"}.`,
"Simplify your answer.",
],
steps: [
operation === "\\div"
? { explanation: "Multiply by the reciprocal of the second fraction (keep, change, flip)." }
: { explanation: "Multiply the numerators and multiply the denominators." },
{ explanation: `Same signs give positive, different signs give negative — here the answer is ${result.n < 0 ? "negative" : "positive"}.` },
{ explanation: "Simplify to lowest terms.", math: result.toSignedKatex() },
],
};
}
export function generateFractionBodmas(difficulty: Difficulty): MathProblem {
const dens = difficulty === 1 ? [2, 3, 4] : difficulty === 2 ? [2, 3, 4, 5] : [2, 3, 4, 5, 6];
const mk = () => {
const d = randomChoice(dens);
return new Fraction(randomInt(1, d - 1), d);
};
const f1 = mk(), f2 = mk(), f3 = mk();
const multFirst = Math.random() < 0.5;
let prompt: string;
let result: Fraction;
if (multFirst) {
const prod = f2.times(f3);
// avoid a negative answer in this (positive-fractions) unit
const op = f1.toDecimal() >= prod.toDecimal() && Math.random() < 0.5 ? "-" : "+";
result = (op === "+" ? f1.plus(prod) : f1.minus(prod)).simplified();
prompt = `${f1.toKatex()} ${op} ${f2.toKatex()} \\times ${f3.toKatex()}`;
} else {
const prod = f1.times(f2);
const op = prod.toDecimal() >= f3.toDecimal() && Math.random() < 0.5 ? "-" : "+";
result = (op === "+" ? prod.plus(f3) : prod.minus(f3)).simplified();
prompt = `${f1.toKatex()} \\times ${f2.toKatex()} ${op} ${f3.toKatex()}`;
}
return {
id: nextId(),
prompt,
answer: { kind: "fraction", numerator: result.n, denominator: result.d },
hints: [
"BODMAS: do the multiplication before the addition or subtraction.",
"Multiply the two fractions first, then combine with a common denominator.",
],
steps: [
{ explanation: "Multiply first (that's the B-O-D-M before A-S)." },
{ explanation: "Then add or subtract, using a common denominator." },
{ explanation: "Simplify.", math: result.toKatex() },
],
};
}
/** Combined signed-fraction practice covering +, -, x and / (Unit 5 topic). */
export function generateSignedFraction(difficulty: Difficulty): MathProblem {
const operation = randomChoice(["+", "-", "\\times", "\\div"] as const);
const dens = difficulty === 1 ? [2, 3, 4] : difficulty === 2 ? [2, 3, 4, 5] : [3, 4, 5, 6];
const [a, b] = signedFractionPair(randomChoice(dens), randomChoice(dens));
let result: Fraction;
if (operation === "+") result = a.plus(b);
else if (operation === "-") result = a.minus(b);
else if (operation === "\\times") result = a.times(b);
else result = a.dividedBy(b);
result = result.simplified();
return {
id: nextId(),
prompt: `${a.toSignedKatex()} ${operation} ${signedOperand(b)}`,
answer: { kind: "fraction", numerator: result.n, denominator: result.d },
hints: [
"Apply the integer sign rules as you work.",
"Give your answer in its simplest form.",
],
steps: [
{ explanation: "Work through the operation, watching the signs." },
{ explanation: "Simplify.", math: result.toSignedKatex() },
],
};
}
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);

View File

@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import type { Difficulty, MathProblem } from "../types";
import { generateFractionBodmas, generateSignedFraction } from "./fraction-problems";
import { generateDefineRatio, generateFractionsAndRatios } from "./ratio-problems";
import { generateIntegerOrderCompare, generateIntegerAddSubtractMixed } from "./integer-problems";
import { generateQuaternaryToDenary } from "./number-system-problems";
function assertWellFormed(p: MathProblem) {
expect(typeof p.prompt).toBe("string");
expect(p.prompt.length).toBeGreaterThan(0);
expect(Array.isArray(p.hints)).toBe(true);
expect(Array.isArray(p.steps)).toBe(true);
const a = p.answer;
if (a.kind === "integer") {
expect(Number.isFinite(a.value)).toBe(true);
} else if (a.kind === "fraction") {
expect(Number.isInteger(a.numerator)).toBe(true);
expect(Number.isInteger(a.denominator)).toBe(true);
expect(a.denominator).not.toBe(0);
} else if (a.kind === "ratio") {
expect(a.parts.length).toBeGreaterThan(0);
a.parts.forEach((v) => expect(Number.isFinite(v)).toBe(true));
}
}
const NEW_GENERATORS: Record<string, (d: Difficulty) => MathProblem> = {
"fraction BODMAS": generateFractionBodmas,
"signed fraction": generateSignedFraction,
"define ratio": generateDefineRatio,
"fractions and ratios": generateFractionsAndRatios,
"integer order/compare": generateIntegerOrderCompare,
"integer add/subtract (mixed)": generateIntegerAddSubtractMixed,
"quaternary to denary": generateQuaternaryToDenary,
};
describe("new practice generators produce well-formed problems", () => {
for (const [name, gen] of Object.entries(NEW_GENERATORS)) {
it(name, () => {
for (const d of [1, 2, 3] as Difficulty[]) {
for (let i = 0; i < 40; i++) assertWellFormed(gen(d));
}
});
}
});
describe("quaternary answer matches its base-4 prompt", () => {
it("converts base 4 correctly", () => {
for (const d of [1, 2, 3] as Difficulty[]) {
for (let i = 0; i < 40; i++) {
const p = generateQuaternaryToDenary(d);
const digits = p.prompt.match(/^(\d+)_\{4\}/)?.[1];
expect(digits).toBeTruthy();
if (p.answer.kind === "integer") {
expect(p.answer.value).toBe(parseInt(digits!, 4));
}
}
}
});
});
describe("order/compare answer is one of the numbers asked about", () => {
it("picks a value from the prompt", () => {
for (let i = 0; i < 60; i++) {
const p = generateIntegerOrderCompare(1);
const nums = (p.prompt.match(/-?\d+/g) ?? []).map(Number);
if (p.answer.kind === "integer") {
expect(nums).toContain(p.answer.value);
}
}
});
});

View File

@@ -0,0 +1,210 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt, randomChoice } from "@/lib/utils";
let counter = 0;
function nextId() {
return `int-${++counter}-${Date.now()}`;
}
/** Wrap negatives in parentheses when they follow an operator, e.g. 5 + (-3). */
function signed(n: number): string {
return n < 0 ? `(${n})` : `${n}`;
}
export function generateIntegerAddSubtract(difficulty: Difficulty): MathProblem {
if (difficulty === 3) {
const a = randomInt(-12, 12);
const b = randomInt(-12, 12);
const c = randomInt(-12, 12);
const op1 = randomChoice(["+", "-"] as const);
const op2 = randomChoice(["+", "-"] as const);
const partial = op1 === "+" ? a + b : a - b;
const value = op2 === "+" ? partial + c : partial - c;
return {
id: nextId(),
prompt: `${a} ${op1} ${signed(b)} ${op2} ${signed(c)}`,
answer: { kind: "integer", value },
hints: [
"Work from left to right, one operation at a time.",
"Subtracting a negative is the same as adding a positive.",
`First: ${a} ${op1} ${signed(b)} = ${partial}`,
],
steps: [
{ explanation: `Work left to right. First: ${a} ${op1} ${signed(b)} = ${partial}` },
{ explanation: `Then: ${partial} ${op2} ${signed(c)} = ${value}` },
],
};
}
const range = difficulty === 1 ? 10 : 20;
const a = randomInt(-range, range);
const b = randomInt(-range, range);
const op = randomChoice(["+", "-"] as const);
const value = op === "+" ? a + b : a - b;
const rule =
op === "+"
? b < 0
? "Adding a negative means moving left — the same as subtracting."
: "Adding a positive means moving right."
: b < 0
? "Subtracting a negative is the same as adding a positive (move right)."
: "Subtracting a positive means moving left.";
return {
id: nextId(),
prompt: `${a} ${op} ${signed(b)}`,
answer: { kind: "integer", value },
hints: [
"Picture the first number on a number line.",
rule,
op === "-" && b < 0 ? "Two minus signs make a plus." : "Watch the sign of the second number.",
],
steps: [
{ explanation: `Start at ${a} on the number line.` },
{ explanation: rule },
{ explanation: `${a} ${op} ${signed(b)} = ${value}` },
],
};
}
export function generateIntegerMultiplyDivide(difficulty: Difficulty): MathProblem {
if (difficulty === 3) {
// triple product, e.g. (-2) × (-3) × (-4)
const a = randomInt(-5, 5) || 2;
const b = randomInt(-5, 5) || 3;
const c = randomInt(-5, 5) || 4;
const value = a * b * c;
const negatives = [a, b, c].filter((n) => n < 0).length;
return {
id: nextId(),
prompt: `${signed(a)} \\times ${signed(b)} \\times ${signed(c)}`,
answer: { kind: "integer", value },
hints: [
"Multiply two numbers at a time.",
"Count the negative signs: an even count gives a positive result, an odd count gives a negative result.",
`There are ${negatives} negative sign${negatives === 1 ? "" : "s"} here.`,
],
steps: [
{ explanation: `Multiply the first two: ${signed(a)} × ${signed(b)} = ${a * b}` },
{ explanation: `Then multiply by the last: ${signed(a * b)} × ${signed(c)} = ${value}` },
{ explanation: `${negatives} negative signs → ${negatives % 2 === 0 ? "positive" : "negative"} answer.` },
],
};
}
const op = randomChoice(["\\times", "\\div"] as const);
const factorRange = difficulty === 1 ? 9 : 12;
if (op === "\\times") {
const a = randomInt(-factorRange, factorRange) || 3;
const b = randomInt(-factorRange, factorRange) || 4;
const value = a * b;
const sameSign = a < 0 === b < 0;
return {
id: nextId(),
prompt: `${signed(a)} \\times ${signed(b)}`,
answer: { kind: "integer", value },
hints: [
"Multiply the numbers as normal, then decide the sign.",
"Same signs → positive. Different signs → negative.",
`These signs are ${sameSign ? "the same" : "different"}.`,
],
steps: [
{ explanation: `Multiply the sizes: ${Math.abs(a)} × ${Math.abs(b)} = ${Math.abs(value)}` },
{ explanation: `${sameSign ? "Same signs give a positive" : "Different signs give a negative"} answer.` },
{ explanation: `${signed(a)} × ${signed(b)} = ${value}` },
],
};
}
// division — build from an exact product so the answer is a whole number
const divisor = (randomInt(-factorRange, factorRange) || 3);
const quotient = (randomInt(-factorRange, factorRange) || 2);
const dividend = divisor * quotient;
const sameSign = divisor < 0 === dividend < 0;
return {
id: nextId(),
prompt: `${signed(dividend)} \\div ${signed(divisor)}`,
answer: { kind: "integer", value: quotient },
hints: [
"Divide the numbers as normal, then decide the sign.",
"Same signs → positive. Different signs → negative.",
`These signs are ${sameSign ? "the same" : "different"}.`,
],
steps: [
{ explanation: `Divide the sizes: ${Math.abs(dividend)} ÷ ${Math.abs(divisor)} = ${Math.abs(quotient)}` },
{ explanation: `${sameSign ? "Same signs give a positive" : "Different signs give a negative"} answer.` },
{ explanation: `${signed(dividend)} ÷ ${signed(divisor)} = ${quotient}` },
],
};
}
export function generateIntegerOrderCompare(difficulty: Difficulty): MathProblem {
const range = difficulty === 1 ? 10 : difficulty === 2 ? 20 : 30;
if (difficulty === 3) {
const nums: number[] = [];
while (nums.length < 3) {
const v = randomInt(-range, range);
if (!nums.includes(v)) nums.push(v);
}
const wantLargest = Math.random() < 0.5;
const value = wantLargest ? Math.max(...nums) : Math.min(...nums);
return {
id: nextId(),
prompt: `\\text{Which is the ${wantLargest ? "largest" : "smallest"}: } ${nums.join(",\\ ")}\\text{ ?}`,
answer: { kind: "integer", value },
hints: ["Picture them on a number line.", "Further right is larger; further left is smaller."],
steps: [{ explanation: `The ${wantLargest ? "largest" : "smallest"} is ${value}.` }],
};
}
const a = randomInt(-range, range);
let b = randomInt(-range, range);
if (a === b) b = a + 1;
const wantGreater = Math.random() < 0.5;
const value = wantGreater ? Math.max(a, b) : Math.min(a, b);
const dir = wantGreater ? "right" : "left";
return {
id: nextId(),
prompt: `\\text{Which is ${wantGreater ? "greater" : "smaller"}, } ${a} \\text{ or } ${b}\\text{? Enter it.}`,
answer: { kind: "integer", value },
hints: [`On a number line, the number further ${dir} wins.`, "Negatives get smaller the further left they are."],
steps: [{ explanation: `${value} is ${wantGreater ? "greater" : "smaller"}.` }],
};
}
/** Add/subtract integers, occasionally as a real-world temperature word problem. */
export function generateIntegerAddSubtractMixed(difficulty: Difficulty): MathProblem {
return Math.random() < 0.3 ? generateTemperatureProblem(difficulty) : generateIntegerAddSubtract(difficulty);
}
export function generateTemperatureProblem(difficulty: Difficulty): MathProblem {
const cities = ["London", "Moscow", "Toronto", "Oslo", "Helsinki"];
const days = ["Monday", "Tuesday", "Wednesday", "Thursday"];
const city = randomChoice(cities);
const day = randomChoice(days);
const start = randomInt(-8, 3);
const rose = Math.random() < 0.5;
const change = randomInt(2, difficulty === 1 ? 6 : 12);
const value = rose ? start + change : start - change;
const verb = rose ? "rose" : "fell";
return {
id: nextId(),
prompt: `\\begin{array}{l}\\text{In ${city} the temperature was ${start}°C on ${day}.}\\\\\\text{The next day it ${verb} by ${change}°C.}\\\\\\text{What was the temperature then?}\\end{array}`,
answer: { kind: "integer", value },
hints: [
`Start at ${start}°C.`,
`${rose ? "Rising" : "Falling"} means ${rose ? "add" : "subtract"} ${change}.`,
`${start} ${rose ? "+" : "-"} ${change} = ${value}`,
],
steps: [
{ explanation: `Start at ${start}°C.` },
{ explanation: `It ${verb}, so ${rose ? "add" : "subtract"} ${change}: ${start} ${rose ? "+" : "-"} ${change} = ${value}` },
{ explanation: `The temperature was ${value}°C.` },
],
};
}

View File

@@ -0,0 +1,136 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt, randomChoice } from "@/lib/utils";
let counter = 0;
function nextId() {
return `np-${++counter}-${Date.now()}`;
}
export function generateIdentityInverse(difficulty: Difficulty): MathProblem {
const kind = randomChoice(
difficulty === 1
? (["identity-add", "zero", "inverse-add"] as const)
: (["identity-add", "identity-mul", "zero", "inverse-add"] as const),
);
if (kind === "identity-add") {
const n = randomInt(-20, 20);
return {
id: nextId(),
prompt: `${n} + 0`,
answer: { kind: "integer", value: n },
hints: ["0 is the identity for addition.", "Adding 0 changes nothing."],
steps: [{ explanation: "Adding the identity 0 leaves the number unchanged.", math: `${n} + 0 = ${n}` }],
};
}
if (kind === "identity-mul") {
const n = randomInt(-20, 20);
return {
id: nextId(),
prompt: `${n} \\times 1`,
answer: { kind: "integer", value: n },
hints: ["1 is the identity for multiplication.", "Multiplying by 1 changes nothing."],
steps: [{ explanation: "Multiplying by the identity 1 leaves the number unchanged.", math: `${n} \\times 1 = ${n}` }],
};
}
if (kind === "zero") {
const n = randomInt(1, 30);
return {
id: nextId(),
prompt: `${n} \\times 0`,
answer: { kind: "integer", value: 0 },
hints: ["Anything multiplied by zero is zero."],
steps: [{ explanation: "Any number times zero is zero.", math: `${n} \\times 0 = 0` }],
};
}
// inverse-add: additive inverse
const n = randomInt(-20, 20) || 5;
return {
id: nextId(),
prompt: `\\text{The additive inverse of } ${n} \\text{ is?}`,
answer: { kind: "integer", value: -n },
hints: ["The additive inverse adds up to 0.", "Flip the sign."],
steps: [{ explanation: "The additive inverse flips the sign so the sum is 0.", math: `${n} + (${-n}) = 0` }],
};
}
export function generateExponentEvaluate(difficulty: Difficulty): MathProblem {
if (difficulty === 3) {
// index law: find the resulting exponent
const base = randomInt(2, 6);
const op = randomChoice(["\\times", "\\div"] as const);
if (op === "\\times") {
const m = randomInt(1, 5);
const n = randomInt(1, 5);
return {
id: nextId(),
prompt: `${base}^{${m}} \\times ${base}^{${n}} = ${base}^{x}`,
answer: { kind: "integer", value: m + n },
hints: ["Same base, multiplying → add the powers.", `${m} + ${n} = ?`],
steps: [{ explanation: "When multiplying powers of the same base, add the exponents.", math: `${base}^{${m}} \\times ${base}^{${n}} = ${base}^{${m + n}}` }],
};
}
const m = randomInt(3, 7);
const n = randomInt(1, m - 1);
return {
id: nextId(),
prompt: `${base}^{${m}} \\div ${base}^{${n}} = ${base}^{x}`,
answer: { kind: "integer", value: m - n },
hints: ["Same base, dividing → subtract the powers.", `${m} - ${n} = ?`],
steps: [{ explanation: "When dividing powers of the same base, subtract the exponents.", math: `${base}^{${m}} \\div ${base}^{${n}} = ${base}^{${m - n}}` }],
};
}
if (difficulty === 2 && Math.random() < 0.5) {
// difference of two powers, e.g. 2^3 - 2^1
const base = randomInt(2, 4);
const a = randomInt(2, 3);
const b = 1;
const value = Math.pow(base, a) - Math.pow(base, b);
return {
id: nextId(),
prompt: `${base}^{${a}} - ${base}^{${b}}`,
answer: { kind: "integer", value },
hints: [`${base}^${a} = ${Math.pow(base, a)}`, `${base}^${b} = ${Math.pow(base, b)}`],
steps: [{ explanation: "Work out each power, then subtract.", math: `${Math.pow(base, a)} - ${Math.pow(base, b)} = ${value}` }],
};
}
// evaluate a single power (includes n^0 and n^1)
const base = randomInt(2, difficulty === 1 ? 6 : 9);
const exp = difficulty === 1 ? randomChoice([0, 1, 2, 2, 3]) : randomChoice([0, 1, 2, 3]);
const value = Math.pow(base, exp);
const note = exp === 0 ? "Any number to the power 0 is 1." : exp === 1 ? "Any number to the power 1 is itself." : `Multiply ${base} by itself ${exp} times.`;
return {
id: nextId(),
prompt: `${base}^{${exp}}`,
answer: { kind: "integer", value },
hints: [note],
steps: [{ explanation: note, math: `${base}^{${exp}} = ${value}` }],
};
}
export function generateDistributive(difficulty: Difficulty): MathProblem {
const a = randomInt(2, difficulty === 1 ? 6 : 9);
const b = randomInt(1, difficulty === 1 ? 6 : 9);
const c = randomInt(1, difficulty === 1 ? 6 : 9);
const value = a * (b + c);
return {
id: nextId(),
prompt: `${a} \\times (${b} + ${c})`,
answer: { kind: "integer", value },
hints: [
"Add inside the bracket first, then multiply.",
"Or multiply the outside number by each number inside.",
`${a} × ${b} + ${a} × ${c}`,
],
steps: [
{ explanation: `Add inside the bracket: ${b} + ${c} = ${b + c}` },
{ explanation: `Multiply: ${a} × ${b + c} = ${value}` },
{ explanation: `Check by distributing: ${a * b} + ${a * c} = ${value}` },
],
};
}

View File

@@ -0,0 +1,104 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt } from "@/lib/utils";
let counter = 0;
function nextId() {
return `ns-${++counter}-${Date.now()}`;
}
export function generateBinaryToDenary(difficulty: Difficulty): MathProblem {
const bits = difficulty === 1 ? randomInt(3, 4) : difficulty === 2 ? randomInt(5, 6) : randomInt(7, 8);
// ensure a leading 1 so the length is meaningful
let binary = "1";
for (let i = 1; i < bits; i++) binary += Math.random() < 0.5 ? "0" : "1";
const len = binary.length;
let value = 0;
const parts: string[] = [];
for (let i = 0; i < len; i++) {
const exp = len - 1 - i;
const digit = parseInt(binary[i], 10);
const place = Math.pow(2, exp);
if (digit === 1) parts.push(`${place}`);
value += digit * place;
}
return {
id: nextId(),
prompt: `${binary}_{2} = ?_{10}`,
answer: { kind: "integer", value },
hints: [
"Each column is a power of 2, doubling from the right: 1, 2, 4, 8, 16 …",
"Add up the place values wherever there is a 1.",
`${parts.join(" + ")} = ${value}`,
],
steps: [
{ explanation: "Label each column with its power of 2, starting at 1 on the right." },
{ explanation: `Add the place values under each 1: ${parts.join(" + ")}` },
{ explanation: `${binary} in binary = ${value} in denary.` },
],
};
}
export function generateQuaternaryToDenary(difficulty: Difficulty): MathProblem {
const digits = difficulty === 1 ? randomInt(2, 3) : difficulty === 2 ? randomInt(3, 4) : randomInt(4, 5);
let quaternary = `${randomInt(1, 3)}`; // leading non-zero digit
for (let i = 1; i < digits; i++) quaternary += `${randomInt(0, 3)}`;
const len = quaternary.length;
let value = 0;
const parts: string[] = [];
for (let i = 0; i < len; i++) {
const exp = len - 1 - i;
const digit = parseInt(quaternary[i], 10);
const place = Math.pow(4, exp);
if (digit !== 0) parts.push(`${digit}×${place}`);
value += digit * place;
}
return {
id: nextId(),
prompt: `${quaternary}_{4} = ?_{10}`,
answer: { kind: "integer", value },
hints: [
"Each column is a power of 4, from the right: 1, 4, 16, 64 …",
"Multiply each digit by its place value and add them up.",
`${parts.join(" + ")} = ${value}`,
],
steps: [
{ explanation: "Label each column with its power of 4, starting at 1 on the right." },
{ explanation: `Add the products: ${parts.join(" + ")}` },
{ explanation: `${quaternary} in base 4 = ${value} in denary.` },
],
};
}
export function generateDigitValue(difficulty: Difficulty): MathProblem {
const digits = difficulty === 1 ? randomInt(3, 4) : difficulty === 2 ? 5 : 6;
const min = Math.pow(10, digits - 1);
const max = Math.pow(10, digits) - 1;
const n = randomInt(min, max);
const str = `${n}`;
// pick a non-zero digit position
const nonZeroPositions = str.split("").map((d, i) => ({ d, i })).filter((x) => x.d !== "0");
const pick = nonZeroPositions[randomInt(0, nonZeroPositions.length - 1)];
const exp = str.length - 1 - pick.i;
const place = Math.pow(10, exp);
const value = parseInt(pick.d, 10) * place;
return {
id: nextId(),
prompt: `\\text{In } ${n} \\text{, what is the value of the digit } ${pick.d}\\text{?}`,
answer: { kind: "integer", value },
hints: [
"Find which column the digit sits in.",
`That column is worth 10^${exp} = ${place}.`,
`${pick.d} × ${place} = ${value}`,
],
steps: [
{ explanation: `The digit ${pick.d} is in the 10^${exp} (${place}) column.` },
{ explanation: `Its value is ${pick.d} × ${place} = ${value}.` },
],
};
}

View File

@@ -7,6 +7,58 @@ function nextId() {
return `rp-${++counter}-${Date.now()}`;
}
export function generateDefineRatio(difficulty: Difficulty): MathProblem {
const contexts = [
{ a: "red", b: "blue", item: "counters in a bag" },
{ a: "boys", b: "girls", item: "in the class" },
{ a: "cats", b: "dogs", item: "at the shelter" },
{ a: "apples", b: "oranges", item: "in the basket" },
];
const ctx = randomChoice(contexts);
const g = randomChoice(difficulty === 1 ? [1, 2] : difficulty === 2 ? [2, 3, 4] : [3, 4, 5, 6]);
const x = randomInt(1, difficulty === 1 ? 6 : 8) * g;
const y = randomInt(1, difficulty === 1 ? 6 : 8) * g;
const simplified = simplifyRatio([x, y]);
return {
id: nextId(),
prompt: `\\begin{array}{l}\\text{There are ${x} ${ctx.a} and ${y} ${ctx.b} ${ctx.item}.}\\\\\\text{Write the ratio of ${ctx.a} to ${ctx.b} in its simplest form.}\\end{array}`,
answer: { kind: "ratio", parts: simplified },
hints: [
`Write it as ${x} : ${y} first.`,
"Divide both parts by their highest common factor.",
],
steps: [
{ explanation: `The ratio of ${ctx.a} to ${ctx.b} is ${x} : ${y}.` },
{ explanation: `In simplest form: ${simplified.join(" : ")}` },
],
};
}
export function generateFractionsAndRatios(difficulty: Difficulty): MathProblem {
const a = randomInt(1, difficulty === 1 ? 4 : 7);
const b = randomInt(1, difficulty === 1 ? 4 : 7);
const whole = a + b;
const which = randomChoice([0, 1] as const);
const part = which === 0 ? a : b;
const [n, d] = simplifyRatio([part, whole]);
return {
id: nextId(),
prompt: `\\begin{array}{l}\\text{Two amounts are shared in the ratio } ${a} : ${b}.\\\\\\text{What fraction of the whole is the ${which === 0 ? "first" : "second"} part?}\\end{array}`,
answer: { kind: "fraction", numerator: n, denominator: d },
hints: [
`The whole is ${a} + ${b} = ${whole} parts.`,
`The ${which === 0 ? "first" : "second"} part is ${part} out of ${whole}.`,
"Simplify the fraction.",
],
steps: [
{ explanation: `Total parts: ${a} + ${b} = ${whole}.` },
{ explanation: `Fraction = ${part}/${whole}, which simplifies to ${n}/${d}.` },
],
};
}
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;