Mathua Architecture
The five-layer stack — UI, API, core engine, grading and storage — and the computerised adaptive diagnostic.
Mathua is a single Go binary with a web delivery mode. The engine core serves a single presentation layer.
Five-layer architecture
The five layers are drawn above and described below. The diagram source is
web/next-app/diagrams/architecture.d2. The website renders it as an interactive graph.
UI Layer — Web frontend, one engine
Mathua ships with a web frontend using React with Next.js, connecting to a shared Postgres database.
API Layer — Four services, one router
The web server exposes a REST API through Go's standard net/http package. No external HTTP framework. Four services: Session (practice management), Graph (concept DAG visualisation), Leaderboard (weekly scoring), Diagnostic (adaptive testing).
Core Engine — Five modules
- DAG Loader — Loads
data/concepts/*.jsonplusenrichment.json, then validates no cycles and no orphans. - Scheduler (SM-2) — Selects the next concept using priority scoring: recency, mastery, decay bonuses. Enforces prerequisite gating and 70/30 new/review balance.
- Generators — Every problem is generated on demand by a parameterised Go function. No static question bank.
- Mastery & Spaced Repetition — Tracks mastery via streak and response time. Simplified SM-2 algorithm schedules reviews.
- Scoring — Two scores: lifetime topic score (permanent) and weekly score (resets every Monday).
Grading — nine types, six Go graders + SymPy
The grading_type on each concept selects the grader. One Router dispatches nine enum values. Six are pure Go. Three route to SymPy:
- Numeric — integer, float, fraction, mixed number, scientific notation (pure Go).
- Choice — case-insensitive multiple-choice matching.
- Comparison — comparison operators (
>,<,=,>=,<=). - Ordering — ordered sequences separated by delimiters.
- Tuple — coordinate/n-tuple parsing.
- Complex —
a + biforms.
Symbolic, Polynomial, Expression — routed through a long-lived Python subprocess running grading/sympy_service.py. The Go client sends a JSON expression pair over stdin. SymPy parses both into expression trees. SymPy then tests equivalence (simplify(expected - answer) == 0, with expand/factor/trigsimp/radsimp/powsimp and numerical verification). Python and SymPy are installed in the production image and pinned in grading/requirements.txt.
The image build verifies the installation, so grading is available in production. There is no pure-Go equivalence fallback for these types: the router sends all three straight to the subprocess.
Storage — SQLite and Postgres
SQLite for local development (mathua.db, WAL internal/platform/sqldb/sqlite.go:19). PostgreSQL for production web access via DATABASE_URL=postgres:// (internal/bootstrap/stores/postgres.go:17 PostgresStore via pgx/v5/stdlib, pgSchema internal/platform/sqldb/schema_postgres.go:3, GREATEST for XP floor, STRING_AGG for activity). cmd/sensim checks DATABASE_URL and opens pgx when postgres:// is set, otherwise SQLite. The data schema is identical across both databases, abstracted behind a Repository interface (internal/bootstrap/stores/store.go:14).
Code layout: ports and adapters
The Go code is split into five bounded contexts. Each context has three layers. ADR-060 records the decision. AGENTS.md §9 states the rule.
| Context | What it owns |
|---|---|
learning | The concept graph, mastery, spaced repetition, the scheduler, the diagnostic, planning and XP. |
competition | The weekly board, the leagues and the level titles. |
identity | Accounts, sign-in, the OAuth providers, guests, settings and avatars. |
administration | Staff roles, question reports, invitations and the audit log. |
assistant | The AI policy registry, the quota and the provider chain. |
domain/holds rules and records. It imports only the standard library.app/holds the use cases. It declares each port that it needs as a Go interface.adapters/holds the HTTP handlers (httpapi), the SQL stores (sqlstore) and the clients for outside services.
Math is a subject. The packages in internal/subjects/math implement the Subject port in internal/learning/domain/subject. A second subject, such as a language course, is a second implementation of that port.
internal/platform holds the code that every context uses: HTTP helpers, the database schema and mail. It imports no context. internal/bootstrap is the composition root. It builds every store, use case and handler and mounts the handlers on one mux. cmd/sensim reads the flags and the environment and calls it.
Two tests check this layout. internal/archtest/arch_test.go reads the imports of every package. internal/archtest/tables_test.go reads the SQL in every store and checks which context writes each table.
The frontend uses the same split. web/next-app/features/<context> holds the code of one context. web/next-app/subjects/math holds the math input and rendering. components/, lib/ and hooks/ are the shared layer. app/ holds the routes. test/featureBoundaries.test.ts checks the imports between these areas.
Administration
Administration has its own document: administration.md covers the four
roles, the permission model, invitations, the last-owner invariant and the audit log.
Diagnostic — Computerised Adaptive Testing
Locates a student's knowledge frontier using a compressed covering set + info-gain CAT (internal/learning/domain/diagnostic/cat.go, internal/learning/domain/diagnostic/report.go:11). The engine builds a minimal covering set of the DAG. It repeatedly picks the concept that gives the maximal entropy reduction. It propagates +0.3 evidence to prerequisites on correct and -0.3 to dependents on incorrect and tracks per-concept KnowledgeConfidence 0–1. The frontier is the highest belief drop. A supplemental diagnostic runs when confidence <0.7.
minTotalQuestions=25 and maxTotalQuestions=45 bound the assessment (internal/learning/domain/diagnostic/cat.go:33-35). The engine does not test all 1250 concepts.
The engine asks two probes per concept in the cover. It stops at 25 questions once the cover is finished. A narrow scope runs out of cover first and stops sooner. A five-concept scope asks ten questions and still reports min_total: 25. The engine does not pad the difference.
The engine targets 80% difficulty via engine.computeDifficulty (internal/learning/app/engine/engine.go:486, weakness→difficulty 0.3–0.8).
Full documentation
For interactive D2 diagrams, internal module detail and formatted code blocks, see the Architecture docs in the web app.
Source: docs/architecture.md