<!-- Study Suite — Content Authoring Guide · v2.16 · [Alkarim Billawala / alkarim.billawala.ca] -->

# Study Suite — Content Authoring Guide (v2.16)

> **Read me first — this file is written for the *assistant*, not the end user.**
> If you are an AI assistant (e.g. Claude) and this document has been given to you, it is your
> operating manual for turning the user's study material into Study Suite content. Act on it; do
> not simply paraphrase it back to the user. The end user is generally *not* expected to read this
> file (only an advanced user would). Everything below tells **you** what to produce and how.
>
> **Authoring system version:** 2.16 · **Pairs with:** Study Suite app v2.17+, pack `formatVersion` 2.0
> **What changed in guide v2.16:** packs may now carry an **optional `drugs[]` array** that powers the app's new
> **Pharmacology** view — a cross-week drug index built from every active pack (new §6b). It's additive and optional
> (packs without it are unaffected); when present, the app merges drug records across enabled packs by a stable
> `rx:` id, groups them by class, and renders faceted fields (use / mechanism / dosing / cautions / adverse /
> monitoring / interactions) with source links back to the originating week's guide. §2 schema + §7 checklist updated.
> **What changed in guide v2.15:** **every pack now ends with a one-page CRAM SHEET** as its final
> `guides[]` entry (new §6a) — a dense, print-friendly "night-before" rapid review of the pack's week(s):
> a do-not-miss/emergency strip, then compact topic cards of *cue → answer* rows. It uses the same
> dark-mode-safe CSS contract as the other guides, and links from items are optional (it's a review
> surface, not a deep-link target). §7 checklist updated to expect it. No schema change (the cram sheet is
> just another guide).
> **What changed in guide v2.14:** kept the option-shuffle guidance in lockstep with app behaviour. The
> app (v0.2.63) now **anchors** "All/None of the above" / "…of these" options — it pins them to their
> authored slot and shuffles only the other options around them — so those are **fine to use** (no longer
> "avoid"). What remains forbidden is **position-REFERENCING** options ("A and B", "both of the above",
> "the first option", "Options 1 and 3"): they reference *specific* other options, so they can't be
> safely shuffled OR anchored. §2/§3a/§7/§7a updated accordingly; the §7a validator now flags only the
> position-referencing kind. No schema change.
> **What changed in guide v2.13:** quality refinements from a side-by-side audit of an outside-LLM pack
> vs. the maintainer pack (same week, same source). No schema change. (1) **Answer-option order no longer
> matters** (§2 question schema): the app **shuffles options at render time**, so place `correct`
> at any index (first is fine for readability) — but **avoid position-dependent options** like "all of
> the above" / "A and B". (2) **Card-type mix** (§3a): target a varied spread and don't go cloze-heavy;
> every `multi` needs ≥1 false option (no "all correct" or "all-but-last" patterns). (3) **Guide
> granularity** (§6): one dense week ≈ **6–10 narrow guides**, not 3–4 broad ones. (4) **`guide.s` is a
> best-effort deep-link + a displayed label** (§6): make it a short heading-like cue. (5) **Source
> practice questions** (§4): if the upload contains "Question 1/Answer 1…", name them as source and write
> **novel** vignettes by default. (6) **Placement example** (§2): `course` is the shared block (`CPC 1`),
> not a broad code (`MED120`). (7) The §7a reference validator gains positional-option + card-mix lints.
> **What changed in guide v2.12:** **build the pack programmatically and validate before delivering**
> (new §7a) — if you can execute code, don't hand-write the JSON: assemble the pack as a data structure,
> run it through a validator that enforces the §7 checklist + the §8 output-hygiene scan, **pretty-print**
> it, and **refuse to emit on any failure**. A copy-pasteable reference validator is included. This
> directly eliminates the most common import-breaking failures (minified JSON, injected citation/grounding
> markers, broken `correct` indices, unbalanced cloze, unrated questions). It does *not* substitute for
> content depth (§3/§6) — a validator checks structure, not teaching quality. No schema change.
> **What changed in guide v2.11:** **file-naming convention** (§8) — name the pack file after its
> placement, `<Year>_<Course>_Week<NN>_<Subject>_v<ver>.json`, so a learner's packs stay organized and
> sort in a folder (the app still identifies/sorts by fields, not the name). No schema change.
> **What changed in guide v2.10:** quality hardening based on observed failure modes — no schema
> change. (1) **Output hygiene** (§8): the deliverable is *pure, valid JSON only* — no prose, no
> markdown fences, and **strip any citation/grounding/footnote markers your tooling injects** (a single
> stray token makes the whole file fail to import); **parse-test before delivering**. (2) **Guides must
> be substantive** (§6): the template is a *minimum skeleton*, not the goal — produce one real guide
> per topic block, with sections/tables, not a single sparse page. (3) **Link items to guides** (§6):
> most questions/cards should carry a `guide` pointer so the Topic Guides reader is actually used.
> (4) **Hit and scale volume** (§3): 10–20 questions and **40–60 cards per week**, multiplied by the
> number of weeks the pack covers. (5) **Fill placement; `course` is the grouping block, not the
> week's subject** (§2). These are baked into the §7 validation checklist. (6) **Version self-check**
> (§0b): on load, the assistant fetches `https://studysuite.app/authoring-version.json` and, if a newer
> guide exists, prompts the user to grab the latest before building (best-effort; degrades gracefully
> when offline).
> **What changed in guide v2.9:** new optional pack field `term` (semester/term, e.g. `"Fall"` /
> `"Semester 2"`) — an optional level **between `year` and `course`** in the placement hierarchy. The
> app (v0.2.49+) renders it as a sub-group and sorts it chronologically, and it is **gracefully
> invisible when unset** (packs without a term group exactly as before). Also adds an **Organizational
> hierarchy & sorting** note (§2): the placement fields are ordered slots — encourage the default
> school → year → (term) → course → week, but users may repurpose them for their own equivalent
> leveling as long as the values still sort. Ask how the program is organized and whether week/topic
> numbers **reset per course** (if so, make the course labels or `weeks` values encode the order).
> **What changed in guide v2.8:** two additions, no schema change. (1) A **cost/usage warning** (§3):
> generating packs is resource-intensive — discourage users from dumping multiple weeks the night
> before an exam, or they may run out of usage mid-build. (2) **Absolute vs. relative difficulty and
> customization** (§5a): the easy/medium/hard tags are *relative* and must always be applied so the
> app can filter; the pack's *absolute* level is customizable (e.g. shift up for a resident sitting a
> Royal College exam) and auto-scales from school/year/field and the difficulty of the supplied
> materials. Question *format* is likewise customizable.
> **What changed in guide v2.7:** theming contract for topic guides — drive guide colors off the
> canonical CSS variables (`--paper`/`--ink`/`--soft`/`--line`/`--accent`/`--accent2`/`--green`/
> `--blue`/`--gold`/`--hi`/`--lo`) instead of hardcoded hex, so the app can re-theme guides to match
> the user's Light/Dark/Black + Navy/Warm choice (see §6). Also adds a **Review volume guideline**:
> 40–60 cards per week of content (§3). No schema change.
> **What changed in guide v2.6:** hosted-site URL updated to **studysuite.app** (the app moved off
> the old studysuite.billawala.ca beta address). No schema or instruction changes.
> **What changed in guide v2.5:** new optional pack field `school` (program/institution, e.g.
> `"UofT — Temerty Medicine"`) — top level of the hosted site's default-pack navigation, above
> `year`/`course`. Include it alongside the other placement fields when known.
> **What changed in guide v2.4:** explicit warning — when revising/regenerating a pack, **never
> change its `id` or existing card `id`s** (a new `id` creates a duplicate in the user's library
> instead of updating; changed card ids wipe that card's review history).
> **What changed in guide v2.3:** materials often arrive across **several messages** (upload limits) —
> you must **confirm the user has sent everything before building the pack**; keep ingesting until
> they explicitly say to proceed (see §0a).
> **What changed in guide v2.2:** new optional pack fields `year` / `course` / `weeks` (curriculum
> placement, e.g. `"Year 1"` / `"CPC 2"` / `"Weeks 25–28"`) — used by the hosted site to group
> default packs; harmless if omitted. Include them when the user tells you where the content sits.
> (the pack schema is unchanged from guide v2.0 — packs made with either guide work in the app).
> **What changed in guide v2.1:** topic guides are delivered **embedded in the pack only** — no
> standalone `.html` files unless the user asks; the app's reader is now called **Topic Guides**
> (was "Content Reviewer"); session difficulty in the app is now **multi-select**; the app is
> hosted at **https://studysuite.app** (with one-click default packs), so most users
> never handle the app file itself.
> **Author / attribution:** Alkarim Billawala / alkarim.billawala.ca.
> **Versioning rule:** the app, this guide, and every pack carry a version. When you revise a pack,
> bump its `version` and `updated` date so changes are trackable — but **keep the pack `id` and all
> existing card `id`s exactly the same**. The app updates packs by `id`: same `id` → the re-uploaded
> pack cleanly **replaces** the old one (no duplicate, review progress preserved for unchanged card
> ids). A **different `id` is the failure mode**: the user gets a duplicate pack (duplicate cards,
> double-counted questions) and has to delete one manually. Only mint a new `id` for genuinely new
> content, never for a revision. If you're revising a pack you didn't create, ask the user for the
> original file (or its `id`) before generating.

---

## 0. What to do the first time you're given this file

When this guide is first loaded and you've read it, **do not immediately demand materials.** First
**teach the user the workflow** in a short, high-level overview, *then* ask them for their content.
Specifically:

1. **Explain the loop in plain terms:**
   - They give *you* their lecture material (notes, slides, transcripts, a syllabus, optionally past quizzes).
   - You turn it into **one content pack** — a single `.json` file — containing exam questions, spaced-repetition cards, and the topic guides embedded inside it.
   - They load that pack into the **Study Suite app** — at **https://studysuite.app** (or a local copy) — by dragging it onto the drop zone.
   - The app then runs three modes over it: **Review** (spaced repetition), **Practice** (one-at-a-time with instant answers), and **Exam** (timed, scored). It also has a **Topic Guides** reader for the guides bundled in the pack.
2. **Tell them what you need from them and what they'll get back:** their materials in → **one `.json` pack** out. That single file contains everything, topic guides included.
3. **Then prompt them to upload** the lecture material they want turned into a pack, and ask the **scope question** in §3 (how many weeks the pack covers / how many questions they want). Mention that they can send material across **multiple messages** if it doesn't fit in one — you'll wait for all of it (§0a).

Keep this overview brief — a few sentences per point. The goal is orientation, not a lecture.

---

## 0a. Wait for ALL the content before building anything

Weekly material is often **too large for one message** — users hit per-query upload limits and
send their content in batches. Because of this:

1. After each upload, briefly acknowledge what you received (e.g. "Got the Week 25 slides and the
   endo lecture notes"), then **ask: "Is that everything, or is there more to come?"**
2. **Do not generate the pack — or any part of it — until the user explicitly confirms** that all
   content is in and tells you to proceed. No drafts, no partial packs, no "starting on what we
   have so far."
3. Keep ingesting across as many messages as it takes. Track what's arrived so you can summarize
   the full inventory (weeks, lectures, file names) back to the user when they say it's complete.
4. When they confirm, restate the inventory in one line, confirm the scope (§3), then build the
   pack from **everything** received.

A pack built from half the week's content is worse than a late pack — it silently teaches an
incomplete syllabus.

---

## 0b. First, check you have the latest version of this guide

This guide is **versioned** (its version is shown in the header at the top of this file). The authoring system evolves, so
before you build, make a quick, one-time check that the user isn't working from an outdated copy:

1. **If you can browse the web**, fetch the small public pointer file at
   **`https://studysuite.app/authoring-version.json`** (this is the project's GitHub Pages repo,
   served on its custom domain). It returns something like
   `{ "authoringGuideVersion": "<latest>", "guideUrl": "https://studysuite.app/Study_Suite_Authoring_Guide_v<latest>.md", "updated": "<date>" }`.
2. **Compare the versions numerically** — treat the version as `major.minor`, so `2.10` is *newer*
   than `2.9` (don't compare as plain text). If the published `authoringGuideVersion` is higher than
   the version in this file's header, tell the user:
   > *"Heads up — you're using authoring guide v&lt;yours&gt;, but v&lt;latest&gt; is now available.
   > For best results, download the latest from &lt;guideUrl&gt; and re-upload it before we build."*
   Then **proceed with the copy you have** — this is a courtesy, not a gate.
3. **If you can't browse the web**, don't fail or stall. Just tell the user which version you're
   working from and that they can check for a newer one at
   `https://studysuite.app/authoring-version.json` (or the project site) and re-upload if needed.

Do this check **once, at the start**, then carry on with the workflow below.

---

## 1. Your job, in one paragraph

Convert the user's study material into **one valid pack `.json` per week or topic-set**, containing
`questions` (for Practice/Exam), `cards` (for Review), and a `guides` array holding the **full HTML of
each topic guide embedded inline**. The pack is the **only deliverable** — do not produce standalone
guide `.html` files unless the user explicitly asks for printable copies. Rate every question's
`difficulty`. Validate everything against §7 before output. Prefer **novel** vignettes over
reproductions of any practice material you're given (§4).

---

## 2. Pack file schema (v2.0)

A pack is a single JSON object:

```json
{
  "pack": "Week 12 — Cardiology",
  "id": "wk12",
  "formatVersion": "2.0",
  "version": "2.0",
  "author": "Alkarim Billawala / alkarim.billawala.ca",
  "createdBy": "Alkarim Billawala / alkarim.billawala.ca",
  "created": "2026-01-15",
  "updated": "2026-01-15",
  "questions": [ /* exam questions, each with a difficulty */ ],
  "cards":     [ /* spaced-repetition cards */ ],
  "guides":    [ /* embedded topic guides: {file, title, html} */ ],
  "drugs":     [ /* OPTIONAL — pharmacology records for the Pharmacology view; see §6b */ ]
}
```

| field | required | notes |
|---|---|---|
| `pack` | yes | Display name in the library and as an exam weighting group. |
| `id` | yes | Short unique slug (`"wk12"`). Reloading a pack with the same `id` **replaces** the old one (and preserves review progress, which is keyed by card `id`). **Revisions must reuse the original `id`** — a new `id` duplicates the pack in the user's library (see Versioning rule above). |
| `formatVersion` | yes | Pack-format version. Use `"2.0"`. |
| `version` | yes | This pack's content version. Bump on revision. |
| `author` / `createdBy` | yes | Attribution. Set both to `"Alkarim Billawala / alkarim.billawala.ca"` unless told otherwise. |
| `created` / `updated` | recommended | ISO date strings. |
| `questions` / `cards` | arrays | Either may be empty, but a useful pack has both. |
| `guides` | yes if any item links a guide | Embedded topic guides — see §6. |
| `drugs` | optional | Pharmacology records that feed the app's cross-week **Pharmacology** view. Additive/optional — omit it and nothing changes. See **§6b**. |
| `school` / `year` / `term` / `course` / `weeks` | optional | Curriculum placement, e.g. `"UofT — Temerty Medicine"` / `"Year 1"` / `"Fall"` (term — optional) / `"CPC 2"` / `"Weeks 25–28"`. Used to **group and chronologically sort** packs in the hosted site's Default study packs panel and the user's Content library; ignored otherwise. `term` sits **between `year` and `course`** and is omitted by most packs (harmless when absent — the level simply doesn't render). Ask the user how their program is organized — see *Organizational hierarchy & sorting* below — and include the fields that apply. |

### Organizational hierarchy & sorting

The placement fields form an **ordered hierarchy** the app uses to group and chronologically sort
packs in both the Default study packs panel and the user's Content library:

> **school → year → `term` (optional) → course → week / unit / topic**

> **Common mistake — keep `course` and the week's subject separate.** `course` is the **grouping level
> that several weeks share** (e.g. `"CPC 1"`, `"Cardiology block"`), so packs nest under it. The week's
> actual subject goes in `weeks` (e.g. `"Week 11 · Immunology II"`) and/or the `pack` display name —
> **not** in `course`. Putting the topic in `course` (e.g. `course:"Immunology II"`) gives every week
> its own one-pack "course" and defeats the grouping; leaving placement fields null drops the pack into
> an "Other" bucket. Fill them all, and ask the user if you don't know their structure.
>
> **Also avoid the opposite mistake — a broad administrative code.** Use the curriculum **block the
> learner thinks in** (e.g. `"CPC 1"`), not a registrar's course code like `"MED120"`, unless the user
> explicitly wants to group by that code. The block is what the user recognizes and what keeps related
> weeks together. (Likewise, prefer a short stable `id` such as `"wk16"` and sequential item ids
> `"wk16_q01"` / `"wk16_c01"`, with guide keys like `"W16_01_<Topic>.html"`.)

Two things to know — and to **ask the user** about:

1. **Encourage the default, but allow custom leveling.** The default meaning (school / year / term /
   course / week) fits most curricula, so present it as the recommended default. But the fields are
   really *generic ordered slots* — a learner whose program isn't shaped that way may repurpose them
   (e.g. `year:"PGY-2"`, `course:"Cardiology block"`), **as long as the values still sort into the
   intended order.** The app orders each level by the **first number found in the pack's `weeks`
   field**, falling back to a numeric-aware natural compare of the label (so `"CPC 1"` precedes
   `"CPC 2"`, and `"Block 10"` follows `"Block 9"`). Whatever labels the user picks, make sure they
   sort.

2. **Document the chronology — especially when numbers reset per course.** Some programs number weeks
   or topics **1, 2, 3… within each course**, so the same "Week 1" recurs across courses. Because the
   app sorts a level by its earliest `weeks` number, a global reset can make courses tie. To keep the
   order right, either (a) give `weeks` a **globally increasing** range across the year (the UofT
   default runs Weeks 1–35 across ITM → CPC 1 → CPC 2), or (b) ensure the **course labels themselves
   sort** (numbered blocks/units), and/or (c) use the optional **`term`** field to separate them (e.g.
   Fall vs Winter). Ask the user up front how their weeks/topics are numbered so the pack encodes a
   sortable chronology.

The `term` level renders **only when a pack actually sets it** — packs that omit it group exactly as
if the level weren't there, so leaving it out costs nothing.

### Shared item fields (questions **and** cards)

| field | required | notes |
|---|---|---|
| `sys` | yes | Short system/group code, e.g. `"Cardio"`. Drives the system breakdown and exam weighting. The app shows friendly labels for `"Endo"`→Endocrine, `"GI"`→Gastrointestinal, `"KU"`→Renal/Urinary; any other string shows as-is. |
| `topic` | yes | Free-text topic, e.g. `"Arrhythmias"`. Appears in the Review topic filter and as a weighting dimension. You may encode a sub-block: `"Cardio I · Arrhythmias"`. |
| `explain` | strongly recommended | Teaching/answer text. **HTML allowed** (`<b>`, `<i>`, `<br>`). |
| `guide` | optional | `{ "f": "file.html", "t": "short title", "s": "section pointer" }`. `f` **must exactly match** the `file` of an embedded guide in `guides[]` (see §6). |
| `id` | recommended | Stable unique id (e.g. `"wk12_c01"`) so review progress survives reloads/edits. |

### Question schema (exam / practice) — now with `difficulty`

```json
{
  "id": "wk12_q01",
  "sys": "Cardio",
  "topic": "Arrhythmias",
  "stem": "A 68-year-old with palpitations has an irregularly irregular pulse and no discrete P waves. Best initial rate-control agent if no pre-excitation?",
  "options": ["Adenosine", "A beta-blocker", "Amiodarone bolus", "Digoxin first-line"],
  "correct": 1,
  "difficulty": "medium",
  "explain": "Irregularly irregular + absent P waves = <b>atrial fibrillation</b>. Without pre-excitation, rate control with a <b>beta-blocker</b> (or non-DHP CCB) is first-line.",
  "guide": { "f": "Topic_C1_Arrhythmias.html", "t": "C1 · Arrhythmias", "s": "Atrial fibrillation — rate control" }
}
```

Rules: `options` has **2+** entries; `correct` is a **0-based integer index**; `stem` is required;
`difficulty` is one of `"easy" | "medium" | "hard"` (see §5). **Put `difficulty` on questions only —
never on cards.**

> **Option order does not matter — the app SHUFFLES options at render time** (app v0.2.62+), for both
> Practice/Exam questions and `mcq`/`multi` cards. So you may put the `correct` option at **any** index
> (placing it first is perfectly fine for readability); the learner never sees a fixed "always-A" pattern.
>
> **"All of the above" / "None of the above" are fine** (app v0.2.63+ **anchors** them — it pins them to
> their authored slot and shuffles only the other options around them, so they always read correctly; the
> "…of these" variants too). **What you must NOT write are position-REFERENCING options** — "A and B",
> "both of the above", "the first option", "Options 1 and 3" — because they point at *specific* other
> options, which move when the rest shuffle. Those can't be safely shuffled or anchored. Write such an
> option as a self-contained answer instead. (The §7a validator flags the position-referencing kind.)

### Card schema (spaced repetition) — six types

Cards carry `sys`, `topic`, `type`, `explain`, optional `guide`. **Cards do not take a difficulty**
(Review shows everything regardless of difficulty). The six types are unchanged from v1:

**1. `mcq`** — single best answer (`correct` = index).
**2. `multi`** — select all (`correct` = array of indices).
**3. `cloze`** — `text` with `{{hidden}}` blanks; no `prompt`.
**4. `order`** — `items` in the **correct** order (app shuffles).
**5. `match`** — `pairs` of `[left, right]`.
**6. `qa`** — `prompt` + `answer` (+ optional `explain`).

```json
{ "sys":"Cardio","topic":"ACS","type":"cloze",
  "text":"Primary PCI for STEMI within {{90 minutes}} of first medical contact; else fibrinolysis within {{30 minutes}}.",
  "explain":"Door-to-balloon ≤90 min; door-to-needle ≤30 min.",
  "guide":{"f":"Topic_C2_ACS.html","t":"C2 · ACS","s":"Reperfusion timing"} }
```

```json
{ "sys":"Cardio","topic":"Heart Failure","type":"multi",
  "prompt":"Which reduce mortality in HFrEF? (select all)",
  "options":["ARNI/ACE inhibitor","Beta-blocker","Loop diuretic","SGLT2 inhibitor"],
  "correct":[0,1,3],
  "explain":"The pillars cut mortality; loop diuretics relieve symptoms only." }
```

(See the v1 examples for `order`, `match`, `qa`, `mcq` — their shapes are identical in v2.0.)

---

## 3. How many questions to generate — **ask, don't assume**

> **⚠ Generating packs is resource-intensive — manage scope and timing.** Building a full pack
> (novel vignettes + 40–60 cards + embedded topic guides) consumes a lot of usage. Warn users **not
> to leave it to the night before an exam and dump multiple weeks at once** — a large multi-week job
> can exhaust their available usage before it finishes, leaving them with nothing. Encourage building
> **one week at a time, well ahead of the exam**. If someone arrives with several weeks the night
> before, flag the risk up front and offer to prioritize the highest-yield week(s) first.

**Default: 10–20 clinical vignettes per week of content.** But **ask the user first** how many they
want and how many weeks this pack covers, then pick a number in range accordingly.

Rationale to share when you ask (this is the real exam pattern to calibrate against):

> Exams here are usually **block exams covering 2–4 weeks** of content, not cumulative
> multi-month finals. The format is **40 questions in one hour**. So a **2-week** block ≈ **20
> questions/week**, a **3-week** block ≈ **13/week**, a **4-week** block ≈ **10/week**. Pick the
> per-week count so the pack(s) for a block land near a realistic 40-question test.

**Cards (spaced repetition): default 40–60 per week of content.** They're for durable recall, not
exam simulation, so they're more generous than questions — but keep them tied to the same topics so
Review and Exam reinforce each other. As with questions, **ask the user** and scale to how many
weeks the pack covers (e.g. a 3-week block ≈ 120–180 cards total). Favor high-yield facts, mechanisms,
and associations over trivia.

> **These are targets to actually hit — and they multiply by the weeks covered.** A common failure is
> shipping a thin pack (e.g. 15–20 cards) for a **multi-week** unit. If a pack spans 2 weeks, that's
> roughly **2× the questions and 2× the 40–60 cards** of a single week; for 3 weeks, 3×. Count what
> you produced against the weeks before delivering (the §7 checklist asks you to confirm this). A
> two-week pack with one tiny guide and 15 cards is not a complete pack.

---

## 3a. Card-type mix — vary the retrieval, don't go cloze-heavy

The six card types exist so Review *mixes* recall styles. A common outside-LLM failure is making the deck
**mostly cloze** (fill-in-the-blank), which turns Review into rote text completion. Aim for a spread
roughly like:

- **qa** ~25–30% — short "what's the key distinction / why" prompts.
- **mcq** ~15–25% — single high-yield associations.
- **cloze** ~15–25% — definitions, numeric thresholds, criteria (**not** the majority of the deck).
- **multi** ~10–15% — "select all"; **every multi card needs at least one FALSE option** and a real
  discriminating choice. Do **not** mark all options correct, and do **not** fall into an "all true
  except the last one" habit — vary which/how many are correct.
- **order** ~10–15% — best for **algorithms / workups / sequences** (e.g. febrile-neutropenia steps,
  an approach to an abnormal CBC, a diagnostic pathway).
- **match** ~10–15% — for clean associations: disease→clue, drug→toxicity, disease→treatment,
  framework→purpose.

These are guides, not quotas — but if your deck is >35% cloze or has multi cards with no false option,
rebalance. (`mcq`/`multi` options are shuffled at render, so author them in any order; "all/none of the
above" are anchored and fine, but never use position-*referencing* options like "both of the above" — see §2.)

---

## 4. Source material — use it, but **don't copy it**

The user may upload their own **WFQs (weekly feedback quizzes)** or other practice questions. You
**may** use these as *input* — to learn the topic emphasis, the level, and the style — but you must
**not rely solely on them**, and you must **not reproduce them**.

> Real exams routinely contain scenarios the student has never seen. So the pack should consist
> **largely of novel questions and new clinical scenarios** that test the same concepts from fresh
> angles — different patient, different presentation, different distractors. Treat provided practice
> questions as a syllabus signal, not a question bank to echo.

When in doubt: same *concept*, new *vignette*.

> **If the upload contains numbered practice questions** (a study guide with "Question 1 … Answer 1 …",
> a quiz, a WFQ), **explicitly recognize them as source practice questions**, tell the user you're using
> them for topic emphasis/level/style, and then write **novel** vignettes that test the same concepts
> from new angles. Do **not** silently reproduce or lightly paraphrase them. The only exception is when
> the user *explicitly* asks for a faithful conversion of an existing question set — then say so and
> convert. Default = novel.

---

## 5. Difficulty rating system (`easy` / `medium` / `hard`)

**Scope:** difficulty applies **only to clinical vignette questions** used in **Practice and Exam**.
It does **not** apply to review cards. The app shows each question's rating to the learner and lets
them choose a **session difficulty** that filters Practice/Exam. Since app v2.8 this is
**multi-select**: Easy/Medium/Hard toggle independently (e.g. hard-only sessions), and "All" selects
all three — which also includes any questions left unrated. This is one more reason to **rate every
question**: an unrated question disappears from any filtered session.

**Rate every question.** Compute the rating at generation time with a **semantic** judgment that
weighs four dimensions together — not a single proxy like stem length:

1. **Overall complexity** — how many reasoning steps from stem to answer.
2. **Number of topics / systems touched** — single concept vs. cross-system integration.
3. **Prerequisite knowledge** — how much underlying mechanism, interpretation, or calculation the
   reader must already hold (e.g. acid-base math, embryology, pharmacodynamics).
4. **Distractors / red flags / pitfalls** — how many options are deliberately tempting, and whether
   the correct answer is counter-intuitive.

Anchored definitions:

- **easy** — single-step recognition of a classic presentation or one well-known fact; one concept;
  distractors are not very tempting. *E.g. "ACE inhibitor is contraindicated in pregnancy."*
- **medium** — integrate a few data points, apply an algorithm/guideline, or know a specific
  mechanism or test; at least one genuinely plausible distractor. *(Most vignettes land here.)*
- **hard** — multi-step reasoning, often across systems; demands calculation/interpretation or
  embryologic/physiologic prerequisites; the right answer is subtle or counter-intuitive and the
  distractors are close. *E.g. a triple acid-base disorder requiring the delta-delta, or euglycemic
  DKA mechanism on an SGLT2 inhibitor.*

Aim for an **exam-shaped spread** — more medium than easy, fewest hard (a rough 30 / 55 / 15 split
of easy / medium / hard works well). Don't force a quota; rate honestly, but if everything comes out
"medium," push yourself to separate the genuinely simple recall from the genuinely integrative items.

---

## 5a. Absolute vs. relative difficulty — and customizing for the learner

The `easy`/`medium`/`hard` ratings in §5 are **relative**: they rank questions *within a pack* so the
app can filter Practice/Exam by difficulty. **Always apply them, on every question, for every learner**
— the app reads these tags, and an unrated question silently drops out of any filtered session.

Distinct from that is the pack's **absolute** difficulty — the overall level the whole easy→hard band
is pitched at. The defaults in this guide are tuned for **medical students**. Two controls sit on top
of that default:

**1. The learner can dial absolute difficulty up or down.** Ask who the pack is for. A resident
preparing for a **Royal College** exam (or any graduate/board exam) should get a pack shifted
**upward** — harder stems, more cross-system integration, subtler distractors, less hand-holding —
while a pre-clerkship student gets the default band. Honour an explicit request ("make these harder,
I'm studying for the Royal College"). **Crucially, still rate every question `easy`/`medium`/`hard`
relative to that shifted band**, so filtering keeps working: a "hard" item in a resident pack is
simply harder in absolute terms than a "hard" in a med-student pack. The rating is always *within-pack
relative*; the band it sits on is what moves.

**2. Auto-scale absolute difficulty from context** — even when not explicitly asked:

- **School / year / field** (the `school` / `year` / `course` fields, §2). Higher training levels are
  harder. In the default packs there is a deliberate **step up from Year 1 to Year 2**; a
  residency/fellowship context steps up further again.
- **The supplied materials themselves.** Mirror the level of the user's notes, slides, and especially
  any **example questions or past quizzes** they share — treat the difficulty of those items as a
  direct indicator of the absolute level to target (while still writing *novel* questions per §4).

**Format is customizable too.** The default is single-best-answer clinical vignettes, but the learner
can ask for a different mix — more cloze/short-answer, more "select all," image-anchored stems, longer
or shorter stems, etc. Honour the request while keeping each item valid per §7 and rated per §5.

When you're unsure where to pitch a pack, **ask**: *"Who's this for — what year/level — and how hard do
you want it?"*

---

## 6. Topic guides — **embedded in the pack** (no separate files)

Topic guides live **inside the pack** in the `guides` array; the app's **Topic Guides** reader
displays them with no external dependency. **Do not output standalone guide `.html` files** —
they are redundant now that guides ship inside the pack. (Only produce a standalone copy if the
user explicitly asks for a printable version.)

Even though no file is written, each guide still needs a **filename-style key** (e.g.
`Topic_C1_Arrhythmias.html`) — it's the identifier that links items to guides.

> **Guides must be substantive — one real guide per topic block.** The template below is a **minimum
> skeleton, not the target.** A guide that is one heading and a sentence (or a single sparse page for a
> whole week) wastes the feature and short-changes the learner. Aim for:
> - **One guide per major topic block** of the week's material (so a typical week has several guides,
>   and a multi-week pack has more) — not a single mega-guide covering everything. As a rule of thumb,
>   **a dense one-week medical pack lands around 6–10 narrow guides**, each with a tight title — not
>   3–4 broad guides that staple unrelated topics together (e.g. split "Lymphadenopathy & Lymphoma"
>   and "Plasma-cell disorders" rather than one "everything-lymphoid" guide).
> - **Real teaching content in each:** multiple `<h2>`/`<h3>` sections, at least one or two `<table>`s
>   or key-point boxes, covering the high-yield facts, mechanisms, associations, and "do-not-miss"
>   items of that block. Match the depth of the source material; a guide should stand on its own as a
>   revision sheet.
>
> **Link items to their guide.** Most questions and cards should carry a `guide` pointer
> (`{ "f": …, "t": …, "s": … }`) into the relevant embedded guide so the app can deep-link from an
> item to the right section. A pack where few or no items reference a guide leaves the Topic Guides
> reader disconnected — wire them up as you write each item.

The `guides` array holds one object per guide:

```json
"guides": [
  {
    "file": "Topic_C1_Arrhythmias.html",
    "title": "C1 · Arrhythmias",
    "html": "<!doctype html><meta charset=\"utf-8\"><title>C1 · Arrhythmias</title> … full guide HTML … "
  }
]
```

- `file` — **must exactly equal** every `guide.f` that points to this guide. This is how the app
  resolves a link to the embedded copy (it's an identifier, not a real file).
- `title` — shown in the Topic Guides sidebar. **Name it well**: the sidebar sorts intelligently by
  parsing a leading `Week NN`, `Topic NN`, or bare `NN —` prefix (roman numerals like "KU III" also
  sort numerically), so titles like `"Week 12 — Arrhythmias"` or `"Topic 03 Adrenal"` order
  themselves correctly.
- `html` — the **complete HTML document** of the guide. It renders in a sandboxed iframe, so it
  carries its own `<style>`.

**What `guide.s` is — a label AND a best-effort deep-link.** The app does two things with `guide.s`:
it **displays** it to the learner ("Deeper detail: …") next to the Open-guide link, and it tries to
**scroll** the opened guide to the matching spot. The scroll is best-effort, in this order: (1) an
`id` anchor if you wrote `guide.f = "File.html#anchor"`; (2) otherwise the first heading whose text
**contains** `guide.s` (case-insensitive substring); (3) otherwise a text search/highlight of `guide.s`
in the body. So `guide.s` does **not** have to exactly equal a heading — but you get the best result by
making it a **short, heading-like cue** that actually appears in that guide (e.g. `"5 · HIT"` when the
guide has an `<h2>HIT</h2>`), rather than a long abstract micro-objective that matches nothing. Adding
`id="..."` to your `<h2>`/`<h3>` headings (and pointing `guide.f` at them) makes the jump exact.

### Theming contract — IMPORTANT for colors

The app re-themes every guide at runtime so it matches the user's chosen appearance (Light / Dark /
Black, and the Navy or Warm color family). It does this by **overriding a fixed set of CSS variable
names** inside the guide. So: **drive every color off these variables** (with the light values as
fallbacks) — do **not** hardcode hex colors for text, backgrounds, borders, or accents, or the guide
will look wrong (e.g. a white page) in dark mode.

Canonical variable names the app overrides (use these exact names):
`--paper` (page background), `--ink` (text), `--soft` (muted text), `--line` (borders/rules),
`--accent` (primary accent), `--accent2` (secondary accent), `--green` / `--blue` / `--gold`
(status colors), `--hi` (highlight/important), `--lo` (secondary highlight). Define them in `:root`
with sensible **light** defaults; the app swaps them per theme automatically.

### Guide HTML template (goes in the `html` value)

```html
<!doctype html><meta charset="utf-8">
<title>C1 · Arrhythmias</title>
<style>
  :root{--paper:#f3efe6;--ink:#1d1b16;--soft:#56524a;--line:#cfc8b6;--accent:#7c2d2d;--accent2:#9a5a2a;--green:#2f6b4f;--blue:#2d5a6b;--gold:#8a6d1f;--hi:#a23b2e;--lo:#2d5a6b}
  body{max-width:820px;margin:40px auto;padding:0 22px;font:18px/1.6 Georgia,serif;color:var(--ink);background:var(--paper)}
  h1{font-size:30px;margin:0 0 4px}
  h2{font-size:13px;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);margin:26px 0 6px}
  td,th{border:1px solid var(--line);padding:7px 10px;text-align:left;font-size:15px}
  .key{border-left:3px solid var(--accent);background:var(--paper);padding:10px 14px;margin:10px 0}
</style>
<h1>C1 · Arrhythmias</h1>
<p>One-line orientation.</p>
<h2 id="atrial-fibrillation">Atrial fibrillation — rate control</h2>
<div class="key">Irregularly irregular, no P waves → <b>beta-blocker</b> or non-DHP CCB first-line.</div>
```

Embed the full HTML (escaped for JSON) as the `html` value of the matching `guides[]` entry.

---

## 6a. The cram sheet — the LAST guide in every pack

**Every pack ends with a one-page cram sheet** as the **final `guides[]` entry**: a dense, print-friendly
"night-before" rapid review of the pack's week(s). It is a regular guide (same `{file,title,html}` shape,
same dark-mode-safe CSS contract from §6) — just purpose-built as a single high-density revision page.

- **Title it** so it sorts last and reads clearly, e.g. `"Week 16 · 9 Cram Sheet"` (use the next number
  after your last topic guide). `file` key e.g. `"W16_09_Cram_Sheet.html"`.
- **Scope = this pack's week(s) only.** One sheet per pack (per week, or per the pack's week-range) — not
  one giant sheet for a whole course.
- **Structure** (mirror `Night-Before_Cram_Sheet.html`):
  - a top **"do-not-miss / emergency" strip** — the can't-miss diagnoses, red flags, and first actions;
  - then **compact multi-column topic cards**, each a tight list of **cue → answer** rows (the single
    highest-yield fact per line: classic clue → diagnosis/next step/drug). Think "everything you'd want
    on one page the night before," not prose.
- **CSS:** dark-mode-safe, variables only (§6) — the legacy standalone Night-Before sheet hardcodes hex;
  the embedded version must theme cleanly. A multi-column print layout (`column-count`) and a print button
  are encouraged but optional.
- **Linking:** items generally do **not** point their `guide` at the cram sheet (it's a review surface,
  not a section target) — keep wiring questions/cards to the substantive topic guides.

> Density over completeness: the cram sheet distills, it doesn't re-teach. If it reads like a paragraph,
> tighten it into cue → answer rows.

---

## 6b. Pharmacology data (`drugs[]`) — optional, powers the Pharmacology view

A pack **may** include a top-level **`drugs[]`** array. It is **optional and additive** — leave it out and nothing changes; include it and the app's **Pharmacology** view builds a cross-week **drug index** from every *enabled* pack: it merges your records (and those from other packs) by a stable id, groups them by drug class, and shows the faceted fields with a link back to the week/guide each fact came from. This is content the learner reads alongside the topic guides — only add it when your source material actually covers the drug.

**One record per drug, per pack:**

```json
{
  "id": "rx:metformin",
  "name": "Metformin",
  "class": "rx:biguanide",
  "facets": {
    "use": "First-line type 2 diabetes",
    "mechanism": "↓ hepatic gluconeogenesis; ↑ peripheral glucose uptake",
    "adverse": "GI upset, B12 malabsorption; rare lactic acidosis",
    "contra": "eGFR < 30",
    "monitoring": "Renal function, B12"
  },
  "ref": { "week": "Week 25 · Endocrine I", "topic": "Biguanide", "guide": "W25_02_Diabetes_Pharmacology.html" }
}
```

| field | required | notes |
|---|---|---|
| `id` | yes | A **stable identifier**, `rx:<slug>`, where `<slug>` is the lowercase generic name, hyphenated — e.g. `rx:metformin`, `rx:atorvastatin`, `rx:piperacillin-tazobactam`. **Use the SAME id for the same drug in every pack** — that is exactly how the app merges, say, a statin mentioned in three weeks into ONE growing entry instead of three duplicates. Don't mint a new id for a drug that plausibly already has one. |
| `name` | yes | Human-readable display name (e.g. `"Metformin"`). |
| `class` | optional | A class id, `rx:<class-slug>` (e.g. `rx:statin`, `rx:ace-inhibitor`, `rx:sglt2-inhibitor`). The view groups drugs under their class heading. Use a consistent slug across packs; omit if no sensible class. |
| `facets` | yes | An object holding **only the facets your source covers**, drawn from exactly these keys: `use`, `mechanism`, `dosing`, `contra` (cautions/contraindications), `adverse` (adverse effects), `monitoring`, `interactions`. Each value is a **short phrase, not prose** — it renders as a faceted line. Include 2–5 of them; never invent keys. |
| `ref` | yes | `{ week, topic, guide }` locating the source so the view can link back. `week` = this pack's `weeks` string (or just the week label); `topic` = the topic/section; **`guide` MUST equal the `file` of a real `guides[]` entry** in this pack — the app deep-links to it. |

**Rules of thumb:**

- **Reuse ids; that's the whole point.** Consistent `rx:` ids across weeks are what let the index consolidate a drug; inconsistent ids produce duplicates. Pick the obvious generic-name slug.
- **One record per drug per pack** — don't repeat the same drug twice in one pack's `drugs[]`. (Across *different* packs is expected and is what merges.)
- **Short facet values.** Aim for the one high-yield phrase per facet, like the cram sheet's cue→answer density — not a paragraph.
- **Point `ref.guide` at the guide that teaches the drug** (often a pharmacology or therapeutics guide). If you have no matching guide, you can still include the drug, but a valid `guide` file makes the back-link work.
- **It's optional.** If the week isn't drug-heavy, skip `drugs[]` entirely — an empty or absent array is fine and never fails validation.

---

## 7. Validation checklist (every item must pass)

**Pack:** JSON object with `id`, `pack`, `formatVersion`, `version`, `author`; `questions` and/or
`cards` arrays; a `guides` array if any item carries a `guide`.

**Each question:** `sys`, `topic`, `stem`; `options.length ≥ 2`; `correct` integer in
`0…options.length-1`; **`difficulty` ∈ {easy, medium, hard}**.

**Each card:** `sys`, `topic`, valid `type`; and by type — `mcq` (`prompt`, `options` ≥2, `correct`
in range) · `multi` (`prompt`, `options`, `correct` = array of in-range indices) · `cloze` (`text`
with ≥1 `{{…}}`) · `order` (`prompt`, `items` ≥2, in correct order) · `match` (`prompt`, `pairs` of
2-element arrays) · `qa` (`prompt`, `answer`). All non-`cloze` cards need a `prompt`.

**Guides:** every `guide.f` used anywhere **resolves to a `guides[]` entry whose `file` matches**;
every `guides[]` entry has non-empty `html`; **the LAST guide is the pack's cram sheet (§6a)**.

**JSON hygiene:** 0-based `correct`; straight quotes; HTML inside strings must keep the JSON valid
(escape inner double quotes, or use single quotes inside the HTML).

**Parses cleanly (do this last):** the whole file is valid JSON with **no prose, code fences, or
citation/grounding/footnote markers** anywhere (see §8). Run it through a strict JSON parser — if it
doesn't parse, it won't import.

**Substance & coverage:** **6–10 narrow topic guides for a dense one-week pack** (not a single sparse
page), each with real sections/tables, **plus a cram sheet as the final guide (§6a)**; **most questions
and cards carry a `guide` pointer**; counts meet the
targets and are **scaled to the weeks covered** (10–20 questions — favour the upper end, 18–20, for a
dense week — and 40–60 cards *per week*); difficulty lands near the **~30 / 55 / 15** easy/medium/hard
spread, with **hard** items testing contraindications, emergencies, or multi-step reasoning (not just
longer stems).

**Options & card mix:** **no position-REFERENCING options** anywhere (`"A and B"`, `"both of the above"`,
`"the first option"`) — options are shuffled at render (§2). ("All/None of the above" are fine — the app
anchors them.) Card types are **varied, not cloze-heavy** (cloze ≲ 35% of the deck); **every `multi` card
has ≥1 false option** (never all-correct, never "all-but-the-last").

**Placement filled:** `school` / `year` / (`term`) / `course` / `weeks` are present (ask if unknown,
don't leave null), and **`course` is the grouping block shared across weeks (e.g. a course/block name),
not the week's subject.**

---

## 7a. Build it programmatically and validate — don't hand-write the JSON

**If you can execute code (a Python sandbox, code interpreter, notebook, etc.), build the pack with a
script instead of typing JSON by hand.** Hand-authored JSON is where the import-breaking failures come
from — and they are *silent*: the user drags in the file and nothing appears, with no error. The most
common ones are mechanical and 100% catchable:

- **Minified / single-line JSON** that's unreadable and hard to diff → fixed by pretty-printing.
- **Citation / grounding / footnote markers your tooling injects** — `start_span`/`end_span`, `【…】`,
  ` ``` ` fences, `[1]`-style cites — **a single one makes the whole file fail to parse** (see §8).
- **Broken `correct` indices, unbalanced `{{…}}` cloze blanks, a `guide.f` that matches no guide, a
  question with no `difficulty`** — each silently breaks or degrades the pack.

The workflow that prevents all of the above:

1. **Assemble** the pack as a native data structure (lists/dicts), not a hand-typed string.
2. **Validate** it against the §7 checklist *and* scan for the §8 artifacts.
3. **Pretty-print** with 1- or 2-space indentation.
4. **Refuse to write/deliver** if validation fails — fix, then re-run.

> A validator checks **structure, not substance.** It cannot tell you a guide is too thin or a question
> is one shallow line — that's on the content instructions in §3 (volume) and §6 (substantive guides).
> Treat the heuristic warnings below (guide length, explanation length, counts) as nudges, not a pass.

### Reference validator (Python — copy, adapt, run)

```python
import json, re
from collections import Counter

CARD_TYPES = {"mcq", "multi", "cloze", "order", "match", "qa"}
ARTIFACTS  = ["start_span", "end_span", "【", "】", "```"]  # any of these breaks the import
# Options that REFERENCE specific other options break when the app shuffles. ("All/None of the above|
# these" are NOT flagged — the app v0.2.63+ anchors them to their slot.) Conservative on purpose, so it
# won't false-positive on legit answers like "Hepatitis A and B" or "Vitamin A and D".
POSITIONAL = re.compile(r"\b(both of the above|neither of the above|both of these|"
                        r"(option|answer)s? [a-e0-9]\b|"
                        r"the (first|second|third|fourth|last) (option|answer|choice))", re.I)

def validate_pack(pack):
    errs, warns = [], []
    for k in ("pack", "id", "formatVersion", "version"):
        if not pack.get(k): errs.append(f"missing top-level field: {k}")
    qs, cs, gs = pack.get("questions", []), pack.get("cards", []), pack.get("guides", [])
    gfiles = {g.get("file") for g in gs}

    ids = [x.get("id") for x in qs + cs if x.get("id")]
    for i, n in Counter(ids).items():
        if n > 1: errs.append(f"duplicate id: {i} (x{n})")

    for q in qs:
        qid = q.get("id", "?")
        if not all(q.get(k) for k in ("sys", "topic", "stem")): errs.append(f"{qid}: missing sys/topic/stem")
        opts = q.get("options", [])
        if len(opts) < 2 or not isinstance(q.get("correct"), int) or not (0 <= q.get("correct", -1) < len(opts)):
            errs.append(f"{qid}: bad options/correct")
        if q.get("difficulty") not in ("easy", "medium", "hard"):
            errs.append(f"{qid}: difficulty must be easy/medium/hard")
        if q.get("guide", {}).get("f") and q["guide"]["f"] not in gfiles:
            errs.append(f"{qid}: guide.f '{q['guide']['f']}' matches no guide")
        if len(q.get("explain", "")) < 80: warns.append(f"{qid}: explanation looks thin")
        for o in opts:
            if isinstance(o, str) and POSITIONAL.search(o):
                warns.append(f"{qid}: position-dependent option {o!r} — reword (options are shuffled)")

    for c in cs:
        cid, t = c.get("id", "?"), c.get("type")
        if t not in CARD_TYPES: errs.append(f"{cid}: bad card type {t!r}"); continue
        if "difficulty" in c: errs.append(f"{cid}: cards must NOT carry difficulty")
        if t in {"mcq", "multi", "order", "match", "qa"} and not c.get("prompt"):
            errs.append(f"{cid}: {t} needs a prompt")
        if t == "mcq" and not (0 <= c.get("correct", -1) < len(c.get("options", []))):
            errs.append(f"{cid}: bad mcq correct")
        if t == "multi" and not (isinstance(c.get("correct"), list)
                                 and all(0 <= i < len(c.get("options", [])) for i in c.get("correct", []))):
            errs.append(f"{cid}: bad multi correct")
        if t == "multi" and isinstance(c.get("correct"), list) and len(c["correct"]) >= len(c.get("options", [])):
            warns.append(f"{cid}: multi marks ALL options correct — add a false option")
        if t in {"mcq", "multi"}:
            for o in c.get("options", []):
                if isinstance(o, str) and POSITIONAL.search(o):
                    warns.append(f"{cid}: position-dependent option {o!r} — reword (options are shuffled)")
        if t == "cloze":
            txt = c.get("text", "")
            if txt.count("{{") != txt.count("}}") or txt.count("{{") == 0:
                errs.append(f"{cid}: unbalanced/empty cloze blanks")
        if t == "qa" and not c.get("answer"): errs.append(f"{cid}: qa needs an answer")
        if t == "match" and (not c.get("pairs") or any(len(p) != 2 for p in c.get("pairs", []))):
            errs.append(f"{cid}: match pairs must be 2-element arrays")
        if c.get("guide", {}).get("f") and c["guide"]["f"] not in gfiles:
            errs.append(f"{cid}: guide.f '{c['guide']['f']}' matches no guide")

    for g in gs:
        if not g.get("html"): errs.append(f"guide {g.get('file')}: empty html")
        elif len(g["html"]) < 1500: warns.append(f"guide {g.get('file')}: looks thin (<1500 chars)")
    if gs and not any("cram" in (g.get("title","") or "").lower() for g in gs):
        warns.append("no cram-sheet guide — every pack should end with a one-page cram sheet (§6a)")

    blob = json.dumps(pack, ensure_ascii=False)
    hit = [m for m in ARTIFACTS if m in blob]
    if hit: errs.append(f"citation/markdown artifacts present: {hit}")

    types = Counter(c.get("type") for c in cs)
    if cs and types.get("cloze", 0) > 0.35 * len(cs):
        warns.append(f"cloze cards {types['cloze']}/{len(cs)} (>35%) — vary the card mix (more qa/mcq/order)")
    if len(gs) < 6:
        warns.append(f"only {len(gs)} guide(s) — a dense one-week pack usually wants ~6–10 narrow guides")

    diffs = Counter(q.get("difficulty") for q in qs)
    print(f"questions={len(qs)} cards={len(cs)} guides={len(gs)} "
          f"difficulty={dict(diffs)} cardtypes={dict(types)}")
    return errs, warns

# usage:
# errs, warns = validate_pack(pack)
# for w in warns: print("warn:", w)
# if errs:
#     for e in errs: print("ERROR:", e)
#     raise SystemExit("NOT WRITTEN — fix the errors above.")
# with open(out_name, "w", encoding="utf-8") as f:
#     json.dump(pack, f, ensure_ascii=False, indent=1)   # pretty-printed, never minified
```

**If you cannot run code,** you can't skip validation — you just do it by hand: re-read §7 item by item,
then paste the finished text into a strict JSON parser (per §8) and confirm it parses *before* delivering.

---

## 8. Output instructions

> **⚠ Output hygiene — the #1 cause of a pack that won't import.** The pack file must be **pure,
> valid JSON and nothing else.** Before you deliver:
> - **No prose, no explanations, and no Markdown code fences** inside the file — the file is JSON from
>   the first `{` to the last `}`.
> - **Strip every citation / grounding / source / footnote marker your tooling may inject** — e.g.
>   bracketed span tags like `[span_0](start_span)…[span_0](end_span)`, `【…】`, superscript reference
>   numbers, or `[1]`-style cites. **A single stray marker anywhere makes `JSON.parse` reject the
>   entire file**, so the user sees nothing import.
> - **Parse-test the final text with a strict JSON parser** (it either parses cleanly or it doesn't).
>   Also re-run the §7 checklist. Do not hand over a pack you have not verified parses. **If you can run
>   code, do all of this with the §7a validator rather than by eye — it catches every artifact above.**

When you finish, produce **one downloadable file** (not just code in chat):

1. The **pack `.json`**, **with the topic guides embedded** in `guides[]`. This single file is the
   complete deliverable — the embedded guides make it fully self-sufficient (Topic Guides reader
   included). **Name the file to mirror the pack's placement (§2)** so the learner's packs stay
   organized and sort sensibly in a folder: **`<Year>_<Course>_Week<NN>_<Subject>_v<ver>.json`** —
   e.g. `Year1_CPC1_Week09_Pediatrics_v2.0.json`, or `Year1_Cardiology_Week12_Arrhythmias_v1.0.json`.
   Drop any level the learner doesn't use (a single-week pack with no course is just
   `Week12_Cardiology_v1.0.json`), and use the same week number(s) as the `weeks` field so name and
   metadata agree. The filename is for the learner's convenience only — inside the app, packs are
   identified by `id` and sorted by their `school`/`year`/`course`/`weeks` fields, not the filename.
2. A short report in chat: how many questions (and the easy/medium/hard split), how many cards, and
   which guides are embedded.

Do **not** emit standalone guide `.html` files unless the user asks for printable copies.

---

## 9. How the human uses the output (for your closing summary)

1. Open the app at **https://studysuite.app** (nothing to install; a local copy of the
   app `.html` works identically).
2. Drag the new `.json` pack onto the drop zone — it persists and appears in the **Content
   library**, toggleable on/off. (The site also offers the author's default packs with one-click
   "+ Add to library".)
3. Pick a **Session difficulty** — Easy/Medium/Hard are multi-select toggles, "All" selects all
   three — for Practice and Exam; **Review** ignores difficulty. Open **Topic Guides** to read the
   bundled guides.
4. **Backup all** periodically — one file with packs, progress, and settings, restorable anywhere.

Load multiple weeks and toggle to whatever's being studied. Add weeks anytime by generating more
packs with this guide. Bump each pack's `version`/`updated` when you revise it.
