# StockLearn Learning-Pack Protocol — schemaVersion `1.1`

A **learning pack** is a single JSON document that an AI skill (or a human) produces and
that StockLearn renders and quizzes. The pack is the *only* interface between
content generation and the app: the app contains no content intelligence of its own.

The authoritative definition is the zod schema in [`src/lib/schema.ts`](../src/lib/schema.ts).
This document explains it for skill authors.

## Design rules

1. **The app is dumb.** It renders card markdown, asks questions, checks answers against
   `answer`, shows `explanation`, and keeps wrong answers for review. Anything you want the learner to see
   must be in the pack.
2. **Educational only.** Packs must not contain buy/sell/hold recommendations. The app will
   render whatever you write — responsibility for content lies with the pack author.
3. **Separate principles from data.** Timeless concepts go in cards *without* `dataAsOf`.
   Cards quoting real figures (prices, revenue, ratios) **must** set `dataAsOf` to the date
   the figures were true; the app shows a "data from …" badge once they are older than 90 days.
4. **Ids are slugs and are stable.** Progress (seen cards, answers, lapses) is keyed by
   `packId / unitId / cardId / questionId`. Re-generating a pack with the same ids keeps learner
   progress; changing ids resets it.
5. **Unknown keys are rejected.** Every object is validated strictly, so typos like
   `"explaination"` are reported instead of silently ignored.

## Top level: `Pack`

| Field           | Type       | Required | Notes                                                        |
| --------------- | ---------- | -------- | ------------------------------------------------------------ |
| `schemaVersion` | `string`   | yes      | `"1.0"` or `"1.1"`. Use `"1.1"` only if you use 1.1 features. |
| `id`            | `string`   | yes      | Slug, unique among all packs a learner loads. `[a-z0-9._-]`  |
| `title`         | `string`   | yes      |                                                              |
| `description`   | `string`   | no       | One or two sentences shown in the library.                   |
| `language`      | `string`   | no       | BCP-47 tag, e.g. `"en"`, `"zh-CN"`.                          |
| `topic`         | `string`   | no       | Free-form path, e.g. `"stocks/valuation"`.                   |
| `generatedAt`   | ISO date   | no       | When the pack was generated. Useful for time-sensitive data. |
| `source`        | `string`   | no       | Which skill / model produced it.                             |
| `units`         | `Unit[]`   | yes      | At least one. Ids unique within the pack.                    |

## `Unit`

| Field       | Type         | Required | Notes                                    |
| ----------- | ------------ | -------- | ---------------------------------------- |
| `id`        | `string`     | yes      | Slug, unique within the pack.            |
| `title`     | `string`     | yes      |                                          |
| `cards`     | `Card[]`     | yes      | At least one. Shown one at a time, in order. |
| `questions` | `Question[]` | yes      | May be empty, but a unit without questions has no quiz. |
| `feedback`  | `"immediate" \| "end"` | no (1.1) | `"end"` withholds all correct/incorrect feedback until the learner has answered every question in the unit — use it when one answer would give away the next (e.g. linked estimates). Default `"immediate"`. |

## `Card`

| Field      | Type       | Required | Notes                                                           |
| ---------- | ---------- | -------- | --------------------------------------------------------------- |
| `id`       | `string`   | yes      | Slug, unique within the unit.                                   |
| `title`    | `string`   | no       |                                                                 |
| `body`     | `string`   | yes      | **Markdown** (GitHub-flavoured: tables, lists, bold, code).     |
| `dataAsOf` | ISO date   | no       | Required *by convention* whenever the body quotes real figures. |
| `tags`     | `string[]` | no       | Free-form. Suggested: `"principle"`, `"data"`.                  |

Wide markdown tables are fine — the app renders them full width on desktop and scrolls them
horizontally on narrow screens.

## `Question`

| Field         | Type                                   | Required | Notes                                                                 |
| ------------- | -------------------------------------- | -------- | --------------------------------------------------------------------- |
| `id`          | `string`                               | yes      | Slug, unique within the unit.                                         |
| `type`        | `"single" \| "multiple" \| "boolean" \| "estimate"` | yes | See below. `"estimate"` requires schemaVersion 1.1. |
| `prompt`      | `string`                               | yes      | Markdown.                                                             |
| `options`     | `{ id: string, text: string }[]`       | choice types | ≥ 2 for single/multiple/boolean; omit (or `[]`) for estimate. Option ids unique within the question. |
| `answer`      | `string[]`                             | yes      | Choice types: correct option ids (≥ 1). Estimate: exactly one numeric string, e.g. `["18.9"]`. |
| `tolerance`   | `number`                               | no (estimate only) | Relative tolerance, 0 < t < 1. Default 0.25. A guess counts as correct when \|ln(guess ÷ answer)\| ≤ ln(1 + t), i.e. a symmetric ±t band on a log scale. |
| `unit`        | `string`                               | no (estimate only) | Label shown after the number, e.g. `"×"`, `"%"`, `"$"`. |
| `explanation` | `string`                               | yes      | Markdown, shown after answering. Explain *why*, not just which.       |
| `difficulty`  | `1 \| 2 \| 3`                          | no       | 1 = recall, 2 = apply, 3 = analyse.                                   |
| `cardRefs`    | `string[]`                             | no       | Card ids **in the same unit** this question tests. Drives weakness export. |

Type rules, enforced by the validator:

- `single` — exactly one id in `answer`.
- `multiple` — one or more ids in `answer`; the learner must select exactly that set.
- `boolean` — exactly two options and exactly one answer. Conventionally
  `{"id":"true","text":"True"}` / `{"id":"false","text":"False"}`, but any two options work.
- `estimate` (1.1) — the learner types a number; no options. The app shows their guess, the
  actual value, the percentage difference and the accepted band, then the explanation. Use it
  for "what multiple would the market pay" style judgements. Answers must be positive for the
  band to be meaningful.

Always fill `cardRefs`: when a learner gets a question wrong, the app records a *lapse*
against each referenced card, and that is what the weakness report sends back to the skill.

## Validation

The app validates every pack on import with [`validatePack`](../src/lib/schema.ts) and
shows each problem as `path: message`, for example:

```
units[0].questions[2].answer[0]: "e" is not one of this question's option ids
units[1].cards[0].body: must not be empty
schemaVersion: unsupported schemaVersion; this app supports 1.0
```

To check a pack without the UI:

```bash
node -e 'import("./src/lib/schema.ts")' # or simply import the sample in a vitest
npm test
```

## Example

The sample pack, [`src/lib/__tests__/fixtures/sample.json`](../src/lib/__tests__/fixtures/sample.json), is a
complete, valid example: one unit about the price-to-book ratio with three cards (two
principle cards, one data card with `dataAsOf` and a markdown table) and four questions
covering all three question types.

```json
{
  "schemaVersion": "1.0",
  "id": "price-to-book-basics",
  "title": "Price-to-Book Ratio: Basics",
  "language": "en",
  "topic": "stocks/valuation",
  "generatedAt": "2026-08-01",
  "source": "skills/stock-fundamentals.md",
  "units": [
    {
      "id": "pb-fundamentals",
      "title": "Understanding Price-to-Book",
      "cards": [
        {
          "id": "what-is-pb",
          "title": "What the P/B ratio measures",
          "body": "The **price-to-book (P/B) ratio** compares …",
          "tags": ["principle"]
        },
        {
          "id": "computing-pb",
          "title": "Computing P/B from real figures",
          "body": "| Item | Value |\n|---|---|\n| Share price | $42.00 |\n…",
          "dataAsOf": "2025-12-31",
          "tags": ["data"]
        }
      ],
      "questions": [
        {
          "id": "q-definition",
          "type": "single",
          "prompt": "Market cap **$8B**, equity **$4B**. P/B?",
          "options": [
            { "id": "a", "text": "0.5" },
            { "id": "b", "text": "2.0" },
            { "id": "c", "text": "4.0" }
          ],
          "answer": ["b"],
          "explanation": "P/B = market cap ÷ equity = 8 ÷ 4 = **2.0**.",
          "difficulty": 1,
          "cardRefs": ["what-is-pb", "computing-pb"]
        }
      ]
    }
  ]
}
```

## Versioning

- `schemaVersion` follows `MAJOR.MINOR`. Minor bumps only *add optional fields*; major bumps
  may change meaning. The app lists the versions it accepts in
  `SUPPORTED_SCHEMA_VERSIONS` and will keep accepting old versions.
- **1.1** (Aug 2026): adds question type `estimate` (with `tolerance`, `unit`) and unit-level
  `feedback: "end"`. A pack that declares `"1.0"` and uses either is rejected with a clear error.
- Packs are about **anything** — stocks are just the first skill. The schema has no
  finance-specific fields on purpose.
