← Docs
Documentation

Mathua System Design

Full system design: schema, scheduler, SM-2, grading, the API surface, and the trade-offs behind each.

Mathua is an open-source adaptive mathematics learning platform. A single Go binary with web delivery mode -- Next.js/React static export -- sharing one engine core.


1. High-Level Architecture

Mathua follows a five-layer architecture. All layers are compiled into a single Go binary. The UI layer has a single implementation -- the web frontend (React/Next.js served as a static export) -- calling into the same engine through the REST API.

Layer Breakdown

LayerTechnologyResponsibility
UIReact/Next.js (static export)Rendering, user interaction, client state
APInet/http (Go stdlib)REST endpoints, JWT auth, rate limiting, CORS
Core EngineGoDAG loading, SM-2 scheduling, problem generation, mastery tracking, scoring, weakness propagation, CAT diagnostic
GradingGo + SymPy (Python subprocess, shipped in the image)9 grading types: 6 pure-Go graders + SymPy for symbolic/polynomial/expression
StorageSQLite (local/dev), PostgreSQL (production)Persistence via Repository interface

The architecture is streamlined with a single UI delivery method through the REST API.


2. Technology Choices

ComponentChoiceRationale
LanguageGoSingle binary, fast compilation, excellent concurrency for subprocess management
DatabaseSQLite / PostgreSQLSQLite for zero-config offline use; PostgreSQL for concurrent web access
ORMNone (raw SQL with database/sql)Maximum control over query performance; schema is small enough that an ORM adds unnecessary abstraction
Problem GenerationGo functions + SymPyAll problems are procedurally generated -- no static question bank. SymPy handles symbolic math equivalence checking
FrontendNext.js 14 (static export)Server-rendered React, static files served by the Go binary itself
StylingTailwind CSS + CSS custom propertiesDark/light theme through CSS variables, no runtime JS for theming
AuthJWT (HS256) with bcryptStateless auth, 30-day tokens, rate-limited endpoints
DiagramsD2Declarative, auto-layout, pre-rendered to SVG for use in docs and the web app

3. Database Design

Schema Overview

Eighteen tables. The five that carry the learning loop are students, concept_progress, sessions, attempts and questions. The other thirteen support the product around it: active_sessions, student_topic_speed, quiz_completions, avatar_images, password_resets, identities, email_verifications, link_tokens, question_reports, admin_invitations, admin_audit, server_sessions and schema_migrations.

Table Details

students -- User accounts

ColumnTypeNotes
idTEXT PKUUID
nameTEXTDisplay name
usernameTEXTUnique login identifier
password_hashTEXTbcrypt hash
course_idTEXTActive course filter
xp_totalINTEGERLifetime XP (MAX(0, xp_total+?) floor sqlite.go:199)
xp_todayINTEGERResets via xp_date check (MAX(0, …))
xp_dateTEXTDate of xp_today bucket (YYYY-MM-DD)
daily_xp_goalINTEGERDaily goal (default 30, xp.DefaultDailyGoal; migrated from legacy 150 via versioned backfill sqlite.go)
settingsTEXT (JSON)Arbitrary key-value settings (pause_until, accommodations.extra_time)
diagnostic_completedINTEGERBoolean flag
share_tokenTEXTs_ + 12 random bytes (engine.go:1270)
leagueTEXTbronze→diamond (leaderboard)
league_weekTEXTISO week
league_movedINTEGERPromotion/demotion flag
created_atTEXTISO 8601

concept_progress -- Per-concept student state with SM-2 spaced repetition

ColumnTypeNotes
student_idTEXT PK FKComposite with concept_id
concept_idTEXT PKMaps to DAG concept
statusTEXTUNSEEN → LEARNING → PRACTICING → MASTERED
streakINTEGERConsecutive correct answers
sm2_repetitionsINTEGERSM-2 repetition count
sm2_intervalINTEGERDays until next review
sm2_efactorREALEasiness factor (≥ 1.3)
next_review_dueTEXTISO 8601 date for spaced repetition
weakness_scoreREAL0.0–1.0, propagated to dependents

sessions -- Practice sessions

ColumnTypeNotes
idTEXT PKUUID
student_idTEXT FK
started_atTEXTISO 8601 timestamp

attempts -- Individual answer records

ColumnTypeNotes
idINTEGER PKAuto-increment
session_idTEXT FK
concept_idTEXT
answerTEXTRaw student input
expectedTEXTCorrect answer
correctINTEGERBoolean (0/1)
elapsed_secondsREALResponse time
timestampTEXT

questions -- Optional pre-seeded bank

ColumnTypeNotes
idINTEGER PK
concept_idTEXT
questionTEXT
answerTEXT
explanationTEXT
difficultyREAL0.0–1.0

Indexes

  • concept_progress(student_id) -- Fast progress lookups
  • concept_progress(student_id, status, mastered_at) -- Weekly leaderboard (sqlite.go:1549 GetWeeklyLeaderboard)
  • sessions(student_id) -- Session history
  • attempts(session_id) -- Attempts per session
  • attempts(student_id, timestamp) -- Time-series queries
  • attempts(student_id, concept_id, timestamp) -- Aggregate efficacy GetAllAttempts (sqlite.go:1401)
  • questions(concept_id) -- Question lookup
  • students(share_token) -- Share link lookup (migrate.go:69)
  • active_sessions(student_id), active_sessions(attempt_id) -- Session recovery
  • student_topic_speed(student_id) -- Per-topic learning speed

SQLite Configuration

PRAGMA journal_mode=WAL;      -- Concurrent reads during writes
PRAGMA foreign_keys=ON;       -- Referential integrity
PRAGMA busy_timeout=5000;     -- 5s wait before locking error

Migrations

Migrations are embedded directly in the application binary -- no external migration tool. All DDL uses CREATE TABLE IF NOT EXISTS. Incremental migrations use ALTER TABLE ... ADD COLUMN guarded by error-checking for duplicate column names.

Dual-Database Strategy

The Repository interface abstracts the storage layer (store.go:127):

  • SQLiteStore -- Fully implemented (sqlite.go:1, WAL migrate.go:23), used for local dev and DATABASE_URL=sqlite:mathua.db / :memory: tests
  • PostgresStore -- Fully implemented via pgx/v5/stdlib (postgres.go:21 pgSchema, $1 placeholders, GREATEST for XP floor, STRING_AGG for activity, RANDOM() for questions) — used when DATABASE_URL=postgres:// main.go:69

The codebase checks DATABASE_URL at startup and opens pgx for postgres:// else SQLite (mathua.db).


4. Core Engine

The engine is the central orchestrator. It holds references to every subsystem and coordinates the full practice loop.

Modules

ModuleFileResponsibility
DAG Loaderinternal/concepts/ (loader.go:13, dag.go)Loads data/concepts/*.json + enrichment.json heuristics (Encompasses, InterferenceGroup, Variants), validates no cycles/orphans/dup IDs, no singletons, encompass cycles, produces topological sort via Kahn's algorithm
Schedulerinternal/scheduler/ (scheduler.go:243, scheduler.go:87)Selects next concept using priority 0.5×days +0.2×(1-mastery)+0.3×weakness +5 if DECAYING +2 layering −3 interference, interleaving window (no same subdomain in last 2), top-3 dissimilar + 70/30 new/review balance
Generators22 domain registries (internal/generator/*)Every problem is generated on demand by a parameterized Go function via Registry.GenerateContext(ctx{Seed,Difficulty}) generator/registry.go:74 — no static question bank
Mastery Machineinternal/mastery/ (mastery/machine.go:41, sm2.go:34)State machine: UNSEEN → LEARNING → PRACTICING → MASTERED (with DECAYING at read time 14d mastery/status.go:24), SM-2 scaled by per-topic learningSpeed 0.5–2.0 sm2.go:15
Scoringinternal/scoring/Two scores: lifetime topic score (permanent) and weekly score (resets Monday) + 30 daily XP goal xp.DefaultDailyGoal (xp/policy.go)
Weakness PropagationIn engine engine.go:1575If a concept's weakness > 0.3, propagates w × 0.3 to dependents
Diagnosticinternal/diagnostic/ (cat.go:1, report.go:11)Compressed covering set + info-gain CAT with ±0.3 evidence propagation, per-concept KnowledgeConfidence 0–1, frontier at max belief drop, supplemental when <0.7
Plannerinternal/planning/Course paths loaded from data/courses.json (28 courses)
Lessonsinternal/lessons/Markdown lesson content from data/lessons/ + 1250 KP shards data/lessons/kp/*.json ×3 audit_lessons.py:191

DAG (Concept Graph)

Concept relationships are defined as JSON files:

{
  "id": "algebra.quadratic-formula",
  "label": "Quadratic Formula",
  "domain": "algebra",
  "subdomain": "equations",
  "grading_type": "numeric",
  "prerequisites": ["algebra.quadratic-equations", "algebra.square-roots"],
  "mastery_threshold": { "streak": 3, "avg_time_seconds": 0 }
}

The DAG loader:

  1. Loads all JSON files from data/concepts/
  2. Validates: no cycles (DFS cycle detection), no orphaned prerequisites, no duplicate IDs
  3. Produces a topological sort (Kahn's algorithm)
  4. Builds bidirectional maps for prereqsOf and dependentsOf

Scheduler

The scheduler selects the next concept using a priority formula:

priority = 0.5 × daysSinceLastSeen
         + 0.2 × (1 - masteryScore)
         + 0.3 × weaknessScore

Decaying concepts (≥14 days since last review) get +5 priority bonus. The scheduler then maintains a 70/30 new/review ratio when selecting from the candidate pool -- 70% of questions should be new material, 30% should be spaced repetition reviews.


5. SM-2 Algorithm & Mastery

Mastery State Machine

Four stored states, one computed state. Advancement is decided by evidence, not by a streak.

StateTransitionCondition
UNSEEN → LEARNINGFirst correct answerEvidence reaches the first rung
LEARNING → PRACTICINGConsistent across attemptsEvidence reaches the next rung
PRACTICING → MASTEREDDemonstrated and transferableEvidence reaches the mastery rung
MASTERED → DECAYING14+ days idleComputed at read time, never stored

The evidence score is assembled from the last six attempts (EvidenceWindow). Each attempt contributes by correctness, the difficulty it was served at, the variety of problem instance, and elapsed time. A concept counts as demonstrated at MasteryThreshold = 0.6 once at least MasteryEvidenceFloor = 6 attempts support it. An attempt with no recorded difficulty is credited at NeutralDifficulty = 0.55, the difficulty the engine serves with no learner information, rather than dropped.

Status only moves forward. A miss lowers the evidence score but does not un-learn a concept: MASTERED is an exit, and decay asks about retention, not attainment. The required_streak and time_threshold fields still exist — SM-2 reads the streak to grade a review — but they no longer gate the ladder.

This replaced a streak-based ladder, and the cost of the old one is worth recording because it was invisible for months. All three tiers were gated on the single condition streak >= required AND avg time <= threshold, so the ladder was one test repeated until the streak was long enough: for the 1250 concepts whose required_streak is 10, MASTERED cost 30 consecutive correct answers, all-or-nothing, because one miss zeroed the streak. See docs/mastery-semantics.md for the measurements and ADR-038 for the decision.

SM-2 Spaced Repetition

The SM-2 algorithm determines review intervals:

quality < 3:
  rep = 0, interval = 1, ef unchanged

quality >= 3:
  rep++
  ef += 0.1 - (5 - q) × (0.08 + (5 - q) × 0.02)
  ef = max(1.3, ef)
  interval:
    rep == 1 → 1 day
    rep == 2 → 6 days
    rep > 2 → round(prev_interval × ef)

Quality is derived from correctness and response time:

ConditionQuality
Streak met AND time ratio ≤ 0.55
Streak met AND time ratio ≤ 1.04
Streak met AND time ratio > 1.03
Streak not met0

XP Scoring

The task type does not change the base. Speed is not rewarded. There is no streak bonus. Each of those three was removed on purpose, and internal/xp/policy.go records why in its package comment.

The removed time multiplier paid for guessing and penalised careful work. The removed streak bonus let a learner on a long run out-earn the same work done carefully.

base = EffortBase(timeThreshold)      # clamped to 1-5 XP, from expected effort
XP = int(base × PerformanceMultiplier)  # correct answers only

PerformanceMultiplier is 1.1 inside the concept's own time threshold and 1.0 outside it. Because the base is clamped to 1-5 and the award truncates, int(base × 1.1) == base for every base in range: on the integer award the tier is absorbed by rounding. It is still real in the model, and ExpectedXP returns it as a float for the workload estimator to read.

Levels

Concepts MasteredTitle
0Novice
32Apprentice
64Student
96Scholar
128Adept
160Expert
192Master
224Grandmaster
256Math Architect

6. Grading System

The grading system uses a strategy pattern with a central Router that dispatches to the correct grader based on grading_type.

Grader Strategies

GraderGrading TypesImplementation
NumericnumericParses integers, decimals, fractions (3/4), mixed numbers (1 1/2, also the spoken 1 and 1/2), scientific notation (1e2). Strips commas and leading +. Uses big.Rat for exact rational arithmetic. Float tolerance 1e-9. Negative mixed numbers subtract their fraction (-1 1/2 = -1.5).
Multiple Choicemultiple_choiceCase-insensitive equality + single-letter matching (e.g., "B" matches "Option B").
ComparisoncomparisonOperator matching: >, <, =, >=, <=, !=, ==.
OrderingorderingSplits on whitespace/comma/semicolon/pipe. Positional exact match (order-sensitive).
TupletupleStrips parentheses and semicolons, splits on comma. Positional exact match.
ComplexcomplexPreprocesses polar form r(cos + i sin), handles ±, converts i → I, delegates to SymPy.
SymPysymbolic, polynomial, expression (and complex)Long-lived Python subprocess. Sends JSON via stdin, reads JSON from stdout.

There is no pure-Go fallback for the SymPy types: the production image installs Python + SymPy from grading/requirements.txt and the build fails if grading is unavailable, so this path is always present.

SymPy Subprocess Integration

For symbolic math (polynomial simplification, expression equivalence), Mathua spawns a Python subprocess running grading/sympy_service.py:

  1. Input: JSON line on stdin {id, expected, answer}
  2. Preprocessing: Strip variable assignments (y = ...), strip + C constants, normalize e^x → exp(x), ln → log, |x| → Abs(x), handle implicit multiplication (2sin(x) → 2*sin(x))
  3. Parsing: sympy.parsing.sympy_parser with implicit multiplication support
  4. Symbolic equivalence: simplify(diff) == 0, expand(diff) == 0, trigsimp, radsimp, powsimp, factor, ee.equals(ea)
  5. Numerical verification: 10 random points checked within 1e-6 tolerance
  6. Output: JSON line on stdout {id, correct, feedback}
  7. Safeguards: 3-second SIGALRM timeout, 500-character input limit, character allowlist

The service is bundled into the production image (pinned grading/requirements.txt) and self-tested at build time; there is no pure-Go fallback for these types.


7. API Layer

The web server exposes a REST API through Go's standard net/http package. No external HTTP framework.

Middleware Stack

MiddlewarePurpose
corsSets Access-Control-Allow-Origin from CORS_ALLOWED_ORIGINS allowlist (server.go:32), handles OPTIONS (204), Vary: Origin
logRequestLogs method and path
authMiddlewareReads Authorization: Bearer <token>, validates HS256 JWT (auth.go:98), injects studentID into context
authLimiterToken-bucket rate limiter (5 rate, 10 burst, 1 min window) on auth endpoints server.go:224
writeLimiter20 burst, 3s window on write endpoints (/api/study/answer, /api/quiz/*, /api/goal/*, /api/reviews/*) server.go:208
shareLimiter10 burst, 6s window on GET /api/share/* server.go:209

All Routes

MethodPathAuthPurpose
POST/api/auth/signupwrite: authLimiterCreate account → JWT
POST/api/auth/loginwrite: authLimiterLogin → JWT
GET/api/auth/meBearerCurrent user + scores
GET/api/progress/{student_id}optionalAll concept progress
GET/api/scores/{student_id}optionalAggregated scores
GET/api/configno{auth_enabled: bool}
GET/api/graphnoFull DAG node list
GET/api/leaderboardnoWeekly leaderboard
GET/api/leaguesauthWeekly leagues with promotion/demotion
POST/api/shareauthEnable/disable share link (s_ token engine.go:1270)
GET/api/share/{token}shareLimiter, publicRead-only parent/teacher report
GET/api/coursesauthAvailable courses (22)
GET/api/courses/{id}authCourse detail + progress
POST/api/courses/{id}/diagnosticauthSet course + activate path
GET/api/transcriptauthTranscript JSON or ?format=csv
POST/api/goalauthPrerequisite chain for concept_ids
POST/api/goal/diagnosticwrite: writeLimiter, optionalStart goal diagnostic
POST/api/goal/diagnostic/answerwrite: writeLimiter, optionalSubmit goal diagnostic answer (graded via stored problem)
POST/api/goal/planwrite: writeLimiter, optionalPersist diagnostic + readiness report
GET/api/weaknessesauthWeakness scores by domain (weakness >0.2)
POST/api/goals/xpauthSet daily XP goal (10–100, xp.MinDailyGoal–xp.MaxDailyGoal)
GET/PUT/api/settingsauthStudent settings JSON (pause_until, accommodations.extra_time)
GET/api/reviews/dueauthCount due reviews (paused → 0)
POST/api/reviews/sessionwrite: writeLimiter, authCreate review-only session
POST/api/reviews/answerwrite: writeLimiter, authSubmit review answer
GET/api/lessonsnoAll lessons with progress (?student_id=)
GET/api/lessons/bodynoSingle lesson markdown body
GET/api/lessons/{id}/practicenoGenerated/curated practice questions (?count=5) — also writes the study anchor (expected answer + explanation) that /api/study/answer grades against, so a client cannot grade itself
GET/api/lessons/{id}/kpnoKP shards (3 per concept) with worked example
POST/api/study/answerwrite: writeLimiter, optionalStudy seam LessonQuiz→SubmitAnswer (CONTEXT.md Seam) — graded against the server-side anchor, never a client-supplied expected; 409 + re-serve when no anchor exists, student_id impersonation guard
POST/api/quiz/sessionwrite: writeLimiter, optionalQuiz every 50 XP at 80% difficulty (quiz.go:1)
POST/api/quiz/answerwrite: writeLimiter, optionalSubmit quiz answer (TaskQuiz 20 engine.go:1126)
GET/api/admin/overviewadminCounts for the admin landing page: accounts, administrators, corpus size, stored questions, recent audit events
GET/api/admin/usersadminSearch accounts by email / id / username / name (?q=&limit=&offset=). Projects storage.AdminUser, which has no field for a credential or for learning state
PATCH/api/admin/users/{id}admin{"role":"owner"|"admin"|"moderator"|"student"} — the only mutation. The body contributes one field; every other key is ignored. Refuses 409 when it would leave zero administrators, and audits the change. See docs/administration.md for the permission matrix and CanAssign
GET/api/admin/auditadminAudit trail, newest first. Read-only: no PUT/PATCH/DELETE route and no update or delete method on either store
GET/api/activityauthDaily activity heatmap (?days=365)
GET/api/efficacyauthFirst-pass/second-pass efficacy
GET/api/efficacy/allnoAggregate efficacy across students
GET/api/healthno{"status":"ok"}

Auth Flow

Signup: POST /auth/signup {name, username, password}
  → bcrypt hash password
  → CreateUser in SQLite
  → Generate HS256 JWT with 30-day expiry
  → Return {token, student_id, name}

Login: POST /auth/login {username, password}
  → FindByUsername
  → bcrypt.Compare
  → Generate JWT
  → Return token

Validate: Authorization: Bearer <token>
  → Parse HS256 JWT
  → Extract "sub" → studentID
  → Inject into context

JWT secret is from JWT_SECRET env var (hex-decoded 32 bytes or raw string ≥ 32 chars), or randomly generated each run.


8. Frontend Architecture

Web App (Next.js 14 -- Static Export)

The frontend uses Next.js in static export mode -- meaning the Go binary serves pre-built HTML/JS/CSS files with no Node.js runtime. This keeps deployment simple: a single Go binary that is both API server and file server.

Directory structure:

web/next-app/
├── app/                    # Next.js pages (App Router)
│   ├── page.tsx            # Home page
│   ├── diagnose/           # CAT diagnostic flow
│   ├── practice/           # Practice session
│   ├── progress/           # Progress visualization
│   ├── lessons/            # Lesson content
│   ├── leaderboard/        # Weekly scores
│   └── docs/               # Documentation
├── components/             # Shared React components
│   ├── Header.tsx          # Sticky nav bar
│   ├── FormulaBlock.tsx    # KaTeX formula display
│   └── ...
├── diagrams/               # D2 source files
├── public/diagrams/        # Pre-rendered SVGs
└── tailwind.config.js      # Custom theme

State management: Each page manages its own state with React hooks (useState, useEffect). No global state library -- the API is the source of truth.

API client: Axios-based, with a thin wrapper in lib/.

Theming: CSS custom properties for dark/light mode. Uses a warm palette: dark backgrounds are #0b0f1a (not pure black), light surfaces are #fefcf4 (warm parchment). Gold accent (#c8a96e) marks mastery and structure.


9. End-to-End Data Flow

Here's the complete flow when a student submits an answer:

Step-by-Step

  1. Client sends POST /api/study/answer with {concept_id, answer, question}
  2. Auth middleware validates JWT, injects studentID into request context
  3. Handler looks the question up in the server-side anchor — written when the question was served — and calls Engine.SubmitStudyAnswer(studentID, conceptID, answer, elapsed). There is no client-supplied expected answer to fall back on: no anchor means a 409 and a re-serve, not a guess.
  4. Grading -- route to the correct grader based on grading_type: numeric graders parse the answer, SymPy handles symbolic equivalence, etc.
  5. Machine -- mastery.Machine.Next() determines if the student's performance qualifies for a state transition (UNSEEN → LEARNING → etc.)
  6. SM-2 -- scheduler.ComputeSM2() computes new repetition count, interval and easiness factor
  7. Upsert -- repo.UpsertProgress() saves all state (including SM-2 fields) with an SQLite ON CONFLICT ... DO UPDATE query
  8. Weakness -- if the concept's weakness score exceeds 0.3, propagate to dependent concepts at w × 0.3
  9. Attempt -- repo.RecordAttempt() inserts a row into the attempts table
  10. XP -- xp.Award() computes the award from the concept's own effort base, then repo.AddXP() updates the student record
  11. Next -- the client refetches the practice set, which generates the next problem and writes its anchor. The scheduler picks the next concept on the following turn.
  12. Response -- JSON response with result, next question and session state

Total latency target: under 100ms for numeric grading, under 500ms for SymPy-based grading (including subprocess round-trip).


10. Computerized Adaptive Testing (CAT)

Mathua implements a compressed-covering + info-gain CAT to locate a student's knowledge frontier quickly. Instead of testing all 1250 concepts, the diagnostic asks two probes per concept in the covering set, bounded above by maxTotalQuestions=45. A wide scope reaches minTotalQuestions=25; a narrow one runs out of cover first and ends below it.

Algorithm (internal/diagnostic/cat.go:1, report.go:11)

  1. Load all concepts in topological order (from the DAG)
  2. Build a minimal covering set via compressedCover() over the DAG’s prerequisite closure
  3. Repeatedly pick the concept with maximal infoGain() (max entropy reduction at ~0.5 belief)
  4. Generate a question; grade correct+fast vs correct vs wrong
  5. Propagate evidence: correct → +0.3 belief to all prerequisites; wrong → -0.3 to all dependents + related (Bayes conflict weighting)
  6. Track per-concept KnowledgeConfidence 0–1; Frontier is the highest belief drop between sorted beliefs
  7. Stop when every concept has ≥2 probes and confidence ≥0.7; otherwise run supplementalDiagnostic() to probe low-confidence concepts
  8. Report DiagnosticReport{PlacementCourseID, FrontierIdx, GapsByDomain, MasteryLevels, CompletionEstimates} (report.go:11) with 50/100/300 XP completion estimates (report.go:41)

Key Properties

  • Covering-set + info-gain efficiency: 2 × |cover| probes, ≤ 45 total, vs 666 exhaustive; 80% difficulty targeting via engine.computeDifficulty engine.go:157
  • Evidence propagation: correct boosts prereqs, wrong penalizes postrequisites — finds frontier even with conflicting evidence
  • Mastery + automaticity: fast (elapsed < timeThreshold) distinguishes PRACTICING vs LEARNING placement
  • Optimistic merge: Retaking does not delete progress; ApplyGoalResults engine.go:1947 merges with Practicing/Learning vs Unseen
  • Time-bounded: Per-concept AvgTimeSeconds threshold; slow answers are !fast for diagnostic purposes

Goal-Oriented Diagnostic

A variant that targets a specific set of concept goals. StartWithPath runs the same info-gain CAT, restricted to the caller's path rather than the whole graph:

  1. Collects all prerequisite chains for the target concepts
  2. Picks each question by information gain over the covering set, within that path
  3. Produces a readiness assessment: which prerequisites are mastered and which need work

11. Key Design Decisions & Trade-offs

Why Go over Python or Node.js?

FactorGoPythonNode.js
Single binary deployYesRequires runtimeRequires runtime
SymPy subprocessNatural with os/execNativeIndirect
ConcurrencyGoroutinesGILEvent loop
Compile-time safetyStrongRuntimeRuntime
Startup time~5ms~500ms~100ms

The biggest win: a single ~15MB binary that is both API server and static file server. Deploy with scp mathua user@server: && ./mathua --serve.

Why SQLite + PostgreSQL (not just one)?

ScenarioDatabaseReason
Student running locallySQLiteZero config, no server, file-based
Self-hosted / homelabSQLiteSame binary, just point at a path
Multi-user web deploymentPostgreSQLConcurrent writes, connection pooling
CI / TestsSQLite (:memory:)Fast, isolated, reproducible

The Repository interface makes this transparent. The schema is identical across both databases.

Why Generated Questions (not a static bank)?

  • Infinite variety: Every session is unique
  • No cheating: A static bank can be memorized and shared
  • Adaptive difficulty: Parameters can be tuned in real-time based on student performance
  • No storage: Questions are generated on-demand and never persisted

Trade-off: generation latency. Complex polynomial questions require a SymPy subprocess round-trip (typically 50–200ms). Simple numeric questions are pure Go and take <1ms.

Why No ORM?

The schema has 28 tables with straightforward relationships. Raw SQL with database/sql gives:

  • Full control over query optimization (especially the SM-2 upsert and leaderboard queries)
  • No query generation overhead
  • Transparent SQL for debugging
  • Easy to port between SQLite and PostgreSQL

Trade-off: more boilerplate for CRUD operations. Worth it for the control over the SM-2 upsert query and leaderboard computation.

Why D2 over alternatives?

The project previously used Mermaid. D2 provides:

  • Superior auto-layout (especially for directed graphs with many nodes)
  • Cleaner syntax -- declarative, readable, version-control friendly
  • First-class theme support (light/dark themes via -t 0 / -t 200)
  • Pre-rendered SVGs -- no runtime rendering cost

Why Static Next.js Export (not SSR)?

The Go binary serves the Next.js static export directly. This means:

  • No Node.js server needed in production
  • A single port for both API and static files
  • Simpler deployment -- just the Go binary plus the out/ directory
  • Trade-off: no server-side rendering, but for a learning app this is acceptable

Summary

Mathua is an intentionally simple system. One language (Go), two databases (SQLite + PostgreSQL via pgx/v5 storage/postgres.go:21 / storage/sqlite.go:1, same Repository store.go:477), one engine core with a single delivery method through the REST API. The complexity is in the algorithms -- SM-2 spaced repetition scaled by learningSpeed, CAT compressed-cover + info-gain diagnostics, 22 domain registries (1250 concepts), 9 grading types via Router -- not in the infrastructure. Every component can be understood by reading its source file start to finish.

Source: docs/system-design.md