Math KG — System architecture

Internal engineering reference. Snapshot as of 2026-07-09. Describes what exists today and how to inspect it.

TL;DR

Three write-path pipelines converge on a shared Cloudflare D1 database and R2 blob store, and one portal reads it all.

  1. Content extraction pulls every published lesson from school-api, produces pedagogical briefs at three altitudes (asset · lesson · chapter), and writes them to R2 + D1.
  2. LO authoring creates Learning Objectives per (curriculum, thread, grade) from framework sources (NCERT for CBSE, ATP for Merdeka) via the /author-los skill, with server-side validation and audit.
  3. Tagging maps each extracted asset to zero-or-more LOs via /tag-grade: a matcher pass with per-chapter fanout, then adversarial verification, then gap detection.

A SME review layer sits on top of tagging — humans approve/edit/reject via the KG MCP; the portal shows the overlay live. The compute is many parallel Claude Code sessions (not a hosted service); state is fully in the cloud so any session can be rebooted from any login without losing progress. Everything is served through a static + live-overlay portal at kg.classrootsedu.in. A parallel LO knowledge graph lives in Neo4j Aura for graph queries.

System diagram

school-api MCP chapters · lessons · slides · sim data.js · videos Framework PDFs CBSE NCERT / Ganita Prakash · Merdeka ATP (Drive) Extracted corpus (R2 briefs) and D1 LO catalog (used as input to tagging) Content extraction /extract-batch → Workflow fanout N sub-agents · claim → extract → brief self-validate → return JSON LO authoring /author-los · SME rule book (Notion) SME writes LOs · server-side validation R-LO-1 / R-CANON / R-EDGE-4 enforced Tagging /tag-grade · matcher + adversarial verify per-chapter Workflow fanout · v3 rules R1-R7 gap detection → coverage report Cloudflare R2 bucket: math-kg-state briefs/asset · briefs/lesson · briefs/chapter raw/whisper · raw/sim_datajs Cloudflare D1 db: cbse-kg-portal-reviews (shared state) extraction_claims · brief_index · los · edges ai_tags · tag_reviews · sme_added_tags Neo4j Aura Merdeka fractions v1 loaded · v3 pending LO · Asset nodes CONTAINS · TAGGED_TO edges writes both R2 + D1 writes D1.ai_tags manual SME review — via KG MCP approve_tag · edit_tag · reject_tag · add_sme_tag writes tag_reviews + sme_added_tags overlays build_skeletons.py (offline) pulls R2 briefs + D1 state → validates → per-thread assets_index.json (baked into portal) Cloudflare Pages · kg.classrootsedu.in (Cf-Access gated) Static: assets_index.json chapter → lesson → asset tree (baked) Live: /api/extraction-status · /api/ai-tags · /api/reviews reads D1 per portal load — no redeploy Neo4j link (Aura Browser) separate console for KG queries Portal is read-only for tags. SMEs invoke KG MCP write tools; portal overlays render within seconds via /api/reviews.

Three pipelines write to shared D1 + R2 state (color-coded arrows: blue = extraction, purple = LO authoring / SME review, orange = tagging). Dashed arrows = live/manual (Neo4j is loaded manually from D1 for graph queries; portal live overlay reads D1 on every load). Dashed box = SME review layer that overlays on top of AI tagging.

State stores

Cloudflare R2 — bucket math-kg-state

Account 71519131ff2aeeda400f7e429873fe06 (Vinay). Blobs only — no query API. Access via wrangler r2 object.

PrefixContentsOwnership
briefs/lesson/<lesson_id>.jsonBottom-up lesson brief (13 sections)Written by /extract-lesson
briefs/slides/<lesson_id>.jsonSlide-by-slide progressionWritten by /extract-lesson
briefs/asset/<slide_id>.jsonPer-sim / per-video brief (11 keys)Written by /extract-lesson
briefs/chapter/<chapter_id>.jsonBottom-up chapter brief (once all lessons done)Composed post-batch
raw/whisper/<video_slide_id>.jsonWhisper transcript + segmentsWritten by /extract-lesson
raw/sim_datajs/<sim_slide_id>.js.zstzstd-compressed original sim data.jsWritten by /extract-lesson
corpus/shallow/<cur>_<thread>.jsonPortal-facing shallow corpus (legacy for fractions pilot)Pilot artifact — not written by new pipeline
corpus/deep/<chapter_id>.jsonDeep per-chapter dumpPilot artifact

Cloudflare D1 — database cbse-kg-portal-reviews

SQLite at the edge. Database id 59d0a9ac-3370-4882-aa82-8c08b6619a75. All mutation via wrangler d1 execute or via the Pages Functions API.

TablePurposeKey columns
extraction_claimsCoordination + progress per lessonlesson_id, chapter_id, thread, grade, status, claimed_by_email, claim_expires, completed_at, brief_url
brief_indexIndex of every brief written to R2 (audit + drift detection)brief_id, altitude, curriculum, thread, grade, chapter_id, lesson_id, asset_id, r2_url, content_hash, authored_at, authored_by
losLearning Objectives (46 Merdeka + 67 CBSE loaded)id, curriculum, thread, primary_thread, canonical_statement, bloom_verb, bloom_level, grade, status
los_changesFull audit trail for every LO mutationlo_id, before_json, after_json, actor_email, changed_at
edgesWithin-thread LO relationshipssource_lo, target_lo, edge_type, thread, actor_email
cross_thread_prereqsCross-thread LO prerequisites (placeholders supported)source_thread, source_lo, target_thread, target_lo, status
ai_tagsAI-generated LO-to-asset tags (562 Merdeka fractions loaded)tag_id, curriculum, thread, grade, asset_id, lo_id, confidence, evidence, rationale, review_status
tag_reviewsSME review overlay (approve / edit / reject actions)tag_id, action, reviewer_email, new_confidence, new_rationale, created_at
sme_added_tagsNet-new tags added by SMEs during reviewtag_id, curriculum, thread, grade, asset_id, lo_id, added_by, confidence
sme_tokensBearer tokens for MCP access (SHA-256 hashed at rest)token_hash, email, label, expires_at, revoked_at, last_used_at

Neo4j Aura

Managed graph database. Currently loaded: Merdeka fractions v1 tags (562 TAGGED_TO edges over 290 Asset nodes and 46 LO nodes). v3 tag reload pending. CBSE side: 67 LO nodes exist, no Asset nodes yet (school-api has no CBSE content).

Node / edgeMeaning
(:LO {id, curriculum, primary_thread, canonical_statement, bloom_verb, grade})Learning Objective — one per authored LO
(:Asset {id, kind, title, chapter_id, lesson_id, slide_position})Sim / video / lesson-level asset
(:LO)-[:PREREQUISITE_OF]->(:LO)Within-thread ordering
(:Asset)-[:CONTAINS]->(:Asset)Chapter contains lessons contains sims/videos
(:Asset)-[:TAGGED_TO {confidence, review_status, tag_origin}]->(:LO)AI-generated tag, later overlaid with SME reviews

Query surface: Neo4j Aura Browser (creds in prior scratchpad neo4j.env) OR via the kg-mcp Python MCP server (19 tools including list_los, create_lo, bulk_create_tags, list_gaps).

Portal static assets

Files at curriculum/cbse/fractions/portal/data/<curriculum>/<thread>/. Deployed to Cloudflare Pages on every wrangler pages deploy.

PathPurpose
assets_index.jsonChapter → lesson → asset tree with skeleton + baked briefs + validation state
deep/corpus_deep_<chapter_id>.jsonPer-chapter deep expansion (loaded lazily by portal on chapter click)
<thread>_los.json / _edges.json / _tags.json / _gaps.jsonKG snapshots — portal fallback when the live /api/* is unreachable

Content extraction pipeline

Per-lesson flow, orchestrated by /extract-batch at the chapter or grade level:

  1. Discovery. /extract-batch queries D1 for pending extraction_claims in scope.
  2. Atomic claim. Optimistic UPDATE flips rows to status='claimed' with a 2-hour timeout. Losers of the race skip.
  3. Parallel fanout. Workflow spawns one sub-agent per claimed lesson (auto-capped at ~10 concurrent). Each sub-agent invokes /extract-lesson --as-subagent.
  4. Per-lesson work (each sub-agent):
    • mcp__school-api__slide_list for the lesson's slide manifest.
    • For each video slide: download mp4 → Whisper transcribe → upload transcript to raw/whisper/ → compose video brief (arc as array of phase objects, transcript_ref pointer).
    • For each simulation slide: fetch data.js → parse into content buckets → zstd-upload raw to raw/sim_datajs/ → compose sim brief.
    • For each image slide: vision-LLM → single-sentence pedagogical_intentdelete image bytes. Only the intent line persists.
    • Compose slides brief and lesson brief bottom-up (lesson brief needs all children as input).
    • Self-validate against the reference schema (13 lesson keys with minimum char/count thresholds; 5 identifier fields per asset brief; arc as array). Return {lesson_id, validated, gaps, r2_keys_written, brief_url}.
  5. Central commit. The /extract-batch parent walks the returned results. validated:true → D1 status='done' + write brief_index rows. validated:false → reset D1 to status='pending' with the gap list in error_summary. Sub-agent crash → same reset with gap='sub-agent-crashed'.
  6. Portal refresh. Post-batch, run build_skeletons.py to pull the newly-done briefs from R2 into assets_index.json, then wrangler pages deploy. Live D1 status shows up in the portal without redeploy via /api/extraction-status.
Note The self-validation gate is what prevents brief drift. Session A's earlier PoC wrote briefs with arc as a prose string; the new pipeline rejects those, marks the D1 row pending instead of done, and the portal shows the lesson as · not started instead of ✓ done.

LO authoring pipeline

Learning Objectives are the atomic units of the knowledge graph — one canonical statement per skill a learner acquires at a given grade. LOs are authored by Curriculum SMEs (currently Vinay, Arjun, Mani) using the /author-los skill against a framework source. LOs are NOT auto-derived from extracted content; they exist independently, and are what content later gets tagged to.

Trigger + inputs

Flow

  1. SME opens a fresh Claude Code session, runs /join to bootstrap identity + MCP setup, then /author-los.
  2. Skill loads current LO catalogue for the thread (via mcp__kg__list_los) so no duplicates are authored.
  3. Skill loads the framework PDFs + prior-grade LO catalogue as context, invokes the LO Authoring Prompt from Notion.
  4. For each LO the model produces: canonical statement, bloom_verb, bloom_level, conceptual_object, primary_thread, prerequisite edges, cross-thread prereqs, source citations (framework + clause_ref), misconception references.
  5. Each LO write goes through mcp__kg__create_lo → server-side validation (banned verbs, canonical uniqueness, grade order for edges, orphan detection) → D1 los + los_changes (audit).
  6. Edges go through mcp__kg__create_edge and mcp__kg__create_cross_thread_prereq. Cross-thread edges support PLACEHOLDER targets for un-authored target threads.
  7. Skill outputs companion artifacts per grade to disk: scope_g<N>.md, coverage_matrix_g<N>.md, appends to authoring_questions.md. These are for human review, not portal consumption.

Server-side validation (what rejects bad LOs)

RuleWhat it checks
R-LO-1Banned verbs (understand / know / learn) — LOs must state observable behavior
R-CANONCanonical statement's first word matches the declared bloom_verb
R-EDGE-4Prerequisite edges point from earlier grade → later grade (no reverse edges)
R-EDGE-5No cycles in the prerequisite graph
R-EDGE-6No orphan LOs (every non-root LO has ≥1 incoming prerequisite; explicit is_definitional_root flag overrides)

Bad LOs bounce at the API with 422 <rule citation> — never reach Neo4j.

Current state

CBSE LOs
67 authored across the Number strand (fractions + adjacent threads). Loaded to Neo4j Aura.
Merdeka LOs
53 authored across G2 / G4 / G5 / G6 fractions. v3 rules applied.
Edges
208 within-thread + 23 cross-thread (all Merdeka cross-thread edges are proposed_pending_human_review placeholders).
Audit
Every LO mutation writes before/after JSON to los_changes. Reviewable per LO.

Tagging pipeline

Tagging maps extracted assets (sims / videos / lessons / chapters) to authored LOs. Runs after both content extraction and LO authoring are done for a grade. Three stages, each with different fanout characteristics.

Trigger + inputs

Stage A · Matcher (per-chapter Workflow fanout)

Stage B · Adversarial verification

Stage C · Gap detection

Commit

All tags land in D1 ai_tags via POST /api/tags/bulk (batched, per-row validated, cap 5000/call). The commit is atomic per batch. Every tag carries a batch_id for provenance.

Current state

Merdeka fractions v3
405 / 408 assets tagged (99.3% coverage) · 14 flagged (adversarially refuted) · 3 untagged · 5 LO gaps.
CBSE fractions
Not yet run — waiting on extracted content.
Cross-grade recovery
113 tags in Merdeka v3 point at cross-grade LOs (a G5 asset tagged to a G4 LO because the concept is G4-level).

SME review workflow

AI tagging is not final. Every tag lands as review_status='pending' (or 'flagged' if Stage B refuted it); a Tagging Reviewer walks the queue and applies one of four actions.

Portal is read-only. Writes go through KG MCP.

This is a deliberate architectural choice — as of 2026-07-07 the portal at kg.classrootsedu.in has NO interactive tag-modification UI. It shows current tag state (raw AI tags + review overlays) as read-only badges. Reviewers write via the kg-mcp Python MCP server exposing four tag-review tools:

MCP toolActionWrites to
mcp__kg__approve_tagConfirm the tag is correct as-istag_reviews (action=approve)
mcp__kg__edit_tagModify confidence or rationale, keep the LO mappingtag_reviews (action=edit)
mcp__kg__reject_tagRemove from LO card display (preserved in table for audit)tag_reviews (action=reject)
mcp__kg__add_sme_tagAdd a net-new tag SMEs think the AI missedsme_added_tags (net-new row)

Overlay model

The portal computes displayed tags at load time by overlaying tag_reviews onto ai_tags:

Overlay is computed live via GET /api/reviews — no redeploy needed. When an SME runs mcp__kg__approve_tag, the portal reflects the badge on the next page load.

Auth

Every MCP write goes through Authorization: Bearer <sme-token>. Tokens are issued via curriculum/pipeline/issue_sme_token.py <email>, stored hashed at rest in sme_tokens, and audit-logged on every use. Portal-side auth uses Cloudflare Access with a @classrootsedu.com allowlist.

Current state

Reviewers
Vinay (Curriculum SME + Tagging Reviewer) · Arjun + Mani onboarding underway.
Review queue
Live view at Coverage & review. Sortable, filterable, MCP-command-per-row (copy → paste into Claude Code).
Runbook
curriculum/pipeline/runbooks/03_tagging_reviewer.md. Also in SME_starter_kit.zip.

Portal architecture

Two data planes

The portal reads from two sources on every page load:

Portal Discipline · No snapshot JSON for mutable state

Rule. If a portal surface renders state that mutates at runtime — tag counts, coverage gaps, extraction status, retirement status, SME review verdicts, freshly-uploaded assets — it reads live via /api/*. It never reads a bundled JSON snapshot for that state, even as a fallback. A snapshot that ships at build time is authoritative only for state that changes at build time (chapter tree structure, lesson metadata, static brief content).

Why. Every "portal disagrees with live data" bug ultimately traces to a bundled snapshot that outlived the state it captured. Two examples caught during 2026-07-09 Round-2 review: retired-edge graph draws (edge status went stale between deploys) and Coverage Report gaps disagreeing with list_gaps (the panel computed against a snapshot that predated the last SME approval sweep). Both would have been avoided by writing the surface as a live-first fetch from day one.

How to apply. When adding a new surface, ask: can this state change between deploys? If yes, put it on a /api/* endpoint that reads D1 or R2 live. Client-side compute over already-fetched live data is acceptable when the computation is deterministic and the inputs are themselves live-sourced (e.g. computing coverage from a live /api/ai-tags pull is fine — the drift risk is on the server side, not in the client formula). Bundled JSON for mutable state is not.

Exception. A static baseline may ship alongside a live endpoint as a first-paint accelerator, but only if the live endpoint overrides it on hit and the code path treats the static as a stale hint, not truth. See assets_index.json + /api/lesson-full for the reference pattern.

Pages Functions API

EndpointPurpose
GET /api/extraction-statusLive extraction_claims status per lesson (used by portal to render pills)
GET /api/ai-tagsRaw ai_tags rows (portal overlays SME reviews on top)
GET/POST/PATCH /api/reviewsSME approve/edit/reject actions
POST /api/tags/bulkBulk insert AI tags from /tag-grade
GET /api/los / edges / cross-threadLO catalog CRUD; used by portal + KG MCP
GET /api/gapsServer-side coverage gaps compute (LOs with 0 non-flagged tags)
GET /api/whoamiAuth sanity check (Cf-Access header OR bearer token)

Auth: Cf-Access-Authenticated-User-Email header (browser path) OR Authorization: Bearer <sme-token> (MCP path). Bearer tokens hashed at rest in sme_tokens.

Filter model

Portal filters cascade in this order: curriculum (Merdeka / CBSE) → thread (5 ATP Elemen, or all) → strand (18 pedagogical sub-groupings, populated per-thread) → grade (G1-G10) → status (done / incomplete / in-progress / not-started / errored). Any filter combination narrows both the chapter list and the lesson list within each expanded chapter.

Knowledge graph

Neo4j Aura holds the LO relationships; the D1 los and edges tables are the write-path (any LO mutation writes to D1 first and is periodically loaded into Aura). Aura is the read-path for graph traversal queries.

Merdeka LOs
46 loaded (9 G2 · 12 G4 · 15 G5 · 10 G6). Fractions strand. v3 tag rules pending Neo4j reload.
CBSE LOs
67 loaded across the Number strand. No asset nodes yet (school-api has no CBSE content).
Merdeka assets
290 Asset nodes (chapters + lessons + sims + videos) with 562 TAGGED_TO edges under v1 tags.
Orphan LOs
4 pending SME cleanup (separate track from the extraction pipeline).
MCP interface
kg-mcp Python stdio server exposes 19 tools: whoami, list_los, get_lo, create_lo, update_lo, retire_lo, list_edges, create_edge, retire_edge, list_cross_thread_prereqs, create_cross_thread_prereq, update_cross_thread_prereq, list_ai_tags, bulk_create_tags, list_gaps, approve_tag, edit_tag, reject_tag, add_sme_tag.

Skills catalog

Each skill is a self-contained project-scoped slash command at .claude/commands/<name>.md. Invoke via Skill(skill='<name>', args='...') from any session.

SkillPurposeWrites to
/extract-batchClaim + parallel-extract N lessons in scope (chapter or grade)D1 extraction_claims, spawns sub-agents via Workflow
/extract-lessonExtract one lesson end-to-end with self-validationR2 briefs + raw, D1 (standalone) or JSON return (sub-agent)
/extraction-statusRead-only view of D1 extraction progressNothing — SELECT only
/rebuild-portal-indexRegenerate assets_index.json per (curriculum, thread) and deployPortal static + Cloudflare Pages
/extract-corpusLegacy from fractions pilot — deep corpus for LO taggingR2 corpus/
/author-losAuthor new LOs + edges for a (curriculum, thread, grade)D1 los + edges + cross_thread_prereqs
/tag-gradeAI-tag every asset in a grade against its LOs, adversarially verifyD1 ai_tags
/joinFresh-session bootstrap; verifies MCPs; prints current stateNothing — informational

Coordination model

No scheduler, no central orchestrator. Sessions coordinate via optimistic D1 claims. Two rules keep it consistent:

  1. Atomic claim. Every /extract-batch or sub-agent claim goes through a single SQL UPDATE ... WHERE status='pending'. The database serializes; the losing session gets 0 rows changed and picks a different lesson.
  2. 2-hour timeout. A claimed row auto-expires if not marked done within 2 hours. Any subsequent claim treats expired rows as reclaimable. If a session dies mid-work, work returns to the pool automatically.

Session identity = the email associated with the KG MCP bearer token (issued via curriculum/pipeline/issue_sme_token.py). Every mutation carries the actor email; every D1 write is traceable.

Parallelism cap: Workflow auto-caps at min(16, CPU cores − 2) concurrent sub-agents per session, so passing 47 lessons to parallel() runs them in waves of ~10 without any explicit batching in the skill.

Observability

Every operational question maps to one of these commands:

How many lessons are in each state?

curl -s "https://kg.classrootsedu.in/api/extraction-status?curriculum=merdeka&thread=numbers" \
  | jq '.counts'
# → {pending: 424, claimed: 3, done: 4, errored: 0}

What is Session A currently working on?

wrangler d1 execute cbse-kg-portal-reviews --remote --command="
  SELECT lesson_id, chapter_id, claimed_by_email, claimed_at, claim_expires
    FROM extraction_claims
   WHERE status='claimed'
   ORDER BY claimed_at DESC LIMIT 20;"

What errored + why?

wrangler d1 execute cbse-kg-portal-reviews --remote --command="
  SELECT lesson_id, error_summary
    FROM extraction_claims
   WHERE status='errored'
      OR (status='pending' AND error_summary IS NOT NULL)
   ORDER BY completed_at DESC LIMIT 20;"

What R2 objects exist for a lesson?

# List all briefs written for a lesson
wrangler d1 execute cbse-kg-portal-reviews --remote --command="
  SELECT altitude, r2_url, authored_at, authored_by, content_hash
    FROM brief_index
   WHERE lesson_id='<uuid>'
   ORDER BY authored_at;"

# Pull the actual brief content
wrangler r2 object get math-kg-state/briefs/lesson/<lesson_id>.json --pipe --remote

Live progress rollup by chapter

wrangler d1 execute cbse-kg-portal-reviews --remote --command="
  SELECT chapter_id, grade,
         SUM(status='done')      AS done,
         SUM(status='claimed')   AS claimed,
         SUM(status='pending')   AS pending,
         SUM(status='errored')   AS errored,
         COUNT(*)                AS total
    FROM extraction_claims
   WHERE curriculum='merdeka' AND thread='numbers'
   GROUP BY chapter_id, grade
   ORDER BY grade, chapter_id;"

The portal Asset inspector renders the same data visually — chapter progress counters (0/47 · 3⚠), per-lesson status pills, and clickable schema-gap panels when a brief fails validation.

LO catalogue — what's authored where?

wrangler d1 execute cbse-kg-portal-reviews --remote --command="
  SELECT curriculum, primary_thread AS thread, grade, COUNT(*) AS los
    FROM los
   WHERE status='active'
   GROUP BY curriculum, primary_thread, grade
   ORDER BY curriculum, primary_thread, grade;"

Tag coverage per LO — what's tagged, flagged, or a gap?

curl -s "https://kg.classrootsedu.in/api/gaps?curriculum=merdeka&thread=numbers&grade=G5" \
  | jq '{total_los: (.los | length), gaps: (.gaps | length), reasons: (.gaps | group_by(.reason) | map({(.[0].reason): length}) | add)}'
# → {total_los: 15, gaps: 2, reasons: {no_tag: 1, all_flagged: 1}}

SME review actions — who's approving what?

wrangler d1 execute cbse-kg-portal-reviews --remote --command="
  SELECT reviewer_email, action, COUNT(*) AS n
    FROM tag_reviews
   WHERE curriculum='merdeka'
   GROUP BY reviewer_email, action
   ORDER BY reviewer_email, action;"

LO change audit — full mutation history for one LO

wrangler d1 execute cbse-kg-portal-reviews --remote --command="
  SELECT changed_at, actor_email, before_json, after_json
    FROM los_changes
   WHERE lo_id='<lo-id>'
   ORDER BY changed_at;"

What's not wired yet

Snapshot: 2026-07-09. Owner: Vinay (vinay@classrootsedu.com). Access-gated to @classrootsedu.com. For deeper history, see docs/superpowers/handoffs/HANDOVER-2026-07-08-mass-math-asset-extraction.md and the earlier fractions-pilot handovers.