Skill tree progression system & whitespace support

This commit is contained in:
2026-02-15 07:30:34 +00:00
parent 13550505c1
commit 6d6815af02
22 changed files with 2883 additions and 238 deletions

View File

@@ -0,0 +1,507 @@
# Skill Tree Progression System & Whitespace Support
## Context
keydr currently tracks only a-z lowercase letters in its adaptive unlock system. Since keydr aims to be a coding-focused typing tutor, it must also train capitals, numbers, punctuation, whitespace (tabs/newlines), and code-specific symbols. The current flat a-z progression needs to be replaced with a branching skill tree that lets players choose their training path after mastering lowercase letters. Additionally, code drills currently strip newlines into spaces, making them unrealistic for real-world code practice.
## Skill Tree Structure
The tree is flat: a-z is the root, and all other branches are direct siblings at the same level. Once a-z is complete, all branches unlock simultaneously and the user can choose any order.
```
┌─────────────────┐
│ a-z Lowercase │ (ROOT - everyone starts here)
│ 26 keys, freq │
│ order unlock │
└────────┬────────┘
┌─────────┬──────────┼──────────┬──────────┐
▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────┐
│Capitals │ │Numbers │ │ Prose │ │White- │ │ Code │
│ A-Z │ │ 0-9 │ │ Punct. │ │ space │ │ Symbols │
│ 3 lvls │ │ 2 lvls │ │ 3 lvls │ │ 2 lvls │ │ 4 lvls │
└─────────┘ └────────┘ └────────┘ └────────┘ └──────────┘
```
### Prerequisites
- **a-z Lowercase** (root): Always available from start
- **All other branches**: Require a-z complete (all 26 lowercase letters confident). Once a-z is done, all 5 branches unlock simultaneously. User freely chooses which to pursue.
---
## Branch Status State Machine
Each branch has an explicit status:
```rust
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BranchStatus {
Locked, // Prerequisites not met
Available, // Prerequisites met, user hasn't started
InProgress, // User has begun drilling this branch
Complete, // All levels in branch are done
}
```
**Transitions:**
- `Locked → Available`: When a-z branch reaches `Complete`
- `Available → InProgress`: **Only** when user explicitly launches a branch drill from the skill tree (start-on-select model). The global adaptive drill does NOT auto-start branches.
- `InProgress → Complete`: When all keys in all levels of the branch reach confidence >= 1.0
**Multiple branches active**: Yes. The user can have multiple branches `InProgress` simultaneously. Each tracks its own current level independently.
**Global adaptive scope**: Only includes keys from `InProgress` and `Complete` branches. `Available` branches are not included — the user must visit the skill tree to start them.
---
## Detailed Level Breakdown
### Branch: a-z Lowercase (Root)
Uses existing frequency-order system. Starts with 6 keys, unlocks one at a time when all current keys reach confidence >= 1.0. Branch is "complete" when all 26 letters are confident.
Order: `e t a o i n s h r d l c u m w f g y p b v k j x q z`
Total keys: **26**
### Branch: Capital Letters (3 levels)
- **Level 1 — Common Sentence Capitals** (8 keys): `T I A S W H B M`
- **Level 2 — Name Capitals** (10 keys): `J D R C E N P L F G`
- **Level 3 — Remaining Capitals** (8 keys): `O U K V Y X Q Z`
Total keys: **26**
Text generation rules:
- First word of each "sentence" (after `.` `?` `!` or at drill start) gets capitalized
- ~10-15% of words get capitalized as proper-noun-like words
- Focused capital letter is boosted (40% chance to appear in word starts)
### Branch: Numbers (2 levels)
- **Level 1 — Common Digits** (5 keys): `1 2 3 4 5`
- **Level 2 — All Digits** (5 keys): `0 6 7 8 9`
Total keys: **10**
Text generation rules:
- ~15% of words replaced with number expressions using only unlocked digits
- Patterns: counts ("3 items"), years ("2024"), IDs ("room 42"), measurements ("7 miles")
### Branch: Prose Punctuation (3 levels)
- **Level 1 — Essential** (3 keys): `. , '`
- **Level 2 — Common** (4 keys): `; : " -`
- **Level 3 — Expressive** (4 keys): `? ! ( )`
Total keys: **11**
Text generation rules follow natural prose patterns:
- `.` ends sentences (every 5-15 words), `,` separates clauses
- `'` in contractions (don't, it's, we'll)
- `"` wrapping quoted phrases, `;` between clauses, `:` before lists
- `-` in compound words (well-known), `?` for questions, `!` for exclamations
- `( )` for parenthetical asides
### Branch: Whitespace (2 levels)
- **Level 1 — Enter/Return** (1 key): `\n`
- **Level 2 — Tab/Indent** (1 key): `\t`
Total keys: **2**
Text generation rules:
- Line breaks at sentence boundaries (every ~60-80 chars)
- Tabs for indentation in code-like structures
- Once unlocked, **default adaptive drills automatically become multi-line**
### Branch: Code Symbols (4 levels)
- **Level 1 — Arithmetic & Assignment** (5 keys): `= + * /` and `-` (shared with Prose Punct L2)
- **Level 2 — Grouping** (6 keys): `{ } [ ] < >`
- **Level 3 — Logic & Reference** (5 keys): `& | ^ ~` and `!` (shared with Prose Punct L3)
- **Level 4 — Special** (7 keys): `` @ # $ % _ \ ` ``
Total keys: **23** (21 unique + 2 shared with Prose Punctuation)
Text generation rules:
- L1: Prose with simple expressions (`x = a + b`, `total = price * qty`)
- L2: Code-pattern templates (`if (x) { return y; }`, `arr[0]`)
- L3: Bitwise/logical patterns (`a & b`, `!flag`, `*ptr`)
- L4: Language-specific patterns (`@decorator`, `#include`, `snake_case`)
**Grand total**: 98 keys across branches, **96 unique keys** (after deducting 2 shared: `-` and `!`). `TOTAL_UNIQUE_KEYS` is derived at startup by collecting all keys from all branch definitions into a `HashSet` and taking `len()`. Stored as a field on `SkillTree` for use in scoring and UI.
---
## Shared Keys Between Branches
Two keys appear in multiple branches:
- `-` appears in Prose Punctuation L2 and Code Symbols L1
- `!` appears in Prose Punctuation L3 and Code Symbols L3
**Rule**: Confidence is tracked once per character in `KeyStatsStore` (keyed by `char`). If a user masters `-` in Prose Punctuation, it is automatically confident in Code Symbols too. When checking level completion, the branch reads the single confidence value for that char. This is idempotent — no special handling needed.
---
## Focused Key Policy
### Global Adaptive Drill (from menu)
1. Collect all keys from all `InProgress` branches (current level's keys only) plus all `Complete` branch keys
2. Find the key with the **lowest confidence < 1.0** across this entire set
3. If all keys are confident, no focused key (maintenance mode)
4. Boost the focused key in text generation (40% probability)
### Branch-Specific Drill (from skill tree)
1. Collect keys from the selected branch including **all prior completed levels** (as background reinforcement) plus the **current level's keys**, plus all a-z keys
2. Find the key with the **lowest confidence < 1.0** within the **current level keys only** (prior level keys are reinforcement, not focus targets)
3. If all current level keys are confident, advance the level and focus on the weakest new key
4. Boost the focused key in text generation (40% probability)
5. Prior-level keys always appear in generated text for reinforcement but are never the focused key
### Branches with Zero Progress
When a branch is `Available` but user hasn't started it yet:
- Launching a drill from that branch transitions it to `InProgress` at level 1
- The focused key is the weakest among level 1's keys (likely all at 0.0 confidence, so pick the first in definition order)
---
## Scoring
Current formula: `complexity = unlocked_count / 26`
**New formula**: `complexity = total_unlocked_keys / TOTAL_UNIQUE_KEYS`
Where `TOTAL_UNIQUE_KEYS = 96` is computed from branch definitions (deduplicated across shared keys). This scales naturally — the more branches the user has unlocked, the higher the complexity multiplier.
Level formula remains: `level = floor(sqrt(total_score / 100))`.
Menu header changes from `"X/26 letters"` to `"X/96 keys"`.
---
## Skill Tree UI
### New Screen: `AppScreen::SkillTree`
Accessible from menu via `[t] Skill Tree`. Renders **vertically** as a scrollable list.
```
╔══════════════════════════════════════════════════════════════════╗
║ SKILL TREE ║
╠══════════════════════════════════════════════════════════════════╣
║ ║
║ ★ Lowercase a-z COMPLETE 26/26 ║
║ ████████████████████████████████████████ Level 26/26 ║
║ ║
║ ── Branches (unlocked after a-z) ────────────────────────── ║
║ ║
║ ► Capitals A-Z Lvl 2/3 18/26 keys ║
║ ████████████████████░░░░░░░░░░░░ 69% ║
║ ║
║ Numbers 0-9 Lvl 0/2 0/10 keys ║
║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║
║ ║
║ Prose Punctuation Lvl 1/3 3/11 keys ║
║ ██████████░░░░░░░░░░░░░░░░░░░░░ 27% ║
║ ║
║ Whitespace Lvl 0/2 0/2 keys ║
║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║
║ ║
║ Code Symbols Lvl 0/4 0/23 keys ║
║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║
║ ║
╠══════════════════════════════════════════════════════════════════╣
║ ► Capitals A-Z Level 2/3 ║
║ L1: T I A S W H B M (complete) ║
║ L2: J [D] R C E N P L F G (in progress, focused: D) ║
║ L3: O U K V Y X Q Z (locked) ║
║ Avg Confidence: ████████░░ 82% ║
║ ║
║ [Enter] Start Drill [↑↓/jk] Navigate [q] Back ║
╚══════════════════════════════════════════════════════════════════╝
```
**Layout:**
- **Top section**: Vertical list of all branches with status prefix, level, key count, progress bar
- **Bottom section**: Detail panel showing per-level key breakdown, confidence bars, focused key
- **Footer**: Controls
**Node states (prefix):**
- Locked: grayed out, no prefix, not selectable
- Available: normal color, no prefix
- In Progress `►`: accent color
- Complete `★`: gold/green
**Navigation:** `↑↓` / `j/k` move selection. `Enter` launches branch drill. `q` returns to menu.
**Keyboard diagram**: For non-printable keys (`Enter`, `Tab`), show them as labeled keys on the keyboard diagram in their standard positions. No special handling needed — they're physical keys with fixed positions.
---
## Code & Passage Drill Changes (Unranked Modes)
Code and Passage drills remain as separate menu options.
1. **Unranked tagging**: Add `ranked: bool` to `DrillResult` with `#[serde(default = "default_true")]` for backward compat
2. **Derive ranked from DrillContext**: At drill start, set `ranked = (drill_mode == Adaptive)`. Code/Passage → `ranked = false`.
3. **No progression**: `finish_drill()` gates skill tree updates on `result.ranked`
4. **History replay**: `rebuild_from_history()` uses `result.ranked` as the gate. No legacy fallback — since we reset on schema change (WIP policy), old history without `ranked` field won't exist.
5. **Visual indicators**:
- Drill header: "Code Drill (Unranked)" / "Passage Drill (Unranked)" in dimmed/muted color
- Result screen: "Unranked — does not count toward skill tree"
- Stats dashboard history: unranked rows shown with muted styling
---
## Whitespace Handling
### Tokenized Render Model (`typing_area.rs`)
Replace direct char→span rendering with a `RenderToken` approach to handle one-to-many char-to-cell mapping:
```rust
struct RenderToken {
target_idx: usize, // Index into DrillState.target
display: String, // What to show (e.g., "↵", "→···", "a")
style: Style, // Computed style (correct/incorrect/cursor/pending)
}
```
**Display mapper:**
- `\n` → visible `↵` marker token + hard line break (new `Line` in paragraph)
- `\t` → visible `→` marker + padding `·` tokens to next 4-char tab stop
- All other chars → single token with char as display
**Cursor/style mapping:** Maintain a `Vec<(usize, usize)>` mapping from `target_idx` to first display cell position. When highlighting cursor or errors, look up the target index to find which display tokens to style.
**Multi-line rendering:** Change from single `Line` to `Vec<Line>`. Split on newline tokens. Each line is a separate `Line` in the `Paragraph`.
### Input Pipeline (`main.rs` + `session/input.rs`)
Current flow: `main.rs` matches `KeyCode::Char(ch)``app.type_char(ch)`. Enter/Tab are currently consumed by other handlers (menu nav, etc.).
**Changes in `main.rs`:**
- When `screen == Drill` and drill is active:
- `KeyCode::Enter``app.type_char('\n')` **unconditionally** (correctness decided by `process_char()`)
- `KeyCode::Tab``app.type_char('\t')` **unconditionally** (correctness decided by `process_char()`)
- `KeyCode::BackTab` (Shift+Tab) → ignore (no action)
- These must be checked **before** the existing Esc/Enter handlers for drill screen
- If Enter/Tab is typed when not expected, it registers as an error on the current char — same as typing any wrong key
**No changes to `session/input.rs`**: `process_char()` already compares `ch == expected` generically. It will work with `'\n'` and `'\t'` as-is.
### Code Drill Updates (`generator/code_syntax.rs`)
- Embedded snippets change from single-line `&str` to multi-line string literals with preserved indentation
- `extract_code_snippets()`: preserve original newlines and leading whitespace instead of `split_whitespace().join(" ")`
- `generate()`: join snippets with `\n\n` instead of `" "`
---
## Data Model Changes
### Persistence Policy (WIP stage)
**No backward compatibility migration.** On schema mismatch, reset persisted files to defaults. Bump schema version to 2. Add a note in changelog that local progress is intentionally reset for this version. This avoids over-engineering migration logic during early development.
### `ProfileData` (schema v2)
```rust
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfileData {
pub schema_version: u32, // 2
pub skill_tree: SkillTreeProgress, // Replaces unlocked_letters
pub total_score: f64,
pub total_drills: u32,
pub streak_days: u32,
pub best_streak: u32,
pub last_practice_date: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SkillTreeProgress {
pub branches: HashMap<String, BranchProgress>, // String keys for stable JSON
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BranchProgress {
pub status: BranchStatus,
pub current_level: usize, // 0-indexed into branch's levels array
// current_level = 0 means working on first level (plan's "Level 1")
// current_level = levels.len() only when status == Complete
}
```
**Indexing invariant**: `current_level` is always 0-indexed into `BranchDefinition.levels`. When the plan says "Level 1", "Level 2", etc. in human-readable text, that maps to `current_level = 0`, `current_level = 1`, etc. in code. A branch with `current_level = 0` and `status = InProgress` is actively working on its first level.
**HashMap uses `String` keys** (e.g., `"lowercase"`, `"capitals"`, `"numbers"`, etc.) for stable JSON serialization. `BranchId` enum has `to_key() -> &'static str` and `from_key()` methods.
### `DrillResult` Addition
```rust
#[serde(default = "default_true")]
pub ranked: bool,
```
### `KeyStatsStore`
No structural change. Already `HashMap<char, KeyStat>` — works for any char.
---
## Skill Tree Definition (Source of Truth)
Hard-coded static definition in `src/engine/skill_tree.rs`:
```rust
pub struct BranchDefinition {
pub id: BranchId,
pub name: &'static str,
pub levels: Vec<LevelDefinition>,
}
pub struct LevelDefinition {
pub name: &'static str,
pub keys: Vec<char>,
}
```
All branch/level/key definitions are `const`/`static` arrays. No data-driven manifest needed at this stage. The `SkillTree` struct holds:
- The static definition (reference)
- The persisted `SkillTreeProgress` (mutable state)
- Methods: `unlocked_keys(scope)`, `focused_key(scope, &KeyStatsStore)`, `update(&KeyStatsStore)`, `branch_status(id)`, `all_branches()`
---
## Implementation Phases
### Phase 1: Skill Tree Core & Data Model
**Goal**: Replace `LetterUnlock` with `SkillTree`, update persistence.
1. Create `src/engine/skill_tree.rs`:
- `BranchId` enum (`Lowercase, Capitals, Numbers, ProsePunctuation, Whitespace, CodeSymbols`)
- `BranchStatus` enum (`Locked, Available, InProgress, Complete`)
- `BranchDefinition`, `LevelDefinition` structs
- Static branch definitions with all keys per level
- `SkillTree` struct with `update()`, `unlocked_keys()`, `focused_key()`, `branch_status()`
2. Update `src/store/schema.rs`: new `ProfileData` with `SkillTreeProgress`, schema v2, reset on mismatch
3. Add `ranked: bool` to `DrillResult` in `src/session/result.rs`
4. Update `src/app.rs`: replace `letter_unlock: LetterUnlock` with `skill_tree: SkillTree`, update `finish_drill()` to gate on `ranked`, update `rebuild_from_history()`, update scoring complexity formula
5. Delete/replace `src/engine/letter_unlock.rs`
**Key files**: `src/engine/skill_tree.rs` (new), `src/engine/letter_unlock.rs` (delete), `src/store/schema.rs`, `src/session/result.rs`, `src/app.rs`
**Tests**:
- Skill tree status transitions (Locked → Available → InProgress → Complete)
- Shared key confidence propagation
- Focused key selection (global vs branch scope)
- Level completion and advancement
- Schema reset on version mismatch
**Acceptance criteria**: `cargo build` passes, `cargo test` passes, existing adaptive drills work with skill tree (a-z only), scoring uses new formula.
### Phase 2: Whitespace Input & Rendering
**Goal**: Support Enter/Tab in typing drills with proper display.
1. Update `src/ui/components/typing_area.rs`: tokenized render model with `RenderToken`, multi-line support, visible `↵` and `→` markers
2. Update `src/main.rs`: route `KeyCode::Enter``'\n'` and `KeyCode::Tab``'\t'` when in drill mode, ignore `BackTab`
3. Update `src/generator/code_syntax.rs`: preserve newlines/indentation in snippets, change embedded snippets to multi-line, fix `extract_code_snippets()` to preserve whitespace
4. Optionally update `src/generator/passage.rs` with multi-line passage variants
**Key files**: `src/ui/components/typing_area.rs`, `src/main.rs`, `src/generator/code_syntax.rs`
**Tests**:
- RenderToken generation for strings with `\n` and `\t`
- Cursor position mapping with expanded tokens
- Enter/Tab input processing (reuse existing `process_char()` — just verify `'\n'` and `'\t'` work)
**Acceptance criteria**: Code drills display multi-line with visible whitespace markers, Enter/Tab advance the cursor correctly, backspace works across line boundaries.
### Phase 3: Text Generation for Capitals & Punctuation
**Goal**: Generate drill text that naturally incorporates capitals and punctuation.
1. Create `src/generator/capitalize.rs`: post-processing pass that capitalizes sentence starts and occasional words, using only unlocked capital letters
2. Create `src/generator/punctuate.rs`: post-processing pass that inserts periods, commas, apostrophes, etc. at natural positions, using only unlocked punctuation
3. Update `src/generator/phonetic.rs` or `src/app.rs` `generate_text()`: apply capitalize/punctuate passes when those branches are active
4. Update `src/engine/filter.rs` `CharFilter`: add awareness of which char types are allowed (lowercase, uppercase, punctuation, etc.)
**Key files**: `src/generator/capitalize.rs` (new), `src/generator/punctuate.rs` (new), `src/generator/phonetic.rs`, `src/app.rs`, `src/engine/filter.rs`
**Acceptance criteria**: Adaptive drills with Capitals branch active produce properly capitalized text. Drills with Prose Punctuation active have natural punctuation placement.
### Phase 4: Text Generation for Numbers & Code Symbols
**Goal**: Generate drill text with numbers and code symbol patterns.
1. Create `src/generator/numbers.rs`: injects number expressions into prose using only unlocked digits
2. Create `src/generator/code_patterns.rs`: code-pattern templates for Code Symbols branch drills (expressions, brackets, operators)
3. Update `src/app.rs` `generate_text()`: apply number/code passes based on active branches
4. For whitespace branch: when active, insert `\n` at sentence boundaries in generated text
**Key files**: `src/generator/numbers.rs` (new), `src/generator/code_patterns.rs` (new), `src/app.rs`
**Acceptance criteria**: Number expressions use only unlocked digits. Code symbol drills produce recognizable code-like patterns. Whitespace branch generates multi-line output.
### Phase 5: Skill Tree UI
**Goal**: Navigable skill tree screen with branch detail and drill launch.
1. Add `AppScreen::SkillTree` to `src/app.rs`
2. Create `src/ui/components/skill_tree.rs`: vertical branch list + detail panel widget
3. Update `src/main.rs`: handle key events for skill tree screen (navigation, drill launch)
4. Update `src/ui/components/menu.rs`: add `[t] Skill Tree` option
5. Update menu header: show `"X/96 keys"` instead of `"X/26 letters"`
6. Add `DrillMode::BranchDrill(BranchId)` or similar to track drill origin for branch-specific focus
**Key files**: `src/ui/components/skill_tree.rs` (new), `src/app.rs`, `src/main.rs`, `src/ui/components/menu.rs`
**Acceptance criteria**: Can navigate to skill tree from menu, see all branches with correct status, launch a branch-specific drill, return to menu.
### Phase 6: Unranked Mode Polish
**Goal**: Clearly distinguish ranked vs unranked drills in UI.
1. Update drill header in `src/main.rs`: show "(Unranked)" for Code/Passage modes
2. Update `src/ui/components/dashboard.rs` result screen: note "does not count toward skill tree"
3. Update `src/ui/components/stats_dashboard.rs`: muted styling for unranked history rows
4. Verify `rebuild_from_history()` correctly uses `ranked` field to gate skill tree updates
**Key files**: `src/main.rs`, `src/ui/components/dashboard.rs`, `src/ui/components/stats_dashboard.rs`, `src/app.rs`
**Acceptance criteria**: Code/Passage drills clearly marked unranked. Stats history shows visual distinction. Ranked drills advance skill tree, unranked don't.
---
## Verification
### Automated Tests
- **Skill tree transitions**: `Locked → Available → InProgress → Complete` for each branch
- **Shared keys**: Mastering `!` in Prose Punct → confident in Code Symbols too
- **Focused key**: Global scope selects weakest across all active branches; branch scope selects within branch
- **Level advancement**: Completing all keys in a level auto-advances to next
- **Ranked/unranked**: Only ranked drills update skill tree in `rebuild_from_history()`
- **Whitespace tokens**: RenderToken expansion for `\n` and `\t` produces correct display strings and index mapping
- **Input routing**: `'\n'` and `'\t'` correctly processed as typed characters
### Manual Testing
1. Launch app → a-z trunk works as before
2. Complete a-z (or edit profile to simulate) → all 5 branches show as Available
3. Navigate skill tree → select Capitals → launch drill → see capitalized text
4. Complete Capitals L1 → L2 keys appear in drills
5. Launch default adaptive with multiple branches active → text mixes all unlocked keys
6. Launch Code/Passage drill → header shows "(Unranked)", no skill tree progress
7. Start Whitespace branch → default adaptive becomes multi-line
8. Type Enter/Tab in code drills → cursor advances correctly, errors tracked
9. Quit and relaunch → progress preserved
10. Delete `~/.local/share/keydr/` → app resets cleanly to fresh state