N-gram metrics overhaul & UI improvements
This commit is contained in:
@@ -21,9 +21,7 @@ fn bench_extraction(c: &mut Criterion) {
|
|||||||
let keystrokes = make_keystrokes(500);
|
let keystrokes = make_keystrokes(500);
|
||||||
|
|
||||||
c.bench_function("extract_ngram_events (500 keystrokes)", |b| {
|
c.bench_function("extract_ngram_events (500 keystrokes)", |b| {
|
||||||
b.iter(|| {
|
b.iter(|| extract_ngram_events(black_box(&keystrokes), 800.0))
|
||||||
extract_ngram_events(black_box(&keystrokes), 800.0)
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,17 +82,13 @@ fn bench_focus_selection(c: &mut Criterion) {
|
|||||||
let unlocked: Vec<char> = all_chars;
|
let unlocked: Vec<char> = all_chars;
|
||||||
|
|
||||||
c.bench_function("weakest_bigram (3K entries)", |b| {
|
c.bench_function("weakest_bigram (3K entries)", |b| {
|
||||||
b.iter(|| {
|
b.iter(|| bigram_stats.weakest_bigram(black_box(&char_stats), black_box(&unlocked)))
|
||||||
bigram_stats.weakest_bigram(black_box(&char_stats), black_box(&unlocked))
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bench_history_replay(c: &mut Criterion) {
|
fn bench_history_replay(c: &mut Criterion) {
|
||||||
// Build 500 drills of ~300 keystrokes each
|
// Build 500 drills of ~300 keystrokes each
|
||||||
let drills: Vec<Vec<KeyTime>> = (0..500)
|
let drills: Vec<Vec<KeyTime>> = (0..500).map(|_| make_keystrokes(300)).collect();
|
||||||
.map(|_| make_keystrokes(300))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
c.bench_function("history replay (500 drills x 300 keystrokes)", |b| {
|
c.bench_function("history replay (500 drills x 300 keystrokes)", |b| {
|
||||||
b.iter(|| {
|
b.iter(|| {
|
||||||
@@ -103,8 +97,7 @@ fn bench_history_replay(c: &mut Criterion) {
|
|||||||
let mut key_stats = KeyStatsStore::default();
|
let mut key_stats = KeyStatsStore::default();
|
||||||
|
|
||||||
for (drill_idx, keystrokes) in drills.iter().enumerate() {
|
for (drill_idx, keystrokes) in drills.iter().enumerate() {
|
||||||
let (bigram_events, trigram_events) =
|
let (bigram_events, trigram_events) = extract_ngram_events(keystrokes, 800.0);
|
||||||
extract_ngram_events(keystrokes, 800.0);
|
|
||||||
|
|
||||||
for kt in keystrokes {
|
for kt in keystrokes {
|
||||||
if kt.correct {
|
if kt.correct {
|
||||||
@@ -117,13 +110,19 @@ fn bench_history_replay(c: &mut Criterion) {
|
|||||||
|
|
||||||
for ev in &bigram_events {
|
for ev in &bigram_events {
|
||||||
bigram_stats.update(
|
bigram_stats.update(
|
||||||
ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation,
|
ev.key.clone(),
|
||||||
|
ev.total_time_ms,
|
||||||
|
ev.correct,
|
||||||
|
ev.has_hesitation,
|
||||||
drill_idx as u32,
|
drill_idx as u32,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for ev in &trigram_events {
|
for ev in &trigram_events {
|
||||||
trigram_stats.update(
|
trigram_stats.update(
|
||||||
ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation,
|
ev.key.clone(),
|
||||||
|
ev.total_time_ms,
|
||||||
|
ev.correct,
|
||||||
|
ev.has_hesitation,
|
||||||
drill_idx as u32,
|
drill_idx as u32,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
221
docs/plans/2026-02-24-n-grams-statistics-tab.md
Normal file
221
docs/plans/2026-02-24-n-grams-statistics-tab.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Plan: N-grams Statistics Tab
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The n-gram error tracking system (last commit `e7f57dd`) tracks bigram/trigram transition difficulties and uses them to adapt drill selection. However, there's no visibility into what the system has identified as weak or how it's influencing drills. This plan adds a **[6] N-grams** tab to the Statistics page to surface this data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] Dashboard [2] History [3] Activity [4] Accuracy [5] Timing [6] N-grams
|
||||||
|
|
||||||
|
┌─ Active Focus ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Focus: Bigram "th" (difficulty: 1.24) │
|
||||||
|
│ Bigram diff 1.24 > char 'n' diff 0.50 x 0.8 threshold │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌─ Eligible Bigrams (3) ────────────────┐┌─ Watchlist ─────────────────────────┐
|
||||||
|
│ Pair Diff Err% Exp% Red Conf N ││ Pair Red Samples Streak │
|
||||||
|
│ th 1.24 18% 7% 2.10 0.41 32 ││ er 1.82 14/20 2/3 │
|
||||||
|
│ ed 0.89 22% 9% 1.90 0.53 28 ││ in 1.61 8/20 1/3 │
|
||||||
|
│ ng 0.72 14% 8% 1.72 0.58 24 ││ ou 1.53 18/20 1/3 │
|
||||||
|
└────────────────────────────────────────┘└───────────────────────────────────┘
|
||||||
|
Scope: Global | Bigrams: 142 | Trigrams: 387 | Hesitation: >832ms | Tri-gain: 12.0%
|
||||||
|
|
||||||
|
[ESC] Back [Tab] Next tab [1-6] Switch tab
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Decisions
|
||||||
|
|
||||||
|
- **Drill scope**: Tab shows data for `app.drill_scope` (current adaptive scope). A scope label in the summary line makes this explicit (e.g., "Scope: Global" or "Scope: Branch: lowercase").
|
||||||
|
- **Trigram gain**: Sourced from `app.trigram_gain_history` (computed every 50 ranked drills). Always from ranked stats, consistent with bigram/trigram counts shown. The value is a fraction in `[0.0, 1.0]` (count of signal trigrams / total qualified trigrams), so it is mathematically non-negative. Format: `X.X%` (one decimal). When empty: `--` with note "(computed every 50 drills)".
|
||||||
|
- **Eligible vs Watchlist**: Strictly disjoint by construction. Watchlist filter explicitly excludes bigrams that pass all eligibility gates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer Boundaries
|
||||||
|
|
||||||
|
Domain logic (engine) and presentation (UI) are separated:
|
||||||
|
|
||||||
|
- **Engine** (`ngram_stats.rs`): Owns `FocusReasoning` (domain decision explanation), `select_focus_target_with_reasoning()`, filtering/gating/sorting logic for eligible and watchlist bigrams. Returns domain-oriented results.
|
||||||
|
- **UI** (`stats_dashboard.rs`): Owns `NgramTabData`, `EligibleBigramRow`, `WatchlistBigramRow` (view model structs tailored for rendering columns).
|
||||||
|
- **Adapter** (`main.rs`): `build_ngram_tab_data()` is the single point that translates engine output → UI view models. All stats store lookups for display columns happen here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
### 1. `src/engine/ngram_stats.rs` — Domain logic + focus reasoning
|
||||||
|
|
||||||
|
**`FocusReasoning` enum** (domain concept — why the target was selected):
|
||||||
|
```rust
|
||||||
|
pub enum FocusReasoning {
|
||||||
|
BigramWins {
|
||||||
|
bigram_difficulty: f64,
|
||||||
|
char_difficulty: f64,
|
||||||
|
char_key: Option<char>, // None when no focused char exists
|
||||||
|
},
|
||||||
|
CharWins {
|
||||||
|
char_key: char,
|
||||||
|
char_difficulty: f64,
|
||||||
|
bigram_best: Option<(BigramKey, f64)>,
|
||||||
|
},
|
||||||
|
NoBigrams { char_key: char },
|
||||||
|
Fallback,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`select_focus_target_with_reasoning()`** — Unified function returning `(FocusTarget, FocusReasoning)`. Internally calls `focused_key()` and `weakest_bigram()` once. Handles all four match arms without synthetic values.
|
||||||
|
|
||||||
|
**`focus_eligible_bigrams()`** on `BigramStatsStore` — Returns `Vec<(BigramKey, f64 /*difficulty*/, f64 /*redundancy*/)>` sorted by `(difficulty desc, redundancy desc, key lexical asc)`. Same gating as `weakest_bigram()`: sample >= `MIN_SAMPLES_FOR_FOCUS`, streak >= `STABILITY_STREAK_REQUIRED`, redundancy > `STABILITY_THRESHOLD`, difficulty > 0. Returns ALL qualifying entries (no truncation — UI handles truncation to available height).
|
||||||
|
|
||||||
|
**`watchlist_bigrams()`** on `BigramStatsStore` — Returns `Vec<(BigramKey, f64 /*redundancy*/)>` sorted by `(redundancy desc, key lexical asc)`. Criteria: redundancy > `STABILITY_THRESHOLD`, sample_count >= 3 (noise floor), AND NOT fully eligible. Returns ALL qualifying entries.
|
||||||
|
|
||||||
|
**Export constants** — Make `MIN_SAMPLES_FOR_FOCUS` and `STABILITY_STREAK_REQUIRED` `pub(crate)` so the adapter in `main.rs` can pass them into `NgramTabData` without duplicating values.
|
||||||
|
|
||||||
|
### 2. `src/ui/components/stats_dashboard.rs` — View models + rendering
|
||||||
|
|
||||||
|
**View model structs** (presentation-oriented, mapped from engine data by adapter):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct EligibleBigramRow {
|
||||||
|
pub pair: String, // e.g., "th"
|
||||||
|
pub difficulty: f64,
|
||||||
|
pub error_rate_pct: f64, // smoothed, as percentage
|
||||||
|
pub expected_rate_pct: f64,// from char independence, as percentage
|
||||||
|
pub redundancy: f64,
|
||||||
|
pub confidence: f64,
|
||||||
|
pub sample_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WatchlistBigramRow {
|
||||||
|
pub pair: String,
|
||||||
|
pub redundancy: f64,
|
||||||
|
pub sample_count: usize,
|
||||||
|
pub redundancy_streak: u8,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`NgramTabData` struct** (assembled by `build_ngram_tab_data()` in main.rs):
|
||||||
|
```rust
|
||||||
|
pub struct NgramTabData {
|
||||||
|
pub focus_target: FocusTarget,
|
||||||
|
pub focus_reasoning: FocusReasoning,
|
||||||
|
pub eligible: Vec<EligibleBigramRow>,
|
||||||
|
pub watchlist: Vec<WatchlistBigramRow>,
|
||||||
|
pub total_bigrams: usize,
|
||||||
|
pub total_trigrams: usize,
|
||||||
|
pub hesitation_threshold_ms: f64,
|
||||||
|
pub latest_trigram_gain: Option<f64>,
|
||||||
|
pub scope_label: String,
|
||||||
|
// Engine thresholds for watchlist progress denominators:
|
||||||
|
pub min_samples_for_focus: usize, // from ngram_stats::MIN_SAMPLES_FOR_FOCUS
|
||||||
|
pub stability_streak_required: u8, // from ngram_stats::STABILITY_STREAK_REQUIRED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add field** to `StatsDashboard`: `ngram_data: Option<&'a NgramTabData>`
|
||||||
|
|
||||||
|
**Update constructor**, tab header (add `"[6] N-grams"`), footer (`[1-6]`), `render_tab()` dispatch.
|
||||||
|
|
||||||
|
**Rendering methods:**
|
||||||
|
|
||||||
|
- **`render_ngram_tab()`** — Vertical layout: focus (4 lines), lists (Min 5), summary (2 lines).
|
||||||
|
|
||||||
|
- **`render_ngram_focus()`** — Bordered "Active Focus" block.
|
||||||
|
- Line 1: target name in `colors.focused_key()` + bold
|
||||||
|
- Line 2: reasoning in `colors.text_pending()`
|
||||||
|
- When BigramWins + char_key is None: "Bigram selected (no individual char weakness found)"
|
||||||
|
- Empty state: "Complete some adaptive drills to see focus data"
|
||||||
|
|
||||||
|
- **`render_eligible_bigrams()`** — Bordered "Eligible Bigrams (N)" block.
|
||||||
|
- Header in `colors.accent()` + bold
|
||||||
|
- Rows colored by difficulty: `error()` (>1.0), `warning()` (>0.5), `success()` (<=0.5)
|
||||||
|
- Columns: `Pair Diff Err% Exp% Red Conf N`
|
||||||
|
- Narrow (<38 inner): drop Exp% and Conf
|
||||||
|
- Truncate rows to available height
|
||||||
|
- Empty state: "No bigrams meet focus criteria yet"
|
||||||
|
|
||||||
|
- **`render_watchlist_bigrams()`** — Bordered "Watchlist" block.
|
||||||
|
- Columns: `Pair Red Samples Streak`
|
||||||
|
- Samples rendered as `n/{data.min_samples_for_focus}`, Streak as `n/{data.stability_streak_required}` — denominators sourced from `NgramTabData` (engine constants), never hardcoded in UI
|
||||||
|
- All rows in `colors.warning()`
|
||||||
|
- Truncate rows to available height
|
||||||
|
- Empty state: "No approaching bigrams"
|
||||||
|
|
||||||
|
- **`render_ngram_summary()`** — Single line: scope label, bigram/trigram counts, hesitation threshold, trigram gain.
|
||||||
|
|
||||||
|
### 3. `src/main.rs` — Input handling + adapter
|
||||||
|
|
||||||
|
**`handle_stats_key()`**:
|
||||||
|
- `STATS_TAB_COUNT`: 5 → 6
|
||||||
|
- Add `KeyCode::Char('6') => app.stats_tab = 5` in both branches
|
||||||
|
|
||||||
|
**`build_ngram_tab_data(app: &App) -> NgramTabData`** — Dedicated adapter function (single point of engine→UI translation):
|
||||||
|
- Calls `select_focus_target_with_reasoning()`
|
||||||
|
- Calls `focus_eligible_bigrams()` and `watchlist_bigrams()`
|
||||||
|
- Maps engine results to `EligibleBigramRow`/`WatchlistBigramRow` by looking up additional per-bigram stats (error rate, expected rate, confidence, streak) from `app.ranked_bigram_stats` and `app.ranked_key_stats`
|
||||||
|
- Builds scope label from `app.drill_scope`
|
||||||
|
- Only called when `app.stats_tab == 5`
|
||||||
|
|
||||||
|
**`render_stats()`**: Call `build_ngram_tab_data()` when on tab 5, pass `Some(&data)` to StatsDashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add `FocusReasoning` enum and `select_focus_target_with_reasoning()` to `ngram_stats.rs`
|
||||||
|
2. Add `focus_eligible_bigrams()` and `watchlist_bigrams()` to `BigramStatsStore`
|
||||||
|
3. Add unit tests for steps 1-2
|
||||||
|
4. Add view model structs (`EligibleBigramRow`, `WatchlistBigramRow`, `NgramTabData`) and `ngram_data` field to `stats_dashboard.rs`
|
||||||
|
5. Add all rendering methods to `stats_dashboard.rs`
|
||||||
|
6. Update tab header, footer, `render_tab()` dispatch in `stats_dashboard.rs`
|
||||||
|
7. Add `build_ngram_tab_data()` adapter + update `render_stats()` in `main.rs`
|
||||||
|
8. Update `handle_stats_key()` in `main.rs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Unit Tests (in `ngram_stats.rs` test module)
|
||||||
|
|
||||||
|
**`test_focus_eligible_bigrams_gating`** — BigramStatsStore with bigrams at boundary conditions:
|
||||||
|
- sample=25, streak=3, redundancy=2.0 → eligible
|
||||||
|
- sample=15, streak=3, redundancy=2.0 → excluded (samples < 20)
|
||||||
|
- sample=25, streak=2, redundancy=2.0 → excluded (streak < 3)
|
||||||
|
- sample=25, streak=3, redundancy=1.2 → excluded (redundancy <= 1.5)
|
||||||
|
- sample=25, streak=3, redundancy=2.0, confidence=1.5 → excluded (difficulty <= 0)
|
||||||
|
|
||||||
|
**`test_focus_eligible_bigrams_ordering_and_tiebreak`** — 3 eligible bigrams: two with same difficulty but different redundancy, one with lower difficulty. Verify sorted by (difficulty desc, redundancy desc, key lexical asc).
|
||||||
|
|
||||||
|
**`test_watchlist_bigrams_gating`** — Bigrams at boundary:
|
||||||
|
- Fully eligible (sample=25, streak=3) → excluded (goes to eligible list)
|
||||||
|
- High redundancy, low samples (sample=10) → included
|
||||||
|
- High redundancy, low streak (sample=25, streak=1) → included
|
||||||
|
- Low redundancy (1.3) → excluded
|
||||||
|
- Very few samples (sample=2) → excluded (< 3 noise floor)
|
||||||
|
|
||||||
|
**`test_watchlist_bigrams_ordering_and_tiebreak`** — 3 watchlist entries: two with same redundancy. Verify sorted by (redundancy desc, key lexical asc).
|
||||||
|
|
||||||
|
**`test_select_focus_with_reasoning_bigram_wins`** — Bigram difficulty > char difficulty * 0.8. Returns `BigramWins` with correct values and `char_key: Some(ch)`.
|
||||||
|
|
||||||
|
**`test_select_focus_with_reasoning_char_wins`** — Char difficulty high, bigram < threshold. Returns `CharWins` with `bigram_best` populated.
|
||||||
|
|
||||||
|
**`test_select_focus_with_reasoning_no_bigrams`** — No eligible bigrams. Returns `NoBigrams`.
|
||||||
|
|
||||||
|
**`test_select_focus_with_reasoning_bigram_only`** — No focused char, bigram exists. Returns `BigramWins` with `char_key: None`.
|
||||||
|
|
||||||
|
### Build & Existing Tests
|
||||||
|
- `cargo build` — no compile errors
|
||||||
|
- `cargo test` — all existing + new tests pass
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
- Navigate to Statistics → press [6] → see N-grams tab
|
||||||
|
- Tab/BackTab cycles through all 6 tabs
|
||||||
|
- With no drill history: empty states shown for all panels
|
||||||
|
- After several adaptive drills: eligible bigrams appear with plausible data
|
||||||
|
- Scope label reflects current drill scope
|
||||||
|
- Verify layout at 80x24 terminal size — confirm column drop at narrow widths keeps header/data aligned
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# Plan: Bigram Metrics Overhaul — Error Anomaly & Speed Anomaly
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current bigram metrics use `difficulty = (1 - confidence) * redundancy` to gate eligibility and focus. This is fundamentally broken: when a user types faster than target WPM (`confidence > 1.0`), difficulty goes negative — even for bigrams with 100% error rate. The root cause is that "confidence" (a speed-vs-target ratio) and "redundancy" (an error-rate ratio) are conflated into a single metric that can cancel out genuine problems.
|
||||||
|
|
||||||
|
This overhaul replaces the conflated system with two orthogonal anomaly metrics:
|
||||||
|
- **`error_anomaly`** — how much worse a bigram's error rate is compared to what's expected from its constituent characters (same math as current `redundancy_score`, reframed as a percentage)
|
||||||
|
- **`speed_anomaly`** — how much slower a bigram transition is compared to the user's normal speed typing the second character (user-relative, no target WPM dependency)
|
||||||
|
|
||||||
|
Both are displayed as percentages where positive = worse than expected. The UI shows two side-by-side columns, one per anomaly type, with confirmed problems highlighted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persistence / Migration
|
||||||
|
|
||||||
|
**NgramStat is NOT persisted to disk.** N-gram stores are rebuilt from drill history on every startup (see `json_store.rs:104` comment: "N-gram stats are not included — they are always rebuilt from drill history", and `app.rs:1152` `rebuild_ngram_stats()`). The stores are never saved via `save_data()` — only `profile`, `key_stats`, `ranked_key_stats`, and `drill_history` are persisted.
|
||||||
|
|
||||||
|
Therefore:
|
||||||
|
- No serde migration, `#[serde(alias)]`, or backward-compat handling is needed for NgramStat field renames/removals
|
||||||
|
- `#[serde(default)]` annotations on NgramStat fields are vestigial (the derive exists for in-memory cloning, not disk persistence) but harmless to leave
|
||||||
|
- The `Serialize`/`Deserialize` derives on NgramStat can stay (used by BigramStatsStore/TrigramStatsStore types which derive them transitively, though the stores themselves are also not persisted)
|
||||||
|
|
||||||
|
**KeyStat IS persisted** — `confidence` on KeyStat is NOT being changed (used by skill_tree progression). No migration needed there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. `src/engine/ngram_stats.rs` — Metrics engine overhaul
|
||||||
|
|
||||||
|
**NgramStat struct** (line 34):
|
||||||
|
- Remove `confidence: f64` field
|
||||||
|
- Rename `redundancy_streak: u8` → `error_anomaly_streak: u8`
|
||||||
|
- Add `speed_anomaly_streak: u8` with `#[serde(default)]`
|
||||||
|
- **Preserved fields** (explicitly unchanged): `filtered_time_ms`, `best_time_ms`, `sample_count`, `error_count`, `hesitation_count`, `recent_times`, `recent_correct`, `last_seen_drill_index` — all remain and continue to be updated by `update_stat()`
|
||||||
|
|
||||||
|
**`update_stat()`** (line 65):
|
||||||
|
- Remove `confidence = target_time_ms / stat.filtered_time_ms` computation (line 82)
|
||||||
|
- Remove `target_time_ms` parameter (no longer needed)
|
||||||
|
- **Keep** `hesitation` parameter and `drill_index` parameter — these update `hesitation_count` (line 72) and `last_seen_drill_index` (line 66) which are used by trigram pruning and other downstream logic
|
||||||
|
- New signature (module-private, matching current visibility): `fn update_stat(stat: &mut NgramStat, time_ms: f64, correct: bool, hesitation: bool, drill_index: u32)`
|
||||||
|
- All other field updates remain identical (EMA on filtered_time_ms, best_time_ms, recent_times, recent_correct, error_count, sample_count)
|
||||||
|
|
||||||
|
**Constants** (lines 10-16):
|
||||||
|
- Rename `STABILITY_THRESHOLD` → `ERROR_ANOMALY_RATIO_THRESHOLD` (value stays 1.5)
|
||||||
|
- Rename `STABILITY_STREAK_REQUIRED` → `ANOMALY_STREAK_REQUIRED` (value stays 3)
|
||||||
|
- Rename `WATCHLIST_MIN_SAMPLES` → `ANOMALY_MIN_SAMPLES` (value stays 3)
|
||||||
|
- Add `SPEED_ANOMALY_PCT_THRESHOLD: f64 = 50.0` (50% slower than expected)
|
||||||
|
- Add `MIN_CHAR_SAMPLES_FOR_SPEED: usize = 10` (EMA alpha=0.1 needs ~10 samples for initial value to decay to ~35% influence; 5 samples still has ~59% initial-value bias, too noisy for baseline)
|
||||||
|
- Remove `DEFAULT_TARGET_CPM` (no longer used by update_stat or stores)
|
||||||
|
|
||||||
|
**`BigramStatsStore` struct** (line 102):
|
||||||
|
- Remove `target_cpm: f64` field and `default_target_cpm()` helper
|
||||||
|
- `BigramStatsStore::update()` (line 114): Remove `target_time_ms` calculation. Pass-through to `update_stat()` without it.
|
||||||
|
|
||||||
|
**`TrigramStatsStore` struct** (line 285):
|
||||||
|
- Remove `target_cpm: f64` field
|
||||||
|
- `TrigramStatsStore::update()` (line 293): Remove `target_time_ms` calculation. Pass-through to `update_stat()` without it.
|
||||||
|
|
||||||
|
**Remove `get_confidence()`** methods on both stores (lines 121, 300) — they read the deleted `confidence` field. Both are `#[allow(dead_code)]` already.
|
||||||
|
|
||||||
|
**Rename `redundancy_score()`** → **`error_anomaly_ratio()`** (line 132):
|
||||||
|
- Same math internally, just renamed. Returns `e_ab / expected_ab`.
|
||||||
|
|
||||||
|
**New methods on `BigramStatsStore`**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Error anomaly as percentage: (ratio - 1.0) * 100
|
||||||
|
/// Returns None if bigram has no stats.
|
||||||
|
pub fn error_anomaly_pct(&self, key: &BigramKey, char_stats: &KeyStatsStore) -> Option<f64> {
|
||||||
|
let _stat = self.stats.get(key)?;
|
||||||
|
let ratio = self.error_anomaly_ratio(key, char_stats);
|
||||||
|
Some((ratio - 1.0) * 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speed anomaly: % slower than user types char_b in isolation.
|
||||||
|
/// Compares bigram filtered_time_ms to char_b's filtered_time_ms.
|
||||||
|
/// Returns None if bigram has no stats or char_b has < MIN_CHAR_SAMPLES_FOR_SPEED samples.
|
||||||
|
pub fn speed_anomaly_pct(&self, key: &BigramKey, char_stats: &KeyStatsStore) -> Option<f64> {
|
||||||
|
let stat = self.stats.get(key)?;
|
||||||
|
let char_b_stat = char_stats.stats.get(&key.0[1])?;
|
||||||
|
if char_b_stat.sample_count < MIN_CHAR_SAMPLES_FOR_SPEED { return None; }
|
||||||
|
let ratio = stat.filtered_time_ms / char_b_stat.filtered_time_ms;
|
||||||
|
Some((ratio - 1.0) * 100.0)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rename `update_redundancy_streak()`** → **`update_error_anomaly_streak()`** (line 142):
|
||||||
|
- Same logic, uses renamed constant and renamed field
|
||||||
|
|
||||||
|
**New `update_speed_anomaly_streak()`**:
|
||||||
|
- Same pattern as error streak: call `speed_anomaly_pct()`, compare against `SPEED_ANOMALY_PCT_THRESHOLD`
|
||||||
|
- If `speed_anomaly_pct()` returns `None` (char baseline unavailable/under-sampled), **hold previous streak value** — don't reset or increment. The bigram simply can't be evaluated for speed yet.
|
||||||
|
- Requires both bigram samples >= `ANOMALY_MIN_SAMPLES` AND char_b samples >= `MIN_CHAR_SAMPLES_FOR_SPEED` before any streak update occurs.
|
||||||
|
|
||||||
|
**New `BigramAnomaly` struct**:
|
||||||
|
```rust
|
||||||
|
pub struct BigramAnomaly {
|
||||||
|
pub key: BigramKey,
|
||||||
|
pub anomaly_pct: f64,
|
||||||
|
pub sample_count: usize,
|
||||||
|
pub streak: u8,
|
||||||
|
pub confirmed: bool, // streak >= ANOMALY_STREAK_REQUIRED && samples >= MIN_SAMPLES_FOR_FOCUS
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace `focus_eligible_bigrams()` + `watchlist_bigrams()`** with:
|
||||||
|
- **`error_anomaly_bigrams(&self, char_stats: &KeyStatsStore, unlocked: &[char]) -> Vec<BigramAnomaly>`** — All bigrams with `error_anomaly_ratio > ERROR_ANOMALY_RATIO_THRESHOLD` and `samples >= ANOMALY_MIN_SAMPLES`, sorted by anomaly_pct desc. Each entry's `confirmed` flag = `error_anomaly_streak >= ANOMALY_STREAK_REQUIRED && samples >= MIN_SAMPLES_FOR_FOCUS`.
|
||||||
|
- **`speed_anomaly_bigrams(&self, char_stats: &KeyStatsStore, unlocked: &[char]) -> Vec<BigramAnomaly>`** — All bigrams where `speed_anomaly_pct() > Some(SPEED_ANOMALY_PCT_THRESHOLD)` and `samples >= ANOMALY_MIN_SAMPLES`, sorted by anomaly_pct desc. Same confirmed logic using `speed_anomaly_streak`.
|
||||||
|
|
||||||
|
**Replace `weakest_bigram()`** with **`worst_confirmed_anomaly()`**:
|
||||||
|
- Takes `char_stats: &KeyStatsStore` and `unlocked: &[char]`
|
||||||
|
- Collects all confirmed error anomalies and confirmed speed anomalies into a single candidate pool
|
||||||
|
- Each candidate is `(BigramKey, anomaly_pct, anomaly_type)` where type is `Error` or `Speed`
|
||||||
|
- **Dedup per bigram**: If a bigram appears in both error and speed lists, keep whichever has higher anomaly_pct (or prefer error on tie)
|
||||||
|
- Return the single bigram with highest anomaly_pct, or None if no confirmed anomalies
|
||||||
|
- This eliminates ambiguity about same-bigram-in-both-lists — each bigram gets at most one candidacy
|
||||||
|
|
||||||
|
**Update `FocusReasoning` enum** (line 471):
|
||||||
|
Current variants are: `BigramWins { bigram_difficulty, char_difficulty, char_key }`, `CharWins { char_key, char_difficulty, bigram_best }`, `NoBigrams { char_key }`, `Fallback`.
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```rust
|
||||||
|
pub enum FocusReasoning {
|
||||||
|
BigramWins {
|
||||||
|
bigram_anomaly_pct: f64,
|
||||||
|
anomaly_type: AnomalyType, // Error or Speed
|
||||||
|
char_key: Option<char>,
|
||||||
|
},
|
||||||
|
CharWins {
|
||||||
|
char_key: char,
|
||||||
|
bigram_best: Option<(BigramKey, f64)>,
|
||||||
|
},
|
||||||
|
NoBigrams {
|
||||||
|
char_key: char,
|
||||||
|
},
|
||||||
|
Fallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum AnomalyType { Error, Speed }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update `select_focus_target_with_reasoning()`** (line 489):
|
||||||
|
- Call `worst_confirmed_anomaly()` instead of `weakest_bigram()`
|
||||||
|
- **Focus priority rule**: Any confirmed bigram anomaly always wins over char focus. Rationale: char focus is the default skill-tree progression mechanism; confirmed bigram anomalies are exceptional problems that survived a conservative gate (3 consecutive drills above threshold + 20 samples). No cross-scale score comparison needed — confirmation itself is the signal.
|
||||||
|
- When no confirmed bigram anomalies exist, fall back to char focus as before.
|
||||||
|
- Anomaly_pct is unbounded (e.g. 200% = 3x worse than expected) — this is fine because confirmation gating prevents transient spikes from stealing focus, and the value is only used for ranking among confirmed anomalies, not for threshold comparison against char scores.
|
||||||
|
|
||||||
|
**Update `select_focus_target()`** (line 545):
|
||||||
|
- Same delegation change, pass `char_stats` through
|
||||||
|
|
||||||
|
### 2. `src/app.rs` — Streak update call sites & store cleanup
|
||||||
|
|
||||||
|
**`target_cpm` removal checklist** (complete audit of all references):
|
||||||
|
|
||||||
|
| Location | What | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| `ngram_stats.rs:105-106` | `BigramStatsStore.target_cpm` field + serde attr | Remove field |
|
||||||
|
| `ngram_stats.rs:288-289` | `TrigramStatsStore.target_cpm` field + serde attr | Remove field |
|
||||||
|
| `ngram_stats.rs:109-111` | `fn default_target_cpm()` helper | Remove function |
|
||||||
|
| `ngram_stats.rs:11` | `const DEFAULT_TARGET_CPM` | Remove constant |
|
||||||
|
| `ngram_stats.rs:115` | `BigramStatsStore::update()` target_time_ms calc | Remove line |
|
||||||
|
| `ngram_stats.rs:294` | `TrigramStatsStore::update()` target_time_ms calc | Remove line |
|
||||||
|
| `ngram_stats.rs:1386` | Test helper `bigram_stats.target_cpm = DEFAULT_TARGET_CPM` | Remove line |
|
||||||
|
| `app.rs:1155` | `self.bigram_stats.target_cpm = ...` in rebuild_ngram_stats | Remove line |
|
||||||
|
| `app.rs:1157` | `self.ranked_bigram_stats.target_cpm = ...` | Remove line |
|
||||||
|
| `app.rs:1159` | `self.trigram_stats.target_cpm = ...` | Remove line |
|
||||||
|
| `app.rs:1161` | `self.ranked_trigram_stats.target_cpm = ...` | Remove line |
|
||||||
|
| `key_stats.rs:37` | `KeyStatsStore.target_cpm` | **KEEP** — used by `update_key()` for char confidence |
|
||||||
|
| `app.rs:330,332,609,611,1320,1322,1897-1898,1964-1965` | `key_stats.target_cpm = ...` | **KEEP** — KeyStatsStore still uses target_cpm |
|
||||||
|
| `config.rs:142` | `fn target_cpm()` | **KEEP** — still used by KeyStatsStore |
|
||||||
|
|
||||||
|
**At all 6 `update_redundancy_streak` call sites** (lines 899, 915, 1044, 1195, 1212, plus rebuild):
|
||||||
|
- Rename to `update_error_anomaly_streak()`
|
||||||
|
- Add parallel call to `update_speed_anomaly_streak()` passing the appropriate `&KeyStatsStore`:
|
||||||
|
- `&self.key_stats` for `self.bigram_stats` updates
|
||||||
|
- `&self.ranked_key_stats` for `self.ranked_bigram_stats` updates
|
||||||
|
|
||||||
|
**Update `select_focus_target` calls** in `generate_drill` (line ~663) and drill header in main.rs:
|
||||||
|
- Add `ranked_key_stats` parameter (already available at call sites)
|
||||||
|
|
||||||
|
### 3. `src/ui/components/stats_dashboard.rs` — Two-column anomaly display
|
||||||
|
|
||||||
|
**Replace data structs**:
|
||||||
|
- Remove `EligibleBigramRow` (line 20) and `WatchlistBigramRow` (line 30)
|
||||||
|
- Add single `AnomalyBigramRow`:
|
||||||
|
```rust
|
||||||
|
pub struct AnomalyBigramRow {
|
||||||
|
pub pair: String,
|
||||||
|
pub anomaly_pct: f64,
|
||||||
|
pub sample_count: usize,
|
||||||
|
pub streak: u8,
|
||||||
|
pub confirmed: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace `NgramTabData` fields** (line 39):
|
||||||
|
- Remove `eligible_bigrams: Vec<EligibleBigramRow>` and `watchlist_bigrams: Vec<WatchlistBigramRow>`
|
||||||
|
- Add `error_anomalies: Vec<AnomalyBigramRow>` and `speed_anomalies: Vec<AnomalyBigramRow>`
|
||||||
|
|
||||||
|
**Replace render functions**:
|
||||||
|
- Remove `render_eligible_bigrams()` (line 1473) and `render_watchlist_bigrams()` (line 1560)
|
||||||
|
- Add `render_error_anomalies()` and `render_speed_anomalies()`
|
||||||
|
- Each renders a table with columns: `Pair | Anomaly% | Samples | Streak`
|
||||||
|
- Confirmed rows (`.confirmed == true`) use highlight/accent color
|
||||||
|
- Unconfirmed rows use dimmer/warning color
|
||||||
|
- Column titles: `" Error Anomalies ({}) "` and `" Speed Anomalies ({}) "`
|
||||||
|
- Empty states: `" No error anomalies detected"` / `" No speed anomalies detected"`
|
||||||
|
|
||||||
|
**Narrow-width adaptation**:
|
||||||
|
- Wide mode (width >= 60): 50/50 horizontal split, full columns `Pair | Anomaly% | Samples | Streak`
|
||||||
|
- Narrow mode (width < 60): Stack vertically (error on top, speed below). Compact columns: `Pair | Anom% | Smp`
|
||||||
|
- Drop `Streak` column
|
||||||
|
- Abbreviate headers
|
||||||
|
- This mirrors the existing pattern used by the current eligible/watchlist tables
|
||||||
|
- **Vertical space budget** (stacked mode): Each panel gets a minimum of 3 data rows (+ 1 header + 1 border = 5 lines). Remaining vertical space is split evenly. If total available height < 10 lines, show only error anomalies panel (speed anomalies are less actionable). This prevents one panel from starving the other.
|
||||||
|
|
||||||
|
**Update `render_ngram_tab()`** (line 1308):
|
||||||
|
- Split the bottom section into two horizontal chunks (50/50)
|
||||||
|
- Left: `render_error_anomalies()`, Right: `render_speed_anomalies()`
|
||||||
|
- On narrow terminals (width < 60), stack vertically instead
|
||||||
|
|
||||||
|
### 4. `src/main.rs` — Bridge adapter
|
||||||
|
|
||||||
|
**`build_ngram_tab_data()`** (~line 2232):
|
||||||
|
- Call `error_anomaly_bigrams()` and `speed_anomaly_bigrams()` instead of old functions
|
||||||
|
- Map `BigramAnomaly` → `AnomalyBigramRow`
|
||||||
|
- Pass `&ranked_key_stats` for speed anomaly computation
|
||||||
|
|
||||||
|
**Drill header** (~line 1133): `select_focus_target()` signature change (adding `char_stats` param) will require updating the call here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. **`src/engine/ngram_stats.rs`** — Core metrics overhaul (remove confidence from NgramStat, remove target_cpm from stores, add two anomaly systems, new query functions)
|
||||||
|
2. **`src/app.rs`** — Update streak calls, remove target_cpm initialization, update select_focus_target calls
|
||||||
|
3. **`src/ui/components/stats_dashboard.rs`** — Two-column anomaly display, new data structs, narrow-width adaptation
|
||||||
|
4. **`src/main.rs`** — Bridge adapter, select_focus_target call update
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Updates
|
||||||
|
|
||||||
|
- **Rewrite `test_focus_eligible_bigrams_gating`** → `test_error_anomaly_bigrams`: Test that bigrams above error threshold with sufficient samples appear; confirmed flag set correctly based on streak + samples
|
||||||
|
- **Rewrite `test_watchlist_bigrams_gating`** → split into `test_error_anomaly_confirmation` and `test_speed_anomaly_bigrams`
|
||||||
|
- **New `test_speed_anomaly_pct`**: Verify speed anomaly calculation against mock char stats; verify None returned when char_b has < MIN_CHAR_SAMPLES_FOR_SPEED (10) samples; verify correct result at exactly 10 samples (boundary)
|
||||||
|
- **New `test_speed_anomaly_streak_holds_when_char_unavailable`**: Verify streak is not reset when char baseline is insufficient (samples 0, 5, 9 — all below threshold)
|
||||||
|
- **New `test_speed_anomaly_borderline_baseline`**: Verify behavior at sample count transitions (9 → None, 10 → Some) and that early-session noise at exactly 10 samples produces reasonable anomaly values (not extreme outliers from EMA initialization bias)
|
||||||
|
- **Update `test_weakest_bigram*`** → `test_worst_confirmed_anomaly*`: Verify it picks highest anomaly across both types, deduplicates per bigram preferring higher pct (error on tie), returns None when nothing confirmed
|
||||||
|
- **Update focus reasoning tests**: Update `FocusReasoning` variants to new names (`BigramWins` now carries `anomaly_pct` and `anomaly_type` instead of `bigram_difficulty`)
|
||||||
|
- **Update `build_ngram_tab_data_maps_fields_correctly`**: Check `error_anomalies`/`speed_anomalies` fields with `AnomalyBigramRow` assertions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `cargo build` — no compile errors
|
||||||
|
2. `cargo test` — all tests pass
|
||||||
|
3. Manual: N-grams tab shows two columns (Error Anomalies / Speed Anomalies)
|
||||||
|
4. Manual: Confirmed problem bigrams appear highlighted; unconfirmed appear dimmer
|
||||||
|
5. Manual: Drill header still shows `Focus: "th"` for bigram focus
|
||||||
|
6. Manual: Bigrams previously stuck on watchlist due to negative difficulty now appear as confirmed error anomalies
|
||||||
|
7. Manual: On narrow terminal (< 60 cols), columns stack vertically with compact headers
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# Plan: EMA Error Decay + Integrated Bigram/Char Focus Generation
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Two problems with the current n-gram focus system:
|
||||||
|
|
||||||
|
1. **Focus stickiness**: Bigram anomaly uses cumulative `(error_count+1)/(sample_count+2)` Laplace smoothing. A bigram with 20 errors / 25 samples would need ~54 consecutive correct strokes to drop below the 1.5x threshold. Once confirmed, a bigram dominates focus for many drills even as the user visibly improves, while worse bigrams can't take over.
|
||||||
|
|
||||||
|
2. **Post-processing bigram focus causes repetition**: When a bigram is in focus, `apply_bigram_focus()` post-processes finished text by replacing 40% of words with dictionary words containing the bigram. This selects randomly from candidates with no duplicate tracking, causing repeated words. It also means the bigram doesn't influence the actual word selection — it's bolted on after generation and overrides the focused char (the weakest char gets replaced by bigram[0]).
|
||||||
|
|
||||||
|
This plan addresses both: (A) switch error rate to EMA so anomalies respond to recent performance, and (B) integrate bigram focus directly into the word selection algorithm alongside char focus, enabling both to be active simultaneously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part A: EMA Error Rate Decay
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
Add an `error_rate_ema: f64` field to both `NgramStat` and `KeyStat`, updated via exponential moving average on each keystroke (same pattern as existing `filtered_time_ms`). Use this EMA for all anomaly computations instead of cumulative `(error_count+1)/(sample_count+2)`.
|
||||||
|
|
||||||
|
Both bigram AND char error rates must use EMA — `error_anomaly_ratio` divides one by the other, so asymmetric decay would distort the comparison.
|
||||||
|
|
||||||
|
**Alpha = 0.1** (same as timing EMA). Half-life ~7 samples. A bigram at 30% error rate recovering with all-correct strokes: drops below 1.5x threshold after ~15 correct (~2 drills). This is responsive without being twitchy.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
#### `src/engine/ngram_stats.rs`
|
||||||
|
|
||||||
|
**NgramStat struct** (line 34):
|
||||||
|
- Add `error_rate_ema: f64` with `#[serde(default = "default_error_rate_ema")]` and default value `0.5`
|
||||||
|
- Add `fn default_error_rate_ema() -> f64 { 0.5 }` (Laplace-equivalent neutral prior)
|
||||||
|
- Remove `recent_correct: Vec<bool>` — superseded by EMA and never read
|
||||||
|
|
||||||
|
**`update_stat()`** (line 67):
|
||||||
|
- After existing `error_count` increment, add EMA update:
|
||||||
|
```rust
|
||||||
|
let error_signal = if correct { 0.0 } else { 1.0 };
|
||||||
|
if stat.sample_count == 1 {
|
||||||
|
stat.error_rate_ema = error_signal;
|
||||||
|
} else {
|
||||||
|
stat.error_rate_ema = EMA_ALPHA * error_signal + (1.0 - EMA_ALPHA) * stat.error_rate_ema;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Remove `recent_correct` push/trim logic (lines 89-92)
|
||||||
|
- Keep `error_count` and `sample_count` (needed for gating thresholds and display)
|
||||||
|
|
||||||
|
**`smoothed_error_rate_raw()`** (line 95): Remove. After `smoothed_error_rate()` on both BigramStatsStore and TrigramStatsStore switch to `error_rate_ema`, this function has no callers.
|
||||||
|
|
||||||
|
**`BigramStatsStore::smoothed_error_rate()`** (line 120): Change to return `stat.error_rate_ema` instead of `smoothed_error_rate_raw(stat.error_count, stat.sample_count)`.
|
||||||
|
|
||||||
|
**`TrigramStatsStore::smoothed_error_rate()`** (line 333): Same change — return `stat.error_rate_ema`.
|
||||||
|
|
||||||
|
**`error_anomaly_ratio()`** (line 123): No changes needed — it calls `self.smoothed_error_rate()` and `char_stats.smoothed_error_rate()`, which now both return EMA values.
|
||||||
|
|
||||||
|
**Default for NgramStat** (line 50): Set `error_rate_ema: 0.5` (neutral — same as Laplace `(0+1)/(0+2)`).
|
||||||
|
|
||||||
|
#### `src/engine/key_stats.rs`
|
||||||
|
|
||||||
|
**KeyStat struct** (line 7):
|
||||||
|
- Add `error_rate_ema: f64` with `#[serde(default = "default_error_rate_ema")]` and default value `0.5`
|
||||||
|
- Add `fn default_error_rate_ema() -> f64 { 0.5 }` helper
|
||||||
|
- **Note**: KeyStat IS persisted to disk. The `#[serde(default)]` ensures backward compat — existing data without the field gets 0.5.
|
||||||
|
|
||||||
|
**`update_key()`** (line 50) — called for correct strokes:
|
||||||
|
- Add EMA update: `stat.error_rate_ema = if stat.total_count == 1 { 0.0 } else { EMA_ALPHA * 0.0 + (1.0 - EMA_ALPHA) * stat.error_rate_ema }`
|
||||||
|
- Use `total_count` (already incremented on the line before) to detect first sample
|
||||||
|
|
||||||
|
**`update_key_error()`** (line 83) — called for error strokes:
|
||||||
|
- Add EMA update: `stat.error_rate_ema = if stat.total_count == 1 { 1.0 } else { EMA_ALPHA * 1.0 + (1.0 - EMA_ALPHA) * stat.error_rate_ema }`
|
||||||
|
|
||||||
|
**`smoothed_error_rate()`** (line 90): Change to return `stat.error_rate_ema` (or 0.5 for missing keys).
|
||||||
|
|
||||||
|
#### `src/app.rs`
|
||||||
|
|
||||||
|
**`rebuild_ngram_stats()`** (line 1155):
|
||||||
|
- Reset `error_rate_ema` to `0.5` alongside `error_count` and `total_count` for KeyStat stores (lines 1165-1172)
|
||||||
|
- NgramStat stores already reset to `Default` which has `error_rate_ema: 0.5`
|
||||||
|
- The replay loop (line 1177) naturally rebuilds EMA by calling `update_stat()` and `update_key()`/`update_key_error()` in order
|
||||||
|
|
||||||
|
No other app.rs changes needed — the streak update and focus selection code reads through `error_anomaly_ratio()` which now uses EMA values transparently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part B: Integrated Bigram + Char Focus Generation
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
Replace the exclusive `FocusTarget` enum (either char OR bigram) with a `FocusSelection` struct that carries both independently. The weakest char comes from skill_tree progression; the worst bigram anomaly comes from the anomaly system. Both feed into the `PhoneticGenerator` simultaneously. Remove `apply_bigram_focus()` post-processing entirely.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
#### `src/engine/ngram_stats.rs` — Focus selection
|
||||||
|
|
||||||
|
**Replace `FocusTarget` enum** (line 510):
|
||||||
|
```rust
|
||||||
|
// Old
|
||||||
|
pub enum FocusTarget { Char(char), Bigram(BigramKey) }
|
||||||
|
|
||||||
|
// New
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct FocusSelection {
|
||||||
|
pub char_focus: Option<char>,
|
||||||
|
pub bigram_focus: Option<(BigramKey, f64, AnomalyType)>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace `FocusReasoning` enum** (line 523):
|
||||||
|
```rust
|
||||||
|
// Old
|
||||||
|
pub enum FocusReasoning {
|
||||||
|
BigramWins { bigram_anomaly_pct: f64, anomaly_type: AnomalyType, char_key: Option<char> },
|
||||||
|
CharWins { char_key: char, bigram_best: Option<(BigramKey, f64)> },
|
||||||
|
NoBigrams { char_key: char },
|
||||||
|
Fallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
// New — reasoning is now just the selection itself (both fields self-describe)
|
||||||
|
// FocusReasoning is removed; FocusSelection carries all needed info.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Simplify `select_focus_target_with_reasoning()`** → **`select_focus()`**:
|
||||||
|
```rust
|
||||||
|
pub fn select_focus(
|
||||||
|
skill_tree: &SkillTree,
|
||||||
|
scope: DrillScope,
|
||||||
|
ranked_key_stats: &KeyStatsStore,
|
||||||
|
ranked_bigram_stats: &BigramStatsStore,
|
||||||
|
) -> FocusSelection {
|
||||||
|
let unlocked = skill_tree.unlocked_keys(scope);
|
||||||
|
let char_focus = skill_tree.focused_key(scope, ranked_key_stats);
|
||||||
|
let bigram_focus = ranked_bigram_stats.worst_confirmed_anomaly(ranked_key_stats, &unlocked);
|
||||||
|
FocusSelection { char_focus, bigram_focus }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove `select_focus_target()` and `select_focus_target_with_reasoning()` — replaced by `select_focus()`.
|
||||||
|
|
||||||
|
#### `src/generator/mod.rs` — Trait update
|
||||||
|
|
||||||
|
**Update `TextGenerator` trait** (line 14):
|
||||||
|
```rust
|
||||||
|
pub trait TextGenerator {
|
||||||
|
fn generate(
|
||||||
|
&mut self,
|
||||||
|
filter: &CharFilter,
|
||||||
|
focused_char: Option<char>,
|
||||||
|
focused_bigram: Option<[char; 2]>,
|
||||||
|
word_count: usize,
|
||||||
|
) -> String;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `src/generator/phonetic.rs` — Integrated word selection
|
||||||
|
|
||||||
|
**`generate()` method** — rewrite word selection with tiered approach:
|
||||||
|
|
||||||
|
Note: `find_matching(filter, None)` is used (not `focused_char`) because we do our own tiering below. `find_matching` returns ALL words matching the CharFilter — the `focused` param only sorts, never filters — but passing `None` avoids an unnecessary sort we'd discard anyway.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn generate(
|
||||||
|
&mut self,
|
||||||
|
filter: &CharFilter,
|
||||||
|
focused_char: Option<char>,
|
||||||
|
focused_bigram: Option<[char; 2]>,
|
||||||
|
word_count: usize,
|
||||||
|
) -> String {
|
||||||
|
let matching_words: Vec<String> = self.dictionary
|
||||||
|
.find_matching(filter, None) // no char-sort; we tier ourselves
|
||||||
|
.iter().map(|s| s.to_string()).collect();
|
||||||
|
let use_real_words = matching_words.len() >= MIN_REAL_WORDS;
|
||||||
|
|
||||||
|
// Pre-categorize words into tiers for real-word mode
|
||||||
|
let bigram_str = focused_bigram.map(|b| format!("{}{}", b[0], b[1]));
|
||||||
|
let focus_char_lower = focused_char.filter(|ch| ch.is_ascii_lowercase());
|
||||||
|
|
||||||
|
let (bigram_indices, char_indices, other_indices) = if use_real_words {
|
||||||
|
let mut bi = Vec::new();
|
||||||
|
let mut ci = Vec::new();
|
||||||
|
let mut oi = Vec::new();
|
||||||
|
for (i, w) in matching_words.iter().enumerate() {
|
||||||
|
if bigram_str.as_ref().is_some_and(|b| w.contains(b.as_str())) {
|
||||||
|
bi.push(i);
|
||||||
|
} else if focus_char_lower.is_some_and(|ch| w.contains(ch)) {
|
||||||
|
ci.push(i);
|
||||||
|
} else {
|
||||||
|
oi.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(bi, ci, oi)
|
||||||
|
} else {
|
||||||
|
(vec![], vec![], vec![])
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut words: Vec<String> = Vec::new();
|
||||||
|
let mut recent: Vec<String> = Vec::new(); // anti-repeat window
|
||||||
|
|
||||||
|
for _ in 0..word_count {
|
||||||
|
if use_real_words {
|
||||||
|
let word = self.pick_tiered_word(
|
||||||
|
&matching_words,
|
||||||
|
&bigram_indices,
|
||||||
|
&char_indices,
|
||||||
|
&other_indices,
|
||||||
|
&recent,
|
||||||
|
);
|
||||||
|
recent.push(word.clone());
|
||||||
|
if recent.len() > 4 { recent.remove(0); }
|
||||||
|
words.push(word);
|
||||||
|
} else {
|
||||||
|
let word = self.generate_phonetic_word(
|
||||||
|
filter, focused_char, focused_bigram,
|
||||||
|
);
|
||||||
|
words.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
words.join(" ")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New `pick_tiered_word()` method**:
|
||||||
|
```rust
|
||||||
|
fn pick_tiered_word(
|
||||||
|
&mut self,
|
||||||
|
all_words: &[String],
|
||||||
|
bigram_indices: &[usize],
|
||||||
|
char_indices: &[usize],
|
||||||
|
other_indices: &[usize],
|
||||||
|
recent: &[String],
|
||||||
|
) -> String {
|
||||||
|
// Tier selection probabilities:
|
||||||
|
// Both available: 40% bigram, 30% char, 30% other
|
||||||
|
// Only bigram: 50% bigram, 50% other
|
||||||
|
// Only char: 70% char, 30% other (matches current behavior)
|
||||||
|
// Neither: 100% other
|
||||||
|
//
|
||||||
|
// Try up to 6 times to avoid repeating a recent word.
|
||||||
|
for _ in 0..6 {
|
||||||
|
let tier = self.select_tier(bigram_indices, char_indices, other_indices);
|
||||||
|
let idx = tier[self.rng.gen_range(0..tier.len())];
|
||||||
|
let word = &all_words[idx];
|
||||||
|
if !recent.contains(word) {
|
||||||
|
return word.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: accept any non-recent word from full pool
|
||||||
|
let idx = self.rng.gen_range(0..all_words.len());
|
||||||
|
all_words[idx].clone()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`select_tier()` helper**: Returns reference to the tier to sample from based on availability and probability roll. Only considers a tier "available" if it has >= 2 words (prevents unavoidable repeats when a tier has just 1 word and the anti-repeat window rejects it). Falls through to the next tier when the selected tier is too small.
|
||||||
|
|
||||||
|
**`try_generate_word()` / `generate_phonetic_word()`** — add bigram awareness for Markov fallback:
|
||||||
|
- Accept `focused_bigram: Option<[char; 2]>` parameter
|
||||||
|
- Only attempt bigram forcing when both chars pass the CharFilter (avoids pathological starts when bigram chars are rare/unavailable in current filter scope)
|
||||||
|
- When eligible: 30% chance to start word with bigram[0] and force bigram[1] as second char, then continue Markov chain from `[' ', bigram[0], bigram[1]]` prefix
|
||||||
|
- Falls back to existing focused_char logic otherwise
|
||||||
|
|
||||||
|
#### `src/generator/code_syntax.rs` + `src/generator/passage.rs`
|
||||||
|
|
||||||
|
Add `_focused_bigram: Option<[char; 2]>` parameter to their `generate()` signatures (ignored, matching trait).
|
||||||
|
|
||||||
|
#### `src/app.rs` — Pipeline update
|
||||||
|
|
||||||
|
**`generate_text()`** (line 653):
|
||||||
|
- Call `select_focus()` (new function) instead of `select_focus_target()`
|
||||||
|
- Extract `focused_char` from `selection.char_focus` (the actual weakest char)
|
||||||
|
- Extract `focused_bigram` from `selection.bigram_focus.map(|(k, _, _)| k.0)`
|
||||||
|
- Pass both to `generator.generate(filter, focused_char, focused_bigram, word_count)`
|
||||||
|
- **Remove** the `apply_bigram_focus()` call (lines 784-787)
|
||||||
|
- Post-processing passes (capitalize, punctuate, numbers, code_patterns) continue to receive `focused_char` — this is now the real weakest char, not the bigram's first char
|
||||||
|
|
||||||
|
**Remove `apply_bigram_focus()`** method (lines 1087-1131) entirely.
|
||||||
|
|
||||||
|
**Store `FocusSelection`** on App:
|
||||||
|
- Add `pub current_focus: Option<FocusSelection>` field to App (default `None`)
|
||||||
|
- Set in `generate_text()` right after `select_focus()` — captures the focus that was actually used to generate the current drill's text
|
||||||
|
- **Lifecycle**: Set when drill starts (in `generate_text()`). Persists through the drill result screen (so the user sees what was in focus for the drill they just completed). Cleared to `None` when: starting the next drill (overwritten), leaving drill screen, changing drill scope/mode, or on import/reset. This is a snapshot, not live-recomputed — the header always shows what generated the current text.
|
||||||
|
- Used by drill header display in main.rs (reads `app.current_focus` instead of re-calling `select_focus()`)
|
||||||
|
|
||||||
|
#### `src/main.rs` — Drill header + stats adapter
|
||||||
|
|
||||||
|
**Drill header** (line 1134):
|
||||||
|
- Read `app.current_focus` to build focus_text (no re-computation — shows what generated the text)
|
||||||
|
- Display format: `Focus: 'n' + "th"` (both), `Focus: 'n'` (char only), `Focus: "th"` (bigram only)
|
||||||
|
- Replace the current `select_focus_target()` call with reading the stored selection
|
||||||
|
- When `current_focus` is `None`, show no focus text
|
||||||
|
|
||||||
|
**`build_ngram_tab_data()`** (line 2253):
|
||||||
|
- Call `select_focus()` instead of `select_focus_target_with_reasoning()`
|
||||||
|
- Update `NgramTabData` struct: replace `focus_target: FocusTarget` and `focus_reasoning: FocusReasoning` with `focus: FocusSelection`
|
||||||
|
|
||||||
|
#### `src/ui/components/stats_dashboard.rs` — Focus panel
|
||||||
|
|
||||||
|
**`NgramTabData`** (line 28):
|
||||||
|
- Replace `focus_target: FocusTarget` and `focus_reasoning: FocusReasoning` with `focus: FocusSelection`
|
||||||
|
- Remove `FocusTarget` and `FocusReasoning` imports
|
||||||
|
|
||||||
|
**`render_ngram_focus()`** (line 1352):
|
||||||
|
- Show both focus targets when both active:
|
||||||
|
- Line 1: `Focus: Char 'n' + Bigram "th"` (or just one if only one active)
|
||||||
|
- Line 2: Details — `Char 'n': weakest key | Bigram "th": error anomaly 250%`
|
||||||
|
- When neither active: show fallback message
|
||||||
|
- Rendering adapts based on which focuses are present
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. **`src/engine/ngram_stats.rs`** — EMA field on NgramStat, EMA-based smoothed_error_rate, `FocusSelection` struct, `select_focus()`, remove old FocusTarget/FocusReasoning
|
||||||
|
2. **`src/engine/key_stats.rs`** — EMA field on KeyStat, EMA updates in update_key/update_key_error, EMA-based smoothed_error_rate
|
||||||
|
3. **`src/generator/mod.rs`** — TextGenerator trait: add `focused_bigram` parameter
|
||||||
|
4. **`src/generator/phonetic.rs`** — Tiered word selection with bigram+char, anti-repeat window, Markov bigram awareness
|
||||||
|
5. **`src/generator/code_syntax.rs`** — Add ignored `focused_bigram` parameter
|
||||||
|
6. **`src/generator/passage.rs`** — Add ignored `focused_bigram` parameter
|
||||||
|
7. **`src/app.rs`** — Use `select_focus()`, pass both focuses to generator, remove `apply_bigram_focus()`, store `current_focus`
|
||||||
|
8. **`src/main.rs`** — Update drill header, update `build_ngram_tab_data()` adapter
|
||||||
|
9. **`src/ui/components/stats_dashboard.rs`** — Update NgramTabData, render_ngram_focus for dual focus display
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Updates
|
||||||
|
|
||||||
|
### Part A (EMA)
|
||||||
|
- **Update `test_error_anomaly_bigrams`**: Set `error_rate_ema` directly instead of relying on cumulative error_count/sample_count for anomaly ratio computation
|
||||||
|
- **Update `test_worst_confirmed_anomaly_dedup`** and **`_prefers_error_on_tie`**: Same — set EMA values
|
||||||
|
- **New `test_error_rate_ema_decay`**: Verify that after N correct strokes, error_rate_ema drops as expected. Verify anomaly ratio crosses below threshold after reasonable recovery (~15 correct strokes from 30% error rate).
|
||||||
|
- **New `test_error_rate_ema_rebuild_from_history`**: Verify that rebuilding from drill history produces same EMA as live updates (deterministic replay)
|
||||||
|
- **New `test_ema_ranking_stability_during_recovery`**: Two bigrams both confirmed. Bigram A has higher anomaly. User corrects bigram A over several drills while bigram B stays bad. Verify that A's anomaly drops below B's and B becomes the new worst_confirmed_anomaly — clean handoff without oscillation.
|
||||||
|
- **Update key_stats tests**: Verify EMA updates in `update_key()` and `update_key_error()`, backward compat (serde default)
|
||||||
|
|
||||||
|
### Part B (Integrated focus)
|
||||||
|
- **Replace focus reasoning tests** (`test_select_focus_with_reasoning_*`): Replace with `test_select_focus_*` testing `FocusSelection` struct — verify both char_focus and bigram_focus are populated independently
|
||||||
|
- **New `test_phonetic_bigram_focus_increases_bigram_words`**: Generate 1200 words with focused_bigram, verify significantly more words contain the bigram than without
|
||||||
|
- **New `test_phonetic_dual_focus_no_excessive_repeats`**: Generate text with both focuses, verify no word appears > 3 times consecutively
|
||||||
|
- **Update `build_ngram_tab_data_maps_fields_correctly`**: Update for `FocusSelection` struct instead of FocusTarget/FocusReasoning
|
||||||
|
- **New `test_find_matching_focused_is_sort_only`** (in `dictionary.rs` or `phonetic.rs`): Verify that `find_matching(filter, Some('k'))` and `find_matching(filter, None)` return the same set of words (same membership, potentially different order). Guards against future regressions where focused param accidentally becomes a filter.
|
||||||
|
- No `apply_bigram_focus` tests exist to remove (method was untested)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `cargo build` — no compile errors
|
||||||
|
2. `cargo test` — all tests pass
|
||||||
|
3. Manual: Start adaptive drill, observe both char and bigram appearing in focus header
|
||||||
|
4. Manual: Verify drill text contains focused bigram words AND focused char words mixed naturally
|
||||||
|
5. Manual: Verify no excessive word repetition (the old apply_bigram_focus problem)
|
||||||
|
6. Manual: Practice a bigram focus target correctly for 2-3 drills → verify it drops out of focus and a different bigram (or char-only) takes over
|
||||||
|
7. Manual: N-grams tab shows both focuses in the Active Focus panel
|
||||||
|
8. Manual: Narrow terminal (<60 cols) stacks anomaly panels vertically; very short terminal (<10 rows available for panels) shows only error anomalies panel; focus panel always shows at least line 1
|
||||||
391
src/app.rs
391
src/app.rs
@@ -2,19 +2,18 @@ use std::collections::{HashSet, VecDeque};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
use rand::rngs::SmallRng;
|
use rand::rngs::SmallRng;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::engine::FocusSelection;
|
||||||
use crate::engine::filter::CharFilter;
|
use crate::engine::filter::CharFilter;
|
||||||
use crate::engine::key_stats::KeyStatsStore;
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
use crate::engine::FocusTarget;
|
|
||||||
use crate::engine::ngram_stats::{
|
use crate::engine::ngram_stats::{
|
||||||
self, BigramKey, BigramStatsStore, TrigramStatsStore, extract_ngram_events,
|
self, BigramStatsStore, TrigramStatsStore, extract_ngram_events, select_focus,
|
||||||
select_focus_target,
|
|
||||||
};
|
};
|
||||||
use crate::engine::scoring;
|
use crate::engine::scoring;
|
||||||
use crate::engine::skill_tree::{BranchId, BranchStatus, DrillScope, SkillTree};
|
use crate::engine::skill_tree::{BranchId, BranchStatus, DrillScope, SkillTree};
|
||||||
@@ -35,14 +34,16 @@ use crate::generator::passage::{
|
|||||||
use crate::generator::phonetic::PhoneticGenerator;
|
use crate::generator::phonetic::PhoneticGenerator;
|
||||||
use crate::generator::punctuate;
|
use crate::generator::punctuate;
|
||||||
use crate::generator::transition_table::TransitionTable;
|
use crate::generator::transition_table::TransitionTable;
|
||||||
use crate::keyboard::model::KeyboardModel;
|
|
||||||
use crate::keyboard::display::BACKSPACE;
|
use crate::keyboard::display::BACKSPACE;
|
||||||
|
use crate::keyboard::model::KeyboardModel;
|
||||||
|
|
||||||
use crate::session::drill::DrillState;
|
use crate::session::drill::DrillState;
|
||||||
use crate::session::input::{self, KeystrokeEvent};
|
use crate::session::input::{self, KeystrokeEvent};
|
||||||
use crate::session::result::{DrillResult, KeyTime};
|
use crate::session::result::{DrillResult, KeyTime};
|
||||||
use crate::store::json_store::JsonStore;
|
use crate::store::json_store::JsonStore;
|
||||||
use crate::store::schema::{DrillHistoryData, ExportData, KeyStatsData, ProfileData, EXPORT_VERSION};
|
use crate::store::schema::{
|
||||||
|
DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData,
|
||||||
|
};
|
||||||
use crate::ui::components::menu::Menu;
|
use crate::ui::components::menu::Menu;
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
@@ -108,6 +109,8 @@ const MASTERY_MESSAGES: &[&str] = &[
|
|||||||
"One more key conquered!",
|
"One more key conquered!",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const POST_DRILL_INPUT_LOCK_MS: u64 = 800;
|
||||||
|
|
||||||
struct DownloadJob {
|
struct DownloadJob {
|
||||||
downloaded_bytes: Arc<AtomicU64>,
|
downloaded_bytes: Arc<AtomicU64>,
|
||||||
total_bytes: Arc<AtomicU64>,
|
total_bytes: Arc<AtomicU64>,
|
||||||
@@ -135,7 +138,10 @@ pub fn next_available_path(path_str: &str) -> String {
|
|||||||
let path = std::path::Path::new(path_str).to_path_buf();
|
let path = std::path::Path::new(path_str).to_path_buf();
|
||||||
let parent = path.parent().unwrap_or(std::path::Path::new("."));
|
let parent = path.parent().unwrap_or(std::path::Path::new("."));
|
||||||
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("json");
|
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("json");
|
||||||
let full_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("export");
|
let full_stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("export");
|
||||||
|
|
||||||
// Strip existing trailing -N suffix to get base stem
|
// Strip existing trailing -N suffix to get base stem
|
||||||
let base_stem = if let Some(pos) = full_stem.rfind('-') {
|
let base_stem = if let Some(pos) = full_stem.rfind('-') {
|
||||||
@@ -272,6 +278,8 @@ pub struct App {
|
|||||||
pub user_median_transition_ms: f64,
|
pub user_median_transition_ms: f64,
|
||||||
pub transition_buffer: Vec<f64>,
|
pub transition_buffer: Vec<f64>,
|
||||||
pub trigram_gain_history: Vec<f64>,
|
pub trigram_gain_history: Vec<f64>,
|
||||||
|
pub current_focus: Option<FocusSelection>,
|
||||||
|
pub post_drill_input_lock_until: Option<Instant>,
|
||||||
rng: SmallRng,
|
rng: SmallRng,
|
||||||
transition_table: TransitionTable,
|
transition_table: TransitionTable,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -293,38 +301,39 @@ impl App {
|
|||||||
|
|
||||||
let store = JsonStore::new().ok();
|
let store = JsonStore::new().ok();
|
||||||
|
|
||||||
let (key_stats, ranked_key_stats, skill_tree, profile, drill_history) = if let Some(ref s) = store {
|
let (key_stats, ranked_key_stats, skill_tree, profile, drill_history) =
|
||||||
// load_profile returns None if file exists but can't parse (schema mismatch)
|
if let Some(ref s) = store {
|
||||||
let pd = s.load_profile();
|
// load_profile returns None if file exists but can't parse (schema mismatch)
|
||||||
|
let pd = s.load_profile();
|
||||||
|
|
||||||
match pd {
|
match pd {
|
||||||
Some(pd) if !pd.needs_reset() => {
|
Some(pd) if !pd.needs_reset() => {
|
||||||
let ksd = s.load_key_stats();
|
let ksd = s.load_key_stats();
|
||||||
let rksd = s.load_ranked_key_stats();
|
let rksd = s.load_ranked_key_stats();
|
||||||
let lhd = s.load_drill_history();
|
let lhd = s.load_drill_history();
|
||||||
let st = SkillTree::new(pd.skill_tree.clone());
|
let st = SkillTree::new(pd.skill_tree.clone());
|
||||||
(ksd.stats, rksd.stats, st, pd, lhd.drills)
|
(ksd.stats, rksd.stats, st, pd, lhd.drills)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Schema mismatch or parse failure: full reset of all stores
|
||||||
|
(
|
||||||
|
KeyStatsStore::default(),
|
||||||
|
KeyStatsStore::default(),
|
||||||
|
SkillTree::default(),
|
||||||
|
ProfileData::default(),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
} else {
|
||||||
// Schema mismatch or parse failure: full reset of all stores
|
(
|
||||||
(
|
KeyStatsStore::default(),
|
||||||
KeyStatsStore::default(),
|
KeyStatsStore::default(),
|
||||||
KeyStatsStore::default(),
|
SkillTree::default(),
|
||||||
SkillTree::default(),
|
ProfileData::default(),
|
||||||
ProfileData::default(),
|
Vec::new(),
|
||||||
Vec::new(),
|
)
|
||||||
)
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
KeyStatsStore::default(),
|
|
||||||
KeyStatsStore::default(),
|
|
||||||
SkillTree::default(),
|
|
||||||
ProfileData::default(),
|
|
||||||
Vec::new(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut key_stats_with_target = key_stats;
|
let mut key_stats_with_target = key_stats;
|
||||||
key_stats_with_target.target_cpm = config.target_cpm();
|
key_stats_with_target.target_cpm = config.target_cpm();
|
||||||
@@ -421,6 +430,8 @@ impl App {
|
|||||||
user_median_transition_ms: 0.0,
|
user_median_transition_ms: 0.0,
|
||||||
transition_buffer: Vec::new(),
|
transition_buffer: Vec::new(),
|
||||||
trigram_gain_history: Vec::new(),
|
trigram_gain_history: Vec::new(),
|
||||||
|
current_focus: None,
|
||||||
|
post_drill_input_lock_until: None,
|
||||||
rng: SmallRng::from_entropy(),
|
rng: SmallRng::from_entropy(),
|
||||||
transition_table,
|
transition_table,
|
||||||
dictionary,
|
dictionary,
|
||||||
@@ -454,6 +465,23 @@ impl App {
|
|||||||
self.settings_editing_download_dir = false;
|
self.settings_editing_download_dir = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn arm_post_drill_input_lock(&mut self) {
|
||||||
|
self.post_drill_input_lock_until =
|
||||||
|
Some(Instant::now() + Duration::from_millis(POST_DRILL_INPUT_LOCK_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_post_drill_input_lock(&mut self) {
|
||||||
|
self.post_drill_input_lock_until = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn post_drill_input_lock_remaining_ms(&self) -> Option<u64> {
|
||||||
|
self.post_drill_input_lock_until.and_then(|until| {
|
||||||
|
until
|
||||||
|
.checked_duration_since(Instant::now())
|
||||||
|
.map(|remaining| remaining.as_millis().max(1) as u64)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn export_data(&mut self) {
|
pub fn export_data(&mut self) {
|
||||||
let path = std::path::Path::new(&self.settings_export_path);
|
let path = std::path::Path::new(&self.settings_export_path);
|
||||||
|
|
||||||
@@ -643,6 +671,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_drill(&mut self) {
|
pub fn start_drill(&mut self) {
|
||||||
|
self.clear_post_drill_input_lock();
|
||||||
let (text, source_info) = self.generate_text();
|
let (text, source_info) = self.generate_text();
|
||||||
self.drill = Some(DrillState::new(&text));
|
self.drill = Some(DrillState::new(&text));
|
||||||
self.drill_source_info = source_info;
|
self.drill_source_info = source_info;
|
||||||
@@ -659,17 +688,16 @@ impl App {
|
|||||||
let scope = self.drill_scope;
|
let scope = self.drill_scope;
|
||||||
let all_keys = self.skill_tree.unlocked_keys(scope);
|
let all_keys = self.skill_tree.unlocked_keys(scope);
|
||||||
|
|
||||||
// Select focus target: single char or bigram
|
// Select focus targets: char and bigram independently
|
||||||
let focus_target = select_focus_target(
|
let selection = select_focus(
|
||||||
&self.skill_tree,
|
&self.skill_tree,
|
||||||
scope,
|
scope,
|
||||||
&self.ranked_key_stats,
|
&self.ranked_key_stats,
|
||||||
&self.ranked_bigram_stats,
|
&self.ranked_bigram_stats,
|
||||||
);
|
);
|
||||||
let (focused_char, focused_bigram) = match &focus_target {
|
self.current_focus = Some(selection.clone());
|
||||||
FocusTarget::Char(ch) => (Some(*ch), None),
|
let focused_char = selection.char_focus;
|
||||||
FocusTarget::Bigram(key) => (Some(key.0[0]), Some(key.clone())),
|
let focused_bigram = selection.bigram_focus.map(|(k, _, _)| k.0);
|
||||||
};
|
|
||||||
|
|
||||||
// Generate base lowercase text using only lowercase keys from scope
|
// Generate base lowercase text using only lowercase keys from scope
|
||||||
let lowercase_keys: Vec<char> = all_keys
|
let lowercase_keys: Vec<char> = all_keys
|
||||||
@@ -684,7 +712,8 @@ impl App {
|
|||||||
let dict = Dictionary::load();
|
let dict = Dictionary::load();
|
||||||
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||||
let mut generator = PhoneticGenerator::new(table, dict, rng);
|
let mut generator = PhoneticGenerator::new(table, dict, rng);
|
||||||
let mut text = generator.generate(&filter, lowercase_focused, word_count);
|
let mut text =
|
||||||
|
generator.generate(&filter, lowercase_focused, focused_bigram, word_count);
|
||||||
|
|
||||||
// Apply capitalization if uppercase keys are in scope
|
// Apply capitalization if uppercase keys are in scope
|
||||||
let cap_keys: Vec<char> = all_keys
|
let cap_keys: Vec<char> = all_keys
|
||||||
@@ -694,7 +723,8 @@ impl App {
|
|||||||
.collect();
|
.collect();
|
||||||
if !cap_keys.is_empty() {
|
if !cap_keys.is_empty() {
|
||||||
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||||
text = capitalize::apply_capitalization(&text, &cap_keys, focused_char, &mut rng);
|
text =
|
||||||
|
capitalize::apply_capitalization(&text, &cap_keys, focused_char, &mut rng);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply punctuation if punctuation keys are in scope
|
// Apply punctuation if punctuation keys are in scope
|
||||||
@@ -722,7 +752,8 @@ impl App {
|
|||||||
if !digit_keys.is_empty() {
|
if !digit_keys.is_empty() {
|
||||||
let has_dot = all_keys.contains(&'.');
|
let has_dot = all_keys.contains(&'.');
|
||||||
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||||
text = numbers::apply_numbers(&text, &digit_keys, has_dot, focused_char, &mut rng);
|
text =
|
||||||
|
numbers::apply_numbers(&text, &digit_keys, has_dot, focused_char, &mut rng);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply code symbols only if this drill is for the CodeSymbols branch,
|
// Apply code symbols only if this drill is for the CodeSymbols branch,
|
||||||
@@ -781,11 +812,6 @@ impl App {
|
|||||||
text = insert_line_breaks(&text);
|
text = insert_line_breaks(&text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// After all generation: if bigram focus, swap some words for bigram-containing words
|
|
||||||
if let Some(ref bigram) = focused_bigram {
|
|
||||||
text = self.apply_bigram_focus(&text, &filter, bigram);
|
|
||||||
}
|
|
||||||
|
|
||||||
(text, None)
|
(text, None)
|
||||||
}
|
}
|
||||||
DrillMode::Code => {
|
DrillMode::Code => {
|
||||||
@@ -796,13 +822,10 @@ impl App {
|
|||||||
.unwrap_or_else(|| self.config.code_language.clone());
|
.unwrap_or_else(|| self.config.code_language.clone());
|
||||||
self.last_code_drill_language = Some(lang.clone());
|
self.last_code_drill_language = Some(lang.clone());
|
||||||
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||||
let mut generator = CodeSyntaxGenerator::new(
|
let mut generator =
|
||||||
rng,
|
CodeSyntaxGenerator::new(rng, &lang, &self.config.code_download_dir);
|
||||||
&lang,
|
|
||||||
&self.config.code_download_dir,
|
|
||||||
);
|
|
||||||
self.code_drill_language_override = None;
|
self.code_drill_language_override = None;
|
||||||
let text = generator.generate(&filter, None, word_count);
|
let text = generator.generate(&filter, None, None, word_count);
|
||||||
(text, Some(generator.last_source().to_string()))
|
(text, Some(generator.last_source().to_string()))
|
||||||
}
|
}
|
||||||
DrillMode::Passage => {
|
DrillMode::Passage => {
|
||||||
@@ -821,7 +844,7 @@ impl App {
|
|||||||
self.config.passage_downloads_enabled,
|
self.config.passage_downloads_enabled,
|
||||||
);
|
);
|
||||||
self.passage_drill_selection_override = None;
|
self.passage_drill_selection_override = None;
|
||||||
let text = generator.generate(&filter, None, word_count);
|
let text = generator.generate(&filter, None, None, word_count);
|
||||||
(text, Some(generator.last_source().to_string()))
|
(text, Some(generator.last_source().to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -891,18 +914,43 @@ impl App {
|
|||||||
|
|
||||||
// Extract and update n-gram stats for all drill modes
|
// Extract and update n-gram stats for all drill modes
|
||||||
let drill_index = self.drill_history.len() as u32;
|
let drill_index = self.drill_history.len() as u32;
|
||||||
let hesitation_thresh = ngram_stats::hesitation_threshold(self.user_median_transition_ms);
|
let hesitation_thresh =
|
||||||
|
ngram_stats::hesitation_threshold(self.user_median_transition_ms);
|
||||||
let (bigram_events, trigram_events) =
|
let (bigram_events, trigram_events) =
|
||||||
extract_ngram_events(&result.per_key_times, hesitation_thresh);
|
extract_ngram_events(&result.per_key_times, hesitation_thresh);
|
||||||
|
// Collect unique bigram keys for per-drill streak updates
|
||||||
|
let mut seen_bigrams: std::collections::HashSet<ngram_stats::BigramKey> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
for ev in &bigram_events {
|
for ev in &bigram_events {
|
||||||
self.bigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index);
|
seen_bigrams.insert(ev.key.clone());
|
||||||
self.bigram_stats.update_redundancy_streak(&ev.key, &self.key_stats);
|
self.bigram_stats.update(
|
||||||
|
ev.key.clone(),
|
||||||
|
ev.total_time_ms,
|
||||||
|
ev.correct,
|
||||||
|
ev.has_hesitation,
|
||||||
|
drill_index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Update streaks once per drill per unique bigram (not per event)
|
||||||
|
for key in &seen_bigrams {
|
||||||
|
self.bigram_stats
|
||||||
|
.update_error_anomaly_streak(key, &self.key_stats);
|
||||||
|
self.bigram_stats
|
||||||
|
.update_speed_anomaly_streak(key, &self.key_stats);
|
||||||
}
|
}
|
||||||
for ev in &trigram_events {
|
for ev in &trigram_events {
|
||||||
self.trigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index);
|
self.trigram_stats.update(
|
||||||
|
ev.key.clone(),
|
||||||
|
ev.total_time_ms,
|
||||||
|
ev.correct,
|
||||||
|
ev.has_hesitation,
|
||||||
|
drill_index,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ranked {
|
if ranked {
|
||||||
|
let mut seen_ranked_bigrams: std::collections::HashSet<ngram_stats::BigramKey> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
for kt in &result.per_key_times {
|
for kt in &result.per_key_times {
|
||||||
if kt.correct {
|
if kt.correct {
|
||||||
self.ranked_key_stats.update_key(kt.key, kt.time_ms);
|
self.ranked_key_stats.update_key(kt.key, kt.time_ms);
|
||||||
@@ -911,11 +959,29 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for ev in &bigram_events {
|
for ev in &bigram_events {
|
||||||
self.ranked_bigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index);
|
seen_ranked_bigrams.insert(ev.key.clone());
|
||||||
self.ranked_bigram_stats.update_redundancy_streak(&ev.key, &self.ranked_key_stats);
|
self.ranked_bigram_stats.update(
|
||||||
|
ev.key.clone(),
|
||||||
|
ev.total_time_ms,
|
||||||
|
ev.correct,
|
||||||
|
ev.has_hesitation,
|
||||||
|
drill_index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for key in &seen_ranked_bigrams {
|
||||||
|
self.ranked_bigram_stats
|
||||||
|
.update_error_anomaly_streak(key, &self.ranked_key_stats);
|
||||||
|
self.ranked_bigram_stats
|
||||||
|
.update_speed_anomaly_streak(key, &self.ranked_key_stats);
|
||||||
}
|
}
|
||||||
for ev in &trigram_events {
|
for ev in &trigram_events {
|
||||||
self.ranked_trigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index);
|
self.ranked_trigram_stats.update(
|
||||||
|
ev.key.clone(),
|
||||||
|
ev.total_time_ms,
|
||||||
|
ev.correct,
|
||||||
|
ev.has_hesitation,
|
||||||
|
drill_index,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let update = self
|
let update = self
|
||||||
.skill_tree
|
.skill_tree
|
||||||
@@ -1003,6 +1069,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.last_result = Some(result);
|
self.last_result = Some(result);
|
||||||
|
if !self.milestone_queue.is_empty() || self.drill_mode != DrillMode::Adaptive {
|
||||||
|
self.arm_post_drill_input_lock();
|
||||||
|
}
|
||||||
|
|
||||||
// Adaptive mode auto-continues unless milestone popups must be shown first.
|
// Adaptive mode auto-continues unless milestone popups must be shown first.
|
||||||
if self.drill_mode == DrillMode::Adaptive && self.milestone_queue.is_empty() {
|
if self.drill_mode == DrillMode::Adaptive && self.milestone_queue.is_empty() {
|
||||||
@@ -1036,15 +1105,36 @@ impl App {
|
|||||||
|
|
||||||
// Extract and update n-gram stats
|
// Extract and update n-gram stats
|
||||||
let drill_index = self.drill_history.len() as u32;
|
let drill_index = self.drill_history.len() as u32;
|
||||||
let hesitation_thresh = ngram_stats::hesitation_threshold(self.user_median_transition_ms);
|
let hesitation_thresh =
|
||||||
|
ngram_stats::hesitation_threshold(self.user_median_transition_ms);
|
||||||
let (bigram_events, trigram_events) =
|
let (bigram_events, trigram_events) =
|
||||||
extract_ngram_events(&result.per_key_times, hesitation_thresh);
|
extract_ngram_events(&result.per_key_times, hesitation_thresh);
|
||||||
|
let mut seen_bigrams: std::collections::HashSet<ngram_stats::BigramKey> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
for ev in &bigram_events {
|
for ev in &bigram_events {
|
||||||
self.bigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index);
|
seen_bigrams.insert(ev.key.clone());
|
||||||
self.bigram_stats.update_redundancy_streak(&ev.key, &self.key_stats);
|
self.bigram_stats.update(
|
||||||
|
ev.key.clone(),
|
||||||
|
ev.total_time_ms,
|
||||||
|
ev.correct,
|
||||||
|
ev.has_hesitation,
|
||||||
|
drill_index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for key in &seen_bigrams {
|
||||||
|
self.bigram_stats
|
||||||
|
.update_error_anomaly_streak(key, &self.key_stats);
|
||||||
|
self.bigram_stats
|
||||||
|
.update_speed_anomaly_streak(key, &self.key_stats);
|
||||||
}
|
}
|
||||||
for ev in &trigram_events {
|
for ev in &trigram_events {
|
||||||
self.trigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index);
|
self.trigram_stats.update(
|
||||||
|
ev.key.clone(),
|
||||||
|
ev.total_time_ms,
|
||||||
|
ev.correct,
|
||||||
|
ev.has_hesitation,
|
||||||
|
drill_index,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update transition buffer for hesitation baseline
|
// Update transition buffer for hesitation baseline
|
||||||
@@ -1056,6 +1146,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.last_result = Some(result);
|
self.last_result = Some(result);
|
||||||
|
self.arm_post_drill_input_lock();
|
||||||
self.screen = AppScreen::DrillResult;
|
self.screen = AppScreen::DrillResult;
|
||||||
self.save_data();
|
self.save_data();
|
||||||
}
|
}
|
||||||
@@ -1081,52 +1172,6 @@ impl App {
|
|||||||
|
|
||||||
/// Replace up to 40% of words with dictionary words containing the target bigram.
|
/// Replace up to 40% of words with dictionary words containing the target bigram.
|
||||||
/// No more than 3 consecutive bigram-focused words to prevent repetitive feel.
|
/// No more than 3 consecutive bigram-focused words to prevent repetitive feel.
|
||||||
fn apply_bigram_focus(&mut self, text: &str, filter: &CharFilter, bigram: &BigramKey) -> String {
|
|
||||||
let bigram_str: String = bigram.0.iter().collect();
|
|
||||||
let words: Vec<&str> = text.split(' ').collect();
|
|
||||||
if words.is_empty() {
|
|
||||||
return text.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find dictionary words that contain the bigram and pass the filter
|
|
||||||
let dict = Dictionary::load();
|
|
||||||
let candidates: Vec<&str> = dict
|
|
||||||
.find_matching(filter, None)
|
|
||||||
.into_iter()
|
|
||||||
.filter(|w| w.contains(&bigram_str))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if candidates.is_empty() {
|
|
||||||
return text.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let max_replacements = (words.len() * 2 + 4) / 5; // ~40%
|
|
||||||
let mut replaced = 0;
|
|
||||||
let mut consecutive = 0;
|
|
||||||
let mut result_words: Vec<String> = Vec::with_capacity(words.len());
|
|
||||||
|
|
||||||
for word in &words {
|
|
||||||
let already_has = word.contains(&bigram_str);
|
|
||||||
if already_has {
|
|
||||||
consecutive += 1;
|
|
||||||
result_words.push(word.to_string());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if replaced < max_replacements && consecutive < 3 {
|
|
||||||
let candidate = candidates[self.rng.gen_range(0..candidates.len())];
|
|
||||||
result_words.push(candidate.to_string());
|
|
||||||
replaced += 1;
|
|
||||||
consecutive += 1;
|
|
||||||
} else {
|
|
||||||
consecutive = 0;
|
|
||||||
result_words.push(word.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result_words.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the rolling transition buffer with new inter-keystroke intervals.
|
/// Update the rolling transition buffer with new inter-keystroke intervals.
|
||||||
fn update_transition_buffer(&mut self, per_key_times: &[KeyTime]) {
|
fn update_transition_buffer(&mut self, per_key_times: &[KeyTime]) {
|
||||||
for kt in per_key_times {
|
for kt in per_key_times {
|
||||||
@@ -1152,67 +1197,121 @@ impl App {
|
|||||||
fn rebuild_ngram_stats(&mut self) {
|
fn rebuild_ngram_stats(&mut self) {
|
||||||
// Reset n-gram stores
|
// Reset n-gram stores
|
||||||
self.bigram_stats = BigramStatsStore::default();
|
self.bigram_stats = BigramStatsStore::default();
|
||||||
self.bigram_stats.target_cpm = self.config.target_cpm();
|
|
||||||
self.ranked_bigram_stats = BigramStatsStore::default();
|
self.ranked_bigram_stats = BigramStatsStore::default();
|
||||||
self.ranked_bigram_stats.target_cpm = self.config.target_cpm();
|
|
||||||
self.trigram_stats = TrigramStatsStore::default();
|
self.trigram_stats = TrigramStatsStore::default();
|
||||||
self.trigram_stats.target_cpm = self.config.target_cpm();
|
|
||||||
self.ranked_trigram_stats = TrigramStatsStore::default();
|
self.ranked_trigram_stats = TrigramStatsStore::default();
|
||||||
self.ranked_trigram_stats.target_cpm = self.config.target_cpm();
|
|
||||||
self.transition_buffer.clear();
|
self.transition_buffer.clear();
|
||||||
self.user_median_transition_ms = 0.0;
|
self.user_median_transition_ms = 0.0;
|
||||||
|
|
||||||
// Reset char-level error/total counts (timing fields are untouched)
|
// Reset char-level error/total counts and EMA (timing fields are untouched)
|
||||||
for stat in self.key_stats.stats.values_mut() {
|
for stat in self.key_stats.stats.values_mut() {
|
||||||
stat.error_count = 0;
|
stat.error_count = 0;
|
||||||
stat.total_count = 0;
|
stat.total_count = 0;
|
||||||
|
stat.error_rate_ema = 0.5;
|
||||||
}
|
}
|
||||||
for stat in self.ranked_key_stats.stats.values_mut() {
|
for stat in self.ranked_key_stats.stats.values_mut() {
|
||||||
stat.error_count = 0;
|
stat.error_count = 0;
|
||||||
stat.total_count = 0;
|
stat.total_count = 0;
|
||||||
|
stat.error_rate_ema = 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Take drill_history out temporarily to avoid borrow conflict
|
// Take drill_history out temporarily to avoid borrow conflict
|
||||||
let history = std::mem::take(&mut self.drill_history);
|
let history = std::mem::take(&mut self.drill_history);
|
||||||
|
|
||||||
for (drill_index, result) in history.iter().enumerate() {
|
for (drill_index, result) in history.iter().enumerate() {
|
||||||
let hesitation_thresh = ngram_stats::hesitation_threshold(self.user_median_transition_ms);
|
let hesitation_thresh =
|
||||||
|
ngram_stats::hesitation_threshold(self.user_median_transition_ms);
|
||||||
let (bigram_events, trigram_events) =
|
let (bigram_events, trigram_events) =
|
||||||
extract_ngram_events(&result.per_key_times, hesitation_thresh);
|
extract_ngram_events(&result.per_key_times, hesitation_thresh);
|
||||||
|
|
||||||
// Rebuild char-level error/total counts from history
|
// Rebuild char-level error/total counts and EMA from history
|
||||||
for kt in &result.per_key_times {
|
for kt in &result.per_key_times {
|
||||||
if kt.correct {
|
if kt.correct {
|
||||||
let stat = self.key_stats.stats.entry(kt.key).or_default();
|
let stat = self.key_stats.stats.entry(kt.key).or_default();
|
||||||
stat.total_count += 1;
|
stat.total_count += 1;
|
||||||
|
// Update error rate EMA for correct stroke
|
||||||
|
if stat.total_count == 1 {
|
||||||
|
stat.error_rate_ema = 0.0;
|
||||||
|
} else {
|
||||||
|
stat.error_rate_ema = 0.1 * 0.0 + 0.9 * stat.error_rate_ema;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.key_stats.update_key_error(kt.key);
|
self.key_stats.update_key_error(kt.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect unique bigram keys seen this drill for per-drill streak updates
|
||||||
|
let mut seen_bigrams: std::collections::HashSet<ngram_stats::BigramKey> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
|
|
||||||
for ev in &bigram_events {
|
for ev in &bigram_events {
|
||||||
self.bigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index as u32);
|
seen_bigrams.insert(ev.key.clone());
|
||||||
self.bigram_stats.update_redundancy_streak(&ev.key, &self.key_stats);
|
self.bigram_stats.update(
|
||||||
|
ev.key.clone(),
|
||||||
|
ev.total_time_ms,
|
||||||
|
ev.correct,
|
||||||
|
ev.has_hesitation,
|
||||||
|
drill_index as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Update streaks once per drill per unique bigram (not per event)
|
||||||
|
for key in &seen_bigrams {
|
||||||
|
self.bigram_stats
|
||||||
|
.update_error_anomaly_streak(key, &self.key_stats);
|
||||||
|
self.bigram_stats
|
||||||
|
.update_speed_anomaly_streak(key, &self.key_stats);
|
||||||
}
|
}
|
||||||
for ev in &trigram_events {
|
for ev in &trigram_events {
|
||||||
self.trigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index as u32);
|
self.trigram_stats.update(
|
||||||
|
ev.key.clone(),
|
||||||
|
ev.total_time_ms,
|
||||||
|
ev.correct,
|
||||||
|
ev.has_hesitation,
|
||||||
|
drill_index as u32,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.ranked {
|
if result.ranked {
|
||||||
|
let mut seen_ranked_bigrams: std::collections::HashSet<ngram_stats::BigramKey> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
|
|
||||||
for kt in &result.per_key_times {
|
for kt in &result.per_key_times {
|
||||||
if kt.correct {
|
if kt.correct {
|
||||||
let stat = self.ranked_key_stats.stats.entry(kt.key).or_default();
|
let stat = self.ranked_key_stats.stats.entry(kt.key).or_default();
|
||||||
stat.total_count += 1;
|
stat.total_count += 1;
|
||||||
|
if stat.total_count == 1 {
|
||||||
|
stat.error_rate_ema = 0.0;
|
||||||
|
} else {
|
||||||
|
stat.error_rate_ema = 0.1 * 0.0 + 0.9 * stat.error_rate_ema;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.ranked_key_stats.update_key_error(kt.key);
|
self.ranked_key_stats.update_key_error(kt.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for ev in &bigram_events {
|
for ev in &bigram_events {
|
||||||
self.ranked_bigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index as u32);
|
seen_ranked_bigrams.insert(ev.key.clone());
|
||||||
self.ranked_bigram_stats.update_redundancy_streak(&ev.key, &self.ranked_key_stats);
|
self.ranked_bigram_stats.update(
|
||||||
|
ev.key.clone(),
|
||||||
|
ev.total_time_ms,
|
||||||
|
ev.correct,
|
||||||
|
ev.has_hesitation,
|
||||||
|
drill_index as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for key in &seen_ranked_bigrams {
|
||||||
|
self.ranked_bigram_stats
|
||||||
|
.update_error_anomaly_streak(key, &self.ranked_key_stats);
|
||||||
|
self.ranked_bigram_stats
|
||||||
|
.update_speed_anomaly_streak(key, &self.ranked_key_stats);
|
||||||
}
|
}
|
||||||
for ev in &trigram_events {
|
for ev in &trigram_events {
|
||||||
self.ranked_trigram_stats.update(ev.key.clone(), ev.total_time_ms, ev.correct, ev.has_hesitation, drill_index as u32);
|
self.ranked_trigram_stats.update(
|
||||||
|
ev.key.clone(),
|
||||||
|
ev.total_time_ms,
|
||||||
|
ev.correct,
|
||||||
|
ev.has_hesitation,
|
||||||
|
drill_index as u32,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1282,6 +1381,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn go_to_menu(&mut self) {
|
pub fn go_to_menu(&mut self) {
|
||||||
|
self.clear_post_drill_input_lock();
|
||||||
self.screen = AppScreen::Menu;
|
self.screen = AppScreen::Menu;
|
||||||
self.drill = None;
|
self.drill = None;
|
||||||
self.drill_source_info = None;
|
self.drill_source_info = None;
|
||||||
@@ -1289,6 +1389,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn go_to_stats(&mut self) {
|
pub fn go_to_stats(&mut self) {
|
||||||
|
self.clear_post_drill_input_lock();
|
||||||
self.stats_tab = 0;
|
self.stats_tab = 0;
|
||||||
self.history_selected = 0;
|
self.history_selected = 0;
|
||||||
self.history_confirm_delete = false;
|
self.history_confirm_delete = false;
|
||||||
@@ -1562,10 +1663,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_code_downloads(&mut self) {
|
pub fn start_code_downloads(&mut self) {
|
||||||
let queue = build_code_download_queue(
|
let queue =
|
||||||
&self.config.code_language,
|
build_code_download_queue(&self.config.code_language, &self.code_intro_download_dir);
|
||||||
&self.code_intro_download_dir,
|
|
||||||
);
|
|
||||||
|
|
||||||
self.code_intro_download_total = queue.len();
|
self.code_intro_download_total = queue.len();
|
||||||
self.code_download_queue = queue;
|
self.code_download_queue = queue;
|
||||||
@@ -1662,10 +1761,8 @@ impl App {
|
|||||||
let snippets_limit = self.code_intro_snippets_per_repo;
|
let snippets_limit = self.code_intro_snippets_per_repo;
|
||||||
|
|
||||||
// Get static references for thread
|
// Get static references for thread
|
||||||
let repo_ref: &'static crate::generator::code_syntax::CodeRepo =
|
let repo_ref: &'static crate::generator::code_syntax::CodeRepo = &lang.repos[repo_idx];
|
||||||
&lang.repos[repo_idx];
|
let block_style_ref: &'static crate::generator::code_syntax::BlockStyle = &lang.block_style;
|
||||||
let block_style_ref: &'static crate::generator::code_syntax::BlockStyle =
|
|
||||||
&lang.block_style;
|
|
||||||
|
|
||||||
let handle = thread::spawn(move || {
|
let handle = thread::spawn(move || {
|
||||||
let ok = download_code_repo_to_cache_with_progress(
|
let ok = download_code_repo_to_cache_with_progress(
|
||||||
@@ -1931,12 +2028,11 @@ impl App {
|
|||||||
// Editable text field handled directly in key handler.
|
// Editable text field handled directly in key handler.
|
||||||
}
|
}
|
||||||
6 => {
|
6 => {
|
||||||
self.config.code_snippets_per_repo =
|
self.config.code_snippets_per_repo = match self.config.code_snippets_per_repo {
|
||||||
match self.config.code_snippets_per_repo {
|
0 => 1,
|
||||||
0 => 1,
|
n if n >= 200 => 0,
|
||||||
n if n >= 200 => 0,
|
n => n + 10,
|
||||||
n => n + 10,
|
};
|
||||||
};
|
|
||||||
}
|
}
|
||||||
// 7 = Download Code Now (action button)
|
// 7 = Download Code Now (action button)
|
||||||
8 => {
|
8 => {
|
||||||
@@ -1998,12 +2094,11 @@ impl App {
|
|||||||
// Editable text field handled directly in key handler.
|
// Editable text field handled directly in key handler.
|
||||||
}
|
}
|
||||||
6 => {
|
6 => {
|
||||||
self.config.code_snippets_per_repo =
|
self.config.code_snippets_per_repo = match self.config.code_snippets_per_repo {
|
||||||
match self.config.code_snippets_per_repo {
|
0 => 200,
|
||||||
0 => 200,
|
1 => 0,
|
||||||
1 => 0,
|
n => n.saturating_sub(10).max(1),
|
||||||
n => n.saturating_sub(10).max(1),
|
};
|
||||||
};
|
|
||||||
}
|
}
|
||||||
// 7 = Download Code Now (action button)
|
// 7 = Download Code Now (action button)
|
||||||
8 => {
|
8 => {
|
||||||
|
|||||||
@@ -202,10 +202,19 @@ code_language = "go"
|
|||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
let serialized = toml::to_string_pretty(&config).unwrap();
|
let serialized = toml::to_string_pretty(&config).unwrap();
|
||||||
let deserialized: Config = toml::from_str(&serialized).unwrap();
|
let deserialized: Config = toml::from_str(&serialized).unwrap();
|
||||||
assert_eq!(config.code_downloads_enabled, deserialized.code_downloads_enabled);
|
assert_eq!(
|
||||||
|
config.code_downloads_enabled,
|
||||||
|
deserialized.code_downloads_enabled
|
||||||
|
);
|
||||||
assert_eq!(config.code_download_dir, deserialized.code_download_dir);
|
assert_eq!(config.code_download_dir, deserialized.code_download_dir);
|
||||||
assert_eq!(config.code_snippets_per_repo, deserialized.code_snippets_per_repo);
|
assert_eq!(
|
||||||
assert_eq!(config.code_onboarding_done, deserialized.code_onboarding_done);
|
config.code_snippets_per_repo,
|
||||||
|
deserialized.code_snippets_per_repo
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.code_onboarding_done,
|
||||||
|
deserialized.code_onboarding_done
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ pub struct KeyStat {
|
|||||||
pub error_count: usize,
|
pub error_count: usize,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub total_count: usize,
|
pub total_count: usize,
|
||||||
|
#[serde(default = "default_error_rate_ema")]
|
||||||
|
pub error_rate_ema: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_error_rate_ema() -> f64 {
|
||||||
|
0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for KeyStat {
|
impl Default for KeyStat {
|
||||||
@@ -27,6 +33,7 @@ impl Default for KeyStat {
|
|||||||
recent_times: Vec::new(),
|
recent_times: Vec::new(),
|
||||||
error_count: 0,
|
error_count: 0,
|
||||||
total_count: 0,
|
total_count: 0,
|
||||||
|
error_rate_ema: 0.5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +74,13 @@ impl KeyStatsStore {
|
|||||||
if stat.recent_times.len() > 30 {
|
if stat.recent_times.len() > 30 {
|
||||||
stat.recent_times.remove(0);
|
stat.recent_times.remove(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update error rate EMA (correct stroke = 0.0 signal)
|
||||||
|
if stat.total_count == 1 {
|
||||||
|
stat.error_rate_ema = 0.0;
|
||||||
|
} else {
|
||||||
|
stat.error_rate_ema = EMA_ALPHA * 0.0 + (1.0 - EMA_ALPHA) * stat.error_rate_ema;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_confidence(&self, key: char) -> f64 {
|
pub fn get_confidence(&self, key: char) -> f64 {
|
||||||
@@ -84,13 +98,20 @@ impl KeyStatsStore {
|
|||||||
let stat = self.stats.entry(key).or_default();
|
let stat = self.stats.entry(key).or_default();
|
||||||
stat.error_count += 1;
|
stat.error_count += 1;
|
||||||
stat.total_count += 1;
|
stat.total_count += 1;
|
||||||
|
|
||||||
|
// Update error rate EMA (error stroke = 1.0 signal)
|
||||||
|
if stat.total_count == 1 {
|
||||||
|
stat.error_rate_ema = 1.0;
|
||||||
|
} else {
|
||||||
|
stat.error_rate_ema = EMA_ALPHA * 1.0 + (1.0 - EMA_ALPHA) * stat.error_rate_ema;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Laplace-smoothed error rate: (errors + 1) / (total + 2).
|
/// EMA-based error rate for a key.
|
||||||
pub fn smoothed_error_rate(&self, key: char) -> f64 {
|
pub fn smoothed_error_rate(&self, key: char) -> f64 {
|
||||||
match self.stats.get(&key) {
|
match self.stats.get(&key) {
|
||||||
Some(s) => (s.error_count as f64 + 1.0) / (s.total_count as f64 + 2.0),
|
Some(s) => s.error_rate_ema,
|
||||||
None => 0.5, // (0 + 1) / (0 + 2) = 0.5
|
None => 0.5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,4 +163,50 @@ mod tests {
|
|||||||
"confidence should be < 1.0 for slow typing, got {conf}"
|
"confidence should be < 1.0 for slow typing, got {conf}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ema_error_rate_correct_strokes() {
|
||||||
|
let mut store = KeyStatsStore::default();
|
||||||
|
// All correct strokes → EMA should be 0.0 for first, stay near 0
|
||||||
|
store.update_key('a', 200.0);
|
||||||
|
assert!((store.smoothed_error_rate('a') - 0.0).abs() < f64::EPSILON);
|
||||||
|
for _ in 0..10 {
|
||||||
|
store.update_key('a', 200.0);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
store.smoothed_error_rate('a') < 0.01,
|
||||||
|
"All correct → EMA near 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ema_error_rate_error_strokes() {
|
||||||
|
let mut store = KeyStatsStore::default();
|
||||||
|
// First stroke is error
|
||||||
|
store.update_key_error('b');
|
||||||
|
assert!((store.smoothed_error_rate('b') - 1.0).abs() < f64::EPSILON);
|
||||||
|
// Follow with correct strokes → EMA decays
|
||||||
|
for _ in 0..20 {
|
||||||
|
store.update_key('b', 200.0);
|
||||||
|
}
|
||||||
|
let rate = store.smoothed_error_rate('b');
|
||||||
|
assert!(
|
||||||
|
rate < 0.15,
|
||||||
|
"After 20 correct, EMA should be < 0.15, got {rate}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ema_error_rate_default_for_missing_key() {
|
||||||
|
let store = KeyStatsStore::default();
|
||||||
|
assert!((store.smoothed_error_rate('z') - 0.5).abs() < f64::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ema_error_rate_serde_default() {
|
||||||
|
// Verify backward compat: deserializing old data without error_rate_ema gets 0.5
|
||||||
|
let json = r#"{"filtered_time_ms":200.0,"best_time_ms":200.0,"confidence":1.0,"sample_count":10,"recent_times":[],"error_count":2,"total_count":10}"#;
|
||||||
|
let stat: KeyStat = serde_json::from_str(json).unwrap();
|
||||||
|
assert!((stat.error_rate_ema - 0.5).abs() < f64::EPSILON);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ pub mod ngram_stats;
|
|||||||
pub mod scoring;
|
pub mod scoring;
|
||||||
pub mod skill_tree;
|
pub mod skill_tree;
|
||||||
|
|
||||||
pub use ngram_stats::FocusTarget;
|
pub use ngram_stats::FocusSelection;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -567,9 +567,7 @@ impl SkillTree {
|
|||||||
let newly_mastered: Vec<char> = if let Some(before) = before_stats {
|
let newly_mastered: Vec<char> = if let Some(before) = before_stats {
|
||||||
before_unlocked
|
before_unlocked
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|&&ch| {
|
.filter(|&&ch| before.get_confidence(ch) < 1.0 && stats.get_confidence(ch) >= 1.0)
|
||||||
before.get_confidence(ch) < 1.0 && stats.get_confidence(ch) >= 1.0
|
|
||||||
})
|
|
||||||
.copied()
|
.copied()
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -51,24 +51,39 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
],
|
],
|
||||||
has_builtin: true,
|
has_builtin: true,
|
||||||
block_style: BlockStyle::Braces(&[
|
block_style: BlockStyle::Braces(&[
|
||||||
"fn ", "pub fn ", "async fn ", "pub async fn ", "impl ", "trait ", "struct ", "enum ",
|
"fn ",
|
||||||
"macro_rules! ", "mod ", "const ", "static ", "type ", "pub struct ", "pub enum ",
|
"pub fn ",
|
||||||
"pub trait ", "pub mod ", "pub const ", "pub static ", "pub type ",
|
"async fn ",
|
||||||
|
"pub async fn ",
|
||||||
|
"impl ",
|
||||||
|
"trait ",
|
||||||
|
"struct ",
|
||||||
|
"enum ",
|
||||||
|
"macro_rules! ",
|
||||||
|
"mod ",
|
||||||
|
"const ",
|
||||||
|
"static ",
|
||||||
|
"type ",
|
||||||
|
"pub struct ",
|
||||||
|
"pub enum ",
|
||||||
|
"pub trait ",
|
||||||
|
"pub mod ",
|
||||||
|
"pub const ",
|
||||||
|
"pub static ",
|
||||||
|
"pub type ",
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
CodeLanguage {
|
CodeLanguage {
|
||||||
key: "python",
|
key: "python",
|
||||||
display_name: "Python",
|
display_name: "Python",
|
||||||
extensions: &[".py", ".pyi"],
|
extensions: &[".py", ".pyi"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "cpython",
|
||||||
key: "cpython",
|
urls: &[
|
||||||
urls: &[
|
"https://raw.githubusercontent.com/python/cpython/main/Lib/json/encoder.py",
|
||||||
"https://raw.githubusercontent.com/python/cpython/main/Lib/json/encoder.py",
|
"https://raw.githubusercontent.com/python/cpython/main/Lib/pathlib/__init__.py",
|
||||||
"https://raw.githubusercontent.com/python/cpython/main/Lib/pathlib/__init__.py",
|
],
|
||||||
],
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: true,
|
has_builtin: true,
|
||||||
block_style: BlockStyle::Indentation(&["def ", "class ", "async def ", "@"]),
|
block_style: BlockStyle::Indentation(&["def ", "class ", "async def ", "@"]),
|
||||||
},
|
},
|
||||||
@@ -76,15 +91,13 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
key: "javascript",
|
key: "javascript",
|
||||||
display_name: "JavaScript",
|
display_name: "JavaScript",
|
||||||
extensions: &[".js", ".mjs"],
|
extensions: &[".js", ".mjs"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "node-stdlib",
|
||||||
key: "node-stdlib",
|
urls: &[
|
||||||
urls: &[
|
"https://raw.githubusercontent.com/nodejs/node/main/lib/path.js",
|
||||||
"https://raw.githubusercontent.com/nodejs/node/main/lib/path.js",
|
"https://raw.githubusercontent.com/nodejs/node/main/lib/url.js",
|
||||||
"https://raw.githubusercontent.com/nodejs/node/main/lib/url.js",
|
],
|
||||||
],
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: true,
|
has_builtin: true,
|
||||||
block_style: BlockStyle::Braces(&[
|
block_style: BlockStyle::Braces(&[
|
||||||
"function ",
|
"function ",
|
||||||
@@ -101,14 +114,10 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
key: "go",
|
key: "go",
|
||||||
display_name: "Go",
|
display_name: "Go",
|
||||||
extensions: &[".go"],
|
extensions: &[".go"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "go-stdlib",
|
||||||
key: "go-stdlib",
|
urls: &["https://raw.githubusercontent.com/golang/go/master/src/fmt/print.go"],
|
||||||
urls: &[
|
}],
|
||||||
"https://raw.githubusercontent.com/golang/go/master/src/fmt/print.go",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: true,
|
has_builtin: true,
|
||||||
block_style: BlockStyle::Braces(&["func ", "type "]),
|
block_style: BlockStyle::Braces(&["func ", "type "]),
|
||||||
},
|
},
|
||||||
@@ -119,9 +128,7 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
repos: &[
|
repos: &[
|
||||||
CodeRepo {
|
CodeRepo {
|
||||||
key: "ts-node",
|
key: "ts-node",
|
||||||
urls: &[
|
urls: &["https://raw.githubusercontent.com/TypeStrong/ts-node/main/src/index.ts"],
|
||||||
"https://raw.githubusercontent.com/TypeStrong/ts-node/main/src/index.ts",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
CodeRepo {
|
CodeRepo {
|
||||||
key: "deno-std",
|
key: "deno-std",
|
||||||
@@ -195,9 +202,7 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
},
|
},
|
||||||
CodeRepo {
|
CodeRepo {
|
||||||
key: "jq",
|
key: "jq",
|
||||||
urls: &[
|
urls: &["https://raw.githubusercontent.com/jqlang/jq/master/src/builtin.c"],
|
||||||
"https://raw.githubusercontent.com/jqlang/jq/master/src/builtin.c",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
has_builtin: true,
|
has_builtin: true,
|
||||||
@@ -229,9 +234,7 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
},
|
},
|
||||||
CodeRepo {
|
CodeRepo {
|
||||||
key: "fmt",
|
key: "fmt",
|
||||||
urls: &[
|
urls: &["https://raw.githubusercontent.com/fmtlib/fmt/master/include/fmt/format.h"],
|
||||||
"https://raw.githubusercontent.com/fmtlib/fmt/master/include/fmt/format.h",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
has_builtin: true,
|
has_builtin: true,
|
||||||
@@ -274,7 +277,13 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
],
|
],
|
||||||
has_builtin: true,
|
has_builtin: true,
|
||||||
block_style: BlockStyle::EndDelimited(&[
|
block_style: BlockStyle::EndDelimited(&[
|
||||||
"def ", "class ", "module ", "attr_", "scope ", "describe ", "it ",
|
"def ",
|
||||||
|
"class ",
|
||||||
|
"module ",
|
||||||
|
"attr_",
|
||||||
|
"scope ",
|
||||||
|
"describe ",
|
||||||
|
"it ",
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
CodeLanguage {
|
CodeLanguage {
|
||||||
@@ -319,9 +328,7 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
repos: &[
|
repos: &[
|
||||||
CodeRepo {
|
CodeRepo {
|
||||||
key: "nvm",
|
key: "nvm",
|
||||||
urls: &[
|
urls: &["https://raw.githubusercontent.com/nvm-sh/nvm/master/nvm.sh"],
|
||||||
"https://raw.githubusercontent.com/nvm-sh/nvm/master/nvm.sh",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
CodeRepo {
|
CodeRepo {
|
||||||
key: "oh-my-zsh",
|
key: "oh-my-zsh",
|
||||||
@@ -340,9 +347,7 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
repos: &[
|
repos: &[
|
||||||
CodeRepo {
|
CodeRepo {
|
||||||
key: "kong",
|
key: "kong",
|
||||||
urls: &[
|
urls: &["https://raw.githubusercontent.com/Kong/kong/master/kong/init.lua"],
|
||||||
"https://raw.githubusercontent.com/Kong/kong/master/kong/init.lua",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
CodeRepo {
|
CodeRepo {
|
||||||
key: "luarocks",
|
key: "luarocks",
|
||||||
@@ -359,41 +364,60 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
key: "kotlin",
|
key: "kotlin",
|
||||||
display_name: "Kotlin",
|
display_name: "Kotlin",
|
||||||
extensions: &[".kt", ".kts"],
|
extensions: &[".kt", ".kts"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "kotlinx-coroutines",
|
||||||
key: "kotlinx-coroutines",
|
urls: &[
|
||||||
urls: &[
|
"https://raw.githubusercontent.com/Kotlin/kotlinx.coroutines/master/kotlinx-coroutines-core/common/src/flow/Builders.kt",
|
||||||
"https://raw.githubusercontent.com/Kotlin/kotlinx.coroutines/master/kotlinx-coroutines-core/common/src/flow/Builders.kt",
|
"https://raw.githubusercontent.com/Kotlin/kotlinx.coroutines/master/kotlinx-coroutines-core/common/src/channels/Channel.kt",
|
||||||
"https://raw.githubusercontent.com/Kotlin/kotlinx.coroutines/master/kotlinx-coroutines-core/common/src/channels/Channel.kt",
|
],
|
||||||
],
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::Braces(&[
|
block_style: BlockStyle::Braces(&[
|
||||||
"fun ", "class ", "object ", "interface ", "suspend fun ",
|
"fun ",
|
||||||
"public ", "private ", "internal ", "override fun ", "open ",
|
"class ",
|
||||||
"data class ", "sealed ", "abstract ",
|
"object ",
|
||||||
"val ", "var ", "enum ", "annotation ", "typealias ",
|
"interface ",
|
||||||
|
"suspend fun ",
|
||||||
|
"public ",
|
||||||
|
"private ",
|
||||||
|
"internal ",
|
||||||
|
"override fun ",
|
||||||
|
"open ",
|
||||||
|
"data class ",
|
||||||
|
"sealed ",
|
||||||
|
"abstract ",
|
||||||
|
"val ",
|
||||||
|
"var ",
|
||||||
|
"enum ",
|
||||||
|
"annotation ",
|
||||||
|
"typealias ",
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
CodeLanguage {
|
CodeLanguage {
|
||||||
key: "scala",
|
key: "scala",
|
||||||
display_name: "Scala",
|
display_name: "Scala",
|
||||||
extensions: &[".scala"],
|
extensions: &[".scala"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "scala-stdlib",
|
||||||
key: "scala-stdlib",
|
urls: &[
|
||||||
urls: &[
|
"https://raw.githubusercontent.com/scala/scala/2.13.x/src/library/scala/collection/immutable/List.scala",
|
||||||
"https://raw.githubusercontent.com/scala/scala/2.13.x/src/library/scala/collection/immutable/List.scala",
|
"https://raw.githubusercontent.com/scala/scala/2.13.x/src/library/scala/collection/mutable/HashMap.scala",
|
||||||
"https://raw.githubusercontent.com/scala/scala/2.13.x/src/library/scala/collection/mutable/HashMap.scala",
|
"https://raw.githubusercontent.com/scala/scala/2.13.x/src/library/scala/Option.scala",
|
||||||
"https://raw.githubusercontent.com/scala/scala/2.13.x/src/library/scala/Option.scala",
|
],
|
||||||
],
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::Braces(&[
|
block_style: BlockStyle::Braces(&[
|
||||||
"def ", "class ", "object ", "trait ", "case class ",
|
"def ",
|
||||||
"val ", "var ", "type ", "implicit ", "given ", "extension ",
|
"class ",
|
||||||
|
"object ",
|
||||||
|
"trait ",
|
||||||
|
"case class ",
|
||||||
|
"val ",
|
||||||
|
"var ",
|
||||||
|
"type ",
|
||||||
|
"implicit ",
|
||||||
|
"given ",
|
||||||
|
"extension ",
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
CodeLanguage {
|
CodeLanguage {
|
||||||
@@ -461,18 +485,29 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
key: "dart",
|
key: "dart",
|
||||||
display_name: "Dart",
|
display_name: "Dart",
|
||||||
extensions: &[".dart"],
|
extensions: &[".dart"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "flutter",
|
||||||
key: "flutter",
|
urls: &[
|
||||||
urls: &[
|
"https://raw.githubusercontent.com/flutter/flutter/master/packages/flutter/lib/src/widgets/framework.dart",
|
||||||
"https://raw.githubusercontent.com/flutter/flutter/master/packages/flutter/lib/src/widgets/framework.dart",
|
],
|
||||||
],
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::Braces(&[
|
block_style: BlockStyle::Braces(&[
|
||||||
"void ", "Future ", "Future<", "class ", "int ", "String ", "bool ", "static ", "factory ",
|
"void ",
|
||||||
"Widget ", "get ", "set ", "enum ", "typedef ", "extension ",
|
"Future ",
|
||||||
|
"Future<",
|
||||||
|
"class ",
|
||||||
|
"int ",
|
||||||
|
"String ",
|
||||||
|
"bool ",
|
||||||
|
"static ",
|
||||||
|
"factory ",
|
||||||
|
"Widget ",
|
||||||
|
"get ",
|
||||||
|
"set ",
|
||||||
|
"enum ",
|
||||||
|
"typedef ",
|
||||||
|
"extension ",
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
CodeLanguage {
|
CodeLanguage {
|
||||||
@@ -495,22 +530,23 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
],
|
],
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::EndDelimited(&[
|
block_style: BlockStyle::EndDelimited(&[
|
||||||
"def ", "defp ", "defmodule ",
|
"def ",
|
||||||
"defmacro ", "defstruct", "defprotocol ", "defimpl ",
|
"defp ",
|
||||||
|
"defmodule ",
|
||||||
|
"defmacro ",
|
||||||
|
"defstruct",
|
||||||
|
"defprotocol ",
|
||||||
|
"defimpl ",
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
CodeLanguage {
|
CodeLanguage {
|
||||||
key: "perl",
|
key: "perl",
|
||||||
display_name: "Perl",
|
display_name: "Perl",
|
||||||
extensions: &[".pl", ".pm"],
|
extensions: &[".pl", ".pm"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "mojolicious",
|
||||||
key: "mojolicious",
|
urls: &["https://raw.githubusercontent.com/mojolicious/mojo/main/lib/Mojolicious.pm"],
|
||||||
urls: &[
|
}],
|
||||||
"https://raw.githubusercontent.com/mojolicious/mojo/main/lib/Mojolicious.pm",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::Braces(&["sub "]),
|
block_style: BlockStyle::Braces(&["sub "]),
|
||||||
},
|
},
|
||||||
@@ -518,30 +554,31 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
key: "zig",
|
key: "zig",
|
||||||
display_name: "Zig",
|
display_name: "Zig",
|
||||||
extensions: &[".zig"],
|
extensions: &[".zig"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "zig-stdlib",
|
||||||
key: "zig-stdlib",
|
urls: &[
|
||||||
urls: &[
|
"https://raw.githubusercontent.com/ziglang/zig/master/lib/std/mem.zig",
|
||||||
"https://raw.githubusercontent.com/ziglang/zig/master/lib/std/mem.zig",
|
"https://raw.githubusercontent.com/ziglang/zig/master/lib/std/fmt.zig",
|
||||||
"https://raw.githubusercontent.com/ziglang/zig/master/lib/std/fmt.zig",
|
],
|
||||||
],
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::Braces(&["pub fn ", "fn ", "const ", "pub const ", "test ", "var "]),
|
block_style: BlockStyle::Braces(&[
|
||||||
|
"pub fn ",
|
||||||
|
"fn ",
|
||||||
|
"const ",
|
||||||
|
"pub const ",
|
||||||
|
"test ",
|
||||||
|
"var ",
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
CodeLanguage {
|
CodeLanguage {
|
||||||
key: "julia",
|
key: "julia",
|
||||||
display_name: "Julia",
|
display_name: "Julia",
|
||||||
extensions: &[".jl"],
|
extensions: &[".jl"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "julia-stdlib",
|
||||||
key: "julia-stdlib",
|
urls: &["https://raw.githubusercontent.com/JuliaLang/julia/master/base/array.jl"],
|
||||||
urls: &[
|
}],
|
||||||
"https://raw.githubusercontent.com/JuliaLang/julia/master/base/array.jl",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::EndDelimited(&["function ", "macro "]),
|
block_style: BlockStyle::EndDelimited(&["function ", "macro "]),
|
||||||
},
|
},
|
||||||
@@ -549,14 +586,10 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
key: "nim",
|
key: "nim",
|
||||||
display_name: "Nim",
|
display_name: "Nim",
|
||||||
extensions: &[".nim"],
|
extensions: &[".nim"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "nim-stdlib",
|
||||||
key: "nim-stdlib",
|
urls: &["https://raw.githubusercontent.com/nim-lang/Nim/devel/lib/pure/strutils.nim"],
|
||||||
urls: &[
|
}],
|
||||||
"https://raw.githubusercontent.com/nim-lang/Nim/devel/lib/pure/strutils.nim",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::Indentation(&["proc ", "func ", "method ", "type "]),
|
block_style: BlockStyle::Indentation(&["proc ", "func ", "method ", "type "]),
|
||||||
},
|
},
|
||||||
@@ -564,14 +597,10 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
key: "ocaml",
|
key: "ocaml",
|
||||||
display_name: "OCaml",
|
display_name: "OCaml",
|
||||||
extensions: &[".ml", ".mli"],
|
extensions: &[".ml", ".mli"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "ocaml-stdlib",
|
||||||
key: "ocaml-stdlib",
|
urls: &["https://raw.githubusercontent.com/ocaml/ocaml/trunk/stdlib/list.ml"],
|
||||||
urls: &[
|
}],
|
||||||
"https://raw.githubusercontent.com/ocaml/ocaml/trunk/stdlib/list.ml",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::Indentation(&["let ", "type ", "module "]),
|
block_style: BlockStyle::Indentation(&["let ", "type ", "module "]),
|
||||||
},
|
},
|
||||||
@@ -596,21 +625,24 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
// Haskell: top-level declarations are indented blocks
|
// Haskell: top-level declarations are indented blocks
|
||||||
block_style: BlockStyle::Indentation(&[
|
block_style: BlockStyle::Indentation(&[
|
||||||
"data ", "type ", "class ", "instance ", "newtype ", "module ",
|
"data ",
|
||||||
|
"type ",
|
||||||
|
"class ",
|
||||||
|
"instance ",
|
||||||
|
"newtype ",
|
||||||
|
"module ",
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
CodeLanguage {
|
CodeLanguage {
|
||||||
key: "clojure",
|
key: "clojure",
|
||||||
display_name: "Clojure",
|
display_name: "Clojure",
|
||||||
extensions: &[".clj", ".cljs"],
|
extensions: &[".clj", ".cljs"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "clojure-core",
|
||||||
key: "clojure-core",
|
urls: &[
|
||||||
urls: &[
|
"https://raw.githubusercontent.com/clojure/clojure/master/src/clj/clojure/core.clj",
|
||||||
"https://raw.githubusercontent.com/clojure/clojure/master/src/clj/clojure/core.clj",
|
],
|
||||||
],
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::Indentation(&["(defn ", "(defn- ", "(defmacro "]),
|
block_style: BlockStyle::Indentation(&["(defn ", "(defn- ", "(defmacro "]),
|
||||||
},
|
},
|
||||||
@@ -618,15 +650,13 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
key: "r",
|
key: "r",
|
||||||
display_name: "R",
|
display_name: "R",
|
||||||
extensions: &[".r", ".R"],
|
extensions: &[".r", ".R"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "shiny",
|
||||||
key: "shiny",
|
urls: &[
|
||||||
urls: &[
|
"https://raw.githubusercontent.com/rstudio/shiny/main/R/bootstrap.R",
|
||||||
"https://raw.githubusercontent.com/rstudio/shiny/main/R/bootstrap.R",
|
"https://raw.githubusercontent.com/rstudio/shiny/main/R/input-text.R",
|
||||||
"https://raw.githubusercontent.com/rstudio/shiny/main/R/input-text.R",
|
],
|
||||||
],
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
// R functions are defined as `name <- function(...)`. Since our extractor only
|
// R functions are defined as `name <- function(...)`. Since our extractor only
|
||||||
// supports `starts_with`, we match roxygen doc blocks that precede functions.
|
// supports `starts_with`, we match roxygen doc blocks that precede functions.
|
||||||
@@ -636,36 +666,30 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
key: "erlang",
|
key: "erlang",
|
||||||
display_name: "Erlang",
|
display_name: "Erlang",
|
||||||
extensions: &[".erl"],
|
extensions: &[".erl"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "cowboy",
|
||||||
key: "cowboy",
|
urls: &[
|
||||||
urls: &[
|
"https://raw.githubusercontent.com/ninenines/cowboy/master/src/cowboy_req.erl",
|
||||||
"https://raw.githubusercontent.com/ninenines/cowboy/master/src/cowboy_req.erl",
|
"https://raw.githubusercontent.com/ninenines/cowboy/master/src/cowboy_http.erl",
|
||||||
"https://raw.githubusercontent.com/ninenines/cowboy/master/src/cowboy_http.erl",
|
],
|
||||||
],
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
// Erlang: -spec and -record use braces for types/fields.
|
// Erlang: -spec and -record use braces for types/fields.
|
||||||
// Erlang functions themselves don't use braces (they end with `.`),
|
// Erlang functions themselves don't use braces (they end with `.`),
|
||||||
// so extraction is limited to type specs and records.
|
// so extraction is limited to type specs and records.
|
||||||
block_style: BlockStyle::Braces(&[
|
block_style: BlockStyle::Braces(&["-spec ", "-record(", "-type ", "-callback "]),
|
||||||
"-spec ", "-record(", "-type ", "-callback ",
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
CodeLanguage {
|
CodeLanguage {
|
||||||
key: "groovy",
|
key: "groovy",
|
||||||
display_name: "Groovy",
|
display_name: "Groovy",
|
||||||
extensions: &[".groovy"],
|
extensions: &[".groovy"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "nextflow",
|
||||||
key: "nextflow",
|
urls: &[
|
||||||
urls: &[
|
"https://raw.githubusercontent.com/nextflow-io/nextflow/master/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy",
|
||||||
"https://raw.githubusercontent.com/nextflow-io/nextflow/master/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy",
|
"https://raw.githubusercontent.com/nextflow-io/nextflow/master/modules/nextflow/src/main/groovy/nextflow/Session.groovy",
|
||||||
"https://raw.githubusercontent.com/nextflow-io/nextflow/master/modules/nextflow/src/main/groovy/nextflow/Session.groovy",
|
],
|
||||||
],
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::Braces(&["def ", "void ", "static ", "public ", "private "]),
|
block_style: BlockStyle::Braces(&["def ", "void ", "static ", "public ", "private "]),
|
||||||
},
|
},
|
||||||
@@ -673,14 +697,12 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
key: "fsharp",
|
key: "fsharp",
|
||||||
display_name: "F#",
|
display_name: "F#",
|
||||||
extensions: &[".fs", ".fsx"],
|
extensions: &[".fs", ".fsx"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "fsharp-compiler",
|
||||||
key: "fsharp-compiler",
|
urls: &[
|
||||||
urls: &[
|
"https://raw.githubusercontent.com/dotnet/fsharp/main/src/Compiler/Utilities/lib.fs",
|
||||||
"https://raw.githubusercontent.com/dotnet/fsharp/main/src/Compiler/Utilities/lib.fs",
|
],
|
||||||
],
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::Indentation(&["let ", "member ", "type ", "module "]),
|
block_style: BlockStyle::Indentation(&["let ", "member ", "type ", "module "]),
|
||||||
},
|
},
|
||||||
@@ -688,18 +710,23 @@ pub const CODE_LANGUAGES: &[CodeLanguage] = &[
|
|||||||
key: "objective-c",
|
key: "objective-c",
|
||||||
display_name: "Objective-C",
|
display_name: "Objective-C",
|
||||||
extensions: &[".m", ".h"],
|
extensions: &[".m", ".h"],
|
||||||
repos: &[
|
repos: &[CodeRepo {
|
||||||
CodeRepo {
|
key: "afnetworking",
|
||||||
key: "afnetworking",
|
urls: &[
|
||||||
urls: &[
|
"https://raw.githubusercontent.com/AFNetworking/AFNetworking/master/AFNetworking/AFURLSessionManager.m",
|
||||||
"https://raw.githubusercontent.com/AFNetworking/AFNetworking/master/AFNetworking/AFURLSessionManager.m",
|
],
|
||||||
],
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
has_builtin: false,
|
has_builtin: false,
|
||||||
block_style: BlockStyle::Braces(&[
|
block_style: BlockStyle::Braces(&[
|
||||||
"- (", "+ (", "- (void)", "- (id)", "- (BOOL)",
|
"- (",
|
||||||
"@interface ", "@implementation ", "@protocol ", "typedef ",
|
"+ (",
|
||||||
|
"- (void)",
|
||||||
|
"- (id)",
|
||||||
|
"- (BOOL)",
|
||||||
|
"@interface ",
|
||||||
|
"@implementation ",
|
||||||
|
"@protocol ",
|
||||||
|
"typedef ",
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -767,8 +794,8 @@ pub fn build_code_download_queue(lang_key: &str, cache_dir: &str) -> Vec<(String
|
|||||||
for lk in &languages_to_download {
|
for lk in &languages_to_download {
|
||||||
if let Some(lang) = language_by_key(lk) {
|
if let Some(lang) = language_by_key(lk) {
|
||||||
for (repo_idx, repo) in lang.repos.iter().enumerate() {
|
for (repo_idx, repo) in lang.repos.iter().enumerate() {
|
||||||
let cache_path = std::path::Path::new(cache_dir)
|
let cache_path =
|
||||||
.join(format!("{}_{}.txt", lang.key, repo.key));
|
std::path::Path::new(cache_dir).join(format!("{}_{}.txt", lang.key, repo.key));
|
||||||
if !cache_path.exists()
|
if !cache_path.exists()
|
||||||
|| std::fs::metadata(&cache_path)
|
|| std::fs::metadata(&cache_path)
|
||||||
.map(|m| m.len() == 0)
|
.map(|m| m.len() == 0)
|
||||||
@@ -1653,7 +1680,8 @@ impl TextGenerator for CodeSyntaxGenerator {
|
|||||||
fn generate(
|
fn generate(
|
||||||
&mut self,
|
&mut self,
|
||||||
_filter: &CharFilter,
|
_filter: &CharFilter,
|
||||||
_focused: Option<char>,
|
_focused_char: Option<char>,
|
||||||
|
_focused_bigram: Option<[char; 2]>,
|
||||||
word_count: usize,
|
word_count: usize,
|
||||||
) -> String {
|
) -> String {
|
||||||
let embedded = self.get_snippets();
|
let embedded = self.get_snippets();
|
||||||
@@ -1721,7 +1749,10 @@ fn approx_token_count(text: &str) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn fit_snippet_to_target(snippet: &str, target_units: usize) -> String {
|
fn fit_snippet_to_target(snippet: &str, target_units: usize) -> String {
|
||||||
let max_units = target_units.saturating_mul(3).saturating_div(2).max(target_units);
|
let max_units = target_units
|
||||||
|
.saturating_mul(3)
|
||||||
|
.saturating_div(2)
|
||||||
|
.max(target_units);
|
||||||
if approx_token_count(snippet) <= max_units {
|
if approx_token_count(snippet) <= max_units {
|
||||||
return snippet.to_string();
|
return snippet.to_string();
|
||||||
}
|
}
|
||||||
@@ -1777,8 +1808,8 @@ where
|
|||||||
|
|
||||||
all_snippets.truncate(snippets_limit);
|
all_snippets.truncate(snippets_limit);
|
||||||
|
|
||||||
let cache_path = std::path::Path::new(cache_dir)
|
let cache_path =
|
||||||
.join(format!("{}_{}.txt", language_key, repo.key));
|
std::path::Path::new(cache_dir).join(format!("{}_{}.txt", language_key, repo.key));
|
||||||
let combined = all_snippets.join("\n---SNIPPET---\n");
|
let combined = all_snippets.join("\n---SNIPPET---\n");
|
||||||
fs::write(cache_path, combined).is_ok()
|
fs::write(cache_path, combined).is_ok()
|
||||||
}
|
}
|
||||||
@@ -1811,8 +1842,12 @@ fn is_noise_snippet(snippet: &str) -> bool {
|
|||||||
.lines()
|
.lines()
|
||||||
.filter(|l| {
|
.filter(|l| {
|
||||||
let t = l.trim();
|
let t = l.trim();
|
||||||
!t.is_empty() && !t.starts_with("//") && !t.starts_with('#') && !t.starts_with("/*")
|
!t.is_empty()
|
||||||
&& !t.starts_with('*') && !t.starts_with("*/")
|
&& !t.starts_with("//")
|
||||||
|
&& !t.starts_with('#')
|
||||||
|
&& !t.starts_with("/*")
|
||||||
|
&& !t.starts_with('*')
|
||||||
|
&& !t.starts_with("*/")
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -1828,8 +1863,15 @@ fn is_noise_snippet(snippet: &str) -> bool {
|
|||||||
|
|
||||||
// Reject if body consists entirely of import/use/require/include statements
|
// Reject if body consists entirely of import/use/require/include statements
|
||||||
let import_prefixes = [
|
let import_prefixes = [
|
||||||
"import ", "from ", "use ", "require", "#include", "using ",
|
"import ",
|
||||||
"package ", "module ", "extern crate ",
|
"from ",
|
||||||
|
"use ",
|
||||||
|
"require",
|
||||||
|
"#include",
|
||||||
|
"using ",
|
||||||
|
"package ",
|
||||||
|
"module ",
|
||||||
|
"extern crate ",
|
||||||
];
|
];
|
||||||
let body_lines: Vec<&str> = meaningful_lines.iter().skip(1).copied().collect();
|
let body_lines: Vec<&str> = meaningful_lines.iter().skip(1).copied().collect();
|
||||||
if !body_lines.is_empty()
|
if !body_lines.is_empty()
|
||||||
@@ -2087,7 +2129,10 @@ fn structural_extract_indent(lines: &[&str]) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while snippet_lines.last().map_or(false, |sl| sl.trim().is_empty()) {
|
while snippet_lines
|
||||||
|
.last()
|
||||||
|
.map_or(false, |sl| sl.trim().is_empty())
|
||||||
|
{
|
||||||
snippet_lines.pop();
|
snippet_lines.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2483,18 +2528,14 @@ z = 99
|
|||||||
println!(" ({lines} lines, {bytes} bytes)");
|
println!(" ({lines} lines, {bytes} bytes)");
|
||||||
total_ok += 1;
|
total_ok += 1;
|
||||||
|
|
||||||
let snippets =
|
let snippets = extract_code_snippets(&content, &lang.block_style);
|
||||||
extract_code_snippets(&content, &lang.block_style);
|
|
||||||
println!(" Extracted {} snippets", snippets.len());
|
println!(" Extracted {} snippets", snippets.len());
|
||||||
lang_total_snippets += snippets.len();
|
lang_total_snippets += snippets.len();
|
||||||
|
|
||||||
// Show first 2 snippets (truncated)
|
// Show first 2 snippets (truncated)
|
||||||
for (si, snippet) in snippets.iter().take(2).enumerate() {
|
for (si, snippet) in snippets.iter().take(2).enumerate() {
|
||||||
let preview: String = snippet
|
let preview: String =
|
||||||
.lines()
|
snippet.lines().take(5).collect::<Vec<_>>().join("\n");
|
||||||
.take(5)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
let suffix = if snippet.lines().count() > 5 {
|
let suffix = if snippet.lines().count() > 5 {
|
||||||
"\n ..."
|
"\n ..."
|
||||||
} else {
|
} else {
|
||||||
@@ -2507,7 +2548,9 @@ z = 99
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
println!(
|
println!(
|
||||||
" --- snippet {} ---\n{}{}",
|
" --- snippet {} ---\n{}{}",
|
||||||
si + 1, indented, suffix,
|
si + 1,
|
||||||
|
indented,
|
||||||
|
suffix,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,3 +39,26 @@ impl Dictionary {
|
|||||||
matching
|
matching
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_matching_focused_is_sort_only() {
|
||||||
|
let dictionary = Dictionary::load();
|
||||||
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
|
||||||
|
let without_focus = dictionary.find_matching(&filter, None);
|
||||||
|
let with_focus = dictionary.find_matching(&filter, Some('k'));
|
||||||
|
|
||||||
|
// Same membership — focused param only reorders, never filters
|
||||||
|
let mut sorted_without: Vec<&str> = without_focus.clone();
|
||||||
|
let mut sorted_with: Vec<&str> = with_focus.clone();
|
||||||
|
sorted_without.sort();
|
||||||
|
sorted_with.sort();
|
||||||
|
|
||||||
|
assert_eq!(sorted_without, sorted_with);
|
||||||
|
assert_eq!(without_focus.len(), with_focus.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ pub mod transition_table;
|
|||||||
use crate::engine::filter::CharFilter;
|
use crate::engine::filter::CharFilter;
|
||||||
|
|
||||||
pub trait TextGenerator {
|
pub trait TextGenerator {
|
||||||
fn generate(&mut self, filter: &CharFilter, focused: Option<char>, word_count: usize)
|
fn generate(
|
||||||
-> String;
|
&mut self,
|
||||||
|
filter: &CharFilter,
|
||||||
|
focused_char: Option<char>,
|
||||||
|
focused_bigram: Option<[char; 2]>,
|
||||||
|
word_count: usize,
|
||||||
|
) -> String;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,8 @@ impl TextGenerator for PassageGenerator {
|
|||||||
fn generate(
|
fn generate(
|
||||||
&mut self,
|
&mut self,
|
||||||
_filter: &CharFilter,
|
_filter: &CharFilter,
|
||||||
_focused: Option<char>,
|
_focused_char: Option<char>,
|
||||||
|
_focused_bigram: Option<[char; 2]>,
|
||||||
word_count: usize,
|
word_count: usize,
|
||||||
) -> String {
|
) -> String {
|
||||||
let use_builtin = self.selection == "all" || self.selection == "builtin";
|
let use_builtin = self.selection == "all" || self.selection == "builtin";
|
||||||
|
|||||||
@@ -56,9 +56,14 @@ impl PhoneticGenerator {
|
|||||||
Some(filtered.last().unwrap().0)
|
Some(filtered.last().unwrap().0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_phonetic_word(&mut self, filter: &CharFilter, focused: Option<char>) -> String {
|
fn generate_phonetic_word(
|
||||||
|
&mut self,
|
||||||
|
filter: &CharFilter,
|
||||||
|
focused_char: Option<char>,
|
||||||
|
focused_bigram: Option<[char; 2]>,
|
||||||
|
) -> String {
|
||||||
for _attempt in 0..5 {
|
for _attempt in 0..5 {
|
||||||
let word = self.try_generate_word(filter, focused);
|
let word = self.try_generate_word(filter, focused_char, focused_bigram);
|
||||||
if word.len() >= MIN_WORD_LEN {
|
if word.len() >= MIN_WORD_LEN {
|
||||||
return word;
|
return word;
|
||||||
}
|
}
|
||||||
@@ -67,14 +72,46 @@ impl PhoneticGenerator {
|
|||||||
"the".to_string()
|
"the".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_generate_word(&mut self, filter: &CharFilter, focused: Option<char>) -> String {
|
fn try_generate_word(
|
||||||
|
&mut self,
|
||||||
|
filter: &CharFilter,
|
||||||
|
focused: Option<char>,
|
||||||
|
focused_bigram: Option<[char; 2]>,
|
||||||
|
) -> String {
|
||||||
let mut word = Vec::new();
|
let mut word = Vec::new();
|
||||||
|
|
||||||
// Start with space prefix
|
// Try bigram-start: 30% chance to start word with bigram[0],bigram[1]
|
||||||
let start_char = if let Some(focus) = focused {
|
let bigram_eligible =
|
||||||
|
focused_bigram.filter(|b| filter.is_allowed(b[0]) && filter.is_allowed(b[1]));
|
||||||
|
let start_char = if let Some(bg) = bigram_eligible {
|
||||||
|
if self.rng.gen_bool(0.3) {
|
||||||
|
word.push(bg[0]);
|
||||||
|
word.push(bg[1]);
|
||||||
|
// Continue Markov chain from the bigram
|
||||||
|
let prefix = vec![' ', bg[0], bg[1]];
|
||||||
|
if let Some(probs) = self.table.segment(&prefix) {
|
||||||
|
Self::pick_weighted_from(&mut self.rng, probs, filter)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else if let Some(focus) = focused {
|
||||||
|
if self.rng.gen_bool(0.4) && filter.is_allowed(focus) {
|
||||||
|
word.push(focus);
|
||||||
|
let prefix = vec![' ', ' ', focus];
|
||||||
|
if let Some(probs) = self.table.segment(&prefix) {
|
||||||
|
Self::pick_weighted_from(&mut self.rng, probs, filter)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else if let Some(focus) = focused {
|
||||||
if self.rng.gen_bool(0.4) && filter.is_allowed(focus) {
|
if self.rng.gen_bool(0.4) && filter.is_allowed(focus) {
|
||||||
word.push(focus);
|
word.push(focus);
|
||||||
// Get next char from transition table
|
|
||||||
let prefix = vec![' ', ' ', focus];
|
let prefix = vec![' ', ' ', focus];
|
||||||
if let Some(probs) = self.table.segment(&prefix) {
|
if let Some(probs) = self.table.segment(&prefix) {
|
||||||
Self::pick_weighted_from(&mut self.rng, probs, filter)
|
Self::pick_weighted_from(&mut self.rng, probs, filter)
|
||||||
@@ -189,65 +226,151 @@ impl PhoneticGenerator {
|
|||||||
|
|
||||||
word.iter().collect()
|
word.iter().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pick_tiered_word(
|
||||||
|
&mut self,
|
||||||
|
all_words: &[String],
|
||||||
|
bigram_indices: &[usize],
|
||||||
|
char_indices: &[usize],
|
||||||
|
other_indices: &[usize],
|
||||||
|
recent: &[String],
|
||||||
|
) -> String {
|
||||||
|
for _ in 0..6 {
|
||||||
|
let tier = self.select_tier(bigram_indices, char_indices, other_indices);
|
||||||
|
let idx = tier[self.rng.gen_range(0..tier.len())];
|
||||||
|
let word = &all_words[idx];
|
||||||
|
if !recent.contains(word) {
|
||||||
|
return word.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: accept any word from full pool
|
||||||
|
let idx = self.rng.gen_range(0..all_words.len());
|
||||||
|
all_words[idx].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_tier<'a>(
|
||||||
|
&mut self,
|
||||||
|
bigram_indices: &'a [usize],
|
||||||
|
char_indices: &'a [usize],
|
||||||
|
other_indices: &'a [usize],
|
||||||
|
) -> &'a [usize] {
|
||||||
|
let has_bigram = bigram_indices.len() >= 2;
|
||||||
|
let has_char = char_indices.len() >= 2;
|
||||||
|
|
||||||
|
// Tier selection probabilities:
|
||||||
|
// Both available: 40% bigram, 30% char, 30% other
|
||||||
|
// Only bigram: 50% bigram, 50% other
|
||||||
|
// Only char: 70% char, 30% other
|
||||||
|
// Neither: 100% other
|
||||||
|
let roll: f64 = self.rng.gen_range(0.0..1.0);
|
||||||
|
|
||||||
|
match (has_bigram, has_char) {
|
||||||
|
(true, true) => {
|
||||||
|
if roll < 0.4 {
|
||||||
|
bigram_indices
|
||||||
|
} else if roll < 0.7 {
|
||||||
|
char_indices
|
||||||
|
} else {
|
||||||
|
if other_indices.len() >= 2 {
|
||||||
|
other_indices
|
||||||
|
} else if has_char {
|
||||||
|
char_indices
|
||||||
|
} else {
|
||||||
|
bigram_indices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(true, false) => {
|
||||||
|
if roll < 0.5 {
|
||||||
|
bigram_indices
|
||||||
|
} else {
|
||||||
|
if other_indices.len() >= 2 {
|
||||||
|
other_indices
|
||||||
|
} else {
|
||||||
|
bigram_indices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(false, true) => {
|
||||||
|
if roll < 0.7 {
|
||||||
|
char_indices
|
||||||
|
} else {
|
||||||
|
if other_indices.len() >= 2 {
|
||||||
|
other_indices
|
||||||
|
} else {
|
||||||
|
char_indices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(false, false) => {
|
||||||
|
// Use other_indices if available, otherwise all words
|
||||||
|
if other_indices.len() >= 2 {
|
||||||
|
other_indices
|
||||||
|
} else {
|
||||||
|
char_indices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextGenerator for PhoneticGenerator {
|
impl TextGenerator for PhoneticGenerator {
|
||||||
fn generate(
|
fn generate(
|
||||||
&mut self,
|
&mut self,
|
||||||
filter: &CharFilter,
|
filter: &CharFilter,
|
||||||
focused: Option<char>,
|
focused_char: Option<char>,
|
||||||
|
focused_bigram: Option<[char; 2]>,
|
||||||
word_count: usize,
|
word_count: usize,
|
||||||
) -> String {
|
) -> String {
|
||||||
// keybr's approach: prefer real words when enough match the filter
|
|
||||||
// Collect matching words into owned Vec to avoid borrow conflict
|
|
||||||
let matching_words: Vec<String> = self
|
let matching_words: Vec<String> = self
|
||||||
.dictionary
|
.dictionary
|
||||||
.find_matching(filter, focused)
|
.find_matching(filter, None)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
let use_real_words = matching_words.len() >= MIN_REAL_WORDS;
|
let use_real_words = matching_words.len() >= MIN_REAL_WORDS;
|
||||||
|
|
||||||
|
// Pre-categorize words into tiers for real-word mode
|
||||||
|
let bigram_str = focused_bigram.map(|b| format!("{}{}", b[0], b[1]));
|
||||||
|
let focus_char_lower = focused_char.filter(|ch| ch.is_ascii_lowercase());
|
||||||
|
|
||||||
|
let (bigram_indices, char_indices, other_indices) = if use_real_words {
|
||||||
|
let mut bi = Vec::new();
|
||||||
|
let mut ci = Vec::new();
|
||||||
|
let mut oi = Vec::new();
|
||||||
|
for (i, w) in matching_words.iter().enumerate() {
|
||||||
|
if bigram_str.as_ref().is_some_and(|b| w.contains(b.as_str())) {
|
||||||
|
bi.push(i);
|
||||||
|
} else if focus_char_lower.is_some_and(|ch| w.contains(ch)) {
|
||||||
|
ci.push(i);
|
||||||
|
} else {
|
||||||
|
oi.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(bi, ci, oi)
|
||||||
|
} else {
|
||||||
|
(vec![], vec![], vec![])
|
||||||
|
};
|
||||||
|
|
||||||
let mut words: Vec<String> = Vec::new();
|
let mut words: Vec<String> = Vec::new();
|
||||||
let mut last_word = String::new();
|
let mut recent: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for _ in 0..word_count {
|
for _ in 0..word_count {
|
||||||
if use_real_words {
|
if use_real_words {
|
||||||
// Pick a real word (avoid consecutive duplicates).
|
let word = self.pick_tiered_word(
|
||||||
// If focused is set, bias sampling toward words containing that key.
|
&matching_words,
|
||||||
let focus = focused.filter(|ch| ch.is_ascii_lowercase());
|
&bigram_indices,
|
||||||
let focused_indices: Vec<usize> = if let Some(ch) = focus {
|
&char_indices,
|
||||||
matching_words
|
&other_indices,
|
||||||
.iter()
|
&recent,
|
||||||
.enumerate()
|
);
|
||||||
.filter_map(|(i, w)| w.contains(ch).then_some(i))
|
recent.push(word.clone());
|
||||||
.collect()
|
if recent.len() > 4 {
|
||||||
} else {
|
recent.remove(0);
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
let mut picked = None;
|
|
||||||
for _ in 0..6 {
|
|
||||||
let idx = if !focused_indices.is_empty() && self.rng.gen_bool(0.70) {
|
|
||||||
let j = self.rng.gen_range(0..focused_indices.len());
|
|
||||||
focused_indices[j]
|
|
||||||
} else {
|
|
||||||
self.rng.gen_range(0..matching_words.len())
|
|
||||||
};
|
|
||||||
let word = matching_words[idx].clone();
|
|
||||||
if word != last_word {
|
|
||||||
picked = Some(word);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let word = match picked {
|
|
||||||
Some(w) => w,
|
|
||||||
None => self.generate_phonetic_word(filter, focused),
|
|
||||||
};
|
|
||||||
last_word.clone_from(&word);
|
|
||||||
words.push(word);
|
words.push(word);
|
||||||
} else {
|
} else {
|
||||||
// Fall back to phonetic pseudo-words
|
let word = self.generate_phonetic_word(filter, focused_char, focused_bigram);
|
||||||
let word = self.generate_phonetic_word(filter, focused);
|
|
||||||
words.push(word);
|
words.push(word);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,7 +395,7 @@ mod tests {
|
|||||||
Dictionary::load(),
|
Dictionary::load(),
|
||||||
SmallRng::seed_from_u64(42),
|
SmallRng::seed_from_u64(42),
|
||||||
);
|
);
|
||||||
let focused_text = focused_gen.generate(&filter, Some('k'), 1200);
|
let focused_text = focused_gen.generate(&filter, Some('k'), None, 1200);
|
||||||
let focused_count = focused_text
|
let focused_count = focused_text
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.filter(|w| w.contains('k'))
|
.filter(|w| w.contains('k'))
|
||||||
@@ -280,7 +403,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut baseline_gen =
|
let mut baseline_gen =
|
||||||
PhoneticGenerator::new(table, Dictionary::load(), SmallRng::seed_from_u64(42));
|
PhoneticGenerator::new(table, Dictionary::load(), SmallRng::seed_from_u64(42));
|
||||||
let baseline_text = baseline_gen.generate(&filter, None, 1200);
|
let baseline_text = baseline_gen.generate(&filter, None, None, 1200);
|
||||||
let baseline_count = baseline_text
|
let baseline_count = baseline_text
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.filter(|w| w.contains('k'))
|
.filter(|w| w.contains('k'))
|
||||||
@@ -291,4 +414,64 @@ mod tests {
|
|||||||
"focused_count={focused_count}, baseline_count={baseline_count}"
|
"focused_count={focused_count}, baseline_count={baseline_count}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_phonetic_bigram_focus_increases_bigram_words() {
|
||||||
|
let dictionary = Dictionary::load();
|
||||||
|
let table = TransitionTable::build_from_words(&dictionary.words_list());
|
||||||
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
|
||||||
|
let mut bigram_gen = PhoneticGenerator::new(
|
||||||
|
table.clone(),
|
||||||
|
Dictionary::load(),
|
||||||
|
SmallRng::seed_from_u64(42),
|
||||||
|
);
|
||||||
|
let bigram_text = bigram_gen.generate(&filter, None, Some(['t', 'h']), 1200);
|
||||||
|
let bigram_count = bigram_text
|
||||||
|
.split_whitespace()
|
||||||
|
.filter(|w| w.contains("th"))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let mut baseline_gen =
|
||||||
|
PhoneticGenerator::new(table, Dictionary::load(), SmallRng::seed_from_u64(42));
|
||||||
|
let baseline_text = baseline_gen.generate(&filter, None, None, 1200);
|
||||||
|
let baseline_count = baseline_text
|
||||||
|
.split_whitespace()
|
||||||
|
.filter(|w| w.contains("th"))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
bigram_count > baseline_count,
|
||||||
|
"bigram_count={bigram_count}, baseline_count={baseline_count}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_phonetic_dual_focus_no_excessive_repeats() {
|
||||||
|
let dictionary = Dictionary::load();
|
||||||
|
let table = TransitionTable::build_from_words(&dictionary.words_list());
|
||||||
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
|
||||||
|
let mut generator =
|
||||||
|
PhoneticGenerator::new(table, Dictionary::load(), SmallRng::seed_from_u64(42));
|
||||||
|
let text = generator.generate(&filter, Some('k'), Some(['t', 'h']), 200);
|
||||||
|
let words: Vec<&str> = text.split_whitespace().collect();
|
||||||
|
|
||||||
|
// Check no word appears > 3 times consecutively
|
||||||
|
let mut max_consecutive = 1;
|
||||||
|
let mut current_run = 1;
|
||||||
|
for i in 1..words.len() {
|
||||||
|
if words[i] == words[i - 1] {
|
||||||
|
current_run += 1;
|
||||||
|
max_consecutive = max_consecutive.max(current_run);
|
||||||
|
} else {
|
||||||
|
current_run = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
max_consecutive <= 3,
|
||||||
|
"Max consecutive repeats = {max_consecutive}, expected <= 3"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
681
src/main.rs
681
src/main.rs
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ use serde::{Serialize, de::DeserializeOwned};
|
|||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::store::schema::{
|
use crate::store::schema::{
|
||||||
DrillHistoryData, ExportData, KeyStatsData, ProfileData, EXPORT_VERSION,
|
DrillHistoryData, EXPORT_VERSION, ExportData, KeyStatsData, ProfileData,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct JsonStore {
|
pub struct JsonStore {
|
||||||
@@ -136,9 +136,18 @@ impl JsonStore {
|
|||||||
|
|
||||||
let files: Vec<(&str, String)> = vec![
|
let files: Vec<(&str, String)> = vec![
|
||||||
("profile.json", serde_json::to_string_pretty(&data.profile)?),
|
("profile.json", serde_json::to_string_pretty(&data.profile)?),
|
||||||
("key_stats.json", serde_json::to_string_pretty(&data.key_stats)?),
|
(
|
||||||
("key_stats_ranked.json", serde_json::to_string_pretty(&data.ranked_key_stats)?),
|
"key_stats.json",
|
||||||
("lesson_history.json", serde_json::to_string_pretty(&data.drill_history)?),
|
serde_json::to_string_pretty(&data.key_stats)?,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"key_stats_ranked.json",
|
||||||
|
serde_json::to_string_pretty(&data.ranked_key_stats)?,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lesson_history.json",
|
||||||
|
serde_json::to_string_pretty(&data.drill_history)?,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Stage phase: write .tmp files
|
// Stage phase: write .tmp files
|
||||||
@@ -172,9 +181,7 @@ impl JsonStore {
|
|||||||
let had_original = final_path.exists();
|
let had_original = final_path.exists();
|
||||||
|
|
||||||
// Back up existing file if it exists
|
// Back up existing file if it exists
|
||||||
if had_original
|
if had_original && let Err(e) = fs::rename(&final_path, &bak_path) {
|
||||||
&& let Err(e) = fs::rename(&final_path, &bak_path)
|
|
||||||
{
|
|
||||||
// Rollback: restore already committed files
|
// Rollback: restore already committed files
|
||||||
for (committed_final, committed_bak, committed_had) in &committed {
|
for (committed_final, committed_bak, committed_had) in &committed {
|
||||||
if *committed_had {
|
if *committed_had {
|
||||||
@@ -335,12 +342,19 @@ mod tests {
|
|||||||
// Now create a store that points to a nonexistent subdir of the same tmpdir
|
// Now create a store that points to a nonexistent subdir of the same tmpdir
|
||||||
// so that staging .tmp writes will fail
|
// so that staging .tmp writes will fail
|
||||||
let bad_dir = _dir.path().join("nonexistent_subdir");
|
let bad_dir = _dir.path().join("nonexistent_subdir");
|
||||||
let bad_store = JsonStore { base_dir: bad_dir.clone() };
|
let bad_store = JsonStore {
|
||||||
|
base_dir: bad_dir.clone(),
|
||||||
|
};
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
let export = make_test_export(&config);
|
let export = make_test_export(&config);
|
||||||
let result = bad_store.import_all(&export);
|
let result = bad_store.import_all(&export);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result.unwrap_err().to_string().contains("Import failed during staging"));
|
assert!(
|
||||||
|
result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("Import failed during staging")
|
||||||
|
);
|
||||||
|
|
||||||
// Original file in the real store is unchanged
|
// Original file in the real store is unchanged
|
||||||
let after_content = fs::read_to_string(store.file_path("profile.json")).unwrap();
|
let after_content = fs::read_to_string(store.file_path("profile.json")).unwrap();
|
||||||
@@ -390,5 +404,4 @@ mod tests {
|
|||||||
// Should have been cleaned up
|
// Should have been cleaned up
|
||||||
assert!(!store.file_path("profile.json.bak").exists());
|
assert!(!store.file_path("profile.json.bak").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,20 @@ use crate::ui::theme::Theme;
|
|||||||
pub struct Dashboard<'a> {
|
pub struct Dashboard<'a> {
|
||||||
pub result: &'a DrillResult,
|
pub result: &'a DrillResult,
|
||||||
pub theme: &'a Theme,
|
pub theme: &'a Theme,
|
||||||
|
pub input_lock_remaining_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Dashboard<'a> {
|
impl<'a> Dashboard<'a> {
|
||||||
pub fn new(result: &'a DrillResult, theme: &'a Theme) -> Self {
|
pub fn new(
|
||||||
Self { result, theme }
|
result: &'a DrillResult,
|
||||||
|
theme: &'a Theme,
|
||||||
|
input_lock_remaining_ms: Option<u64>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
result,
|
||||||
|
theme,
|
||||||
|
input_lock_remaining_ms,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,16 +123,31 @@ impl Widget for Dashboard<'_> {
|
|||||||
]);
|
]);
|
||||||
Paragraph::new(chars_line).render(layout[4], buf);
|
Paragraph::new(chars_line).render(layout[4], buf);
|
||||||
|
|
||||||
let help = Paragraph::new(Line::from(vec![
|
let help = if let Some(ms) = self.input_lock_remaining_ms {
|
||||||
Span::styled(
|
Paragraph::new(Line::from(vec![
|
||||||
" [c/Enter/Space] Continue ",
|
Span::styled(
|
||||||
Style::default().fg(colors.accent()),
|
" Input temporarily blocked ",
|
||||||
),
|
Style::default().fg(colors.warning()),
|
||||||
Span::styled("[r] Retry ", Style::default().fg(colors.accent())),
|
),
|
||||||
Span::styled("[q] Menu ", Style::default().fg(colors.accent())),
|
Span::styled(
|
||||||
Span::styled("[s] Stats ", Style::default().fg(colors.accent())),
|
format!("({ms}ms remaining)"),
|
||||||
Span::styled("[x] Delete", Style::default().fg(colors.accent())),
|
Style::default()
|
||||||
]));
|
.fg(colors.warning())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
} else {
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" [c/Enter/Space] Continue ",
|
||||||
|
Style::default().fg(colors.accent()),
|
||||||
|
),
|
||||||
|
Span::styled("[r] Retry ", Style::default().fg(colors.accent())),
|
||||||
|
Span::styled("[q] Menu ", Style::default().fg(colors.accent())),
|
||||||
|
Span::styled("[s] Stats ", Style::default().fg(colors.accent())),
|
||||||
|
Span::styled("[x] Delete", Style::default().fg(colors.accent())),
|
||||||
|
]))
|
||||||
|
};
|
||||||
help.render(layout[6], buf);
|
help.render(layout[6], buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,22 @@ impl KeyboardDiagram<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let offsets: &[u16] = &[3, 4, 6];
|
let offsets: &[u16] = &[3, 4, 6];
|
||||||
|
let keyboard_width = letter_rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(row_idx, row)| {
|
||||||
|
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
let row_end = offset + row.len() as u16 * key_width;
|
||||||
|
match row_idx {
|
||||||
|
0 => row_end + 3, // [B]
|
||||||
|
1 => row_end + 3, // [E]
|
||||||
|
2 => row_end + 3, // [S]
|
||||||
|
_ => row_end,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
|
||||||
|
|
||||||
for (row_idx, row) in letter_rows.iter().enumerate() {
|
for (row_idx, row) in letter_rows.iter().enumerate() {
|
||||||
let y = inner.y + row_idx as u16;
|
let y = inner.y + row_idx as u16;
|
||||||
@@ -283,18 +299,18 @@ impl KeyboardDiagram<'_> {
|
|||||||
let is_next = self.next_key == Some(TAB);
|
let is_next = self.next_key == Some(TAB);
|
||||||
let is_sel = self.is_sentinel_selected(TAB);
|
let is_sel = self.is_sentinel_selected(TAB);
|
||||||
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
|
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
|
||||||
buf.set_string(inner.x, y, "[T]", style);
|
buf.set_string(start_x, y, "[T]", style);
|
||||||
}
|
}
|
||||||
2 => {
|
2 => {
|
||||||
let is_dep = self.shift_held;
|
let is_dep = self.shift_held;
|
||||||
let style = modifier_key_style(is_dep, false, false, colors);
|
let style = modifier_key_style(is_dep, false, false, colors);
|
||||||
buf.set_string(inner.x, y, "[S]", style);
|
buf.set_string(start_x, y, "[S]", style);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
let x = start_x + offset + col_idx as u16 * key_width;
|
||||||
if x + key_width > inner.x + inner.width {
|
if x + key_width > inner.x + inner.width {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -326,7 +342,7 @@ impl KeyboardDiagram<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render trailing modifier key
|
// Render trailing modifier key
|
||||||
let row_end_x = inner.x + offset + row.len() as u16 * key_width;
|
let row_end_x = start_x + offset + row.len() as u16 * key_width;
|
||||||
match row_idx {
|
match row_idx {
|
||||||
1 => {
|
1 => {
|
||||||
if row_end_x + 3 <= inner.x + inner.width {
|
if row_end_x + 3 <= inner.x + inner.width {
|
||||||
@@ -351,7 +367,7 @@ impl KeyboardDiagram<'_> {
|
|||||||
// Backspace at end of first row
|
// Backspace at end of first row
|
||||||
if inner.height >= 3 {
|
if inner.height >= 3 {
|
||||||
let y = inner.y;
|
let y = inner.y;
|
||||||
let row_end_x = inner.x + offsets[0] + letter_rows[0].len() as u16 * key_width;
|
let row_end_x = start_x + offsets[0] + letter_rows[0].len() as u16 * key_width;
|
||||||
if row_end_x + 3 <= inner.x + inner.width {
|
if row_end_x + 3 <= inner.x + inner.width {
|
||||||
let is_dep = self.depressed_keys.contains(&BACKSPACE);
|
let is_dep = self.depressed_keys.contains(&BACKSPACE);
|
||||||
let is_next = self.next_key == Some(BACKSPACE);
|
let is_next = self.next_key == Some(BACKSPACE);
|
||||||
@@ -373,6 +389,24 @@ impl KeyboardDiagram<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let offsets: &[u16] = &[0, 5, 5, 6];
|
let offsets: &[u16] = &[0, 5, 5, 6];
|
||||||
|
let keyboard_width = self
|
||||||
|
.model
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(row_idx, row)| {
|
||||||
|
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
let row_end = offset + row.len() as u16 * key_width;
|
||||||
|
match row_idx {
|
||||||
|
0 => row_end + 6, // [Bksp]
|
||||||
|
2 => row_end + 7, // [Enter]
|
||||||
|
3 => row_end + 6, // [Shft]
|
||||||
|
_ => row_end,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
|
||||||
|
|
||||||
for (row_idx, row) in self.model.rows.iter().enumerate() {
|
for (row_idx, row) in self.model.rows.iter().enumerate() {
|
||||||
let y = inner.y + row_idx as u16;
|
let y = inner.y + row_idx as u16;
|
||||||
@@ -391,7 +425,7 @@ impl KeyboardDiagram<'_> {
|
|||||||
let is_sel = self.is_sentinel_selected(TAB);
|
let is_sel = self.is_sentinel_selected(TAB);
|
||||||
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
|
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
|
||||||
let label = format!("[{}]", display::key_short_label(TAB));
|
let label = format!("[{}]", display::key_short_label(TAB));
|
||||||
buf.set_string(inner.x, y, &label, style);
|
buf.set_string(start_x, y, &label, style);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2 => {
|
2 => {
|
||||||
@@ -401,10 +435,10 @@ impl KeyboardDiagram<'_> {
|
|||||||
let style = Style::default()
|
let style = Style::default()
|
||||||
.fg(readable_fg(bg, colors.warning()))
|
.fg(readable_fg(bg, colors.warning()))
|
||||||
.bg(bg);
|
.bg(bg);
|
||||||
buf.set_string(inner.x, y, "[Cap]", style);
|
buf.set_string(start_x, y, "[Cap]", style);
|
||||||
} else {
|
} else {
|
||||||
let style = Style::default().fg(colors.text_pending()).bg(colors.bg());
|
let style = Style::default().fg(colors.text_pending()).bg(colors.bg());
|
||||||
buf.set_string(inner.x, y, "[ ]", style);
|
buf.set_string(start_x, y, "[ ]", style);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,14 +446,14 @@ impl KeyboardDiagram<'_> {
|
|||||||
if offset >= 6 {
|
if offset >= 6 {
|
||||||
let is_dep = self.shift_held;
|
let is_dep = self.shift_held;
|
||||||
let style = modifier_key_style(is_dep, false, false, colors);
|
let style = modifier_key_style(is_dep, false, false, colors);
|
||||||
buf.set_string(inner.x, y, "[Shft]", style);
|
buf.set_string(start_x, y, "[Shft]", style);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
let x = start_x + offset + col_idx as u16 * key_width;
|
||||||
if x + key_width > inner.x + inner.width {
|
if x + key_width > inner.x + inner.width {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -451,7 +485,7 @@ impl KeyboardDiagram<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render trailing modifier keys
|
// Render trailing modifier keys
|
||||||
let after_x = inner.x + offset + row.len() as u16 * key_width;
|
let after_x = start_x + offset + row.len() as u16 * key_width;
|
||||||
match row_idx {
|
match row_idx {
|
||||||
0 => {
|
0 => {
|
||||||
if after_x + 6 <= inner.x + inner.width {
|
if after_x + 6 <= inner.x + inner.width {
|
||||||
@@ -484,34 +518,13 @@ impl KeyboardDiagram<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute full keyboard width from rendered rows (including trailing modifier keys),
|
|
||||||
// so the space bar centers relative to the keyboard, not the container.
|
|
||||||
let keyboard_width = self
|
|
||||||
.model
|
|
||||||
.rows
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(row_idx, row)| {
|
|
||||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
|
||||||
let row_end = offset + row.len() as u16 * key_width;
|
|
||||||
match row_idx {
|
|
||||||
0 => row_end + 6, // [Bksp]
|
|
||||||
2 => row_end + 7, // [Enter]
|
|
||||||
3 => row_end + 6, // [Shft]
|
|
||||||
_ => row_end,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0)
|
|
||||||
.min(inner.width);
|
|
||||||
|
|
||||||
// Space bar row (row 4)
|
// Space bar row (row 4)
|
||||||
let space_y = inner.y + 4;
|
let space_y = inner.y + 4;
|
||||||
if space_y < inner.y + inner.height {
|
if space_y < inner.y + inner.height {
|
||||||
let space_name = display::key_display_name(SPACE);
|
let space_name = display::key_display_name(SPACE);
|
||||||
let space_label = format!("[ {space_name} ]");
|
let space_label = format!("[ {space_name} ]");
|
||||||
let space_width = space_label.len() as u16;
|
let space_width = space_label.len() as u16;
|
||||||
let space_x = inner.x + (keyboard_width.saturating_sub(space_width)) / 2;
|
let space_x = start_x + (keyboard_width.saturating_sub(space_width)) / 2;
|
||||||
if space_x + space_width <= inner.x + inner.width {
|
if space_x + space_width <= inner.x + inner.width {
|
||||||
let is_dep = self.depressed_keys.contains(&SPACE);
|
let is_dep = self.depressed_keys.contains(&SPACE);
|
||||||
let is_next = self.next_key == Some(SPACE);
|
let is_next = self.next_key == Some(SPACE);
|
||||||
@@ -527,6 +540,16 @@ impl KeyboardDiagram<'_> {
|
|||||||
let letter_rows = self.model.letter_rows();
|
let letter_rows = self.model.letter_rows();
|
||||||
let key_width: u16 = 5;
|
let key_width: u16 = 5;
|
||||||
let offsets: &[u16] = &[1, 3, 5];
|
let offsets: &[u16] = &[1, 3, 5];
|
||||||
|
let keyboard_width = letter_rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(row_idx, row)| {
|
||||||
|
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
offset + row.len() as u16 * key_width
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
|
||||||
|
|
||||||
if inner.height < 3 || inner.width < 30 {
|
if inner.height < 3 || inner.width < 30 {
|
||||||
return;
|
return;
|
||||||
@@ -541,7 +564,7 @@ impl KeyboardDiagram<'_> {
|
|||||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
|
||||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
let x = start_x + offset + col_idx as u16 * key_width;
|
||||||
if x + key_width > inner.x + inner.width {
|
if x + key_width > inner.x + inner.width {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ impl Widget for SkillTreeWidget<'_> {
|
|||||||
let notice_lines = footer_notice
|
let notice_lines = footer_notice
|
||||||
.map(|text| wrapped_line_count(text, inner.width as usize))
|
.map(|text| wrapped_line_count(text, inner.width as usize))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let show_notice =
|
let show_notice = footer_notice.is_some()
|
||||||
footer_notice.is_some() && (inner.height as usize >= hint_lines.len() + notice_lines + 8);
|
&& (inner.height as usize >= hint_lines.len() + notice_lines + 8);
|
||||||
let footer_needed = hint_lines.len() + if show_notice { notice_lines } else { 0 } + 1;
|
let footer_needed = hint_lines.len() + if show_notice { notice_lines } else { 0 } + 1;
|
||||||
let footer_height = footer_needed
|
let footer_height = footer_needed
|
||||||
.min(inner.height.saturating_sub(5) as usize)
|
.min(inner.height.saturating_sub(5) as usize)
|
||||||
@@ -161,7 +161,10 @@ impl Widget for SkillTreeWidget<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
footer_lines.extend(hint_lines.into_iter().map(|line| {
|
footer_lines.extend(hint_lines.into_iter().map(|line| {
|
||||||
Line::from(Span::styled(line, Style::default().fg(colors.text_pending())))
|
Line::from(Span::styled(
|
||||||
|
line,
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
))
|
||||||
}));
|
}));
|
||||||
let footer = Paragraph::new(footer_lines).wrap(Wrap { trim: false });
|
let footer = Paragraph::new(footer_lines).wrap(Wrap { trim: false });
|
||||||
footer.render(layout[3], buf);
|
footer.render(layout[3], buf);
|
||||||
|
|||||||
@@ -6,12 +6,39 @@ use ratatui::widgets::{Block, Clear, Paragraph, Widget};
|
|||||||
use std::collections::{BTreeSet, HashMap};
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
|
||||||
use crate::engine::key_stats::KeyStatsStore;
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
|
use crate::engine::ngram_stats::{AnomalyType, FocusSelection};
|
||||||
use crate::keyboard::display::{self, BACKSPACE, ENTER, MODIFIER_SENTINELS, SPACE, TAB};
|
use crate::keyboard::display::{self, BACKSPACE, ENTER, MODIFIER_SENTINELS, SPACE, TAB};
|
||||||
use crate::keyboard::model::KeyboardModel;
|
use crate::keyboard::model::KeyboardModel;
|
||||||
use crate::session::result::DrillResult;
|
use crate::session::result::DrillResult;
|
||||||
use crate::ui::components::activity_heatmap::ActivityHeatmap;
|
use crate::ui::components::activity_heatmap::ActivityHeatmap;
|
||||||
use crate::ui::theme::Theme;
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// N-grams tab view models
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct AnomalyBigramRow {
|
||||||
|
pub bigram: String,
|
||||||
|
pub anomaly_pct: f64,
|
||||||
|
pub sample_count: usize,
|
||||||
|
pub error_count: usize,
|
||||||
|
pub error_rate_ema: f64,
|
||||||
|
pub speed_ms: f64,
|
||||||
|
pub expected_baseline: f64,
|
||||||
|
pub confirmed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NgramTabData {
|
||||||
|
pub focus: FocusSelection,
|
||||||
|
pub error_anomalies: Vec<AnomalyBigramRow>,
|
||||||
|
pub speed_anomalies: Vec<AnomalyBigramRow>,
|
||||||
|
pub total_bigrams: usize,
|
||||||
|
pub total_trigrams: usize,
|
||||||
|
pub hesitation_threshold_ms: f64,
|
||||||
|
pub latest_trigram_gain: Option<f64>,
|
||||||
|
pub scope_label: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct StatsDashboard<'a> {
|
pub struct StatsDashboard<'a> {
|
||||||
pub history: &'a [DrillResult],
|
pub history: &'a [DrillResult],
|
||||||
pub key_stats: &'a KeyStatsStore,
|
pub key_stats: &'a KeyStatsStore,
|
||||||
@@ -24,6 +51,7 @@ pub struct StatsDashboard<'a> {
|
|||||||
pub history_selected: usize,
|
pub history_selected: usize,
|
||||||
pub history_confirm_delete: bool,
|
pub history_confirm_delete: bool,
|
||||||
pub keyboard_model: &'a KeyboardModel,
|
pub keyboard_model: &'a KeyboardModel,
|
||||||
|
pub ngram_data: Option<&'a NgramTabData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> StatsDashboard<'a> {
|
impl<'a> StatsDashboard<'a> {
|
||||||
@@ -39,6 +67,7 @@ impl<'a> StatsDashboard<'a> {
|
|||||||
history_selected: usize,
|
history_selected: usize,
|
||||||
history_confirm_delete: bool,
|
history_confirm_delete: bool,
|
||||||
keyboard_model: &'a KeyboardModel,
|
keyboard_model: &'a KeyboardModel,
|
||||||
|
ngram_data: Option<&'a NgramTabData>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
history,
|
history,
|
||||||
@@ -52,6 +81,7 @@ impl<'a> StatsDashboard<'a> {
|
|||||||
history_selected,
|
history_selected,
|
||||||
history_confirm_delete,
|
history_confirm_delete,
|
||||||
keyboard_model,
|
keyboard_model,
|
||||||
|
ngram_data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,6 +122,7 @@ impl Widget for StatsDashboard<'_> {
|
|||||||
"[3] Activity",
|
"[3] Activity",
|
||||||
"[4] Accuracy",
|
"[4] Accuracy",
|
||||||
"[5] Timing",
|
"[5] Timing",
|
||||||
|
"[6] N-grams",
|
||||||
];
|
];
|
||||||
let tab_spans: Vec<Span> = tabs
|
let tab_spans: Vec<Span> = tabs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -114,9 +145,9 @@ impl Widget for StatsDashboard<'_> {
|
|||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
let footer_text = if self.active_tab == 1 {
|
let footer_text = if self.active_tab == 1 {
|
||||||
" [ESC] Back [Tab] Next tab [1-5] Switch tab [j/k] Navigate [x] Delete"
|
" [ESC] Back [Tab] Next tab [1-6] Switch tab [j/k] Navigate [x] Delete"
|
||||||
} else {
|
} else {
|
||||||
" [ESC] Back [Tab] Next tab [1-5] Switch tab"
|
" [ESC] Back [Tab] Next tab [1-6] Switch tab"
|
||||||
};
|
};
|
||||||
let footer = Paragraph::new(Line::from(Span::styled(
|
let footer = Paragraph::new(Line::from(Span::styled(
|
||||||
footer_text,
|
footer_text,
|
||||||
@@ -163,6 +194,7 @@ impl StatsDashboard<'_> {
|
|||||||
2 => self.render_activity_tab(area, buf),
|
2 => self.render_activity_tab(area, buf),
|
||||||
3 => self.render_accuracy_tab(area, buf),
|
3 => self.render_accuracy_tab(area, buf),
|
||||||
4 => self.render_timing_tab(area, buf),
|
4 => self.render_timing_tab(area, buf),
|
||||||
|
5 => self.render_ngram_tab(area, buf),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -692,6 +724,17 @@ impl StatsDashboard<'_> {
|
|||||||
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
||||||
let all_rows = &self.keyboard_model.rows;
|
let all_rows = &self.keyboard_model.rows;
|
||||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||||
|
let kbd_width = all_rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, row)| {
|
||||||
|
let off = offsets.get(i).copied().unwrap_or(0);
|
||||||
|
off + row.len() as u16 * key_step
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(inner.width)
|
||||||
|
.min(inner.width);
|
||||||
|
let keyboard_x = inner.x + inner.width.saturating_sub(kbd_width) / 2;
|
||||||
|
|
||||||
for (row_idx, row) in all_rows.iter().enumerate() {
|
for (row_idx, row) in all_rows.iter().enumerate() {
|
||||||
let base_y = if show_shifted {
|
let base_y = if show_shifted {
|
||||||
@@ -711,7 +754,7 @@ impl StatsDashboard<'_> {
|
|||||||
let shifted_y = base_y - 1;
|
let shifted_y = base_y - 1;
|
||||||
if shifted_y >= inner.y {
|
if shifted_y >= inner.y {
|
||||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||||
let x = inner.x + offset + col_idx as u16 * key_step;
|
let x = keyboard_x + offset + col_idx as u16 * key_step;
|
||||||
if x + key_width > inner.x + inner.width {
|
if x + key_width > inner.x + inner.width {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -733,7 +776,7 @@ impl StatsDashboard<'_> {
|
|||||||
|
|
||||||
// Base row
|
// Base row
|
||||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||||
let x = inner.x + offset + col_idx as u16 * key_step;
|
let x = keyboard_x + offset + col_idx as u16 * key_step;
|
||||||
if x + key_width > inner.x + inner.width {
|
if x + key_width > inner.x + inner.width {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -745,20 +788,8 @@ impl StatsDashboard<'_> {
|
|||||||
let display = format_accuracy_cell(key, accuracy, key_width);
|
let display = format_accuracy_cell(key, accuracy, key_width);
|
||||||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modifier key stats row below the keyboard, spread across keyboard width
|
// Modifier key stats row below the keyboard, spread across keyboard width
|
||||||
let kbd_width = all_rows
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, row)| {
|
|
||||||
let off = offsets.get(i).copied().unwrap_or(0);
|
|
||||||
off + row.len() as u16 * key_step
|
|
||||||
})
|
|
||||||
.max()
|
|
||||||
.unwrap_or(inner.width)
|
|
||||||
.min(inner.width);
|
|
||||||
let mod_y = if show_shifted {
|
let mod_y = if show_shifted {
|
||||||
inner.y + all_rows.len() as u16 * 2 + 1
|
inner.y + all_rows.len() as u16 * 2 + 1
|
||||||
} else {
|
} else {
|
||||||
@@ -783,7 +814,7 @@ impl StatsDashboard<'_> {
|
|||||||
let accuracy = self.get_key_accuracy(key);
|
let accuracy = self.get_key_accuracy(key);
|
||||||
let fg_color = accuracy_color(accuracy, colors);
|
let fg_color = accuracy_color(accuracy, colors);
|
||||||
buf.set_string(
|
buf.set_string(
|
||||||
inner.x + positions[i],
|
keyboard_x + positions[i],
|
||||||
mod_y,
|
mod_y,
|
||||||
&labels[i],
|
&labels[i],
|
||||||
Style::default().fg(fg_color),
|
Style::default().fg(fg_color),
|
||||||
@@ -848,6 +879,17 @@ impl StatsDashboard<'_> {
|
|||||||
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
||||||
let all_rows = &self.keyboard_model.rows;
|
let all_rows = &self.keyboard_model.rows;
|
||||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||||
|
let kbd_width = all_rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, row)| {
|
||||||
|
let off = offsets.get(i).copied().unwrap_or(0);
|
||||||
|
off + row.len() as u16 * key_step
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(inner.width)
|
||||||
|
.min(inner.width);
|
||||||
|
let keyboard_x = inner.x + inner.width.saturating_sub(kbd_width) / 2;
|
||||||
|
|
||||||
for (row_idx, row) in all_rows.iter().enumerate() {
|
for (row_idx, row) in all_rows.iter().enumerate() {
|
||||||
let base_y = if show_shifted {
|
let base_y = if show_shifted {
|
||||||
@@ -866,7 +908,7 @@ impl StatsDashboard<'_> {
|
|||||||
let shifted_y = base_y - 1;
|
let shifted_y = base_y - 1;
|
||||||
if shifted_y >= inner.y {
|
if shifted_y >= inner.y {
|
||||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||||
let x = inner.x + offset + col_idx as u16 * key_step;
|
let x = keyboard_x + offset + col_idx as u16 * key_step;
|
||||||
if x + key_width > inner.x + inner.width {
|
if x + key_width > inner.x + inner.width {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -886,7 +928,7 @@ impl StatsDashboard<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||||
let x = inner.x + offset + col_idx as u16 * key_step;
|
let x = keyboard_x + offset + col_idx as u16 * key_step;
|
||||||
if x + key_width > inner.x + inner.width {
|
if x + key_width > inner.x + inner.width {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -897,20 +939,8 @@ impl StatsDashboard<'_> {
|
|||||||
let display = format_timing_cell(key, time_ms, key_width);
|
let display = format_timing_cell(key, time_ms, key_width);
|
||||||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modifier key stats row below the keyboard, spread across keyboard width
|
// Modifier key stats row below the keyboard, spread across keyboard width
|
||||||
let kbd_width = all_rows
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, row)| {
|
|
||||||
let off = offsets.get(i).copied().unwrap_or(0);
|
|
||||||
off + row.len() as u16 * key_step
|
|
||||||
})
|
|
||||||
.max()
|
|
||||||
.unwrap_or(inner.width)
|
|
||||||
.min(inner.width);
|
|
||||||
let mod_y = if show_shifted {
|
let mod_y = if show_shifted {
|
||||||
inner.y + all_rows.len() as u16 * 2 + 1
|
inner.y + all_rows.len() as u16 * 2 + 1
|
||||||
} else {
|
} else {
|
||||||
@@ -935,7 +965,7 @@ impl StatsDashboard<'_> {
|
|||||||
let time_ms = self.get_key_time_ms(key);
|
let time_ms = self.get_key_time_ms(key);
|
||||||
let fg_color = timing_color(time_ms, colors);
|
let fg_color = timing_color(time_ms, colors);
|
||||||
buf.set_string(
|
buf.set_string(
|
||||||
inner.x + positions[i],
|
keyboard_x + positions[i],
|
||||||
mod_y,
|
mod_y,
|
||||||
&labels[i],
|
&labels[i],
|
||||||
Style::default().fg(fg_color),
|
Style::default().fg(fg_color),
|
||||||
@@ -1261,6 +1291,334 @@ impl StatsDashboard<'_> {
|
|||||||
|
|
||||||
Paragraph::new(lines).render(inner, buf);
|
Paragraph::new(lines).render(inner, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- N-grams tab ---
|
||||||
|
|
||||||
|
fn render_ngram_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let data = match self.ngram_data {
|
||||||
|
Some(d) => d,
|
||||||
|
None => {
|
||||||
|
let msg = Paragraph::new(Line::from(Span::styled(
|
||||||
|
"Complete some adaptive drills to see n-gram data",
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)));
|
||||||
|
msg.render(area, buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(4), // focus box
|
||||||
|
Constraint::Min(5), // lists
|
||||||
|
Constraint::Length(2), // summary
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
self.render_ngram_focus(data, layout[0], buf);
|
||||||
|
|
||||||
|
let wide = layout[1].width >= 60;
|
||||||
|
if wide {
|
||||||
|
let lists = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
|
.split(layout[1]);
|
||||||
|
self.render_error_anomalies(data, lists[0], buf);
|
||||||
|
self.render_speed_anomalies(data, lists[1], buf);
|
||||||
|
} else {
|
||||||
|
// Stacked vertically for narrow terminals
|
||||||
|
let available = layout[1].height;
|
||||||
|
if available < 10 {
|
||||||
|
// Only show error anomalies if very little space
|
||||||
|
self.render_error_anomalies(data, layout[1], buf);
|
||||||
|
} else {
|
||||||
|
let half = available / 2;
|
||||||
|
let lists = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(half), Constraint::Min(0)])
|
||||||
|
.split(layout[1]);
|
||||||
|
self.render_error_anomalies(data, lists[0], buf);
|
||||||
|
self.render_speed_anomalies(data, lists[1], buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.render_ngram_summary(data, layout[2], buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_ngram_focus(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(Line::from(Span::styled(
|
||||||
|
" Active Focus ",
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)))
|
||||||
|
.border_style(Style::default().fg(colors.accent()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
if inner.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
match (&data.focus.char_focus, &data.focus.bigram_focus) {
|
||||||
|
(Some(ch), Some((key, anomaly_pct, anomaly_type))) => {
|
||||||
|
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
|
||||||
|
// Line 1: both focuses
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
format!("Char '{ch}'"),
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.focused_key())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(" + ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
format!("Bigram {bigram_label}"),
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.focused_key())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
// Line 2: details
|
||||||
|
if inner.height >= 2 {
|
||||||
|
let type_label = match anomaly_type {
|
||||||
|
AnomalyType::Error => "error",
|
||||||
|
AnomalyType::Speed => "speed",
|
||||||
|
};
|
||||||
|
let detail = format!(
|
||||||
|
" Char '{ch}': weakest key | Bigram {bigram_label}: {type_label} anomaly {anomaly_pct:.0}%"
|
||||||
|
);
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
detail,
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(ch), None) => {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
format!("Char '{ch}'"),
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.focused_key())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
if inner.height >= 2 {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" Char '{ch}': weakest key, no confirmed bigram anomalies"),
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, Some((key, anomaly_pct, anomaly_type))) => {
|
||||||
|
let bigram_label = format!("\"{}{}\"", key.0[0], key.0[1]);
|
||||||
|
let type_label = match anomaly_type {
|
||||||
|
AnomalyType::Error => "error",
|
||||||
|
AnomalyType::Speed => "speed",
|
||||||
|
};
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Focus: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
format!("Bigram {bigram_label}"),
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.focused_key())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!(" ({type_label} anomaly: {anomaly_pct:.0}%)"),
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" Complete some adaptive drills to see focus data",
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Paragraph::new(lines).render(inner, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_anomaly_panel(
|
||||||
|
&self,
|
||||||
|
title: &str,
|
||||||
|
empty_msg: &str,
|
||||||
|
rows: &[AnomalyBigramRow],
|
||||||
|
is_speed: bool,
|
||||||
|
area: Rect,
|
||||||
|
buf: &mut Buffer,
|
||||||
|
) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(Line::from(Span::styled(
|
||||||
|
title.to_string(),
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)))
|
||||||
|
.border_style(Style::default().fg(colors.accent()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
if inner.height < 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
buf.set_string(
|
||||||
|
inner.x,
|
||||||
|
inner.y,
|
||||||
|
empty_msg,
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let narrow = inner.width < 30;
|
||||||
|
|
||||||
|
// Error table: Bigram Anom% Rate Errors Smp Strk
|
||||||
|
// Speed table: Bigram Anom% Speed Smp Strk
|
||||||
|
let header = if narrow {
|
||||||
|
if is_speed {
|
||||||
|
" Bgrm Speed Expct Anom%"
|
||||||
|
} else {
|
||||||
|
" Bgrm Err Smp Rate Exp Anom%"
|
||||||
|
}
|
||||||
|
} else if is_speed {
|
||||||
|
" Bigram Speed Expect Samples Anom%"
|
||||||
|
} else {
|
||||||
|
" Bigram Errors Samples Rate Expect Anom%"
|
||||||
|
};
|
||||||
|
buf.set_string(
|
||||||
|
inner.x,
|
||||||
|
inner.y,
|
||||||
|
header,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
|
||||||
|
let max_rows = (inner.height as usize).saturating_sub(1);
|
||||||
|
for (i, row) in rows.iter().take(max_rows).enumerate() {
|
||||||
|
let y = inner.y + 1 + i as u16;
|
||||||
|
if y >= inner.y + inner.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = if narrow {
|
||||||
|
if is_speed {
|
||||||
|
format!(
|
||||||
|
" {:>4} {:>3.0}ms {:>3.0}ms {:>4.0}%",
|
||||||
|
row.bigram, row.speed_ms, row.expected_baseline, row.anomaly_pct,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
" {:>4} {:>3} {:>3} {:>3.0}% {:>2.0}% {:>4.0}%",
|
||||||
|
row.bigram,
|
||||||
|
row.error_count,
|
||||||
|
row.sample_count,
|
||||||
|
row.error_rate_ema * 100.0,
|
||||||
|
row.expected_baseline * 100.0,
|
||||||
|
row.anomaly_pct,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if is_speed {
|
||||||
|
format!(
|
||||||
|
" {:>6} {:>4.0}ms {:>4.0}ms {:>5} {:>4.0}%",
|
||||||
|
row.bigram,
|
||||||
|
row.speed_ms,
|
||||||
|
row.expected_baseline,
|
||||||
|
row.sample_count,
|
||||||
|
row.anomaly_pct,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
" {:>6} {:>5} {:>5} {:>4.0}% {:>4.0}% {:>5.0}%",
|
||||||
|
row.bigram,
|
||||||
|
row.error_count,
|
||||||
|
row.sample_count,
|
||||||
|
row.error_rate_ema * 100.0,
|
||||||
|
row.expected_baseline * 100.0,
|
||||||
|
row.anomaly_pct,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let color = if row.confirmed {
|
||||||
|
colors.error()
|
||||||
|
} else {
|
||||||
|
colors.warning()
|
||||||
|
};
|
||||||
|
|
||||||
|
buf.set_string(inner.x, y, &line, Style::default().fg(color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_error_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
|
||||||
|
let title = format!(" Error Anomalies ({}) ", data.error_anomalies.len());
|
||||||
|
self.render_anomaly_panel(
|
||||||
|
&title,
|
||||||
|
" No error anomalies detected",
|
||||||
|
&data.error_anomalies,
|
||||||
|
false,
|
||||||
|
area,
|
||||||
|
buf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_speed_anomalies(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
|
||||||
|
let title = format!(" Speed Anomalies ({}) ", data.speed_anomalies.len());
|
||||||
|
self.render_anomaly_panel(
|
||||||
|
&title,
|
||||||
|
" No speed anomalies detected",
|
||||||
|
&data.speed_anomalies,
|
||||||
|
true,
|
||||||
|
area,
|
||||||
|
buf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_ngram_summary(&self, data: &NgramTabData, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let gain_str = match data.latest_trigram_gain {
|
||||||
|
Some(g) => format!("{:.1}%", g * 100.0),
|
||||||
|
None => "--".to_string(),
|
||||||
|
};
|
||||||
|
let gain_note = if data.latest_trigram_gain.is_none() {
|
||||||
|
" (computed every 50 drills)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = format!(
|
||||||
|
" Scope: {} | Bigrams: {} | Trigrams: {} | Hesitation: >{:.0}ms | Tri-gain: {}{}",
|
||||||
|
data.scope_label,
|
||||||
|
data.total_bigrams,
|
||||||
|
data.total_trigrams,
|
||||||
|
data.hesitation_threshold_ms,
|
||||||
|
gain_str,
|
||||||
|
gain_note,
|
||||||
|
);
|
||||||
|
|
||||||
|
buf.set_string(
|
||||||
|
area.x,
|
||||||
|
area.y,
|
||||||
|
&line,
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn accuracy_color(accuracy: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
|
fn accuracy_color(accuracy: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
|
||||||
@@ -1501,3 +1859,79 @@ fn format_duration(secs: f64) -> String {
|
|||||||
format!("{s}s")
|
format!("{s}s")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute the ngram tab panel layout for the given terminal area.
|
||||||
|
/// Returns `(wide, lists_area_height)` where:
|
||||||
|
/// - `wide` = true means side-by-side anomaly panels (width >= 60)
|
||||||
|
/// - `lists_area_height` = height available for the anomaly panels region
|
||||||
|
///
|
||||||
|
/// When `!wide && lists_area_height < 10`, only error anomalies should render.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn ngram_panel_layout(area: Rect) -> (bool, u16) {
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(4), // focus box
|
||||||
|
Constraint::Min(5), // lists
|
||||||
|
Constraint::Length(2), // summary
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
let wide = layout[1].width >= 60;
|
||||||
|
(wide, layout[1].height)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn narrow_short_terminal_shows_only_error_panel() {
|
||||||
|
// 50 cols × 15 rows: narrow (<60) so panels stack vertically.
|
||||||
|
// lists area = 15 - 4 (focus) - 2 (summary) = 9 rows → < 10 → error only.
|
||||||
|
let area = Rect::new(0, 0, 50, 15);
|
||||||
|
let (wide, lists_height) = ngram_panel_layout(area);
|
||||||
|
assert!(!wide, "50 cols should be narrow layout");
|
||||||
|
assert!(
|
||||||
|
lists_height < 10,
|
||||||
|
"lists_height={lists_height}, expected < 10 so only error panel renders"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn narrow_tall_terminal_stacks_both_panels() {
|
||||||
|
// 50 cols × 30 rows: narrow (<60) so panels stack vertically.
|
||||||
|
// lists area = 30 - 4 - 2 = 24 rows → >= 10 → both panels stacked.
|
||||||
|
let area = Rect::new(0, 0, 50, 30);
|
||||||
|
let (wide, lists_height) = ngram_panel_layout(area);
|
||||||
|
assert!(!wide, "50 cols should be narrow layout");
|
||||||
|
assert!(
|
||||||
|
lists_height >= 10,
|
||||||
|
"lists_height={lists_height}, expected >= 10 so both panels stack vertically"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wide_terminal_shows_side_by_side_panels() {
|
||||||
|
// 80 cols × 24 rows: wide (>= 60) so panels render side by side.
|
||||||
|
let area = Rect::new(0, 0, 80, 24);
|
||||||
|
let (wide, _) = ngram_panel_layout(area);
|
||||||
|
assert!(
|
||||||
|
wide,
|
||||||
|
"80 cols should be wide layout with side-by-side panels"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boundary_width_59_is_narrow() {
|
||||||
|
let area = Rect::new(0, 0, 59, 24);
|
||||||
|
let (wide, _) = ngram_panel_layout(area);
|
||||||
|
assert!(!wide, "59 cols should be narrow");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boundary_width_60_is_wide() {
|
||||||
|
let area = Rect::new(0, 0, 60, 24);
|
||||||
|
let (wide, _) = ngram_panel_layout(area);
|
||||||
|
assert!(wide, "60 cols should be wide");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ fn contrast_ratio(a: ratatui::style::Color, b: ratatui::style::Color) -> f64 {
|
|||||||
(hi + 0.05) / (lo + 0.05)
|
(hi + 0.05) / (lo + 0.05)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn choose_cursor_colors(colors: &crate::ui::theme::ThemeColors) -> (ratatui::style::Color, ratatui::style::Color) {
|
fn choose_cursor_colors(
|
||||||
|
colors: &crate::ui::theme::ThemeColors,
|
||||||
|
) -> (ratatui::style::Color, ratatui::style::Color) {
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
|
|
||||||
let base_bg = colors.bg();
|
let base_bg = colors.bg();
|
||||||
@@ -113,7 +115,13 @@ fn choose_cursor_colors(colors: &crate::ui::theme::ThemeColors) -> (ratatui::sty
|
|||||||
if contrast_ratio(cursor_bg, base_bg) < 1.8 {
|
if contrast_ratio(cursor_bg, base_bg) < 1.8 {
|
||||||
let mut best_bg = cursor_bg;
|
let mut best_bg = cursor_bg;
|
||||||
let mut best_ratio = contrast_ratio(cursor_bg, base_bg);
|
let mut best_ratio = contrast_ratio(cursor_bg, base_bg);
|
||||||
for candidate in [colors.accent(), colors.focused_key(), colors.warning(), Color::Black, Color::White] {
|
for candidate in [
|
||||||
|
colors.accent(),
|
||||||
|
colors.focused_key(),
|
||||||
|
colors.warning(),
|
||||||
|
Color::Black,
|
||||||
|
Color::White,
|
||||||
|
] {
|
||||||
let ratio = contrast_ratio(candidate, base_bg);
|
let ratio = contrast_ratio(candidate, base_bg);
|
||||||
if ratio > best_ratio {
|
if ratio > best_ratio {
|
||||||
best_bg = candidate;
|
best_bg = candidate;
|
||||||
|
|||||||
@@ -91,8 +91,12 @@ pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
|||||||
let target_w = requested_w.max(MIN_POPUP_WIDTH).min(area.width);
|
let target_w = requested_w.max(MIN_POPUP_WIDTH).min(area.width);
|
||||||
let target_h = requested_h.max(MIN_POPUP_HEIGHT).min(area.height);
|
let target_h = requested_h.max(MIN_POPUP_HEIGHT).min(area.height);
|
||||||
|
|
||||||
let left = area.x.saturating_add((area.width.saturating_sub(target_w)) / 2);
|
let left = area
|
||||||
let top = area.y.saturating_add((area.height.saturating_sub(target_h)) / 2);
|
.x
|
||||||
|
.saturating_add((area.width.saturating_sub(target_w)) / 2);
|
||||||
|
let top = area
|
||||||
|
.y
|
||||||
|
.saturating_add((area.height.saturating_sub(target_h)) / 2);
|
||||||
|
|
||||||
Rect::new(left, top, target_w, target_h)
|
Rect::new(left, top, target_w, target_h)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user