{
  "contentVersion": "2026.08.14+2",
  "part": "rules",

  "_note": "Loader passes through exactly four keys: unlocks, wiring, scope, todayPlan (js/data/content.js). Anything new therefore lives INSIDE todayPlan or it never reaches the app. The whole todayPlan block is live: progression.js:planConfig() merges it over the spec defaults, techniqueScore() reads the eight weights and the penalties, and derive.js packs to the budgets and parks from the runs it replays. Change a number here and her history re-derives under the new rule — that is the point of it living in content (spec 06 §0, §4.2).",

  "unlocks": [
    { "id": "unlock.first-mark",
      "label": "First one logged",
      "note": "You marked your first move. The skills bars are live from here.",
      "requires": { "kind": "movesAtLeast", "stage": "learning", "count": 1 } },

    { "id": "unlock.feet-foundation",
      "label": "Feet foundation",
      "note": "Three feet-and-ankle moves under way. The pre-pointe track is worth a look.",
      "requires": { "kind": "categoryAtLeast", "categoryId": "cat.feet-ankles", "stage": "learning", "count": 3 } },

    { "id": "unlock.week-one",
      "label": "A week of practice",
      "note": "Seven days in a row. That is the hard part done.",
      "requires": { "kind": "streakAtLeast", "days": 7 } },

    { "id": "unlock.turns-open",
      "label": "Turns unlocked",
      "note": "Spotting is holding up. Pirouettes and chaînés are ready to work on.",
      "requires": { "kind": "all", "of": [
        { "kind": "moveAtLeast", "techniqueId": "tech.chaines-ballet", "stage": "learning" },
        { "kind": "moveAtLeast", "techniqueId": "tech.pirouette-en-dehors", "stage": "learning" }
      ] } },

    { "id": "unlock.prepointe-halfway",
      "label": "Four weeks of strength banked",
      "note": "Four weeks of the strength track done. The strength is the point — pointe itself is your teacher's call.",
      "requires": { "kind": "prePointeWeeks", "weeks": 4 } },

    { "id": "unlock.quiz-clean",
      "label": "Clean sweep",
      "note": "Every quiz question right in one run.",
      "requires": { "kind": "quizBestAtLeast", "score": 4 } }
  ],

  "wiring": [
    { "id": "wire.drill-to-today",
      "note": "A move set to learning or ready puts its drill into tonight's practice.",
      "when": "move:stage",
      "minStage": "learning",
      "maxStage": "ready",
      "effect": "suggestDrill" },

    { "id": "wire.weakest-to-today",
      "note": "The weakest category contributes one move to tonight's practice.",
      "when": "always",
      "effect": "suggestWeakestCategory",
      "count": 1 },

    { "id": "wire.quiz-miss-review",
      "note": "A missed quiz question resurfaces the technique it was about.",
      "when": "quiz:miss",
      "effect": "reviewTechnique" },

    { "id": "wire.routine-to-day",
      "note": "Finishing a routine counts its minutes towards the day and the streak.",
      "when": "routine:complete",
      "effect": "creditMinutes" },

    { "id": "wire.routine-credits-techniques",
      "note": "Finishing a routine also freshens every technique its exercises name, so those bars stop looking neglected.",
      "when": "routine:complete",
      "effect": "creditTechniques",
      "readsFrom": "routine.steps[].techniqueIds" },

    { "id": "wire.performed-to-skills",
      "note": "Stage changes always move the skills bars; nothing is stored, it is derived.",
      "when": "move:stage",
      "effect": "recomputeSkills" },

    { "id": "wire.focus-pins-today",
      "note": "A focus set by a teacher or parent sits at the top of tonight's practice, above everything the app chose.",
      "when": "focus:set",
      "effect": "pinToToday",
      "max": 1 },

    { "id": "wire.post-class-cooldown",
      "note": "After a logged class, the cool down is offered first — the rest of the plan can wait until tomorrow.",
      "when": "class:logged",
      "effect": "suggestRoutine",
      "routineId": "rt.cool" },

    { "id": "wire.reentry-easy-win",
      "note": "Back after a few days away, tonight is one short thing you already know, not a pile of catching up.",
      "when": "return:afterBreak",
      "effect": "suggestRoutine",
      "routineId": "rt.reentry" },

    { "id": "wire.park-acknowledged",
      "note": "Anything suggested and skipped three days running gets parked, and the app says so out loud instead of quietly dropping it.",
      "when": "plan:ignoredRun",
      "effect": "parkWithMessage" }
  ],

  "scope": {
    "keptRungBand": 2,
    "parallelRungBand": 1,
    "parallelRequiresSave": true,
    "note": "spec 08 §3.2. A parallel-track level is a different class she may or may not be in, so it is browsable but not plannable until she saves it. Tuning these is a content edit, not a code edit."
  },

  "todayPlan": {
    "maxItems": 4,
    "scopeStates": ["current", "kept", "saved"],

    "sources": [
      { "kind": "focusPinned", "max": 1,
        "note": "The teacher's focus is never scored and never competes — it is simply first." },
      { "kind": "drillsForActiveMoves", "max": 2 },
      { "kind": "weakestCategoryMove", "max": 1 },
      { "kind": "trackRoutine", "track": "pre-pointe", "max": 1, "preferMaxMins": 8,
        "prefer": "shortestThatFits",
        "note": "Prefer the shortest routine on the track that fits tonight's budget — a six-minute suggestion actually gets done." }
    ],

    "freeze": {
      "generateOncePerDay": true,
      "repackOnBudgetChange": true,
      "note": "One plan a day, frozen. A list that reshuffles under her is a list she cannot trust or finish. Changing the time budget re-packs it — a smaller budget drops items rather than shaming her."
    },

    "budgets": {
      "options": [5, 15, 30],
      "default": 15,
      "remember": true,
      "fillTo": 0.9,
      "overshoot": 1.2,
      "packing": [
        { "upToMin": 5,  "maxItems": 1, "onePerCategory": true },
        { "upToMin": 15, "maxItems": 3, "onePerCategory": true },
        { "upToMin": 30, "maxItems": 4, "onePerCategory": false }
      ],
      "note": "Time available is the single thing that decides whether a practice happens, so it is a chooser and not a guess."
    },

    "ranking": {
      "weights": {
        "weakness": 0.80,
        "staleness": 0.70,
        "quizMiss": 0.60,
        "exam": 0.90,
        "focusCategory": 0.55,
        "progression": 0.50,
        "prePointe": 0.45,
        "jitter": 0.10
      },
      "weakness":    { "from": "1 - categoryPercent/100" },
      "staleness":   { "saturateDays": 21, "neverPractisedCountsAs": 21 },
      "quizMiss":    { "windowDays": 30, "perMiss": 0.40, "dueBonus": 0.30, "max": 1,
                       "boxDays": [1, 3, 7, 16, 35] },
      "exam":        { "rampDays": 42, "onlyTechniquesInExamScope": true },
      "progression": { "learning": 0.90, "none": 0.60, "ready": 0.35, "performed": 0.05 },
      "prePointe":   { "onlyWhenTrackActive": true, "onlyWhenWeekBehind": true, "sessionsPerWeekTarget": 2 },
      "jitter":      { "seed": "day:techniqueId", "stableWithinDay": true },
      "clipEngagement": { "watchedMistakeSegmentBonus": 0.15 },
      "parallelEcho": { "bonus": 0.35,
                        "_note": "The same move met in two classes a week is worth a little more of one evening. Read from the level index, never from a comparison between the two levels." },
      "focusPinned": { "weight": 2.0,
                       "_note": "The pin comes first and is not scored at all. This weight only decides the order when a teacher has named more than one move — nothing the app computes outranks a note from an adult who watched her dance." },
      "note": "Weights are spec 06 §4.2 and are certainly wrong on the first try — which is the point of them living in content. The projection is pure, so changing a number here re-derives her history rather than migrating it."
    },

    "penalties": {
      "repeatedSuggestion": 0.25,
      "repeatedSuggestionMaxRuns": 3,
      "declined": 0.60,
      "outOfScope": 1.00,
      "doneToday": "exclude",
      "addedManually": "exclude",
      "addedManuallyExcludeDays": 2,
      "_scopeNote": "Work from a class she is not in is demoted, never removed. Nothing is hidden and nothing is locked — a move she has gone looking for stays reachable, it just does not turn up uninvited on a Tuesday night.",
      "note": "Nothing is ever deleted from the pool, only quietly demoted. She chose it herself, or she already did it — either way the app has no business suggesting it again tonight."
    },

    "parks": {
      "ignoredRunToPark": 3,
      "parkDays": 7,
      "byReason": {
        "not tonight": 5,
        "no space": 3,
        "too hard": 7,
        "don't like it": 30
      },
      "rememberAsPreference": ["don't like it"],
      "announce": true,
      "note": "Naming the pause is what stops it reading as nagging: one line, past tense, and a promise it comes back. Nothing is ever removed for good."
    },

    "variety": {
      "mechanisms": [
        { "id": "var.staleness-rotation",
          "note": "Anything practised falls down the list on its own, because staleness climbs on everything else. The library rotates without anybody managing it." },
        { "id": "var.daily-jitter",
          "note": "Ties break differently each day but stay stable within the day, so the plan she opened at breakfast is the plan she finishes at night." },
        { "id": "var.repeat-penalty-then-park",
          "note": "Suggested and skipped three days running means the app has misread her. It parks the item for a week and says so." },
        { "id": "var.category-diversity",
          "note": "One item per category when time is short. Three foot drills in a row is a chore, not a practice." }
      ],
      "minDaysBeforeRepeatingSameItem": 2,
      "maxSameCategoryPerPlan": 2,
      "note": "Four independent mechanisms, so no single one has to carry it. Between them the same item can never sit at the top of the list forever."
    },

    "guarantees": {
      "oneFinishableWin": true,
      "finishableStages": ["ready", "performed"],
      "noBacklog": true,
      "pinnedFocusMax": 1,
      "note": "A plan made only of weaknesses is a plan about failing, so at least one item is always something she can already do. Missed practice never queues up — there is no catching up to be done, ever."
    },

    "reentry": {
      "afterDaysAway": 3,
      "budgetMin": 5,
      "maxItems": 1,
      "candidateStages": ["ready", "performed"],
      "leadWith": "whatIsStillTrue",
      "note": "Coming back after a gap, the first line is her name and what has not changed — longest streak, moves performed. One small thing she is good at, and no mention of anything broken."
    },

    "prePointeTrack": {
      "sessionsPerWeek": 2,
      "rollingWeeks": 8,
      "resetsOnMissedWeek": false,
      "producesScore": false,
      "producesVerdict": false,
      "note": "The eight weeks are a rolling count of work done, and a missed week costs one cell rather than the block. Nothing in this file may add up to a statement about being ready for pointe — no score, no gate, no implied verdict. That call belongs to a teacher who can see her, with her physio. Enforced in code by validator rule E019, not just by copy."
    }
  }
}
