Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
b0f130e
Add confidence tuning settings and align rounded scores across UI.
Jul 29, 2026
227f431
Bump version to 1.2.2 on dev.
Jul 29, 2026
6031e6e
Make confidence tuning config-file only.
Jul 29, 2026
39a0f5d
Fix lint errors: unused imports, unsorted imports, ambiguous unicode …
Jul 29, 2026
6f18b79
Regenerate stale practice-screen snapshot.
Jul 29, 2026
6363d4f
Release 1.2.3: windowed confidence, stats and practice UX.
Jul 29, 2026
8f15838
Merge origin/main into dev for PR #32.
Jul 29, 2026
8497234
Fix lint errors after merge for PR #32.
Jul 29, 2026
8b9667d
Add ruff pre-commit hook.
Jul 29, 2026
0ffd097
Add pyright pre-commit hook.
Jul 29, 2026
95912b9
Fix pyright errors so pre-commit pyright hook passes.
Jul 29, 2026
1cb3d9c
Update README for 1.2.3 stats layout and pre-commit hooks.
Jul 29, 2026
989e438
Refresh practice/stats UI snapshots and document CI checks.
Jul 29, 2026
6de029b
ignore lead spaces
Jul 30, 2026
c00a185
Document custom layouts and fix platform paths; refresh Settings layo…
Jul 30, 2026
d2c0bdf
Bias word-list drills toward weak bigrams like Markov mode.
Jul 30, 2026
87c07d0
Gate unlocks on measured transition confidence and tune focus bias vi…
Jul 30, 2026
41fbe0b
Prioritize weak keys over transition focus and drop same-key bigrams.
Jul 30, 2026
2a863bb
refactor
Jul 30, 2026
f2f9e5a
refactoring
Jul 30, 2026
0a3b8b7
refactor
Jul 30, 2026
366dde1
refactor
Jul 30, 2026
f5b8a1c
refactor
Jul 30, 2026
14a7450
refactor
Jul 30, 2026
7699bf7
refactor
Jul 30, 2026
06a2e6d
Release 1.3.0: weak-focus word quota and lesson variety.
Jul 31, 2026
8eec76f
Add configurable generated word length for lessons and WPM math.
Jul 31, 2026
3e3e3ff
Align wordlist sampling with generated word bounds and default 2-4.
Jul 31, 2026
c524ab7
Merge remote-tracking branch 'origin/main' into dev
Copilot Jul 31, 2026
5d7ed3b
fix: restore CI after v1.3.0 merge artifacts
Jul 31, 2026
ca88bcb
Merge origin/main into dev for PR #35
Jul 31, 2026
0c9ae3d
Merge origin/main into dev
Jul 31, 2026
e3c724b
fix: calibrating focus display and skill heatmap without lesson bias
Jul 31, 2026
c011719
refactor: split skill from confidence for unlock and focus
Jul 31, 2026
9454649
fix: keep dictionary word bounds separate from Markov bounds
Jul 31, 2026
d869e39
Fix transition focus when measured keys are ready but bigrams lag.
Jul 31, 2026
5b6ef2f
Prioritize bigram practice for newly-mastered keys, gate progression …
Aug 4, 2026
6cba189
refactor: tighten skill/confidence split and lesson gating
Aug 5, 2026
92b01f3
fix: keep wordlist words intact during focus drill
Aug 5, 2026
b1052ce
Release 1.3.1.
Aug 5, 2026
7d55166
fix: persist alphabet_size bump before a lesson shows the extra letters
Aug 10, 2026
6035d77
Release 2.0.0.
Aug 21, 2026
b4d0609
Merge main into dev (post-squash resync).
Aug 21, 2026
d04591c
Update practice-screen snapshot for frozen HUD.
Aug 21, 2026
af1ba24
Store per-session stats instead of keystroke logs; prune outside rete…
Sep 24, 2026
3e949ab
Release 2.1.0.
Sep 24, 2026
c2e7783
Merge main into dev (post-squash resync for 2.0.0)
Sep 24, 2026
fdc5311
Merge main into dev (post-squash resync).
Sep 24, 2026
37de928
Rebuild the README demo GIF from a seeded, simulated run
Sep 24, 2026
a6fec61
Save the weakest pair on the session result
Oct 7, 2026
4ed2d60
Address review of the weakest-pair change
Oct 7, 2026
4a61da3
Fix the attempt floors, accuracy target and WPM gate
Oct 7, 2026
d6b75ec
Replace focus selection with one ordered ladder
Oct 7, 2026
3cbf5ef
Release 2.2.0.
Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,9 @@ NO_COLOR=1 TERM=xterm-256color LC_ALL=C.UTF-8 \
uv run pytest tests/presentation/test_snapshots.py -m snapshot --snapshot-update
```

Regenerate the demo GIF after updating snapshots:
Regenerate the demo GIF after UI changes. The script drives the real app with
a simulated typist (seeded history, mid-lesson practice, stats, settings), so
it does not depend on the snapshot baselines:

```bash
uv pip install pillow cairosvg # one-off; system cairo on macOS/Linux
Expand Down
29 changes: 29 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@ rationale lives in commit history/diffs — these are pointers, not narratives.
Milestone-level feature work (what shipped in M1–M4, the keybr algorithm
design) stays in `PLAN.md` §5/§6.

## 2.2.0

- **One focus ladder** — `select_lesson_focus` (`domain/focus.py`) replaces
`select_focus`, `select_focus_transition`, `remedial_focus` and the
newest-key unmeasured-pair fallback. Order: keys not cleared, unlock-gate
cohort pairs, weak pairs by (1 − confidence) × language frequency
(`TransitionTable.pair_weight`), then review. Each rule keeps the last
lesson's focus until it clears or stalls at 3× its attempt floor
(`domain.confidence.stall_attempts_cap`, moved from `domain.unlock`).
Removed: the lesson WPM gate (`remedial_focus`) and the review penalty in
focus scoring. Sessions now save `focus_pair` when the focus was a pair
(written only when set, still schema 5; old rows read back unchanged).
- **Attempt floors count real presses** — `combine_sessions` now sums
`attempt_count` over the window without recency weights; speed and accuracy
keep theirs. Weighted attempts settled at about 3.24 × presses per lesson,
so a key or cohort pair with 2–3 presses per lesson never reached
`min_confidence_attempts` (10) or the transition stall cap (12) and blocked
the next letter. The aggregates cache moves to `schema_version` 2; older
cache files read as a miss and rebuild from the session rows (session rows
are unchanged).
- **Accuracy is scored against 95%, not 100%** — skill and confidence use
`min(speed, min(1, accuracy / ACCURACY_TARGET))` with
`ACCURACY_TARGET = 0.95` (`domain/confidence.py`), so one typo no longer
needs about 199 clean presses before a key clears. Recency-weighted errors
stay fractional instead of being rounded, so one typo in the previous
lesson counts as 0.7. The transition gate now calls the same
`clears_threshold` rule as key unlock and focus. HUD and Stats still show
raw accuracy.

## 2.1.0

- **Session store: per-session stats instead of keystroke logs** — a finished
Expand Down
6 changes: 3 additions & 3 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ keystrike/
│ │ ├── protocols.py # Clock, IdGenerator, SessionRepository, SettingsRepository, LayoutRepository, LanguageProvider, CodeSnippetProvider, AggregatesCache, FreeformTextProvider, StatsRebuilder, LearningRateEstimator
│ │ ├── null_adapters.py # NullSessionRepository, NullStatsRebuilder, NullLearningRateEstimator [DONE]
│ │ ├── aggregate.py # pure: Keystroke → KeyStats rollup [DONE]
│ │ ├── confidence.py # pure: key_confidence, confidence_of, select_focus, compute_unlocked [DONE]
│ │ ├── confidence.py # pure: key_confidence, confidence_of, select_lesson_focus, compute_unlocked [DONE]
│ │ ├── generator.py # pure: AdaptiveGenerator (Markov + focus letter) [DONE]
│ │ ├── markov.py # TransitionTable value object + sampler [DONE]
│ │ ├── regression.py # pure: polynomial fit, estimate_sessions_to_goal [DONE]
Expand Down Expand Up @@ -233,7 +233,7 @@ all fixed before M3 began (95 tests passing at that point) — see
shorter contexts, filtered to the unlocked alphabet), `domain/generator.py`
(`AdaptiveGenerator`: word length 2–4 by default, `p_stop = min(1, 1.3**length/max_len)`,
focus letter guaranteed via injection if the Markov walk didn't produce it),
`domain/confidence.py` (`confidence_of`, `compute_unlocked`, `select_focus` —
`domain/confidence.py` (`confidence_of`, `compute_unlocked`, `select_lesson_focus` —
faithful to §6 below), `application/build_lesson.py` (`BuildLesson` ties
layout + stats + settings + language provider into a `Lesson(text, state)`),
`infrastructure/languages/` (`BundledLanguageProvider`, gzip+JSON), and
Expand Down Expand Up @@ -288,7 +288,7 @@ characters (`():=_[]"`) that aren't part of any `Layout.learn_order` — a
strict filter would reject nearly every real snippet. Instead
`select_snippet` weight-picks by how often the snippet contains the focus
char (`snippet.count(focus_char) + 1`), so unlock/focus state still comes
from the exact same `compute_unlocked`/`select_focus` logic as English mode
from the exact same `compute_unlocked`/`select_lesson_focus` logic as English mode
(factored out to `_lesson_progress()`, shared by `BuildLesson` and the new
`BuildCodeLesson` in `application/build_lesson.py`) — it just doesn't gate
which literal characters can appear in the practice text.
Expand Down
Binary file modified docs/assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/research/typing-pedagogy.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@ Sources:

## Gap found in current implementation

~~`confidence_of`/`key_confidence` (`confidence.py:18-36`) compute mastery purely from `mean_time_ns` vs. target speed. `KeyStats.error_count` is tracked and persisted (`aggregate.py:55`, `models.py:34`) but never read by `confidence_of`, `compute_unlocked`, or `select_focus` — so a key typed fast but frequently wrong currently reads as "confident" and can unlock the next key prematurely, contradicting Keybr's dual speed+accuracy threshold and the accuracy-first principle (point 2 above).~~ **Fixed**: `confidence_of` uses `min(speed, accuracy)` where speed is `target_ms / actual_ms` and accuracy is `samples / (samples + error_count)`. Fast-but-sloppy or slow-but-accurate keys no longer compensate each other (unlike a product). Windowed aggregates also recency-weight mean time, accuracy, and attempt counts so stale practice doesn't mask recent sloppiness. `compute_unlocked`/`select_focus`/`GetHeatmap` all inherit this via `confidence_of`.
~~`confidence_of`/`key_confidence` (`confidence.py:18-36`) compute mastery purely from `mean_time_ns` vs. target speed. `KeyStats.error_count` is tracked and persisted (`aggregate.py:55`, `models.py:34`) but never read by `confidence_of`, `compute_unlocked`, or `select_focus` — so a key typed fast but frequently wrong currently reads as "confident" and can unlock the next key prematurely, contradicting Keybr's dual speed+accuracy threshold and the accuracy-first principle (point 2 above).~~ **Fixed**: `confidence_of` uses `min(speed, accuracy score)` where speed is `target_ms / actual_ms`, accuracy is `samples / (samples + error_count)`, and the accuracy score is accuracy ÷ `ACCURACY_TARGET` (0.95, capped at 1.0) — accuracy above about 95% is the goal, not zero errors (see the higher-ed study above). Fast-but-sloppy or slow-but-accurate keys no longer compensate each other (unlike a product). Windowed aggregates also recency-weight mean time and accuracy (errors stay fractional) so stale practice doesn't mask recent sloppiness; attempt counts stay unweighted, because they are the evidence for the attempt floors and the transition stall cap. `compute_unlocked`/`select_lesson_focus`/`GetHeatmap` all inherit this via `confidence_of`.

~~`AdaptiveGenerator.generate_lesson` (`generator.py:30-37`) sampled every word from the plain language-frequency Markov table and only guaranteed the focus key appeared *once* across the whole lesson (via `_inject_focus`) — everything else was generic full-alphabet text, unlike Keybr's "adaptive per-letter frequency weighting based on personal error/latency history" (line 20 above) and short of the deliberate-practice principle (point 4).~~ **Fixed**: `confidence.py` now has `practice_weight(confidence, max_bias=3.0)` — weak keys get up to 4x sampling weight, mastered keys (confidence ≥ 1.0) get baseline weight 1.0. `TransitionTable.sample` (`markov.py`) takes an optional `char_weights` map that multiplies each candidate's language-frequency weight before `rng.choices`; `AdaptiveGenerator.generate_word`/`generate_lesson` thread it through. `BuildLesson` (`build_lesson.py`) computes `char_weights` from each unlocked key's live confidence, so generated text is now generally biased toward weak keys, not just guaranteed to touch the single focus key once.
78 changes: 33 additions & 45 deletions docs/wiki/Confidence-Tuning.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ keys are ignored, and missing nested tables silently fall back to defaults).
| Setting | `settings.toml` key | Default | What it does |
| --- | --- | --- | --- |
| Confidence session window | `confidence_session_window` | `10` | How many recent sessions are replayed into rolling per-key stats used for skill, unlocks, focus, and the heatmap. |
| Min key attempts | `[unlock].min_confidence_attempts` | `10` | Minimum presses on a key before its confidence reaches full weight. Below this, confidence ramps linearly (fewer attempts → lower score). |
| Min key attempts | `[unlock].min_confidence_attempts` | `10` | Minimum presses on a key in the session window before its confidence reaches full weight. Below this, confidence ramps linearly (fewer attempts → lower score). |
| Min bigram attempts | `[unlock].min_transition_confidence_attempts` | `4` | Same ramp for letter-pair (transition) confidence. Default is lower because bigrams are practiced less often than single keys. |
| Gating bigram limit | `[unlock].gating_bigram_limit` | `4` | Directed newest-letter bigrams that must calibrate before the next letter opens. Values are clamped to `2`–`4`. |
| Next-letter unlock threshold | `[unlock].next_letter_unlock_threshold` | `1.0` | Skill threshold every currently-unlocked key must clear before the next letter opens. |
Expand All @@ -43,11 +43,16 @@ multipliers should be **≥ 1.0**. `lesson_word_count` should be **≥ 1**.
`word_min_fraction` should be in **(0.0, 1.0]**. `max_word_repeats`
should be **≥ 1**. Generated word bounds should be **≥ 1** with min ≤ max.

Skill and confidence both use **min(speed, accuracy)**, not their product: a key
must be both fast enough and accurate enough to read as mastered. Speed is
`target_ms / actual_ms`; accuracy is correct attempts ÷ total attempts. Skill
is that ratio without attempt ramp; confidence scales skill by how many presses
you have in the window (see min attempt floors above).
Skill and confidence both use **min(speed, accuracy score)**, not their
product: a key must be both fast enough and accurate enough to read as
mastered. Speed is `target_ms / actual_ms`; accuracy is correct attempts ÷
total attempts. The accuracy score is accuracy ÷ 95% (`ACCURACY_TARGET` in
`domain/confidence.py`), capped at 1.0: the goal is accuracy above about 95%,
not zero errors (see `docs/research/typing-pedagogy.md`). So a key at target
speed with 60 correct presses and one recent typo (98.4%) clears; 90% does
not. Skill is that ratio without attempt ramp; confidence scales skill by how
many presses you have in the window (see min attempt floors above). The HUD
and Stats show raw accuracy.

Example (defaults shown):

Expand Down Expand Up @@ -80,8 +85,12 @@ max_len = 4
**Session window** — Confidence is computed from aggregates over your last *N*
sessions (per layout), not your entire history. Within that window, **recent
sessions count more** than older ones (exponential decay, default 0.7 per step
back), so a bad or good last session moves confidence faster than the flat
average of all window sessions. A longer window smooths noise but reacts slowly;
back) for speed and accuracy, so a bad or good last session moves confidence
faster than the flat average of all window sessions. Errors keep their weight
as a fraction: one typo two lessons back counts as 0.7 of an error, not a
full one. Attempt counts are **not** weighted: the attempt floors and the
stall cap below count every real press in the window, so a key or pair that
gets two or three presses per lesson still reaches its floor. A longer window smooths noise but reacts slowly;
a shorter window tracks recent form but can under-sample rare keys and block
unlocks until those keys appear often enough in recent drills.

Expand All @@ -94,8 +103,8 @@ This pruning is irreversible: if you raise `confidence_session_window` later,
the wider window fills only with sessions recorded after the change.

**Min key attempts** — Prevents a lucky fast streak from reading as mastery.
Until you've pressed a key at least this many times (within the windowed
stats), its confidence is scaled down. Higher values mean slower unlocks and
Until you've pressed a key at least this many times (real presses within the
session window), its confidence is scaled down. Higher values mean slower unlocks and
more conservative focus selection; `1` effectively disables the ramp.

**Min bigram attempts** — Same idea for prev→next letter pairs. Transition
Expand All @@ -121,41 +130,20 @@ directions with up to its two most-recent practiced peers, bounded by
transition data, so incidental measurements cannot expand or replace it.
Every member must reach confidence 1.0 with
`[unlock].min_transition_confidence_attempts` attempts.
A `transition_stall_attempts_cap` (`domain.unlock.default_transition_stall_attempts_cap`,
3× the transition calibration floor by default) releases a specific pair
that's been drilled past the cap without clearing threshold, so one stubborn
bigram can't block progression forever.

**Focus selection** is letter-first: while any unlocked key is below the skill
threshold or key-attempt floor, the lesson emphasizes the weakest unlocked
**key**. While transition progression is blocked, deficient members of the
same gating cohort drive transition focus and share guaranteed lesson coverage.
An older pair can preempt only after enough samples show genuine raw
speed/accuracy regression; sparse old calibration does not delay progression.
Outside a blocked progression gate, normal measured transition review remains.

**Focus is sticky.** Once a key or bigram becomes the focus, it keeps that
focus across lesson builds until it individually clears both the skill
threshold and its attempt floor (the same two-part gate as unlocks, via
`domain.confidence.clears_threshold`) — an unrelated stale-but-mastered key's
review urgency, or the transition gate activating, cannot steal focus away
mid-calibration. Only once *every* unlocked key/bigram has cleared does
review-urgency-based staleness compete for focus across the whole set.

## Lesson WPM gate

A finished session's own words-per-minute is compared against its own target
speed (converted CPM→WPM the same way `SettingsScreen` does). If that
session's WPM fell short, the *next* lesson's focus is confined to the
weakest key or bigram from that session's own `lesson_alphabet` — the letters
that actually appeared in the text — instead of ordinary weakest-across-window
selection or the newest-key transition gate picking elsewhere. This remains in
effect lesson-over-lesson until a lesson's WPM meets target again, at which
point normal focus selection (including stickiness, above) resumes. Sessions
with no recorded target (`target_speed_cpm == 0`, e.g. very old data) never
trigger this gate. There is no separate HUD label — the gate only narrows
*which* key/bigram is eligible for focus, so the usual `wk`/`cal`/`rev` reason
still applies to whichever one is chosen.
Every member must pass the same `domain.confidence.clears_threshold` rule as
key unlock and focus.
A `transition_stall_attempts_cap` (`domain.confidence.stall_attempts_cap`,
3× the transition calibration floor by default, so 12 real attempts in the
window) releases a specific pair that's been drilled past the cap without
clearing threshold, so one stubborn bigram can't block progression forever.
At two cohort-coverage slots per lesson, a stuck pair reaches the cap on its
sixth lesson.

**Focus selection** follows one ordered list: keys that have not cleared,
then the unlock-gate cohort pairs, then weak pairs ranked by weakness ×
language frequency, then review of the most overdue key or pair. The last
lesson's focus is kept while it still needs work, until it stalls at the same
3× attempt cap. See [Focus states](Focus-States) for the full rules.

### Transition stats

Expand Down
53 changes: 25 additions & 28 deletions docs/wiki/Focus-States.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,40 +38,37 @@ Reading left to right:
2. **Reason** — `wk`, `cal`, or `rev`. Calibrating adds press progress (`9/10`).
3. **Speed** — target timing ÷ actual timing for the focus key or pair.
4. **Accuracy** — correct attempts ÷ total attempts (percent).
5. **Confidence** — min(speed, accuracy), scaled by attempt count during calibration. Goal is 1.0 for mastery.
5. **Confidence** — min(speed, accuracy score), scaled by attempt count during calibration. The accuracy score is accuracy ÷ 95%, capped at 1.0, so 95% accuracy already counts in full. Goal is 1.0 for mastery.

## Key vs transition focus
## How focus is chosen

**Key focus** applies while any unlocked key is below the performance skill
threshold or the configured key-attempt floor (see `blocks_transition_focus`
in the codebase). Never-typed and sparsely sampled keys therefore take priority.
The lesson emphasizes the weakest unlocked key by ramped confidence, adjusted
for review urgency.
Focus follows one ordered list of rules. The first rule that has candidates
picks the focus (`select_lesson_focus` in `domain/focus.py`):

**Transition focus** applies after every unlocked key meets both requirements.
When the next letter is gated, the same stable 2–4 newest-letter cohort controls
unlocking and lesson coverage. Sparse older pairs cannot preempt it; an older
pair can preempt only after enough samples show raw performance regression.
Same-key repeats (double letters) never count as transitions.
1. **Keys first.** If any unlocked key has not cleared (skill below 1.0 or
fewer presses than `[unlock].min_confidence_attempts`), the weakest such
key is the focus. Never-typed keys count as not cleared.
2. **The unlock gate.** If the next letter waits on the newest key's pair
cohort, the weakest pair of that cohort that is not ready is the focus.
3. **Weak pairs.** Otherwise, the pair that is not cleared and has the highest
(1 − confidence) × language frequency is the focus. Pairs that do not occur
in the language are skipped. Same-key repeats (double letters) never count.
4. **Review.** When everything has cleared, the most overdue key or pair
(by review urgency) is the focus.
5. **Fallback.** Otherwise, the weakest key.

## Focus stays put until it clears

Once a key or pair becomes the focus, it keeps that focus lesson-over-lesson
until it individually clears both confidence (skill ≥ 1.0) and its attempt
floor — the same bar unlocks use. A different key/pair going stale, or a
transition gate opening up, does not steal focus away mid-calibration. Once
everything unlocked has cleared, focus is free to move to whichever key/pair
is due for review (`rev`) again.

## Lesson WPM gate

If a finished lesson's overall words-per-minute came in under its own target,
the next lesson's focus is pinned to the weakest key or pair from *that
lesson's own text* until a lesson's WPM meets target again — a low-speed
lesson pulls focus back to its own weak point rather than drifting to
whatever else is weakest across the full practice history. This doesn't add a
new HUD label; the chosen key/pair still shows the ordinary `wk`, `cal`, or
`rev` reason.
In rules 1–3, the last lesson's focus is kept while it is still a candidate
of that rule. So a key or pair keeps the focus lesson-over-lesson until it
clears both skill and its attempt floor — the same bar unlocks use.

A focus that does not clear after 3× its attempt floor in the session window
(30 presses for a key, 12 for a pair, by default) is **stalled**. It goes back
into the pool, so the weakest candidate is picked again from all of them.

The last focus comes from the last saved session: `focus_key`, plus
`focus_pair` when the focus was a pair.

## Heatmap underline

Expand Down
Loading
Loading