# Curriculum Graph — Authoring Conventions (schema v1)

*Authored 2026-07-13 by silicon-zack (orchestrating session). Companion to DESIGN.md.
Every authoring agent MUST follow this exactly; the validator enforces it.*

## Directory layout

```
data/education/curriculum/
  DESIGN.md            structure contract (why + what)
  schema.md            this file (how)
  ORCHESTRATION.md     build run-plan + phase status
  research/            phase-R research briefs (*.md, cited)
  domains/
    math.yaml          dense (v1 target ~60–90 nodes)
    reading-writing.yaml   skeleton
    thinking.yaml          skeleton (gear nodes)
    world-models.yaml      skeleton
  ledgers/
    penelope.yaml
    cora.yaml
```

Code lives in `scripts/education/curriculum_lib.py` (load / validate / frontier) —
built in the tooling phase.

## Node granularity (RESOLVED 2026-07-13 — authoring agents follow this)

Reference: Math Academy's live course taxonomy (`research/math-academy-taxonomy.md`) —
their atomic "topic" ≈ one lesson, FINER than a Common Core standard (1 CC standard →
~2–6 MA topics; their 4th-grade course alone = 140 topics). They afford that fineness
with an auto-sequencing engine; we curate by hand, so we sit deliberately coarser.

**Our node = "packet-rung-sized" ≈ one Math Academy *module* (a 2–5 topic cluster),
NOT one MA topic.** Target ~60–90 nodes for the whole ages-6–9 math band (~2–2.5× coarser
than MA). When deciding whether to SPLIT one node into two, split only on:
- a **strategy/representation change** (e.g. count-up vs regroup; area-model vs standard
  algorithm), or
- a **fluency rung** (a fluency-tier L4 skill is its own node from its untimed-accuracy
  parent — automaticity is a distinct achievement).

Do NOT split on bare-skill-vs-word-problem — that's ONE node carrying different
assessment items (per `gates`), not two nodes. Do NOT split per digit-width unless the
strategy actually changes.

## Domain traversal mode (adjudication #1)

Each `domains/<domain>.yaml` MAY declare a top-level `traversal:` key (sibling of
`domain:`/`strands:`/`nodes:`), one of `constrained` | `free-roam`. **Default is
`constrained` when the key is absent** — the loader codes defensively: a missing OR
unrecognized value collapses to `constrained` (a typo must never silently free-roam a
domain and flood the frontier).

| mode | domains | frontier treatment |
|------|---------|--------------------|
| `constrained` (default) | math, reading-writing | the tight next-edge — each eligible node is an individual frontier edge |
| `free-roam` | world-models, thinking | breadth-first terrain — eligible nodes come back GROUPED as "open to explore (pick by interest)", NOT flooded as individual edges |

Rationale: world-models + thinking have ~39 zero-prereq nodes between them. Rendering
each as its own frontier edge broke both "progressive graph" and "kid-steered
traversal" (panel finding #1). The frontier's job is to answer "what's her tight next
edge" for math/reading and "what's open to roam" for world-models/thinking — the loader
+ frontier + export honor `traversal` so the viewer can render the two differently.

```yaml
domain: world-models
traversal: free-roam                 # optional; omit == constrained
strands: {...}
nodes: [...]
```

## Node schema (yaml)

Each `domains/<domain>.yaml` is `{domain: <name>, traversal?: <mode>, strands: {...},
nodes: [...]}`.

```yaml
domain: math
strands:                             # math uses the 7-way split (re-keyed 2026-07-13)
  counting: "Counting & Number Sense"
  placevalue: "Place Value"
  addsub: "Addition & Subtraction"
  multdiv: "Multiplication & Division"
  fractions: "Fractions"
  measure: "Measurement & data (time, money, length, graphs)"
  geometry: "Geometry & spatial"
nodes:
  - id: math.addsub.add-facts-20      # <domain>.<strand>.<slug>, kebab-case slug
    title: "Addition facts to 20"
    strand: addsub
    band: [6, 8]                      # age band, advisory not gating
    tier: L4                          # target tier: L3 | L4 (L4 = fluency-tier node)
    fluency_aim:                      # REQUIRED iff tier L4, else omit
      metric: problems_per_min_oral   # oral preferred; written aims gated on motor ceiling
      target: 30
      sustain: "2 of 3 separate days"
      source: "research/fluency-aims.md §add-facts"
    gates:                            # measurable, per DESIGN.md §1
      L2: "≥90% on 10 fresh generated items, untimed"
      # L3 = retention. Met via the gumball SM-2 spaced gate OR parent-observed
      # spaced retention (a correct re-check after a gap of days). See "L3 gate".
      L3: "SM-2 spaced gate (3+ spaced reps, 4+ lifetime correct) OR parent-observed spaced re-check after a gap of days"
      L4: "rate per fluency_aim, gumball sprint or paper timed sheet"  # iff tier L4
    instrument_gap: true              # OPTIONAL: no automated L3 instrument -> parent-scored / build-signal
    requires:                         # hard prereqs; DAG; satisfied at prereq ≥ L3
      - math.addsub.make-a-ten-strategy
    helps:                            # soft transfer edges (never duplicated in requires)
      - math.measure.count-up-change
    resources:                        # 0+; a resource-less node = build/buy signal
      - {type: dimensions, ref: "Dimensions Math 1B ch13"}   # resource of record (Penelope)
      - {type: beast,     ref: "BA 2A ch. 1"}
      - {type: singapore, ref: "Primary 2A unit 2"}          # alternate
      - {type: gumball,   ref: "generator:math_facts"}
      - {type: book,      ref: "The Way Things Work"}
      - {type: activity,  ref: "count change at the farmers market"}
    sources: ["CC 2.OA.2", "research/math-spine.md §addition"]   # ≥1, real citations only
    notes: "optional free text: misconceptions, sequencing rationale"
    representations:                    # OPTIONAL; some/all/none of S,G,C,N (see below)
      C: {move: "build 7+5 with ten unifix cubes, fill a ten then count on",
          check: "show me 8 + 6 with cubes"}
      G: {move: "draw a ten-frame, fill it, count the overflow",
          check: "how many cells are left empty?"}
      S: {move: "write it as 7 + 5 = 12; name the make-a-ten step",
          check: "what is 8 + 6?"}
      N: {move: "you have 7 stickers, a friend gives you 5 more",
          check: "how many stickers now?", down: "count objects to 20 first"}
```

## L3 gate — retention, not "gumball only" (adjudication #6)

L3 means **retained**: the skill holds over expanding intervals. It is met by EITHER:
- the **gumball SM-2 spaced gate** (3+ spaced reps, 4+ lifetime correct — the shipped
  review-pack machinery), OR
- **parent-observed spaced retention**: a correct re-check on fresh items after a gap of
  days (the parent scores it). This is a first-class L3 path, not a fallback.

The panel found the SM-2 gate uncheckable for 43/60 math nodes + all of world-models +
reading-decoding (no gumball generator exists). Generalizing the gate to "SM-2 OR
parent-observed spaced re-check" makes L3 honestly checkable everywhere. Where NEITHER
instrument exists yet, flag the node — see below. Never let a gate silently be
uncheckable.

## `instrument_gap` — honest build/buy flag (adjudication #6)

Optional boolean node field. `instrument_gap: true` means **no automated instrument path
exists for this node's L3** — it is parent-scored only, and its presence is a build
signal (we should ship an instrument). The thinking domain already leans on this; math's
`mass-liquid-volume` carries it too.

- Recognized-but-not-required: the validator does NOT error on it, and `validate()` emits
  one INFO line counting the flagged nodes (so the build/buy debt stays visible).
- Absent == `false`. Set it only when the gap is real.

## Representations (S/G/C/N) — teaching rungs (adopted from Rick's LATTICE #658)

Optional per-node `representations` map. Each node MAY carry up to four alternative
**teaching representations** of the same skill, keyed:

| key | representation | what it is |
|-----|----------------|------------|
| `S` | **Symbolic** | the abstract notation / number-sentence form |
| `G` | **Graphical** | the pictorial / diagram / model form (ten-frame, bar model, number line) |
| `C` | **Concrete-manipulative** | hands-on with objects (cubes, coins, fingers) |
| `N` | **Narrative/real-world** | a story or lived situation the skill lives inside |

This is the **Concrete-Pictorial-Abstract + Narrative** pedagogy the Singapore/Dimensions
curriculum Penelope already runs on. The rungs are **ORTHOGONAL to the L1→L4 mastery
ladder and to `tier`/`gates`**: the ladder measures *how well she holds* a skill; the rungs
describe *how to teach/enter* it four different ways. They do NOT gate the frontier and do
NOT change tier.

- **Optional and partial.** A node may carry some, all, or none of S/G/C/N. Absent ==
  no representations (an empty map).
- **Keys are constrained** to exactly `S`, `G`, `C`, `N` (uppercase). Any other key is a
  validation ERROR.
- **Each present rung is `{move, check, down?}`:**
  - `move` (**required**) — the teaching move: how to teach the skill in this representation.
  - `check` (**required**) — one check question the teacher can pose to probe it.
  - `down` (**optional**) — a prereq/entry pointer. It may be a **node id**
    (`<domain>.<strand>.<slug>`) — in which case the validator resolves it (an id-shaped
    `down` that doesn't resolve is a **WARNING**, not an error) — OR **free text** (a plain-
    English entry note), which is always fine.
- Math-oriented in framing but **schema-general** — any domain node may carry rungs.
- **Feeds ed-packet generation** (lead with the concrete rung, rotate toward symbolic as she
  consolidates) AND the "administer" check questions. This is field support only; a later
  pass authors the data — nodes are NOT populated here.

Validation (never errors on the field's presence): keys ⊆ {S,G,C,N}; each present rung has
non-empty `move` + `check`; an id-shaped unresolved `down` warns. `validate()` emits one
INFO line counting how many nodes carry any representation (like the `instrument_gap` count).
The loader reads it into `Node.representations` (a dict of key→Representation `{move, check,
down?}`) and `build_export`/`to_dict` carry it (ordered S,G,C,N, only present rungs) so the
viewer and ed-packet can read it.

## L4-as-prereq semantics — satisfied at L3 (adjudication ACCEPT)

**A fluency (L4-tier) node counts as a SATISFIED prereq at L3 (retention), not L4.** The
frontier's `requires` threshold is L3 for EVERY prereq regardless of that prereq node's
own target tier. Fluency/L4 is a **stretch goal, not a downstream gate** — a kid should
not have to hit an automaticity rate on facts-to-20 before the make-a-ten strategy that
merely *uses* those facts unlocks. This is deliberate; it is not a bug that a downstream
node opens while its L4-tier prereq still sits below L4 in the ledger. (The L4-tier node
itself stays in ITS owner's frontier until L4 — the stretch target persists; it just
doesn't hold anything downstream hostage.)

## Rules (validator-enforced)

1. ids globally unique, format `<domain>.<strand>.<slug>`; strand declared in the file.
2. `requires` forms a DAG (cross-domain edges allowed); every edge target must exist.
3. `tier: L4` ⇔ `fluency_aim` present. L4 gates include an L4 entry.
4. Every node: `gates.L2` + `gates.L3`, `band`, ≥1 `sources` entry.
5. `helps` ∩ `requires` = ∅.
6. Citations must be real — cite the research brief section or the primary source.
   A node whose content an agent cannot ground goes in with `notes: "UNGROUNDED: ..."`
   rather than a fabricated source (the validator flags these for adjudication).

**Recognized OPTIONAL fields (never errors):** `traversal` (domain-level, above),
`instrument_gap` (node-level, above), `notes`, `representations` (node-level S/G/C/N
teaching rungs, above). `instrument_gap` and `representations` each get a validate() INFO
count; unknown top-level keys are ignored by the loader. NB: `representations` is the ONE
optional field with internal structure the validator DOES enforce (constrained keys,
required `move`+`check`, id-shaped `down` resolution) — see its section above.

## Ledger schema

```yaml
kid: penelope
profile_id: 3          # gumball profile id (penelope=3, cora=2)
entries:
  - node: math.measure.read-analog-time
    level: L1          # highest level with evidence, conservative
    as_of: 2026-07-12
    evidence:
      - {date: 2026-07-12, kind: packet, ref: "analog-clocks-2026-07-12",
         note: "finished packet end-to-end; review pack authored, not yet activated"}
    rate_history: []   # [{date, metric, value}] for fluency-tier nodes
```

Seeding is conservative: packet finished = L1 (introduced); L2 needs demonstrated
fresh-item accuracy; L3 via the gumball SM-2 gate OR a parent-observed spaced re-check
(see "L3 gate" — spelling mastery data qualifies). When unsure, the LOWER level + a
note. Never invent evidence.

### Ledger write path (`set_level`, adjudication #8)

Progress updates are a ONE-LINER, not a hand-edit of prose YAML (the DESIGN.md
"abandoned in a month" failure mode). `curriculum_lib.set_level` (and the
`set-level` CLI) create-or-update an entry, append an evidence bullet, and write the
ledger atomically:

```
uv run python -m education.curriculum_lib set-level penelope math.addsub.make-a-ten-strategy L3 \
    --evidence "passed sm-2 spaced gate on mixed items" --as-of 2026-07-14 [--rate "dpm=42"]
```

`--as-of` is required (deterministic — no wall-clock). It validates the node exists and
level ∈ {L1..L4} (L0 is the implicit no-entry default and is refused) before touching
disk. Existing evidence bullets are preserved (append-only). NOTE: hand-authored YAML
comments are not preserved across a programmatic rewrite — the evidence bullets are the
durable record.

## Frontier definition

For kid K: node N is ELIGIBLE iff `ledger(K, N).level < N.tier` AND every `requires`
prereq is at ≥ L3 for K (L4-tier prereqs count as satisfied at L3 — see "L4-as-prereq").
`band` and `helps` never gate.

Eligible nodes are then **split by domain `traversal` mode** (adjudication #1):
- `constrained` domains (math, reading-writing) → the **tight next-edge**, each node
  an individual frontier item.
- `free-roam` domains (world-models, thinking) → **open-to-explore terrain**, returned
  as a grouped set ("N open to explore, pick by interest"), NOT flooded as per-node
  edges.

`frontier(graph, kid)` returns a `FrontierResult` with `.constrained` and
`.free_roam_open` lists (each a `FrontierNode` carrying `traversal` + the satisfying
prereqs); it iterates/len's over both buckets for legacy "is X in the frontier" checks.
The export projection (`build_export`) carries `kids[k].frontier =
{constrained, free_roam_open, constrained_count, free_roam_open_count}`, flat
`frontier_ids` (constrained) + `free_roam_ids`, and a top-level `traversal` domain→mode
map — so the map/viewer layer renders the two kinds differently.
