How Mathua Works

The engine behind the learning: a technical explanation of the concept graph, student model, diagnostic algorithm, task selection, and scoring system.

The Concept Graph

Mathua represents all mathematical knowledge as a directed acyclic graph (a DAG). Each node in the graph is an atomic concept: the smallest unit of mathematical knowledge that can be practiced and mastered independently. Each directed edge is a prerequisite relationship. If concept B has an edge from concept A, then A must be mastered before B is ever shown to the student.

The graph currently contains 630 concepts spanning 17 domains: from early Counting through Calculus, Linear Algebra, and Topology.

{
  "id": "frac.add.diff",
  "label": "Add fractions with different denominators",
  "domain": "fractions",
  "subdomain": "fractions.addition",
  "prerequisites": ["frac.add.same", "arith.factor.lcm"],
  "mastery_threshold": { "streak": 5, "avg_time_seconds": 18.0 }
}

The graph is stored as a flat JSON file: data/concepts.json, and is community-editable. A graph validator runs on every pull request and rejects the change if it introduces a cycle.

arith.factor.gcfarith.factor.lcmfrac.add.difffrac.mixed.add

The Student Model

Every concept in the graph has a state for each student:

Loading…
Single-digit addition
5 correct in a row8 seconds
Multiplication tables
7 correct in a row6 seconds
Add fractions with different denominators
5 correct in a row18 seconds
Solve one-step addition equations
5 correct in a row12 seconds
Long division
5 correct in a row20 seconds

Spaced Repetition

When a concept reaches MASTERED, Mathua immediately schedules its next review using a simplified SM-2 algorithm.

first review:   1 day
second review:  3 days
third review:  interval × ease_factor
ease_factor: starts at 2.5
decreases by 0.2 on each failed review
minimum value: 1.3

Reviews are never presented as a separate "review mode." They are woven into every session by the scheduler, which manages the 70/30 balance between new material and review automatically.

The Diagnostic Algorithm

When a student first opens Mathua, they enter a Computerised Adaptive Testing (CAT) session. The goal is to locate the student's knowledge frontier using as few questions as possible.

  1. I.The concept graph is sorted topologically. The diagnostic starts at the concept at the midpoint of the sorted order.
  2. II.If the student answers correctly within the time limit, the algorithm moves forward: it next tests a concept further along the prerequisite chain.
  3. III.If the student answers incorrectly or exceeds twice the expected time, the algorithm moves backward: it tests a concept earlier in the chain.
  4. IV.This binary search continues with multiple probes per concept until the student's knowledge frontier is clearly established.
  5. V.The diagnostic records a starting mastery estimate for every concept the student passed through. Concepts answered correctly count as LEARNING. Concepts answered quickly and accurately count as conditionally MASTERED and are skipped in early sessions.
Loading…

The diagnostic test asks as few questions as possible. Without this algorithm, a naive assessment of 630 concepts would require up to 630 questions. The CAT approach, combining binary search with the topological ordering, reduces this by roughly 90%.

The diagnostic test can be retaken at any time from your profile. Retaking does not delete progress: it creates a new knowledge estimate that is merged with existing data, always preferring the more optimistic estimate so students are never penalised for reassessing.

The Task Selection Algorithm

The scheduler runs after every question is submitted. It selects the next concept by computing a priority score for every eligible concept.

priority = (0.7 × days_since_last_seen)
+ (0.3 × (1.0 − mastery_score))
+ 5.0   if status == DECAYING
+ 2.0   if newly unlocked this session

Three hard rules the scheduler enforces regardless of priority scores:

A concept whose prerequisites are not all MASTERED is never surfaced.
The same concept is never shown twice in a row.
The scheduler targets a session composition of 70% new and practicing material, 30% review.
Loading…

Scoring and Ranking

Mathua tracks two separate scoring systems:

topic_score = (mastered_concepts × 100)
+ Σ speed_bonus per mastered concept
+ domain_completion_bonus (200 pts if all concepts mastered)
speed_bonus = max(0, (time_limit − avg_time) / time_limit × 50)
weekly_score = (concepts_mastered_this_week × 100)
+ speed_bonus_this_week
+ (current_day_streak × 10)

Generator-Based Problems

Every problem in Mathua is generated on demand by a parameterised Go function: a generator. There is no static question bank.

// internal/generator/arithmetic/add.go
type AddSingleGen struct{}

func (g *AddSingleGen) Generate(difficulty float64) generator.Problem {
    max := int(5 + difficulty*4)
    a   := rand.Intn(max) + 2
    b   := rand.Intn(max) + 2
    ans := a + b
    return generator.Problem{
        Question:    fmt.Sprintf("%d + %d = ?", a, b),
        Answer:      strconv.Itoa(ans),
        Explanation: fmt.Sprintf("%d + %d = %d", a, b, ans),
    }
}

The difficulty parameter scales operand size from 0.0 to 1.0. The scheduler passes a difficulty value based on the student's current mastery score.

Expression Grading with SymPy

From algebra onward, answers are expressions: x = 4, (x+2)(x+3), 2x² + 3x - 5 where numeric comparison is no longer sufficient. Mathua uses a mixed Go/Python grading system: a Go router dispatches to six grader types, and mathematical equivalence for algebra, calculus, differential equations, and trigonometry is handled by a Python subprocess running sympy.

The Router selects the grader by grading_type. Concepts with type polynomial or expression route to sympyGrade(), which spawns a long-lived Python 3 subprocess. If Python or SymPy are not installed, it falls back to a pure-Go string normaliser (symbolic grader).

Loading…
// internal/grader/router.go
func (r *Router) Grade(t GradingType, expected, answer string) Result {
    switch t {
    case GradingNumeric:
        return r.numeric.grade(expected, answer)
    case GradingPolynomial, GradingExpression:
        return r.sympyGrade(expected, answer)
    case GradingMultipleChoice:
        return r.choice.grade(expected, answer)
    case GradingComparison:
        return r.comparison.grade(expected, answer)
    case GradingOrdering:
        return r.ordering.grade(expected, answer)
    }
}

func (r *Router) sympyGrade(expected, answer string) Result {
    if r.sympy == nil {
        var err error
        r.sympy, err = newSympyGrader()
        if err != nil {
            return r.symbolic.grade(expected, answer) // fallback
        }
    }
    return r.sympy.grade(expected, answer)
}

The Go client sends JSON requests to the Python subprocess via stdin. SymPy parses both expressions into trees and checks equivalence with simplify(expected - answer) == 0. This catches identities that string or coefficient comparison never could:

# grading/sympy_service.py
import sys, json
from sympy import simplify, parse_expr, Symbol

for line in sys.stdin:
    req = json.loads(line)
    expected = parse_expr(req["expected"])
    answer   = parse_expr(req["answer"])
    correct  = simplify(expected - answer) == 0
    print(json.dumps({"id": req["id"], "correct": correct}))

Examples of equivalences SymPy can detect:x² + 2x + 1 == (x+1)² · sin²(x) + cos²(x) == 1 · xe^x − e^x + C == e^x(x−1) + C · 2e^(2x) == 2exp(2x)

Ready to find your starting point?