From b0f130edce7182bf2ea30d78e42862b0d2c29170 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 29 Jul 2026 11:05:59 +0300 Subject: [PATCH 01/47] Add confidence tuning settings and align rounded scores across UI. Expose session window and attempt floors in settings so unlocks, focus, and heatmap respond to tunable confidence inputs; round scores to two decimals so weak/mastered decisions match what practice displays. Co-authored-by: Cursor --- docs/wiki/Confidence-Tuning.md | 59 ++++++++++ docs/wiki/Home.md | 1 + src/keystrike/app.py | 12 ++- src/keystrike/application/build_lesson.py | 75 +++++++++++-- .../application/session_use_cases.py | 33 ++++-- .../application/settings_use_cases.py | 21 ++++ src/keystrike/application/stats_use_cases.py | 48 ++++----- src/keystrike/domain/aggregate.py | 41 ++++++- src/keystrike/domain/confidence.py | 102 +++++++++++++++--- src/keystrike/domain/generator.py | 5 +- src/keystrike/domain/models.py | 5 + .../infrastructure/aggregates_cache.py | 6 ++ .../infrastructure/settings_repo_toml.py | 18 ++++ .../presentation/screens/settings.py | 46 ++++++++ tests/application/test_build_lesson.py | 10 +- tests/application/test_session_use_cases.py | 56 ++++++++-- tests/application/test_settings_use_cases.py | 53 +++++++++ tests/application/test_stats_use_cases.py | 77 ++++++++++++- tests/domain/test_aggregate.py | 28 +++++ tests/domain/test_confidence.py | 65 +++++++++++ tests/domain/test_generator.py | 34 ++++++ .../infrastructure/test_settings_repo_toml.py | 6 ++ tests/presentation/test_practice_screen.py | 4 +- tests/presentation/test_settings_screen.py | 6 ++ tests/presentation/test_stats_screen.py | 4 +- uv.lock | 2 +- 26 files changed, 731 insertions(+), 86 deletions(-) create mode 100644 docs/wiki/Confidence-Tuning.md diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md new file mode 100644 index 0000000..78c59cc --- /dev/null +++ b/docs/wiki/Confidence-Tuning.md @@ -0,0 +1,59 @@ +# Confidence tuning + +Keystrike's adaptive engine uses **confidence** — speed × accuracy, scaled by how +much you've practiced — to decide which keys unlock, which key or bigram gets +focus, and how the stats heatmap colors each key. Three settings control how +aggressively the engine trusts your recent performance. + +Open **Settings** from the home screen (`o`), edit the values, then **Ctrl+S** to +save. They are also stored in `settings.toml` under your config directory. + +## Settings + +| 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 confidence, unlocks, focus, and the heatmap. | +| Min key attempts | `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 bigram attempts | `min_transition_confidence_attempts` | `4` | Same ramp for letter-pair (transition) confidence. Default is lower because bigrams are practiced less often than single keys. | + +Valid ranges: window and both attempt floors are **1–100**. + +## What each setting affects + +**Session window** — Confidence is computed from aggregates over your last *N* +sessions (per layout), not your entire history. 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. + +**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 +more conservative focus selection; `1` effectively disables the ramp. + +**Min bigram attempts** — Same idea for prev→next letter pairs. Transition +focus and bigram-weighted lesson text use this floor. Keeping it lower than the +key floor (default 4 vs 10) matches how sparse bigram data is in normal +practice. + +## Tradeoffs + +- **Window too short** — Rare keys in your unlocked set may never accumulate + enough samples in the window; unlocks stall and the heatmap looks empty for + those keys even if you've typed them in older sessions. +- **Window too long** — Stale mistakes and old slow timings linger; focus and + unlocks lag behind your current ability. +- **Min attempts too low** — One good session can inflate confidence and unlock + the next key before accuracy and speed are stable (Keybr-style progression + expects both). +- **Min attempts too high** — Progress feels grindy; focus stays on keys you've + already typed many times. +- **Transition floor** — Raise it if bigram focus switches too eagerly on thin + data; lower it if weak pairs never get targeted. + +After changing the session window, run a practice session or open Stats so +aggregates rebuild from the new window size. + +## Related docs + +- [Word lists](Word-Lists) — optional dictionary drills (same confidence engine). +- [Git sync](Git-sync) — settings (including these fields) sync with your repo. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index b46bb72..594480c 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -6,6 +6,7 @@ Documentation for [Keystrike](https://github.com/egno/keystrike) — an offline - **[Git sync](Git-sync)** — back up and sync settings and sessions via a private git repository (CLI-only, opt-in). - **[Word lists](Word-Lists)** — import a remote dictionary for real-word adaptive drills (Settings, optional). +- **[Confidence tuning](Confidence-Tuning)** — session window and attempt floors for unlocks, focus, and the stats heatmap. ## Project links diff --git a/src/keystrike/app.py b/src/keystrike/app.py index cfdc2da..79020f4 100644 --- a/src/keystrike/app.py +++ b/src/keystrike/app.py @@ -47,7 +47,11 @@ def build_sync() -> SyncServices: session_repo = JsonlSessionRepository(paths) aggregates_cache = FileAggregatesCache(paths) store = GitSyncGateway(paths) - rebuild = RebuildAggregates(repo=session_repo, cache=aggregates_cache) + rebuild = RebuildAggregates( + repo=session_repo, + cache=aggregates_cache, + settings_repo=TomlSettingsRepository(paths), + ) return SyncServices( init=InitSync(gateway=store), pull=PullSync(gateway=store, rebuild=rebuild), @@ -77,7 +81,11 @@ def build() -> KeystrikeApp: settings_repo=settings_repo, layout_repo=layout_repo, ) - rebuild_aggregates = RebuildAggregates(repo=session_repo, cache=aggregates_cache) + rebuild_aggregates = RebuildAggregates( + repo=session_repo, + cache=aggregates_cache, + settings_repo=settings_repo, + ) get_heatmap = GetHeatmap(cache=aggregates_cache, settings_repo=settings_repo) get_history = GetHistory(repo=session_repo) get_daily_learn_budget = GetDailyLearnBudget( diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 18f4f4a..dba14d2 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -10,6 +10,7 @@ from keystrike.domain.aggregate import transition_key from keystrike.domain.confidence import ( + FOCUS_CHAR_BOOST, compute_unlocked, confidence_of, focus_key_from_transition, @@ -49,10 +50,12 @@ def _focus_reason( stats: dict[int, KeyStats], target: float, now: float, + *, + min_attempts: int, ) -> str | None: key_stats = stats.get(focus) urgency = review_urgency(key_stats.last_seen if key_stats else 0.0, now) - confidence = confidence_of(focus, stats, target) + confidence = confidence_of(focus, stats, target, min_attempts=min_attempts) if urgency > 0 and confidence >= _CONFIDENCE_GOOD: return "review" if confidence < _CONFIDENCE_GOOD: @@ -66,11 +69,15 @@ def _focus_reason_transition( transitions: dict[str, TransitionStats], target: float, now: float, + *, + min_attempts: int, ) -> str | None: pair = chr(prev_cp) + chr(next_cp) t_stats = transitions.get(transition_key(prev_cp, next_cp)) urgency = review_urgency(t_stats.last_seen if t_stats else 0.0, now) - confidence = transition_confidence_of(prev_cp, next_cp, transitions, target) + confidence = transition_confidence_of( + prev_cp, next_cp, transitions, target, min_attempts=min_attempts, + ) if urgency > 0 and confidence >= _CONFIDENCE_GOOD: return f"{pair} review transition" if confidence < _CONFIDENCE_GOOD: @@ -106,21 +113,41 @@ def _lesson_progress( ) -> tuple[tuple[int, ...], int, LessonState, tuple[int, int] | None]: target = target_ms_per_char(settings.target_speed_cpm) order = keyboard_order(layout) - unlocked = compute_unlocked(order, settings.alphabet_size, stats, target) + unlocked = compute_unlocked( + order, + settings.alphabet_size, + stats, + target, + min_attempts=settings.min_confidence_attempts, + ) focus_bigram = ( - select_focus_transition(unlocked, transitions, target, now) + select_focus_transition( + unlocked, + transitions, + target, + now, + min_attempts=settings.min_transition_confidence_attempts, + ) if transitions else None ) if focus_bigram is not None: focus = focus_key_from_transition(*focus_bigram) else: - focus = select_focus(unlocked, stats, target, now) + focus = select_focus( + unlocked, + stats, + target, + now, + min_attempts=settings.min_confidence_attempts, + ) keys = tuple( LessonKey( codepoint=cp, unlocked=True, - confidence=confidence_of(cp, stats, target), + confidence=confidence_of( + cp, stats, target, min_attempts=settings.min_confidence_attempts, + ), is_focus=(cp == focus), ) for cp in unlocked @@ -167,9 +194,16 @@ def __call__(self, layout_name: str) -> Lesson: ) for k in state.keys } + char_weights[chr(focus)] *= FOCUS_CHAR_BOOST transition_weights = { transition_key(prev, nxt): transition_practice_weight( - transition_confidence_of(prev, nxt, transitions, target), + transition_confidence_of( + prev, + nxt, + transitions, + target, + min_attempts=settings.min_transition_confidence_attempts, + ), urgency=review_urgency( transitions[transition_key(prev, nxt)].last_seen if transition_key(prev, nxt) in transitions else 0.0, @@ -203,13 +237,32 @@ def __call__(self, layout_name: str) -> Lesson: } if focus_bigram is not None: prev_cp, next_cp = focus_bigram - reason = _focus_reason_transition(prev_cp, next_cp, transitions, target, now) + reason = _focus_reason_transition( + prev_cp, + next_cp, + transitions, + target, + now, + min_attempts=settings.min_transition_confidence_attempts, + ) focus_confidence = transition_confidence_of( - prev_cp, next_cp, transitions, target, + prev_cp, + next_cp, + transitions, + target, + min_attempts=settings.min_transition_confidence_attempts, ) else: - reason = _focus_reason(focus, stats, target, now) - focus_confidence = confidence_of(focus, stats, target) + reason = _focus_reason( + focus, + stats, + target, + now, + min_attempts=settings.min_confidence_attempts, + ) + focus_confidence = confidence_of( + focus, stats, target, min_attempts=settings.min_confidence_attempts, + ) return Lesson( text=text, state=state, diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 65b45f2..7fa8c8b 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -1,8 +1,12 @@ from collections.abc import Sequence from dataclasses import dataclass, field, replace -from keystrike.domain.aggregate import aggregate_session, combine -from keystrike.domain.confidence import compute_unlocked, confidence_of, target_ms_per_char +from keystrike.domain.aggregate import aggregate_session, combine_sessions +from keystrike.domain.confidence import ( + compute_unlocked, + confidence_of, + target_ms_per_char, +) from keystrike.domain.enums import Mode, SessionState from keystrike.domain.generator import typical_chars_per_word from keystrike.domain.learn_order import keyboard_order @@ -103,14 +107,12 @@ def _snapshot_unlock_state( session: Session, duration_ns: int, *, - aggregates_cache: AggregatesCache, + repo: SessionRepository, settings_repo: SettingsRepository, layout_repo: LayoutRepository, ) -> tuple[tuple[int, ...], dict[int, float]]: settings = settings_repo.load() layout = layout_repo.get(session.layout) - aggregates = aggregates_cache.get(session.layout) - prior = aggregates.keys if aggregates else {} draft = SessionResult( schema_version=3, session_id=session.id, @@ -124,15 +126,27 @@ def _snapshot_unlock_state( correct_keystrokes=session.correct_count, lang=session.lang, ) - stats = combine(prior, aggregate_session(draft, session.keystrokes)) + prior_headers = sorted( + repo.iter_headers(session.layout), + key=lambda h: h.started_at, + )[-(settings.confidence_session_window - 1):] + sessions = [ + (header, repo.load_keystrokes(header.session_id)) for header in prior_headers + ] + sessions.append((draft, session.keystrokes)) + stats = combine_sessions(sessions).keys target = target_ms_per_char(settings.target_speed_cpm) unlocked = compute_unlocked( keyboard_order(layout), settings.alphabet_size, stats, target, + min_attempts=settings.min_confidence_attempts, ) - return unlocked, {cp: confidence_of(cp, stats, target) for cp in unlocked} + return unlocked, { + cp: confidence_of(cp, stats, target, min_attempts=settings.min_confidence_attempts) + for cp in unlocked + } def _sync_alphabet_size(unlocked_keys: tuple[int, ...], settings_repo: SettingsRepository) -> None: @@ -166,8 +180,7 @@ def __call__(self, session: Session) -> SessionResult: unlocked_keys: tuple[int, ...] = () key_confidence: dict[int, float] = {} if ( - self.aggregates_cache is not None - and self.settings_repo is not None + self.settings_repo is not None and self.layout_repo is not None ): settings = self.settings_repo.load() @@ -175,7 +188,7 @@ def __call__(self, session: Session) -> SessionResult: unlocked_keys, key_confidence = _snapshot_unlock_state( session, duration_ns, - aggregates_cache=self.aggregates_cache, + repo=self.repo, settings_repo=self.settings_repo, layout_repo=self.layout_repo, ) diff --git a/src/keystrike/application/settings_use_cases.py b/src/keystrike/application/settings_use_cases.py index 522d8d2..72047ee 100644 --- a/src/keystrike/application/settings_use_cases.py +++ b/src/keystrike/application/settings_use_cases.py @@ -14,6 +14,8 @@ from keystrike.domain.protocols import LayoutRepository, SettingsRepository _MIN_LAYOUTS_TO_CYCLE = 2 +_MAX_CONFIDENCE_SESSION_WINDOW = 100 +_MAX_CONFIDENCE_ATTEMPTS = 100 class SettingsValidationError(ValueError): @@ -32,6 +34,9 @@ def __call__( target_speed_unit: TargetSpeedUnit, alphabet_size: int, learn_daily_minutes: int, + confidence_session_window: int, + min_confidence_attempts: int, + min_transition_confidence_attempts: int, ) -> Settings: if target_speed_cpm <= 0: raise SettingsValidationError("Target speed must be a positive integer.") @@ -41,6 +46,19 @@ def __call__( raise SettingsValidationError("Number of letters must be zero or more.") if learn_daily_minutes < 0: raise SettingsValidationError("Daily learn minutes must be zero or more.") + if not 1 <= confidence_session_window <= _MAX_CONFIDENCE_SESSION_WINDOW: + raise SettingsValidationError( + f"Confidence session window must be 1–{_MAX_CONFIDENCE_SESSION_WINDOW}.", + ) + if not 1 <= min_confidence_attempts <= _MAX_CONFIDENCE_ATTEMPTS: + raise SettingsValidationError( + f"Min confidence attempts must be 1–{_MAX_CONFIDENCE_ATTEMPTS}.", + ) + if not 1 <= min_transition_confidence_attempts <= _MAX_CONFIDENCE_ATTEMPTS: + raise SettingsValidationError( + "Min transition confidence attempts must be " + f"1–{_MAX_CONFIDENCE_ATTEMPTS}.", + ) updated = replace( self.repo.load(), layout=layout, @@ -48,6 +66,9 @@ def __call__( target_speed_unit=target_speed_unit, alphabet_size=alphabet_size, learn_daily_minutes=learn_daily_minutes, + confidence_session_window=confidence_session_window, + min_confidence_attempts=min_confidence_attempts, + min_transition_confidence_attempts=min_transition_confidence_attempts, ) self.repo.save(updated) return updated diff --git a/src/keystrike/application/stats_use_cases.py b/src/keystrike/application/stats_use_cases.py index 7eda934..fb54024 100644 --- a/src/keystrike/application/stats_use_cases.py +++ b/src/keystrike/application/stats_use_cases.py @@ -5,20 +5,13 @@ import time from dataclasses import dataclass -from keystrike.domain.aggregate import ( - aggregate_session, - aggregate_transitions, - combine, - combine_transitions, - per_key_deltas, -) +from keystrike.domain.aggregate import combine_sessions, per_key_deltas from keystrike.domain.confidence import ( - accuracy_of, - key_confidence, + confidence_of, review_urgency, target_ms_per_char, ) -from keystrike.domain.models import KeyStats, LayoutAggregates, SessionResult, TransitionStats +from keystrike.domain.models import KeyStats, SessionResult from keystrike.domain.protocols import AggregatesCache, SessionRepository, SettingsRepository from keystrike.domain.regression import estimate_sessions_to_goal @@ -27,22 +20,23 @@ @dataclass(slots=True) class RebuildAggregates: - """Replay every stored session for a layout into a fresh KeyStats cache entry.""" + """Replay the last N sessions (from settings) into the cache.""" repo: SessionRepository cache: AggregatesCache + settings_repo: SettingsRepository def __call__(self, layout: str) -> dict[int, KeyStats]: - key_maps: list[dict[int, KeyStats]] = [] - transition_maps: list[dict[str, TransitionStats]] = [] - for header in self.repo.iter_headers(layout): - keystrokes = self.repo.load_keystrokes(header.session_id) - key_maps.append(aggregate_session(header, keystrokes)) - transition_maps.append(aggregate_transitions(header, keystrokes)) - combined = combine(*key_maps) - combined_transitions = combine_transitions(*transition_maps) - self.cache.put(layout, LayoutAggregates(keys=combined, transitions=combined_transitions)) - return combined + window = self.settings_repo.load().confidence_session_window + headers = sorted( + self.repo.iter_headers(layout), + key=lambda h: h.started_at, + )[-window:] + combined = combine_sessions( + [(header, self.repo.load_keystrokes(header.session_id)) for header in headers], + ) + self.cache.put(layout, combined) + return combined.keys @dataclass(frozen=True, slots=True) @@ -63,12 +57,18 @@ def __call__(self, layout: str) -> HeatmapView: if not aggregates: return HeatmapView(confidence={}, urgency={}) stats = aggregates.keys - target = target_ms_per_char(self.settings_repo.load().target_speed_cpm) + settings = self.settings_repo.load() + target = target_ms_per_char(settings.target_speed_cpm) now = time.time() return HeatmapView( confidence={ - cp: key_confidence(target, k.mean_time_ns) * accuracy_of(k) - for cp, k in stats.items() + cp: confidence_of( + cp, + stats, + target, + min_attempts=settings.min_confidence_attempts, + ) + for cp in stats }, urgency={cp: review_urgency(k.last_seen, now) for cp, k in stats.items()}, ) diff --git a/src/keystrike/domain/aggregate.py b/src/keystrike/domain/aggregate.py index b4ea4c4..8d72331 100644 --- a/src/keystrike/domain/aggregate.py +++ b/src/keystrike/domain/aggregate.py @@ -7,16 +7,17 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from dataclasses import dataclass, field -from .models import KeyStats, Keystroke, SessionResult, TransitionStats +from .models import KeyStats, Keystroke, LayoutAggregates, SessionResult, TransitionStats @dataclass(slots=True) class _Partial: time_samples: list[int] = field(default_factory=list[int]) error_count: int = 0 + attempt_count: int = 0 def per_key_deltas(keystrokes: Iterable[Keystroke]) -> dict[int, list[int]]: @@ -51,6 +52,7 @@ def aggregate_session( for k in all_keystrokes: entry = partial.setdefault(k.codepoint, _Partial()) + entry.attempt_count += 1 if not k.correct: entry.error_count += 1 @@ -67,11 +69,28 @@ def aggregate_session( if p.time_samples else 0.0, error_count=p.error_count, last_seen=session_end_wall, + attempt_count=p.attempt_count, ) for cp, p in partial.items() } +def combine_sessions( + sessions: Sequence[tuple[SessionResult, Iterable[Keystroke]]], +) -> LayoutAggregates: + """Merge per-session stats into one layout aggregate.""" + if not sessions: + return LayoutAggregates(keys={}, transitions={}) + key_maps = [aggregate_session(header, keystrokes) for header, keystrokes in sessions] + transition_maps = [ + aggregate_transitions(header, keystrokes) for header, keystrokes in sessions + ] + return LayoutAggregates( + keys=combine(*key_maps), + transitions=combine_transitions(*transition_maps), + ) + + def merge_key_stats(a: KeyStats, b: KeyStats) -> KeyStats: if a.codepoint != b.codepoint: raise ValueError(f"codepoint mismatch: {a.codepoint} vs {b.codepoint}") @@ -86,6 +105,7 @@ def merge_key_stats(a: KeyStats, b: KeyStats) -> KeyStats: mean_time_ns=mean, error_count=a.error_count + b.error_count, last_seen=max(a.last_seen, b.last_seen), + attempt_count=a.attempt_count + b.attempt_count, ) @@ -126,11 +146,20 @@ def aggregate_transitions( ) -> dict[str, TransitionStats]: partial: dict[str, _Partial] = {} all_keystrokes = list(keystrokes) + last_correct_cp: int | None = None for i, k in enumerate(all_keystrokes): - if not k.correct and i > 0: - key = transition_key(all_keystrokes[i - 1].codepoint, k.codepoint) - partial.setdefault(key, _Partial()).error_count += 1 + if not k.correct: + if i > 0: + key = transition_key(all_keystrokes[i - 1].codepoint, k.codepoint) + entry = partial.setdefault(key, _Partial()) + entry.attempt_count += 1 + entry.error_count += 1 + continue + if last_correct_cp is not None: + key = transition_key(last_correct_cp, k.codepoint) + partial.setdefault(key, _Partial()).attempt_count += 1 + last_correct_cp = k.codepoint for key, samples in per_transition_deltas(all_keystrokes).items(): partial.setdefault(key, _Partial()).time_samples.extend(samples) @@ -146,6 +175,7 @@ def aggregate_transitions( if p.time_samples else 0.0, error_count=p.error_count, last_seen=session_end_wall, + attempt_count=p.attempt_count, ) for key, p in partial.items() } @@ -168,6 +198,7 @@ def merge_transition_stats(a: TransitionStats, b: TransitionStats) -> Transition mean_time_ns=mean, error_count=a.error_count + b.error_count, last_seen=max(a.last_seen, b.last_seen), + attempt_count=a.attempt_count + b.attempt_count, ) diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index 03fdf5e..a81ee80 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -12,6 +12,22 @@ _SECONDS_PER_DAY = 86_400.0 _REVIEW_URGENCY_FULL_DAYS = 3.0 +# Confidence, unlocks, focus, and heatmap use aggregates from this many sessions. +CONFIDENCE_SESSION_WINDOW = 10 +# Raw speed×accuracy confidence ramps linearly until this many attempts per key. +MIN_CONFIDENCE_ATTEMPTS = 10 +# Bigrams are sparser — lower floor so transition focus reflects measured weakness. +MIN_TRANSITION_CONFIDENCE_ATTEMPTS = 4 +# Confidence scores are rounded before thresholds, focus, heatmap, and UI. +CONFIDENCE_DECIMALS = 2 +# ponytail: fixed multipliers; upgrade to settings if users want tunable focus intensity +FOCUS_CHAR_BOOST = 3.0 +FOCUS_WORD_BOOST = 2.0 + + +def round_confidence(value: float) -> float: + """Round confidence for comparisons and display so they stay aligned.""" + return round(value, CONFIDENCE_DECIMALS) def target_ms_per_char(target_speed_cpm: int) -> float: @@ -32,20 +48,51 @@ def accuracy_of(key_stats: KeyStats) -> float: return key_stats.samples / total if total > 0 else 0.0 -def confidence_of(codepoint: int, stats: Mapping[int, KeyStats], target: float) -> float: - """Live confidence for one key, recomputed from the current target so a - stale historical best can't vouch for a key that isn't actually being - typed accurately/quickly right now (Keybr gates unlock on clearing - thresholds "on the current set"; see docs/research/typing-pedagogy.md). +def key_attempts(key_stats: KeyStats) -> int: + return key_stats.attempt_count + + +def transition_attempts(transition_stats: TransitionStats) -> int: + return transition_stats.attempt_count + + +def confidence_sample_factor( + attempts: int, + *, + minimum: int = MIN_CONFIDENCE_ATTEMPTS, +) -> float: + """Ramp toward full confidence as attempts accumulate (ponytail: linear; + upgrade to Wilson interval or similar if sparse keys still feel jumpy).""" + if minimum <= 0: + return 1.0 + if attempts <= 0: + return 0.0 + return min(1.0, attempts / minimum) + + +def confidence_of( + codepoint: int, + stats: Mapping[int, KeyStats], + target: float, + *, + min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, +) -> float: + """Live confidence for one key from aggregated stats (typically the last + `CONFIDENCE_SESSION_WINDOW` sessions), recomputed from the current target. 0.0 for a never-practiced key. Speed confidence is scaled by accuracy so a key typed fast but frequently - wrong doesn't read as mastered (accuracy-first: see docs/research/typing-pedagogy.md). + wrong doesn't read as mastered. Both are scaled down until + `MIN_CONFIDENCE_ATTEMPTS` presses on the key so a lucky first session + can't read as mastered (see docs/research/typing-pedagogy.md). """ key_stats = stats.get(codepoint) if key_stats is None: return 0.0 - return key_confidence(target, key_stats.mean_time_ns) * accuracy_of(key_stats) + raw = key_confidence(target, key_stats.mean_time_ns) * accuracy_of(key_stats) + return round_confidence( + raw * confidence_sample_factor(key_attempts(key_stats), minimum=min_attempts), + ) def compute_unlocked( @@ -55,6 +102,7 @@ def compute_unlocked( target: float, *, threshold: float = 1.0, + min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, ) -> tuple[int, ...]: """The first `alphabet_size` keys are always unlocked; each further key in `learn_order` unlocks only once every currently-unlocked key meets @@ -62,7 +110,10 @@ def compute_unlocked( forced_count = min(alphabet_size, len(learn_order)) unlocked = list(learn_order[:forced_count]) for codepoint in learn_order[forced_count:]: - if not all(confidence_of(k, stats, target) >= threshold for k in unlocked): + if not all( + confidence_of(k, stats, target, min_attempts=min_attempts) >= threshold + for k in unlocked + ): break unlocked.append(codepoint) return tuple(unlocked) @@ -100,15 +151,23 @@ def transition_confidence_of( next_cp: int, stats: Mapping[str, TransitionStats], target: float, + *, + min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, ) -> float: """Live confidence for one bigram transition. 0.0 when never practiced.""" transition_stats = stats.get(chr(prev_cp) + chr(next_cp)) if transition_stats is None: return 0.0 - return ( + raw = ( transition_confidence(target, transition_stats.mean_time_ns) * transition_accuracy_of(transition_stats) ) + return round_confidence( + raw * confidence_sample_factor( + transition_attempts(transition_stats), + minimum=min_attempts, + ), + ) def transition_practice_weight( @@ -155,10 +214,14 @@ def _focus_score( now: float, *, review_penalty: float, + min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, ) -> float: key_stats = stats.get(codepoint) urgency = review_urgency(key_stats.last_seen if key_stats else 0.0, now) - return confidence_of(codepoint, stats, target) * (1.0 - review_penalty * urgency) + return ( + confidence_of(codepoint, stats, target, min_attempts=min_attempts) + * (1.0 - review_penalty * urgency) + ) def select_focus( @@ -168,13 +231,16 @@ def select_focus( now: float, *, review_penalty: float = 0.5, + min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, ) -> int: """The unlocked key a lesson should emphasize — weakest by confidence, but stale keys are treated as weaker so high-confidence keys due for review still surface (SlimStampen-style spacing; see typing-pedagogy.md).""" return min( unlocked, - key=lambda cp: _focus_score(cp, stats, target, now, review_penalty=review_penalty), + key=lambda cp: _focus_score( + cp, stats, target, now, review_penalty=review_penalty, min_attempts=min_attempts, + ), ) @@ -186,11 +252,14 @@ def _transition_focus_score( now: float, *, review_penalty: float, + min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, ) -> float: key = chr(prev_cp) + chr(next_cp) t_stats = stats.get(key) urgency = review_urgency(t_stats.last_seen if t_stats else 0.0, now) - confidence = transition_confidence_of(prev_cp, next_cp, stats, target) + confidence = transition_confidence_of( + prev_cp, next_cp, stats, target, min_attempts=min_attempts, + ) return confidence * (1.0 - review_penalty * urgency) @@ -201,6 +270,7 @@ def select_focus_transition( now: float, *, review_penalty: float = 0.5, + min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, ) -> tuple[int, int] | None: """Weakest unlocked bigram by transition confidence; None when no transition data.""" if not transitions: @@ -211,7 +281,13 @@ def select_focus_transition( return min( pairs, key=lambda p: _transition_focus_score( - p[0], p[1], transitions, target, now, review_penalty=review_penalty, + p[0], + p[1], + transitions, + target, + now, + review_penalty=review_penalty, + min_attempts=min_attempts, ), ) diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index 0f029dd..d2ac338 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from random import Random +from .confidence import FOCUS_WORD_BOOST from .markov import TransitionTable from .models import Layout from .word_bounds import MAX_WORD_LEN, MIN_WORD_LEN @@ -75,7 +76,9 @@ def generate_lesson( wordlist_weights: list[float] | None = None if words and char_weights: wordlist_weights = [ - sum(char_weights.get(ch, 1.0) for ch in w) for w in words + sum(char_weights.get(ch, 1.0) for ch in w) + * (FOCUS_WORD_BOOST if focus_char in w else 1.0) + for w in words ] lesson_words = [ self.generate_word( diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index 1728aba..8ee1e4e 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -41,6 +41,7 @@ class KeyStats: mean_time_ns: float error_count: int last_seen: float + attempt_count: int = 0 @dataclass(frozen=True, slots=True) @@ -51,6 +52,7 @@ class TransitionStats: mean_time_ns: float error_count: int last_seen: float + attempt_count: int = 0 def _empty_transitions() -> dict[str, TransitionStats]: @@ -104,6 +106,9 @@ class Settings: target_speed_cpm: int = 300 # ~46 wpm at typical generated word length target_speed_unit: TargetSpeedUnit = TargetSpeedUnit.WPM alphabet_size: int = 16 # letters force-unlocked from cold start + confidence_session_window: int = 10 # sessions in rolling stats for confidence + min_confidence_attempts: int = 10 # presses before key confidence reaches full weight + min_transition_confidence_attempts: int = 4 # lower floor — bigrams are sparser lang: str = "en" learn_daily_minutes: int = 10 # adaptive mode daily goal (minutes); 0 = no goal wordlist_url: str = "" # non-empty + cached file → real words; else Markov diff --git a/src/keystrike/infrastructure/aggregates_cache.py b/src/keystrike/infrastructure/aggregates_cache.py index a397ec0..77df329 100644 --- a/src/keystrike/infrastructure/aggregates_cache.py +++ b/src/keystrike/infrastructure/aggregates_cache.py @@ -36,6 +36,9 @@ def get(self, layout: str) -> LayoutAggregates | None: mean_time_ns=float(entry["mean_time_ns"]), error_count=int(entry["error_count"]), last_seen=float(entry["last_seen"]), + attempt_count=int( + entry.get("attempt_count", entry["samples"] + entry["error_count"]), + ), ) for cp, entry in keys.items() }, @@ -47,6 +50,9 @@ def get(self, layout: str) -> LayoutAggregates | None: mean_time_ns=float(entry["mean_time_ns"]), error_count=int(entry["error_count"]), last_seen=float(entry["last_seen"]), + attempt_count=int( + entry.get("attempt_count", entry["samples"] + entry["error_count"]), + ), ) for key, entry in transitions.items() }, diff --git a/src/keystrike/infrastructure/settings_repo_toml.py b/src/keystrike/infrastructure/settings_repo_toml.py index 8dd3c72..f209794 100644 --- a/src/keystrike/infrastructure/settings_repo_toml.py +++ b/src/keystrike/infrastructure/settings_repo_toml.py @@ -47,6 +47,18 @@ def load(self) -> Settings: target_speed_cpm=int(raw.get("target_speed_cpm", defaults.target_speed_cpm)), target_speed_unit=target_speed_unit, alphabet_size=int(raw.get("alphabet_size", defaults.alphabet_size)), + confidence_session_window=int( + raw.get("confidence_session_window", defaults.confidence_session_window), + ), + min_confidence_attempts=int( + raw.get("min_confidence_attempts", defaults.min_confidence_attempts), + ), + min_transition_confidence_attempts=int( + raw.get( + "min_transition_confidence_attempts", + defaults.min_transition_confidence_attempts, + ), + ), lang=str(raw.get("lang", defaults.lang)), learn_daily_minutes=int( raw.get("learn_daily_minutes", defaults.learn_daily_minutes), @@ -65,6 +77,12 @@ def save(self, settings: Settings) -> None: ("target_speed_cpm", settings.target_speed_cpm), ("target_speed_unit", settings.target_speed_unit), ("alphabet_size", settings.alphabet_size), + ("confidence_session_window", settings.confidence_session_window), + ("min_confidence_attempts", settings.min_confidence_attempts), + ( + "min_transition_confidence_attempts", + settings.min_transition_confidence_attempts, + ), ("learn_daily_minutes", settings.learn_daily_minutes), ("lang", settings.lang), ] diff --git a/src/keystrike/presentation/screens/settings.py b/src/keystrike/presentation/screens/settings.py index 9995330..5a9c1dc 100644 --- a/src/keystrike/presentation/screens/settings.py +++ b/src/keystrike/presentation/screens/settings.py @@ -96,6 +96,24 @@ def compose(self) -> ComposeResult: id="settings-learn-daily-minutes", type="integer", ) + yield Label("Confidence session window (sessions)") + yield Input( + value=str(settings.confidence_session_window), + id="settings-confidence-session-window", + type="integer", + ) + yield Label("Min key attempts before full confidence") + yield Input( + value=str(settings.min_confidence_attempts), + id="settings-min-confidence-attempts", + type="integer", + ) + yield Label("Min bigram attempts before full transition confidence") + yield Input( + value=str(settings.min_transition_confidence_attempts), + id="settings-min-transition-confidence-attempts", + type="integer", + ) yield Static( "Word list [dim](Ctrl+I import, Ctrl+X clear)[/]", id="settings-wordlist-label", @@ -159,6 +177,31 @@ def action_save(self) -> None: self._show_error("Daily learn minutes must be an integer.") return + confidence_window_raw = self.query_one( + "#settings-confidence-session-window", Input, + ).value + try: + confidence_session_window = int(confidence_window_raw) + except ValueError: + self._show_error("Confidence session window must be an integer.") + return + + min_confidence_raw = self.query_one("#settings-min-confidence-attempts", Input).value + try: + min_confidence_attempts = int(min_confidence_raw) + except ValueError: + self._show_error("Min confidence attempts must be an integer.") + return + + min_transition_raw = self.query_one( + "#settings-min-transition-confidence-attempts", Input, + ).value + try: + min_transition_confidence_attempts = int(min_transition_raw) + except ValueError: + self._show_error("Min transition confidence attempts must be an integer.") + return + layout_select = cast("Select[str]", self.query_one("#settings-layout", Select)) layout = layout_select.value if isinstance(layout, NoSelection): @@ -172,6 +215,9 @@ def action_save(self) -> None: target_speed_unit=target_speed_unit, alphabet_size=alphabet_size, learn_daily_minutes=learn_daily_minutes, + confidence_session_window=confidence_session_window, + min_confidence_attempts=min_confidence_attempts, + min_transition_confidence_attempts=min_transition_confidence_attempts, ) except SettingsValidationError as exc: self._show_error(str(exc)) diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 6580e7c..ca1d98e 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -93,10 +93,12 @@ def test_lesson_uses_transition_focus_when_transitions_weak(): five_days = 5 * 86_400.0 fast = 100_000_000.0 transitions = { - transition_key(a, a): TransitionStats(a, a, 10, fast, 0, now), - transition_key(a, s): TransitionStats(a, s, 10, 400_000_000.0, 0, now - five_days), - transition_key(s, a): TransitionStats(s, a, 10, fast, 0, now), - transition_key(s, s): TransitionStats(s, s, 10, fast, 0, now), + transition_key(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), + transition_key(a, s): TransitionStats( + a, s, 10, 400_000_000.0, 0, now - five_days, attempt_count=10, + ), + transition_key(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), + transition_key(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), } cache = FakeAggregatesCache( by_layout={"qwerty": LayoutAggregates(keys={}, transitions=transitions)}, diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index d26fce4..6d6ec7b 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -19,8 +19,8 @@ key_confidence_values, wpm_sparkline, ) -from keystrike.domain.aggregate import aggregate_session, combine -from keystrike.domain.confidence import compute_unlocked, confidence_of, target_ms_per_char +from keystrike.domain.aggregate import aggregate_session, combine_sessions +from keystrike.domain.confidence import CONFIDENCE_SESSION_WINDOW, compute_unlocked, confidence_of, target_ms_per_char from keystrike.domain.enums import Mode, SessionState from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import KeyStats, LayoutAggregates, SessionResult, Settings @@ -183,23 +183,27 @@ def test_finish_session_persists_unlocked_keys(clock, id_gen): def test_finish_session_bumps_alphabet_size_when_unlocked_set_grows(clock, id_gen): layout = BUNDLED_LAYOUTS["qwerty"] order = keyboard_order(layout) - fast = 50_000_000.0 - stats = {cp: KeyStats(cp, 50, fast, 0, 1_700_000_000.0) for cp in order[:5]} - cache = FakeAggregatesCache( - by_layout={"qwerty": LayoutAggregates(keys=stats, transitions={})}, - ) settings_repo = FakeSettingsRepository(Settings(alphabet_size=5)) layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) repo = FakeSessionRepository() finish = FinishSession( clock=clock, repo=repo, - aggregates_cache=cache, settings_repo=settings_repo, layout_repo=layout_repo, ) start = StartSession(clock=clock, id_gen=id_gen) record = RecordKeystroke(clock=clock, repo=repo) + + warmup = "".join(chr(cp) for cp in order[:5] for _ in range(10)) + warmup_session = start( + warmup, layout="qwerty", mode=Mode.ADAPTIVE, focus_key=order[0], + ) + for ch in warmup: + clock.advance(50_000_000) + record(warmup_session, ch) + finish(warmup_session) + session = start("as", layout="qwerty", mode=Mode.ADAPTIVE, focus_key=order[0]) for ch in "as": clock.advance(100_000_000) @@ -241,12 +245,46 @@ def test_finish_session_persists_key_confidence(clock, id_gen): ) assert result.schema_version == 3 assert set(result.key_confidence.keys()) == set(expected_unlocked) - stats = combine({}, aggregate_session(result, session.keystrokes)) + stats = combine_sessions([(result, session.keystrokes)]).keys for cp in expected_unlocked: assert result.key_confidence[cp] == confidence_of(cp, stats, target) assert repo.headers[0].key_confidence == result.key_confidence +def test_finish_session_key_confidence_uses_confidence_session_window(clock, id_gen): + settings_repo = FakeSettingsRepository() + layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) + repo = FakeSessionRepository() + finish = FinishSession( + clock=clock, + repo=repo, + settings_repo=settings_repo, + layout_repo=layout_repo, + ) + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock, repo=repo) + + for _ in range(CONFIDENCE_SESSION_WINDOW - 1): + session = start("a", layout="qwerty", mode=Mode.ADAPTIVE, focus_key=ord("a")) + clock.advance(400_000_000) + record(session, "a") + finish(session) + + session = start("a", layout="qwerty", mode=Mode.ADAPTIVE, focus_key=ord("a")) + clock.advance(100_000_000) + record(session, "a") + result = finish(session) + + settings = settings_repo.load() + target = target_ms_per_char(settings.target_speed_cpm) + prior = sorted(repo.headers, key=lambda h: h.started_at)[-(CONFIDENCE_SESSION_WINDOW - 1):] + stats = combine_sessions( + [(header, repo.keystrokes[header.session_id]) for header in prior] + + [(result, session.keystrokes)], + ).keys + assert result.key_confidence[ord("a")] == confidence_of(ord("a"), stats, target) + + def test_finish_session_persists_target_speed_cpm(clock, id_gen): settings_repo = FakeSettingsRepository() settings_repo.settings = replace(settings_repo.settings, target_speed_cpm=400) diff --git a/tests/application/test_settings_use_cases.py b/tests/application/test_settings_use_cases.py index f47040b..460e400 100644 --- a/tests/application/test_settings_use_cases.py +++ b/tests/application/test_settings_use_cases.py @@ -21,6 +21,9 @@ def test_update_settings_persists_all_fields(): target_speed_unit=TargetSpeedUnit.WPM, alphabet_size=20, learn_daily_minutes=15, + confidence_session_window=10, + min_confidence_attempts=10, + min_transition_confidence_attempts=4, ) assert result.layout == "dvorak" @@ -28,6 +31,9 @@ def test_update_settings_persists_all_fields(): assert result.target_speed_unit == TargetSpeedUnit.WPM assert result.alphabet_size == 20 assert result.learn_daily_minutes == 15 + assert result.confidence_session_window == 10 + assert result.min_confidence_attempts == 10 + assert result.min_transition_confidence_attempts == 4 assert result.wordlist_url == "" assert repo.settings == result @@ -43,6 +49,9 @@ def test_update_settings_rejects_non_positive_speed(): target_speed_unit=TargetSpeedUnit.CPM, alphabet_size=16, learn_daily_minutes=10, + confidence_session_window=10, + min_confidence_attempts=10, + min_transition_confidence_attempts=4, ) assert repo.settings == Settings() # unchanged @@ -59,6 +68,9 @@ def test_update_settings_rejects_negative_alphabet_size(): target_speed_unit=TargetSpeedUnit.CPM, alphabet_size=-1, learn_daily_minutes=10, + confidence_session_window=10, + min_confidence_attempts=10, + min_transition_confidence_attempts=4, ) assert repo.settings == Settings() # unchanged @@ -75,6 +87,47 @@ def test_update_settings_rejects_negative_learn_daily_minutes(): target_speed_unit=TargetSpeedUnit.CPM, alphabet_size=16, learn_daily_minutes=-1, + confidence_session_window=10, + min_confidence_attempts=10, + min_transition_confidence_attempts=4, + ) + + assert repo.settings == Settings() + + +def test_update_settings_rejects_invalid_confidence_session_window(): + repo = FakeSettingsRepository(Settings()) + update = UpdateSettings(repo=repo) + + with pytest.raises(SettingsValidationError): + update( + layout="qwerty", + target_speed_cpm=300, + target_speed_unit=TargetSpeedUnit.CPM, + alphabet_size=16, + learn_daily_minutes=10, + confidence_session_window=0, + min_confidence_attempts=10, + min_transition_confidence_attempts=4, + ) + + assert repo.settings == Settings() + + +def test_update_settings_rejects_invalid_min_confidence_attempts(): + repo = FakeSettingsRepository(Settings()) + update = UpdateSettings(repo=repo) + + with pytest.raises(SettingsValidationError): + update( + layout="qwerty", + target_speed_cpm=300, + target_speed_unit=TargetSpeedUnit.CPM, + alphabet_size=16, + learn_daily_minutes=10, + confidence_session_window=10, + min_confidence_attempts=0, + min_transition_confidence_attempts=4, ) assert repo.settings == Settings() diff --git a/tests/application/test_stats_use_cases.py b/tests/application/test_stats_use_cases.py index 5bd94fa..db3272a 100644 --- a/tests/application/test_stats_use_cases.py +++ b/tests/application/test_stats_use_cases.py @@ -4,6 +4,7 @@ GetLearningRate, RebuildAggregates, ) +from keystrike.domain.confidence import CONFIDENCE_SESSION_WINDOW, MIN_CONFIDENCE_ATTEMPTS from keystrike.domain.enums import Mode from keystrike.domain.models import KeyStats, Keystroke, LayoutAggregates, SessionResult, Settings from tests.fakes import FakeAggregatesCache, FakeSessionRepository, FakeSettingsRepository @@ -24,7 +25,7 @@ def _header(session_id: str, started_at: float, layout: str = "qwerty") -> Sessi ) -def test_rebuild_aggregates_combines_all_sessions_for_layout(): +def test_rebuild_aggregates_uses_confidence_session_window(): repo = FakeSessionRepository() cache = FakeAggregatesCache() @@ -47,7 +48,7 @@ def test_rebuild_aggregates_combines_all_sessions_for_layout(): Keystroke(codepoint=ord("z"), typed=ord("z"), t_ns=0, correct=True), ] - rebuild = RebuildAggregates(repo=repo, cache=cache) + rebuild = RebuildAggregates(repo=repo, cache=cache, settings_repo=FakeSettingsRepository()) result = rebuild("qwerty") assert set(result) == {ord("a")} @@ -58,6 +59,64 @@ def test_rebuild_aggregates_combines_all_sessions_for_layout(): assert cache.get("dvorak") is None +def test_rebuild_aggregates_drops_sessions_outside_window(): + repo = FakeSessionRepository() + cache = FakeAggregatesCache() + + for i in range(CONFIDENCE_SESSION_WINDOW + 1): + session_id = f"s{i}" + repo.save_header(_header(session_id, started_at=float(i))) + repo.keystrokes[session_id] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), + typed=ord("a"), + t_ns=(100 + i * 10) * 1_000_000, + correct=True, + ), + ] + # Only session 0 typed "z" — outside the window once s{N} exists. + repo.keystrokes["s0"].append( + Keystroke(codepoint=ord("z"), typed=ord("z"), t_ns=300_000_000, correct=True), + ) + + result = RebuildAggregates( + repo=repo, cache=cache, settings_repo=FakeSettingsRepository(), + )("qwerty") + + assert ord("a") in result + assert ord("z") not in result + assert result[ord("a")].samples == CONFIDENCE_SESSION_WINDOW + + +def test_rebuild_aggregates_respects_settings_window(): + repo = FakeSessionRepository() + cache = FakeAggregatesCache() + window = 3 + settings_repo = FakeSettingsRepository(Settings(confidence_session_window=window)) + + for i in range(window + 2): + session_id = f"s{i}" + repo.save_header(_header(session_id, started_at=float(i))) + repo.keystrokes[session_id] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), + typed=ord("a"), + t_ns=100_000_000, + correct=True, + ), + ] + repo.keystrokes["s0"].append( + Keystroke(codepoint=ord("z"), typed=ord("z"), t_ns=100_000_000, correct=True), + ) + + result = RebuildAggregates(repo=repo, cache=cache, settings_repo=settings_repo)("qwerty") + + assert ord("z") not in result + assert result[ord("a")].samples == window + + def test_get_heatmap_empty_cache_returns_empty_view(): cache = FakeAggregatesCache() settings_repo = FakeSettingsRepository() @@ -77,9 +136,19 @@ def test_get_heatmap_confidence_ratio(monkeypatch): repo.save_header(header) repo.keystrokes["s1"] = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True), + *[ + Keystroke( + codepoint=ord("a"), + typed=ord("a"), + t_ns=i * 200_000_000, + correct=True, + ) + for i in range(1, MIN_CONFIDENCE_ATTEMPTS) + ], ] - RebuildAggregates(repo=repo, cache=cache)("qwerty") + RebuildAggregates( + repo=repo, cache=cache, settings_repo=FakeSettingsRepository(), + )("qwerty") get_heatmap = GetHeatmap(cache=cache, settings_repo=settings_repo) view = get_heatmap("qwerty") diff --git a/tests/domain/test_aggregate.py b/tests/domain/test_aggregate.py index 37ade27..40f3715 100644 --- a/tests/domain/test_aggregate.py +++ b/tests/domain/test_aggregate.py @@ -2,6 +2,7 @@ aggregate_session, aggregate_transitions, combine, + combine_sessions, combine_transitions, merge_key_stats, merge_transition_stats, @@ -29,6 +30,17 @@ def _session(session_id: str = "s1", started_at: float = 1000.0, ) +def test_aggregate_counts_attempts_per_key(): + keys = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("x"), t_ns=50_000_000, correct=False), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), + ] + stats = aggregate_session(_session(), keys) + assert stats[ord("a")].attempt_count == 3 + assert stats[ord("a")].samples == 1 + + def test_aggregate_single_session_mean_time(): # Type a, a, a with 100ms between each correct one. Mean should be 100ms. keys = [ @@ -119,6 +131,22 @@ def test_combine_multiple_maps(): assert out[ord("y")].samples == 2 +def test_combine_sessions_merges_multiple_sessions(): + s1 = _session("s1", started_at=1.0) + s2 = _session("s2", started_at=2.0) + keys1 = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), + ] + keys2 = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=300_000_000, correct=True), + ] + out = combine_sessions([(s1, keys1), (s2, keys2)]) + assert out.keys[ord("a")].samples == 2 + assert abs(out.keys[ord("a")].mean_time_ns - 200_000_000) < 1 + + def test_per_transition_deltas_tracks_prev_to_next_pair(): keys = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index 28036d7..505b8d3 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -1,14 +1,17 @@ from keystrike.domain.confidence import ( + MIN_CONFIDENCE_ATTEMPTS, accuracy_of, compute_unlocked, confidence_of, focus_key_from_transition, + key_attempts, key_confidence, practice_weight, review_urgency, select_focus, select_focus_transition, target_ms_per_char, + transition_confidence_of, ) from keystrike.domain.models import KeyStats, TransitionStats @@ -46,6 +49,7 @@ def _stats( mean_time_ns=mean_time_ns, error_count=error_count, last_seen=last_seen, + attempt_count=10 + error_count, ) @@ -63,6 +67,58 @@ def test_accuracy_of_never_correct_is_zero(): assert accuracy_of(stats) == 0.0 +def test_confidence_of_scales_down_with_few_attempts(): + stats = { + ord("a"): KeyStats( + codepoint=ord("a"), + samples=1, + mean_time_ns=100_000_000.0, + error_count=1, + last_seen=0.0, + attempt_count=2, + ), + } + # raw speed×accuracy = 2.0 × 0.5 = 1.0; only 2 attempts → ×0.2 + assert confidence_of(ord("a"), stats, target=200.0) == 0.2 + + +def test_confidence_of_reaches_full_value_at_minimum_attempts(): + stats = {ord("a"): _stats(ord("a"), mean_time_ns=200_000_000.0, error_count=0)} + assert key_attempts(stats[ord("a")]) == MIN_CONFIDENCE_ATTEMPTS + assert confidence_of(ord("a"), stats, target=200.0) == 1.0 + + +def test_compute_unlocked_stalls_when_sparse_key_looks_fast(): + learn_order = (1, 2, 3) + stats = { + 1: _stats(1, mean_time_ns=100_000_000.0), + 2: KeyStats( + 2, samples=1, mean_time_ns=100_000_000.0, error_count=1, last_seen=0.0, + attempt_count=2, + ), + } + unlocked = compute_unlocked(learn_order, alphabet_size=2, stats=stats, target=200.0) + assert unlocked == (1, 2) + + +def test_transition_confidence_scales_down_with_few_attempts(): + stats = { + "ab": TransitionStats( + ord("a"), ord("b"), samples=3, mean_time_ns=200_000_000.0, + error_count=0, last_seen=0.0, attempt_count=2, + ), + } + # raw 1.0 × (2/4) = 0.5 + assert transition_confidence_of(ord("a"), ord("b"), stats, target=200.0) == 0.5 + + +def test_transition_confidence_reaches_full_at_minimum_attempts(): + stats = { + "ab": _transition(ord("a"), ord("b"), 200_000_000.0, attempt_count=4), + } + assert transition_confidence_of(ord("a"), ord("b"), stats, target=200.0) == 1.0 + + def test_confidence_of_penalizes_frequent_errors(): # Fast (2.0 speed-confidence) but wrong half the time -> 1.0, not "mastered". stats = {ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, error_count=10)} @@ -87,6 +143,12 @@ def test_confidence_of_uses_mean_time(): assert confidence_of(ord("a"), stats, target=200.0) == 1.0 +def test_confidence_of_rounds_near_goal_to_mastery_threshold(): + # Raw ~0.996 reads as 1.00 everywhere — no "weak" label vs 1.00 display mismatch. + stats = {ord("i"): _stats(ord("i"), mean_time_ns=200_000_000.0 / 0.996)} + assert confidence_of(ord("i"), stats, target=200.0) == 1.0 + + def test_compute_unlocked_force_includes_alphabet_size(): learn_order = tuple(range(10)) unlocked = compute_unlocked(learn_order, alphabet_size=3, stats={}, target=200.0) @@ -180,7 +242,9 @@ def _transition( *, last_seen: float = 0.0, error_count: int = 0, + attempt_count: int | None = None, ) -> TransitionStats: + attempts = attempt_count if attempt_count is not None else 10 + error_count return TransitionStats( prev_cp=prev_cp, next_cp=next_cp, @@ -188,6 +252,7 @@ def _transition( mean_time_ns=mean_time_ns, error_count=error_count, last_seen=last_seen, + attempt_count=attempts, ) diff --git a/tests/domain/test_generator.py b/tests/domain/test_generator.py index 932b2bd..25ca60b 100644 --- a/tests/domain/test_generator.py +++ b/tests/domain/test_generator.py @@ -83,6 +83,40 @@ def test_generate_wordlist_char_weights_bias(): assert counts["a"] > counts["b"] +def test_generate_lesson_focus_wordlist_overweights_focus_char(): + generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) + words = ["aaa", "bbb"] + focus_counts = 0 + for seed in range(40): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + frozenset("ab"), + focus_char="a", + word_count=8, + char_weights={"a": 1.75, "b": 1.0}, + words=words, + ) + if lesson.count("a") > lesson.count("b"): + focus_counts += 1 + assert focus_counts >= 35 + + +def test_generate_lesson_markov_overweights_focus_char(): + generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) + focus_counts = 0 + for seed in range(40): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + frozenset("ab"), + focus_char="a", + word_count=12, + char_weights={"a": 5.25, "b": 1.0}, + ) + if lesson.count("a") > lesson.count("b"): + focus_counts += 1 + assert focus_counts >= 30 + + def test_generate_lesson_injects_focus_bigram(): generator = AdaptiveGenerator(table=_uniform_table("abc"), rng=Random(0)) for seed in range(10): diff --git a/tests/infrastructure/test_settings_repo_toml.py b/tests/infrastructure/test_settings_repo_toml.py index 0569ba2..ed84c0e 100644 --- a/tests/infrastructure/test_settings_repo_toml.py +++ b/tests/infrastructure/test_settings_repo_toml.py @@ -29,11 +29,17 @@ def test_round_trip(paths): target_speed_cpm=400, target_speed_unit=TargetSpeedUnit.WPM, alphabet_size=20, + confidence_session_window=8, + min_confidence_attempts=12, + min_transition_confidence_attempts=5, ) repo.save(original) loaded = repo.load() assert loaded.layout == original.layout assert loaded.target_speed_cpm == original.target_speed_cpm + assert loaded.confidence_session_window == 8 + assert loaded.min_confidence_attempts == 12 + assert loaded.min_transition_confidence_attempts == 5 assert loaded.updated_at is not None diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index ed00e71..2056442 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -83,7 +83,9 @@ def _build_app( settings_repo=settings_repo, layout_repo=layout_repo, prepare_practice=prepare_practice, - rebuild_aggregates=RebuildAggregates(repo=session_repo, cache=cache), + rebuild_aggregates=RebuildAggregates( + repo=session_repo, cache=cache, settings_repo=settings_repo, + ), get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo), get_history=GetHistory(repo=session_repo), get_daily_learn_budget=get_daily_learn_budget, diff --git a/tests/presentation/test_settings_screen.py b/tests/presentation/test_settings_screen.py index 2f68f8e..9600aa0 100644 --- a/tests/presentation/test_settings_screen.py +++ b/tests/presentation/test_settings_screen.py @@ -64,6 +64,9 @@ async def test_save_persists_changes_and_pops_screen(): app.screen.query_one("#settings-layout", Select).value = "dvorak" app.screen.query_one("#settings-alphabet-size", Input).value = "20" app.screen.query_one("#settings-learn-daily-minutes", Input).value = "15" + app.screen.query_one("#settings-confidence-session-window", Input).value = "8" + app.screen.query_one("#settings-min-confidence-attempts", Input).value = "12" + app.screen.query_one("#settings-min-transition-confidence-attempts", Input).value = "5" await pilot.pause() await pilot.press("ctrl+s") @@ -74,6 +77,9 @@ async def test_save_persists_changes_and_pops_screen(): assert settings_repo.settings.layout == "dvorak" assert settings_repo.settings.alphabet_size == 20 assert settings_repo.settings.learn_daily_minutes == 15 + assert settings_repo.settings.confidence_session_window == 8 + assert settings_repo.settings.min_confidence_attempts == 12 + assert settings_repo.settings.min_transition_confidence_attempts == 5 assert app.screen_stack[-1] is not screen diff --git a/tests/presentation/test_stats_screen.py b/tests/presentation/test_stats_screen.py index 3a392cc..31e336c 100644 --- a/tests/presentation/test_stats_screen.py +++ b/tests/presentation/test_stats_screen.py @@ -29,7 +29,9 @@ def _build_screen( return StatsScreen( layout=layout, layout_repo=layout_repo, - rebuild_aggregates=RebuildAggregates(repo=repo, cache=cache), + rebuild_aggregates=RebuildAggregates( + repo=repo, cache=cache, settings_repo=settings_repo, + ), get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo), get_history=GetHistory(repo=repo), current_target_speed_cpm=settings_repo.settings.target_speed_cpm, diff --git a/uv.lock b/uv.lock index d91faf3..18cb8ee 100644 --- a/uv.lock +++ b/uv.lock @@ -43,7 +43,7 @@ wheels = [ [[package]] name = "keystrike" -version = "1.2.0" +version = "1.2.1" source = { editable = "." } dependencies = [ { name = "platformdirs" }, From 227f431755fe637b1798134d7cd1793883219e3d Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 29 Jul 2026 11:08:08 +0300 Subject: [PATCH 02/47] Bump version to 1.2.2 on dev. Co-authored-by: Cursor --- docs/CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/keystrike/domain/version.py | 2 +- uv.lock | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4eae659..8481da6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,13 @@ 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. +## 1.2.2 + +- Confidence tuning settings: session window, min key attempts, and min bigram + attempts in Settings / `settings.toml`; wiki page `Confidence-Tuning`. +- Round confidence scores to two decimals so unlocks, focus labels, heatmap, and + practice focus notes stay aligned. + ## 1.2.1 - Stats confidence trends normalize stored snapshots to the current goal speed; diff --git a/pyproject.toml b/pyproject.toml index 9645f6e..20c6ceb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keystrike" -version = "1.2.1" +version = "1.2.2" description = "Offline terminal typing tutor with keybr-style adaptive drills and per-layout stats" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keystrike/domain/version.py b/src/keystrike/domain/version.py index c68196d..bc86c94 100644 --- a/src/keystrike/domain/version.py +++ b/src/keystrike/domain/version.py @@ -1 +1 @@ -__version__ = "1.2.0" +__version__ = "1.2.2" diff --git a/uv.lock b/uv.lock index 18cb8ee..2da5970 100644 --- a/uv.lock +++ b/uv.lock @@ -43,7 +43,7 @@ wheels = [ [[package]] name = "keystrike" -version = "1.2.1" +version = "1.2.2" source = { editable = "." } dependencies = [ { name = "platformdirs" }, From 6031e6ea93e141834041457590f9d37376bbabe1 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 29 Jul 2026 11:13:54 +0300 Subject: [PATCH 03/47] Make confidence tuning config-file only. Remove session window and attempt floors from Settings so routine saves do not overwrite advanced adaptive-engine values; document settings.toml. Co-authored-by: Cursor --- README.md | 4 + docs/CHANGELOG.md | 2 +- docs/wiki/Confidence-Tuning.md | 13 ++- docs/wiki/Git-sync.md | 1 + docs/wiki/Home.md | 2 +- docs/wiki/Word-Lists.md | 1 + .../application/settings_use_cases.py | 21 ----- .../presentation/screens/settings.py | 46 ----------- tests/application/test_settings_use_cases.py | 81 +++++++------------ tests/presentation/test_settings_screen.py | 9 +-- 10 files changed, 50 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 360ffe3..d830acc 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,10 @@ drill into that key's confidence trend; `Esc` / `q` returns to the overview. | Letters unlocked up front | 16 | Force-unlocked before confidence gating | | Daily learn goal | 10 min | `0` = no goal | +Advanced confidence tuning (session window, min key/bigram attempts) is +**config-file only** — edit `settings.toml` directly; see the +[Confidence tuning wiki](https://github.com/egno/keystrike/wiki/Confidence-Tuning). + ## Backup sync Opt-in git sync for backing up or moving data between machines. Requires `git` on diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8481da6..346690e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,7 +8,7 @@ design) stays in `PLAN.md` §5/§6. ## 1.2.2 - Confidence tuning settings: session window, min key attempts, and min bigram - attempts in Settings / `settings.toml`; wiki page `Confidence-Tuning`. + attempts in `settings.toml` only; wiki page `Confidence-Tuning`. - Round confidence scores to two decimals so unlocks, focus labels, heatmap, and practice focus notes stay aligned. diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index 78c59cc..6c5dce0 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -5,8 +5,9 @@ much you've practiced — to decide which keys unlock, which key or bigram gets focus, and how the stats heatmap colors each key. Three settings control how aggressively the engine trusts your recent performance. -Open **Settings** from the home screen (`o`), edit the values, then **Ctrl+S** to -save. They are also stored in `settings.toml` under your config directory. +Edit `{config_dir}/settings.toml` directly (see [Git sync](Git-sync) for the +path on your OS). These three fields are **not** on the Settings screen — saving +layout, speed, or other UI settings will not change them. ## Settings @@ -18,6 +19,14 @@ save. They are also stored in `settings.toml` under your config directory. Valid ranges: window and both attempt floors are **1–100**. +Example (defaults shown): + +```toml +confidence_session_window = 10 +min_confidence_attempts = 10 +min_transition_confidence_attempts = 4 +``` + ## What each setting affects **Session window** — Confidence is computed from aggregates over your last *N* diff --git a/docs/wiki/Git-sync.md b/docs/wiki/Git-sync.md index 83c2dfd..5335832 100644 --- a/docs/wiki/Git-sync.md +++ b/docs/wiki/Git-sync.md @@ -209,4 +209,5 @@ Pull rebuilds aggregates from the session index. If something still looks wrong, ## See also - [Keystrike README — Backup sync](https://github.com/egno/keystrike#backup-sync) +- [Confidence tuning](Confidence-Tuning) — advanced fields in `settings.toml` (not the Settings screen) - [Home](Home) diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 594480c..5fb3587 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -6,7 +6,7 @@ Documentation for [Keystrike](https://github.com/egno/keystrike) — an offline - **[Git sync](Git-sync)** — back up and sync settings and sessions via a private git repository (CLI-only, opt-in). - **[Word lists](Word-Lists)** — import a remote dictionary for real-word adaptive drills (Settings, optional). -- **[Confidence tuning](Confidence-Tuning)** — session window and attempt floors for unlocks, focus, and the stats heatmap. +- **[Confidence tuning](Confidence-Tuning)** — session window and attempt floors in `settings.toml` (not the Settings screen). ## Project links diff --git a/docs/wiki/Word-Lists.md b/docs/wiki/Word-Lists.md index 74f12b5..536d41b 100644 --- a/docs/wiki/Word-Lists.md +++ b/docs/wiki/Word-Lists.md @@ -104,5 +104,6 @@ Paste your URL into the Settings field, then **Ctrl+I**. Invalid URLs or empty d ## See also - [Home](Home) +- [Confidence tuning](Confidence-Tuning) — same adaptive engine; advanced knobs in `settings.toml` - [Git sync](Git-sync) — what syncs with `keystrike sync` - [Keystrike README — Settings](https://github.com/egno/keystrike#settings) diff --git a/src/keystrike/application/settings_use_cases.py b/src/keystrike/application/settings_use_cases.py index 72047ee..522d8d2 100644 --- a/src/keystrike/application/settings_use_cases.py +++ b/src/keystrike/application/settings_use_cases.py @@ -14,8 +14,6 @@ from keystrike.domain.protocols import LayoutRepository, SettingsRepository _MIN_LAYOUTS_TO_CYCLE = 2 -_MAX_CONFIDENCE_SESSION_WINDOW = 100 -_MAX_CONFIDENCE_ATTEMPTS = 100 class SettingsValidationError(ValueError): @@ -34,9 +32,6 @@ def __call__( target_speed_unit: TargetSpeedUnit, alphabet_size: int, learn_daily_minutes: int, - confidence_session_window: int, - min_confidence_attempts: int, - min_transition_confidence_attempts: int, ) -> Settings: if target_speed_cpm <= 0: raise SettingsValidationError("Target speed must be a positive integer.") @@ -46,19 +41,6 @@ def __call__( raise SettingsValidationError("Number of letters must be zero or more.") if learn_daily_minutes < 0: raise SettingsValidationError("Daily learn minutes must be zero or more.") - if not 1 <= confidence_session_window <= _MAX_CONFIDENCE_SESSION_WINDOW: - raise SettingsValidationError( - f"Confidence session window must be 1–{_MAX_CONFIDENCE_SESSION_WINDOW}.", - ) - if not 1 <= min_confidence_attempts <= _MAX_CONFIDENCE_ATTEMPTS: - raise SettingsValidationError( - f"Min confidence attempts must be 1–{_MAX_CONFIDENCE_ATTEMPTS}.", - ) - if not 1 <= min_transition_confidence_attempts <= _MAX_CONFIDENCE_ATTEMPTS: - raise SettingsValidationError( - "Min transition confidence attempts must be " - f"1–{_MAX_CONFIDENCE_ATTEMPTS}.", - ) updated = replace( self.repo.load(), layout=layout, @@ -66,9 +48,6 @@ def __call__( target_speed_unit=target_speed_unit, alphabet_size=alphabet_size, learn_daily_minutes=learn_daily_minutes, - confidence_session_window=confidence_session_window, - min_confidence_attempts=min_confidence_attempts, - min_transition_confidence_attempts=min_transition_confidence_attempts, ) self.repo.save(updated) return updated diff --git a/src/keystrike/presentation/screens/settings.py b/src/keystrike/presentation/screens/settings.py index 5a9c1dc..9995330 100644 --- a/src/keystrike/presentation/screens/settings.py +++ b/src/keystrike/presentation/screens/settings.py @@ -96,24 +96,6 @@ def compose(self) -> ComposeResult: id="settings-learn-daily-minutes", type="integer", ) - yield Label("Confidence session window (sessions)") - yield Input( - value=str(settings.confidence_session_window), - id="settings-confidence-session-window", - type="integer", - ) - yield Label("Min key attempts before full confidence") - yield Input( - value=str(settings.min_confidence_attempts), - id="settings-min-confidence-attempts", - type="integer", - ) - yield Label("Min bigram attempts before full transition confidence") - yield Input( - value=str(settings.min_transition_confidence_attempts), - id="settings-min-transition-confidence-attempts", - type="integer", - ) yield Static( "Word list [dim](Ctrl+I import, Ctrl+X clear)[/]", id="settings-wordlist-label", @@ -177,31 +159,6 @@ def action_save(self) -> None: self._show_error("Daily learn minutes must be an integer.") return - confidence_window_raw = self.query_one( - "#settings-confidence-session-window", Input, - ).value - try: - confidence_session_window = int(confidence_window_raw) - except ValueError: - self._show_error("Confidence session window must be an integer.") - return - - min_confidence_raw = self.query_one("#settings-min-confidence-attempts", Input).value - try: - min_confidence_attempts = int(min_confidence_raw) - except ValueError: - self._show_error("Min confidence attempts must be an integer.") - return - - min_transition_raw = self.query_one( - "#settings-min-transition-confidence-attempts", Input, - ).value - try: - min_transition_confidence_attempts = int(min_transition_raw) - except ValueError: - self._show_error("Min transition confidence attempts must be an integer.") - return - layout_select = cast("Select[str]", self.query_one("#settings-layout", Select)) layout = layout_select.value if isinstance(layout, NoSelection): @@ -215,9 +172,6 @@ def action_save(self) -> None: target_speed_unit=target_speed_unit, alphabet_size=alphabet_size, learn_daily_minutes=learn_daily_minutes, - confidence_session_window=confidence_session_window, - min_confidence_attempts=min_confidence_attempts, - min_transition_confidence_attempts=min_transition_confidence_attempts, ) except SettingsValidationError as exc: self._show_error(str(exc)) diff --git a/tests/application/test_settings_use_cases.py b/tests/application/test_settings_use_cases.py index 460e400..d317237 100644 --- a/tests/application/test_settings_use_cases.py +++ b/tests/application/test_settings_use_cases.py @@ -21,9 +21,6 @@ def test_update_settings_persists_all_fields(): target_speed_unit=TargetSpeedUnit.WPM, alphabet_size=20, learn_daily_minutes=15, - confidence_session_window=10, - min_confidence_attempts=10, - min_transition_confidence_attempts=4, ) assert result.layout == "dvorak" @@ -31,13 +28,38 @@ def test_update_settings_persists_all_fields(): assert result.target_speed_unit == TargetSpeedUnit.WPM assert result.alphabet_size == 20 assert result.learn_daily_minutes == 15 - assert result.confidence_session_window == 10 - assert result.min_confidence_attempts == 10 - assert result.min_transition_confidence_attempts == 4 + assert result.confidence_session_window == Settings().confidence_session_window + assert result.min_confidence_attempts == Settings().min_confidence_attempts + assert result.min_transition_confidence_attempts == ( + Settings().min_transition_confidence_attempts + ) assert result.wordlist_url == "" assert repo.settings == result +def test_update_settings_preserves_confidence_fields_from_repo(): + repo = FakeSettingsRepository( + Settings( + confidence_session_window=8, + min_confidence_attempts=12, + min_transition_confidence_attempts=5, + ), + ) + update = UpdateSettings(repo=repo) + + result = update( + layout="qwerty", + target_speed_cpm=300, + target_speed_unit=TargetSpeedUnit.CPM, + alphabet_size=16, + learn_daily_minutes=10, + ) + + assert result.confidence_session_window == 8 + assert result.min_confidence_attempts == 12 + assert result.min_transition_confidence_attempts == 5 + + def test_update_settings_rejects_non_positive_speed(): repo = FakeSettingsRepository(Settings()) update = UpdateSettings(repo=repo) @@ -49,9 +71,6 @@ def test_update_settings_rejects_non_positive_speed(): target_speed_unit=TargetSpeedUnit.CPM, alphabet_size=16, learn_daily_minutes=10, - confidence_session_window=10, - min_confidence_attempts=10, - min_transition_confidence_attempts=4, ) assert repo.settings == Settings() # unchanged @@ -68,9 +87,6 @@ def test_update_settings_rejects_negative_alphabet_size(): target_speed_unit=TargetSpeedUnit.CPM, alphabet_size=-1, learn_daily_minutes=10, - confidence_session_window=10, - min_confidence_attempts=10, - min_transition_confidence_attempts=4, ) assert repo.settings == Settings() # unchanged @@ -87,47 +103,6 @@ def test_update_settings_rejects_negative_learn_daily_minutes(): target_speed_unit=TargetSpeedUnit.CPM, alphabet_size=16, learn_daily_minutes=-1, - confidence_session_window=10, - min_confidence_attempts=10, - min_transition_confidence_attempts=4, - ) - - assert repo.settings == Settings() - - -def test_update_settings_rejects_invalid_confidence_session_window(): - repo = FakeSettingsRepository(Settings()) - update = UpdateSettings(repo=repo) - - with pytest.raises(SettingsValidationError): - update( - layout="qwerty", - target_speed_cpm=300, - target_speed_unit=TargetSpeedUnit.CPM, - alphabet_size=16, - learn_daily_minutes=10, - confidence_session_window=0, - min_confidence_attempts=10, - min_transition_confidence_attempts=4, - ) - - assert repo.settings == Settings() - - -def test_update_settings_rejects_invalid_min_confidence_attempts(): - repo = FakeSettingsRepository(Settings()) - update = UpdateSettings(repo=repo) - - with pytest.raises(SettingsValidationError): - update( - layout="qwerty", - target_speed_cpm=300, - target_speed_unit=TargetSpeedUnit.CPM, - alphabet_size=16, - learn_daily_minutes=10, - confidence_session_window=10, - min_confidence_attempts=0, - min_transition_confidence_attempts=4, ) assert repo.settings == Settings() diff --git a/tests/presentation/test_settings_screen.py b/tests/presentation/test_settings_screen.py index 9600aa0..e96b9d3 100644 --- a/tests/presentation/test_settings_screen.py +++ b/tests/presentation/test_settings_screen.py @@ -64,9 +64,6 @@ async def test_save_persists_changes_and_pops_screen(): app.screen.query_one("#settings-layout", Select).value = "dvorak" app.screen.query_one("#settings-alphabet-size", Input).value = "20" app.screen.query_one("#settings-learn-daily-minutes", Input).value = "15" - app.screen.query_one("#settings-confidence-session-window", Input).value = "8" - app.screen.query_one("#settings-min-confidence-attempts", Input).value = "12" - app.screen.query_one("#settings-min-transition-confidence-attempts", Input).value = "5" await pilot.pause() await pilot.press("ctrl+s") @@ -77,9 +74,9 @@ async def test_save_persists_changes_and_pops_screen(): assert settings_repo.settings.layout == "dvorak" assert settings_repo.settings.alphabet_size == 20 assert settings_repo.settings.learn_daily_minutes == 15 - assert settings_repo.settings.confidence_session_window == 8 - assert settings_repo.settings.min_confidence_attempts == 12 - assert settings_repo.settings.min_transition_confidence_attempts == 5 + assert settings_repo.settings.confidence_session_window == ( + Settings().confidence_session_window + ) assert app.screen_stack[-1] is not screen From 39a0f5d2552672536017e350bf031786faf89bf7 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 29 Jul 2026 14:37:47 +0300 Subject: [PATCH 04/47] Fix lint errors: unused imports, unsorted imports, ambiguous unicode in comments. Also fixes a pyright error in _snapshot_unlock_state (list invariance on Iterator vs list) that CI never reached because ruff failed first. --- src/keystrike/application/session_use_cases.py | 6 +++--- src/keystrike/domain/confidence.py | 2 +- tests/application/test_session_use_cases.py | 11 ++++++++--- tests/domain/test_confidence.py | 4 ++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 7fa8c8b..565059f 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -1,7 +1,7 @@ -from collections.abc import Sequence +from collections.abc import Iterable, Sequence from dataclasses import dataclass, field, replace -from keystrike.domain.aggregate import aggregate_session, combine_sessions +from keystrike.domain.aggregate import combine_sessions from keystrike.domain.confidence import ( compute_unlocked, confidence_of, @@ -130,7 +130,7 @@ def _snapshot_unlock_state( repo.iter_headers(session.layout), key=lambda h: h.started_at, )[-(settings.confidence_session_window - 1):] - sessions = [ + sessions: list[tuple[SessionResult, Iterable[Keystroke]]] = [ (header, repo.load_keystrokes(header.session_id)) for header in prior_headers ] sessions.append((draft, session.keystrokes)) diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index a81ee80..2bed3f2 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -14,7 +14,7 @@ _REVIEW_URGENCY_FULL_DAYS = 3.0 # Confidence, unlocks, focus, and heatmap use aggregates from this many sessions. CONFIDENCE_SESSION_WINDOW = 10 -# Raw speed×accuracy confidence ramps linearly until this many attempts per key. +# Raw speed x accuracy confidence ramps linearly until this many attempts per key. MIN_CONFIDENCE_ATTEMPTS = 10 # Bigrams are sparser — lower floor so transition focus reflects measured weakness. MIN_TRANSITION_CONFIDENCE_ATTEMPTS = 4 diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index 6d6ec7b..ae264e3 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -19,11 +19,16 @@ key_confidence_values, wpm_sparkline, ) -from keystrike.domain.aggregate import aggregate_session, combine_sessions -from keystrike.domain.confidence import CONFIDENCE_SESSION_WINDOW, compute_unlocked, confidence_of, target_ms_per_char +from keystrike.domain.aggregate import combine_sessions +from keystrike.domain.confidence import ( + CONFIDENCE_SESSION_WINDOW, + compute_unlocked, + confidence_of, + target_ms_per_char, +) from keystrike.domain.enums import Mode, SessionState from keystrike.domain.learn_order import keyboard_order -from keystrike.domain.models import KeyStats, LayoutAggregates, SessionResult, Settings +from keystrike.domain.models import SessionResult, Settings from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from tests.fakes import ( FakeAggregatesCache, diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index 505b8d3..cab914e 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -78,7 +78,7 @@ def test_confidence_of_scales_down_with_few_attempts(): attempt_count=2, ), } - # raw speed×accuracy = 2.0 × 0.5 = 1.0; only 2 attempts → ×0.2 + # raw speed x accuracy = 2.0 x 0.5 = 1.0; only 2 attempts -> x0.2 assert confidence_of(ord("a"), stats, target=200.0) == 0.2 @@ -108,7 +108,7 @@ def test_transition_confidence_scales_down_with_few_attempts(): error_count=0, last_seen=0.0, attempt_count=2, ), } - # raw 1.0 × (2/4) = 0.5 + # raw 1.0 x (2/4) = 0.5 assert transition_confidence_of(ord("a"), ord("b"), stats, target=200.0) == 0.5 From 6f18b79ca6c42787444a6c93cc5186a1ca8d86db Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 29 Jul 2026 14:43:19 +0300 Subject: [PATCH 05/47] Regenerate stale practice-screen snapshot. FOCUS_CHAR_BOOST weighting (added in b0f130e) changed the generated lesson text but the checked-in snapshot was never refreshed to match. --- .../test_snapshots/test_practice_screen_snapshot.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg index d30a8c3..3effceb 100644 --- a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg +++ b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg @@ -135,7 +135,7 @@ Acc: 100.0%   Learn: 0.0/10 min   Focus: aweak -ghrfrkil·kfikr·hkrid·wgek·sooft·dawgjl·ldo·fkkdw·fkjrjf·luot·aeku·udhe +gsrfhgod·kfogh·skhad·ufeg·oaoft·raufjd·dda·wkgru·fkksjf·llaa·aeku·ldie  q  w  e  r  t  y  u  i  o  p  From 6363d4f459252af99f3e3ebc71c5caecdee1fddf Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 29 Jul 2026 18:03:51 +0300 Subject: [PATCH 06/47] Release 1.2.3: windowed confidence, stats and practice UX. Confidence uses min(speed, accuracy) with recency-weighted session aggregates; stats trends unified on one widget; practice gets word-wrap, typing colors, underline cursor, idle learn timer, and HUD Last deltas. Co-authored-by: Cursor --- docs/CHANGELOG.md | 27 ++ docs/research/typing-pedagogy.md | 2 +- docs/wiki/Confidence-Tuning.md | 15 +- pyproject.toml | 2 +- src/keystrike/app.py | 20 +- src/keystrike/application/build_lesson.py | 46 +++ src/keystrike/application/prepare_practice.py | 4 + .../application/session_use_cases.py | 311 ++++++++++++++++-- src/keystrike/application/stats_use_cases.py | 199 +++++++++++ src/keystrike/domain/aggregate.py | 94 +++++- src/keystrike/domain/confidence.py | 23 +- src/keystrike/domain/session.py | 37 +++ src/keystrike/domain/version.py | 2 +- .../infrastructure/session_repo_jsonl.py | 6 +- .../presentation/screens/practice.py | 18 +- src/keystrike/presentation/screens/stats.py | 119 ++++--- src/keystrike/presentation/textual_app.py | 14 +- src/keystrike/presentation/theme.py | 15 +- src/keystrike/presentation/widgets/hud.py | 24 +- .../presentation/widgets/kb_heatmap.py | 10 +- .../presentation/widgets/typing_area.py | 90 ++++- tests/application/test_session_use_cases.py | 304 ++++++++++++++++- tests/application/test_stats_use_cases.py | 142 +++++++- tests/domain/test_aggregate.py | 60 +++- tests/domain/test_confidence.py | 10 +- .../infrastructure/test_session_repo_jsonl.py | 7 +- tests/presentation/test_hud.py | 80 ++++- tests/presentation/test_kb_heatmap.py | 14 +- tests/presentation/test_practice_screen.py | 142 +++++++- tests/presentation/test_stats_screen.py | 280 +++++++++++++--- tests/presentation/test_typing_area.py | 64 +++- uv.lock | 2 +- 32 files changed, 1975 insertions(+), 208 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 346690e..00438e2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,33 @@ 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. +## 1.2.3 + +- HUD Learn segment: activity (dim when paused or not started) and goal (green when + daily limit reached) are independent — e.g. dim green when goal reached but idle. +- Lesson text colors: untyped white, typed grey42; wrong key bold red; tripped keys dim yellow. +- Practice "Last" row: WPM, accuracy, and time only (removed redundant Keys count). +- Typing cursor uses bold underline on the target character instead of a block. +- Practice "Last" row shows WPM and accuracy deltas vs the recency-weighted + `confidence_session_window` baseline (green ↑ / red ↓), not just the prior session. +- Stats screen: single `#stats-trends` widget with unified `format_metric_trend_block` + (overview: Layout + Focus blocks; key drill-down: per-key grid — same format as letter stats). +- Stats overview shows layout-wide confidence, speed, and accuracy in a titled grid block; + focus-key confidence in the same grid format below (tracks the current focus + letter across sessions, matching letter drill-down); WPM trend row and session history removed. +- Stats screen layout: title → trends → heatmap caption → heatmap. +- Stats heatmap hint: press a key for letter stats, Esc to return. +- Practice text wraps at word boundaries (`·` dividers, no visible gap); lines break + at words, not mid-word, using the widget width. +- Learn timer pauses after 5s idle (no keystrokes) and resumes on the next key; + idle time does not count toward the daily learn goal. +- Practice focus note shows speed ratio and accuracy % alongside confidence + (e.g. `speed 0.85, accuracy 92.0%, confidence 0.45 / 1.00`). +- Confidence uses min(speed, accuracy) instead of their product — fast-but-sloppy + or slow-but-accurate keys no longer compensate each other for unlocks/focus. +- Windowed aggregates recency-weight speed, accuracy, and attempt counts (decay + 0.7 per session back); recent sessions move confidence faster than older ones. + ## 1.2.2 - Confidence tuning settings: session window, min key attempts, and min bigram diff --git a/docs/research/typing-pedagogy.md b/docs/research/typing-pedagogy.md index dd0801a..701acef 100644 --- a/docs/research/typing-pedagogy.md +++ b/docs/research/typing-pedagogy.md @@ -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.py` now has `accuracy_of(key_stats)` (`samples / (samples + error_count)`), multiplied into speed-confidence at every site that reads `KeyStats` — `confidence_of`'s live branch, `_stamp_peak_confidence` (so `peak_confidence`/`recover_keys=True` inherits it too), and `GetHeatmap` (`stats_use_cases.py`). A key with a high error rate now scores lower confidence even at high speed, so `compute_unlocked`/`select_focus` treat it as still-weak. +~~`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`. ~~`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. diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index 6c5dce0..a15fea6 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -1,6 +1,6 @@ # Confidence tuning -Keystrike's adaptive engine uses **confidence** — speed × accuracy, scaled by how +Keystrike's adaptive engine uses **confidence** — min(speed, accuracy), scaled by how much you've practiced — to decide which keys unlock, which key or bigram gets focus, and how the stats heatmap colors each key. Three settings control how aggressively the engine trusts your recent performance. @@ -19,6 +19,10 @@ layout, speed, or other UI settings will not change them. Valid ranges: window and both attempt floors are **1–100**. +Confidence uses **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. + Example (defaults shown): ```toml @@ -30,9 +34,12 @@ min_transition_confidence_attempts = 4 ## What each setting affects **Session window** — Confidence is computed from aggregates over your last *N* -sessions (per layout), not your entire history. 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. +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; +a shorter window tracks recent form but can under-sample rare keys and block +unlocks until those keys appear often enough in recent drills. **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 diff --git a/pyproject.toml b/pyproject.toml index 20c6ceb..1f13207 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keystrike" -version = "1.2.2" +version = "1.2.3" description = "Offline terminal typing tutor with keybr-style adaptive drills and per-layout stats" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keystrike/app.py b/src/keystrike/app.py index 79020f4..c352117 100644 --- a/src/keystrike/app.py +++ b/src/keystrike/app.py @@ -12,7 +12,13 @@ StartSession, ) from keystrike.application.settings_use_cases import CycleLayout, UpdateSettings -from keystrike.application.stats_use_cases import GetHeatmap, GetHistory, RebuildAggregates +from keystrike.application.stats_use_cases import ( + GetAggregateMetricTrends, + GetHeatmap, + GetHistory, + GetKeyMetricTrends, + RebuildAggregates, +) from keystrike.application.sync_use_cases import GetSyncStatus, InitSync, PullSync, PushSync from keystrike.application.wordlist_use_cases import ( ClearWordList, @@ -73,7 +79,7 @@ def build() -> KeystrikeApp: language_provider = BundledLanguageProvider() start = StartSession(clock=clock, id_gen=id_gen) - record = RecordKeystroke(clock=clock, repo=session_repo) + record = RecordKeystroke(clock=clock) finish = FinishSession( clock=clock, repo=session_repo, @@ -88,6 +94,14 @@ def build() -> KeystrikeApp: ) get_heatmap = GetHeatmap(cache=aggregates_cache, settings_repo=settings_repo) get_history = GetHistory(repo=session_repo) + get_key_metric_trends = GetKeyMetricTrends( + repo=session_repo, + settings_repo=settings_repo, + ) + get_aggregate_metric_trends = GetAggregateMetricTrends( + repo=session_repo, + settings_repo=settings_repo, + ) get_daily_learn_budget = GetDailyLearnBudget( clock=clock, repo=session_repo, @@ -126,6 +140,8 @@ def build() -> KeystrikeApp: rebuild_aggregates=rebuild_aggregates, get_heatmap=get_heatmap, get_history=get_history, + get_key_metric_trends=get_key_metric_trends, + get_aggregate_metric_trends=get_aggregate_metric_trends, cycle_layout=cycle_layout, update_settings=update_settings, import_wordlist=import_wordlist, diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index dba14d2..9f26e89 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -11,14 +11,19 @@ from keystrike.domain.aggregate import transition_key from keystrike.domain.confidence import ( FOCUS_CHAR_BOOST, + accuracy_of, compute_unlocked, confidence_of, focus_key_from_transition, + key_confidence, practice_weight, review_urgency, + round_confidence, select_focus, select_focus_transition, target_ms_per_char, + transition_accuracy_of, + transition_confidence, transition_confidence_of, transition_practice_weight, ) @@ -45,6 +50,35 @@ _CONFIDENCE_GOOD = 1.0 +def _key_focus_metrics( + focus: int, + stats: dict[int, KeyStats], + target: float, +) -> tuple[float, float]: + key_stats = stats.get(focus) + if key_stats is None: + return 0.0, 0.0 + return ( + round_confidence(key_confidence(target, key_stats.mean_time_ns)), + round_confidence(accuracy_of(key_stats)), + ) + + +def _transition_focus_metrics( + prev_cp: int, + next_cp: int, + transitions: dict[str, TransitionStats], + target: float, +) -> tuple[float, float]: + t_stats = transitions.get(transition_key(prev_cp, next_cp)) + if t_stats is None: + return 0.0, 0.0 + return ( + round_confidence(transition_confidence(target, t_stats.mean_time_ns)), + round_confidence(transition_accuracy_of(t_stats)), + ) + + def _focus_reason( focus: int, stats: dict[int, KeyStats], @@ -92,6 +126,8 @@ class Lesson: urgency: dict[int, float] focus_reason: str | None focus_confidence: float | None = None + focus_speed: float | None = None + focus_accuracy: float | None = None @property def focus_key(self) -> int: @@ -235,6 +271,8 @@ def __call__(self, layout_name: str) -> Lesson: cp: review_urgency(stats[cp].last_seen if cp in stats else 0.0, now) for cp in unlocked } + focus_speed: float | None = None + focus_accuracy: float | None = None if focus_bigram is not None: prev_cp, next_cp = focus_bigram reason = _focus_reason_transition( @@ -252,6 +290,10 @@ def __call__(self, layout_name: str) -> Lesson: target, min_attempts=settings.min_transition_confidence_attempts, ) + if reason: + focus_speed, focus_accuracy = _transition_focus_metrics( + prev_cp, next_cp, transitions, target, + ) else: reason = _focus_reason( focus, @@ -263,10 +305,14 @@ def __call__(self, layout_name: str) -> Lesson: focus_confidence = confidence_of( focus, stats, target, min_attempts=settings.min_confidence_attempts, ) + if reason: + focus_speed, focus_accuracy = _key_focus_metrics(focus, stats, target) return Lesson( text=text, state=state, urgency=urgency, focus_reason=reason, focus_confidence=focus_confidence if reason else None, + focus_speed=focus_speed, + focus_accuracy=focus_accuracy, ) diff --git a/src/keystrike/application/prepare_practice.py b/src/keystrike/application/prepare_practice.py index ce6d456..6ce33a0 100644 --- a/src/keystrike/application/prepare_practice.py +++ b/src/keystrike/application/prepare_practice.py @@ -25,6 +25,8 @@ class SessionPrep: focus_key: int | None focus_reason: str | None focus_confidence: float | None + focus_speed: float | None + focus_accuracy: float | None layout_obj: Layout | None lesson_heatmap: dict[int, float] | None lesson_urgency: dict[int, float] | None @@ -47,6 +49,8 @@ def __call__(self) -> SessionPrep | None: focus_key=lesson.focus_key, focus_reason=lesson.focus_reason, focus_confidence=lesson.focus_confidence, + focus_speed=lesson.focus_speed, + focus_accuracy=lesson.focus_accuracy, layout_obj=self.layout_repo.get(settings.layout), lesson_heatmap=lesson.heatmap, lesson_urgency=lesson.urgency, diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 565059f..6bc90fd 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -1,7 +1,7 @@ -from collections.abc import Iterable, Sequence +from collections.abc import Callable, Iterable, Sequence from dataclasses import dataclass, field, replace -from keystrike.domain.aggregate import combine_sessions +from keystrike.domain.aggregate import combine_sessions, session_recency_weights from keystrike.domain.confidence import ( compute_unlocked, confidence_of, @@ -20,7 +20,7 @@ SessionRepository, SettingsRepository, ) -from keystrike.domain.session import BACKSPACE, Session +from keystrike.domain.session import BACKSPACE, Session, active_typing_duration_ns, note_keystroke_for_timer _SPARK = "▁▂▃▄▅▆▇█" @@ -56,7 +56,6 @@ class RecordKeystroke: """Ingest one keystroke. Returns True if the session is now finished.""" clock: Clock - repo: SessionRepository = field(default_factory=NullSessionRepository) def backspace(self, session: Session) -> bool: return self(session, BACKSPACE) @@ -83,7 +82,8 @@ def __call__(self, session: Session, char: str) -> bool: now_ns = self.clock.now_ns() if session.typing_started_at_ns is None: session.typing_started_at_ns = now_ns - t_ns = now_ns - session.typing_started_at_ns + note_keystroke_for_timer(session, now_ns) + t_ns = active_typing_duration_ns(session, now_ns) target_cp = ord(session.target_text[session.position]) typed_cp = ord(char) @@ -98,8 +98,6 @@ def __call__(self, session: Session, char: str) -> bool: else: session.error_positions.add(session.position) - self.repo.append_keystroke(session.id, session.started_at_wall, k) - return session.finished @@ -169,12 +167,11 @@ class FinishSession: def __call__(self, session: Session) -> SessionResult: session.state = SessionState.COMPLETE - started_ns = ( - session.typing_started_at_ns + duration_ns = ( + active_typing_duration_ns(session, self.clock.now_ns()) if session.typing_started_at_ns is not None - else session.started_at_ns + else 0 ) - duration_ns = self.clock.now_ns() - started_ns target_speed_cpm = 0 unlocked_keys: tuple[int, ...] = () @@ -211,6 +208,8 @@ def __call__(self, session: Session) -> SessionResult: key_confidence=key_confidence, target_speed_cpm=target_speed_cpm, ) + for k in session.keystrokes: + self.repo.append_keystroke(session.id, session.started_at_wall, k) self.repo.save_header(result) return result @@ -398,16 +397,96 @@ def format_focus_confidence_trend_line( ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] if not ordered: return "" - values = focus_confidence_values( - headers, limit=limit, current_target_speed_cpm=current_target_speed_cpm, + focus_key = ordered[-1].focus_key + if focus_key is None: + return "" + values = key_confidence_values( + headers, + focus_key, + limit=limit, + current_target_speed_cpm=current_target_speed_cpm, ) - spark = focus_confidence_sparkline( - headers, limit=limit, current_target_speed_cpm=current_target_speed_cpm, + label = _focus_char_label(focus_key) + return format_metric_trend_block( + title=f"Focus '{label}'", + confidence_values=values, + speed_values=[], + accuracy_values=[], + limit=limit, ) - label = _focus_char_label(ordered[-1].focus_key) - return ( - f"[bold]Focus '{label}' confidence[/] ({len(values)} sessions) {spark} " - f"[dim]latest {values[-1]:.2f} peak {max(values):.2f}[/]" + + +# Keep in sync with presentation/theme.py STYLE_TREND_* / STYLE_DELTA_*. +_TREND_CONFIDENCE_COLOR = "cyan" +_TREND_SPEED_COLOR = "green" +_TREND_ACCURACY_COLOR = "yellow" +_DELTA_IMPROVE_COLOR = "green" +_DELTA_REGRESS_COLOR = "red" + + +def _colored_sparkline(spark: str, color: str) -> str: + return f"[{color}]{spark}[/]" if spark else "" + + +def _default_metric_value(value: float) -> str: + return f"{value:.2f}" + + +_GRID_LABEL_WIDTH = 10 +_GRID_VALUE_WIDTH = 8 + + +def _format_metric_trend_line( + label: str, + color: str, + values: Sequence[float], + spark: str, + *, + format_value: Callable[[float], str] | None = None, + suffix: str = "", + show_sessions: bool = True, + grid: bool = False, + spark_width: int = 20, +) -> str: + if not values: + return "" + fmt = format_value or _default_metric_value + label_text = label.ljust(_GRID_LABEL_WIDTH) if grid else label + spark_text = spark.ljust(spark_width) if grid else spark + session_part = f" ({len(values)} sessions) " if show_sessions else " " + latest_str = fmt(values[-1]) + peak_str = fmt(max(values)) + if grid: + values_part = ( + f"latest {latest_str:>{_GRID_VALUE_WIDTH}} " + f"peak {peak_str:>{_GRID_VALUE_WIDTH}}" + ) + else: + values_part = f"latest {latest_str} peak {peak_str}" + line = ( + f"[bold {color}]{label_text}[/]{session_part}" + f"{_colored_sparkline(spark_text, color)} " + f"[dim {color}]{values_part}[/]" + ) + if suffix: + line += f" {suffix}" + return line + + +def format_confidence_trend_line( + values: Sequence[float], + *, + grid: bool = False, + spark_width: int = 20, +) -> str: + return _format_metric_trend_line( + "confidence", + _TREND_CONFIDENCE_COLOR, + values, + value_sparkline(values), + show_sessions=not grid, + grid=grid, + spark_width=spark_width, ) @@ -418,6 +497,9 @@ def format_key_confidence_trend_line( limit: int = 20, current_target_speed_cpm: int = 0, cumulative: float | None = None, + include_key_name: bool = True, + grid: bool = False, + spark_width: int = 20, ) -> str: ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] if not ordered: @@ -428,23 +510,190 @@ def format_key_confidence_trend_line( spark = key_confidence_sparkline( headers, codepoint, limit=limit, current_target_speed_cpm=current_target_speed_cpm, ) - label = _char_label(codepoint) - line = ( - f"[bold]'{label}' confidence[/] ({len(values)} sessions) {spark} " - f"[dim]latest {values[-1]:.2f} peak {max(values):.2f}[/]" - ) + char = _char_label(codepoint) + label = f"'{char}' confidence" if include_key_name else "confidence" + suffix = "" if cumulative is not None: - line += f" [dim]cumulative {cumulative:.2f}[/]" - return line + suffix = f"[dim {_TREND_CONFIDENCE_COLOR}]cumulative {cumulative:.2f}[/]" + return _format_metric_trend_line( + label, + _TREND_CONFIDENCE_COLOR, + values, + spark, + suffix=suffix, + show_sessions=not grid, + grid=grid, + spark_width=spark_width, + ) -def format_session_stats_line(result: SessionResult) -> str: +def format_key_speed_trend_line( + values: Sequence[float], + *, + grid: bool = False, + spark_width: int = 20, +) -> str: + return _format_metric_trend_line( + "speed", + _TREND_SPEED_COLOR, + values, + value_sparkline(values), + show_sessions=not grid, + grid=grid, + spark_width=spark_width, + ) + + +def format_key_accuracy_trend_line( + values: Sequence[float], + *, + grid: bool = False, + spark_width: int = 20, +) -> str: + pct_values = [v * 100 for v in values] + return _format_metric_trend_line( + "accuracy", + _TREND_ACCURACY_COLOR, + pct_values, + value_sparkline(pct_values), + format_value=lambda v: f"{v:.1f}%", + show_sessions=not grid, + grid=grid, + spark_width=spark_width, + ) + + +def format_metric_trend_block( + title: str, + *, + speed_values: Sequence[float], + accuracy_values: Sequence[float], + limit: int = 20, + confidence_values: Sequence[float] | None = None, + headers: Sequence[SessionResult] | None = None, + codepoint: int | None = None, + current_target_speed_cpm: int = 0, + cumulative: float | None = None, +) -> str: + grid_kwargs = {"grid": True, "spark_width": limit} + if headers is not None and codepoint is not None: + ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + session_count = len(ordered) if ordered else max( + len(speed_values), len(accuracy_values), 0, + ) + conf_line = format_key_confidence_trend_line( + headers, + codepoint, + limit=limit, + current_target_speed_cpm=current_target_speed_cpm, + cumulative=cumulative, + include_key_name=False, + **grid_kwargs, + ) + else: + session_count = max( + len(confidence_values or []), + len(speed_values), + len(accuracy_values), + 0, + ) + conf_line = format_confidence_trend_line( + confidence_values or [], **grid_kwargs, + ) + header = f"[bold]{title}[/]" + if session_count: + header += f" ({session_count} sessions)" + else: + return "" + lines = [ + header, + conf_line, + format_key_speed_trend_line(speed_values, **grid_kwargs), + format_key_accuracy_trend_line(accuracy_values, **grid_kwargs), + ] + return "\n".join(line for line in lines if line) + + +def previous_session_header( + repo: SessionRepository, + result: SessionResult, +) -> SessionResult | None: + """Session immediately before ``result`` for the same layout, if any.""" + ordered = sorted(repo.iter_headers(result.layout), key=lambda h: h.started_at) + for i, header in enumerate(ordered): + if header.session_id == result.session_id: + return ordered[i - 1] if i > 0 else None + return None + + +@dataclass(frozen=True, slots=True) +class SessionStatsBaseline: + wpm: float + accuracy_pct: float + + +def confidence_window_session_baseline( + repo: SessionRepository, + result: SessionResult, + *, + window: int, +) -> SessionStatsBaseline | None: + """Recency-weighted WPM/accuracy from prior sessions in the confidence window. + + Uses the same sliding window as ``GetKeyMetricTrends`` and + ``_snapshot_unlock_state``, excluding ``result`` itself. + """ + ordered = sorted(repo.iter_headers(result.layout), key=lambda h: h.started_at) + for i, header in enumerate(ordered): + if header.session_id != result.session_id: + continue + prior = ordered[max(0, i - window + 1):i] + if not prior: + return None + weights = session_recency_weights(len(prior)) + total = sum(weights) + wpm = sum(compute_wpm(h) * w for h, w in zip(prior, weights, strict=True)) / total + acc = sum( + compute_accuracy(h) * 100 * w + for h, w in zip(prior, weights, strict=True) + ) / total + return SessionStatsBaseline(wpm=wpm, accuracy_pct=acc) + return None + + +def _format_metric_delta( + current: float, + previous: float, + *, + higher_is_better: bool = True, + suffix: str = "", +) -> str: + delta = current - previous + if round(abs(delta), 1) == 0: + return "" + improved = delta > 0 if higher_is_better else delta < 0 + color = _DELTA_IMPROVE_COLOR if improved else _DELTA_REGRESS_COLOR + arrow = "↑" if delta > 0 else "↓" + return f" [{color}]{arrow}{abs(delta):.1f}{suffix}[/]" + + +def format_session_stats_line( + result: SessionResult, + *, + baseline: SessionStatsBaseline | None = None, +) -> str: wpm = compute_wpm(result) acc = compute_accuracy(result) * 100 duration = result.duration_ns / 1e9 + wpm_delta = "" + acc_delta = "" + if baseline is not None: + wpm_delta = _format_metric_delta(wpm, baseline.wpm) + acc_delta = _format_metric_delta( + acc, baseline.accuracy_pct, suffix="%", + ) return ( - f"Last: WPM [bold]{wpm:5.1f}[/] " - f"Acc [bold]{acc:5.1f}%[/] " - f"Time [bold]{duration:5.1f}s[/] " - f"Keys [bold]{result.total_keystrokes}[/]" + f"Last: WPM [bold]{wpm:5.1f}[/]{wpm_delta} " + f"Acc [bold]{acc:5.1f}%[/]{acc_delta} " + f"Time [bold]{duration:5.1f}s[/]" ) diff --git a/src/keystrike/application/stats_use_cases.py b/src/keystrike/application/stats_use_cases.py index fb54024..9716d71 100644 --- a/src/keystrike/application/stats_use_cases.py +++ b/src/keystrike/application/stats_use_cases.py @@ -3,12 +3,17 @@ from __future__ import annotations import time +from collections.abc import Mapping from dataclasses import dataclass from keystrike.domain.aggregate import combine_sessions, per_key_deltas from keystrike.domain.confidence import ( + accuracy_of, confidence_of, + key_attempts, + key_confidence, review_urgency, + round_confidence, target_ms_per_char, ) from keystrike.domain.models import KeyStats, SessionResult @@ -105,3 +110,197 @@ def __call__(self, layout: str, codepoint: int, max_sessions: int = 10) -> int | samples.extend(deltas.get(codepoint, [])) return estimate_sessions_to_goal(samples, target_ns) + + +def _normalize_speed_to_current_goal( + speed: float, + stored_target_cpm: int, + current_target_cpm: int, +) -> float: + if stored_target_cpm <= 0 or current_target_cpm <= 0: + return speed + return speed * ( + target_ms_per_char(current_target_cpm) + / target_ms_per_char(stored_target_cpm) + ) + + +def _aggregate_speed_accuracy( + stats: Mapping[int, KeyStats], + target_ms: float, +) -> tuple[float, float]: + total_samples = 0 + total_errors = 0 + weighted_time = 0.0 + for key_stats in stats.values(): + total_samples += key_stats.samples + total_errors += key_stats.error_count + weighted_time += key_stats.mean_time_ns * key_stats.samples + total_attempts = total_samples + total_errors + if total_attempts == 0: + return 0.0, 0.0 + accuracy = round_confidence(total_samples / total_attempts) + if total_samples > 0: + speed = round_confidence( + key_confidence(target_ms, weighted_time / total_samples), + ) + else: + speed = 0.0 + return speed, accuracy + + +def _aggregate_confidence( + stats: Mapping[int, KeyStats], + target_ms: float, + min_attempts: int, +) -> float: + total_weight = 0.0 + weighted_sum = 0.0 + for cp, key_stats in stats.items(): + attempts = key_attempts(key_stats) + if attempts == 0: + continue + conf = confidence_of(cp, stats, target_ms, min_attempts=min_attempts) + weighted_sum += conf * attempts + total_weight += attempts + if total_weight == 0: + return 0.0 + return round_confidence(weighted_sum / total_weight) + + +def _metric_trends( + repo: SessionRepository, + settings_repo: SettingsRepository, + layout: str, + codepoint: int | None, + *, + current_target_speed_cpm: int = 0, +) -> tuple[list[float], list[float], list[float]]: + settings = settings_repo.load() + window = settings.confidence_session_window + fallback_target = target_ms_per_char(settings.target_speed_cpm) + min_attempts = settings.min_confidence_attempts + + all_headers = sorted( + repo.iter_headers(layout), + key=lambda h: h.started_at, + ) + if not all_headers: + return [], [], [] + + ordered = all_headers[-window:] + start_offset = len(all_headers) - len(ordered) + + speeds: list[float] = [] + accuracies: list[float] = [] + confidences: list[float] = [] + for rel_i, header in enumerate(ordered): + abs_i = start_offset + rel_i + window_headers = all_headers[max(0, abs_i - window + 1):abs_i + 1] + sessions = [ + (h, repo.load_keystrokes(h.session_id)) for h in window_headers + ] + combined = combine_sessions(sessions).keys + + if header.target_speed_cpm > 0: + session_target = target_ms_per_char(header.target_speed_cpm) + else: + session_target = fallback_target + + if codepoint is not None: + key_stats = combined.get(codepoint) + if key_stats is None or ( + key_stats.samples == 0 and key_stats.error_count == 0 + ): + speeds.append(0.0) + accuracies.append(0.0) + confidences.append(0.0) + continue + raw_speed = round_confidence( + key_confidence(session_target, key_stats.mean_time_ns), + ) + speed = round_confidence( + _normalize_speed_to_current_goal( + raw_speed, + header.target_speed_cpm, + current_target_speed_cpm, + ), + ) + accuracy = round_confidence(accuracy_of(key_stats)) + confidence = confidence_of( + codepoint, combined, session_target, min_attempts=min_attempts, + ) + else: + speed, accuracy = _aggregate_speed_accuracy(combined, session_target) + speed = round_confidence( + _normalize_speed_to_current_goal( + speed, + header.target_speed_cpm, + current_target_speed_cpm, + ), + ) + confidence = _aggregate_confidence( + combined, session_target, min_attempts, + ) + + speeds.append(speed) + accuracies.append(accuracy) + confidences.append(confidence) + + return speeds, accuracies, confidences + + +@dataclass(slots=True) +class GetKeyMetricTrends: + """Per-session speed and accuracy trends for one key. + + Replays the confidence session window at each session end (same window as + stored key_confidence snapshots) and returns raw speed confidence and + accuracy matching the practice focus-note metrics. + """ + + repo: SessionRepository + settings_repo: SettingsRepository + + def __call__( + self, + layout: str, + codepoint: int, + *, + current_target_speed_cpm: int = 0, + ) -> tuple[list[float], list[float]]: + speeds, accuracies, _ = _metric_trends( + self.repo, + self.settings_repo, + layout, + codepoint, + current_target_speed_cpm=current_target_speed_cpm, + ) + return speeds, accuracies + + +@dataclass(slots=True) +class GetAggregateMetricTrends: + """Per-session layout-wide confidence, speed, and accuracy trends. + + Same replay window as ``GetKeyMetricTrends``, but aggregates across all keys + with attempt-weighted mean confidence, speed, and overall accuracy. + """ + + repo: SessionRepository + settings_repo: SettingsRepository + + def __call__( + self, + layout: str, + *, + current_target_speed_cpm: int = 0, + ) -> tuple[list[float], list[float], list[float]]: + speeds, accuracies, confidences = _metric_trends( + self.repo, + self.settings_repo, + layout, + None, + current_target_speed_cpm=current_target_speed_cpm, + ) + return confidences, speeds, accuracies diff --git a/src/keystrike/domain/aggregate.py b/src/keystrike/domain/aggregate.py index 8d72331..48bebd3 100644 --- a/src/keystrike/domain/aggregate.py +++ b/src/keystrike/domain/aggregate.py @@ -10,6 +10,7 @@ from collections.abc import Iterable, Sequence from dataclasses import dataclass, field +from .confidence import SESSION_RECENCY_DECAY from .models import KeyStats, Keystroke, LayoutAggregates, SessionResult, TransitionStats @@ -75,19 +76,106 @@ def aggregate_session( } +def session_recency_weights( + session_count: int, + *, + decay: float = SESSION_RECENCY_DECAY, +) -> list[float]: + """Newest session (last in chronological order) gets weight 1.0; each older + session is multiplied by `decay`.""" + if session_count <= 0: + return [] + return [decay ** (session_count - 1 - i) for i in range(session_count)] + + +def _combine_key_maps_weighted( + maps: Sequence[dict[int, KeyStats]], + weights: Sequence[float], +) -> dict[int, KeyStats]: + """Merge key stats with recency weights on speed, accuracy, and attempts.""" + by_cp: dict[int, list[tuple[KeyStats, float]]] = {} + for m, weight in zip(maps, weights, strict=True): + for cp, stats in m.items(): + by_cp.setdefault(cp, []).append((stats, weight)) + + out: dict[int, KeyStats] = {} + for cp, entries in by_cp.items(): + weighted_samples = sum(weight * stats.samples for stats, weight in entries) + weighted_errors = sum(weight * stats.error_count for stats, weight in entries) + weighted_attempts = sum(weight * stats.attempt_count for stats, weight in entries) + if weighted_samples > 0: + mean = sum( + stats.mean_time_ns * weight * stats.samples + for stats, weight in entries + ) / weighted_samples + else: + mean = 0.0 + out[cp] = KeyStats( + codepoint=cp, + samples=int(round(weighted_samples)), + mean_time_ns=mean, + error_count=int(round(weighted_errors)), + last_seen=max(stats.last_seen for stats, _ in entries), + attempt_count=int(round(weighted_attempts)), + ) + return out + + +def _combine_transition_maps_weighted( + maps: Sequence[dict[str, TransitionStats]], + weights: Sequence[float], +) -> dict[str, TransitionStats]: + by_key: dict[str, list[tuple[TransitionStats, float]]] = {} + for m, weight in zip(maps, weights, strict=True): + for key, stats in m.items(): + by_key.setdefault(key, []).append((stats, weight)) + + out: dict[str, TransitionStats] = {} + for key, entries in by_key.items(): + weighted_samples = sum(weight * stats.samples for stats, weight in entries) + weighted_errors = sum(weight * stats.error_count for stats, weight in entries) + weighted_attempts = sum(weight * stats.attempt_count for stats, weight in entries) + if weighted_samples > 0: + mean = sum( + stats.mean_time_ns * weight * stats.samples + for stats, weight in entries + ) / weighted_samples + else: + mean = 0.0 + prev_cp, next_cp = entries[0][0].prev_cp, entries[0][0].next_cp + out[key] = TransitionStats( + prev_cp=prev_cp, + next_cp=next_cp, + samples=int(round(weighted_samples)), + mean_time_ns=mean, + error_count=int(round(weighted_errors)), + last_seen=max(stats.last_seen for stats, _ in entries), + attempt_count=int(round(weighted_attempts)), + ) + return out + + def combine_sessions( sessions: Sequence[tuple[SessionResult, Iterable[Keystroke]]], + *, + recency_decay: float = SESSION_RECENCY_DECAY, ) -> LayoutAggregates: - """Merge per-session stats into one layout aggregate.""" + """Merge per-session stats into one layout aggregate. + + Sessions must be in chronological order. Recent sessions weigh more on + mean time, accuracy, and attempt counts so the sample ramp tracks recent + practice, not stale volume alone. + """ if not sessions: return LayoutAggregates(keys={}, transitions={}) + weights = session_recency_weights(len(sessions), decay=recency_decay) key_maps = [aggregate_session(header, keystrokes) for header, keystrokes in sessions] transition_maps = [ aggregate_transitions(header, keystrokes) for header, keystrokes in sessions ] return LayoutAggregates( - keys=combine(*key_maps), - transitions=combine_transitions(*transition_maps), + keys=_combine_key_maps_weighted(key_maps, weights), + transitions=_combine_transition_maps_weighted(transition_maps, weights), ) diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index 2bed3f2..c45cfb1 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -14,7 +14,10 @@ _REVIEW_URGENCY_FULL_DAYS = 3.0 # Confidence, unlocks, focus, and heatmap use aggregates from this many sessions. CONFIDENCE_SESSION_WINDOW = 10 -# Raw speed x accuracy confidence ramps linearly until this many attempts per key. +# Exponential decay per session step when merging windowed stats (most recent = 1.0). +# ponytail: fixed constant; upgrade to settings if users want tunable recency bias. +SESSION_RECENCY_DECAY = 0.7 +# Raw min(speed, accuracy) confidence ramps linearly until this many attempts per key. MIN_CONFIDENCE_ATTEMPTS = 10 # Bigrams are sparser — lower floor so transition focus reflects measured weakness. MIN_TRANSITION_CONFIDENCE_ATTEMPTS = 4 @@ -78,18 +81,22 @@ def confidence_of( min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, ) -> float: """Live confidence for one key from aggregated stats (typically the last - `CONFIDENCE_SESSION_WINDOW` sessions), recomputed from the current target. + `CONFIDENCE_SESSION_WINDOW` sessions, recency-weighted), recomputed from the + current target. 0.0 for a never-practiced key. - Speed confidence is scaled by accuracy so a key typed fast but frequently - wrong doesn't read as mastered. Both are scaled down until + Confidence is min(speed, accuracy) so fast-but-sloppy or slow-but-accurate + cannot read as mastered. Both must clear the bar. Scaled down until `MIN_CONFIDENCE_ATTEMPTS` presses on the key so a lucky first session can't read as mastered (see docs/research/typing-pedagogy.md). """ key_stats = stats.get(codepoint) if key_stats is None: return 0.0 - raw = key_confidence(target, key_stats.mean_time_ns) * accuracy_of(key_stats) + raw = min( + key_confidence(target, key_stats.mean_time_ns), + accuracy_of(key_stats), + ) return round_confidence( raw * confidence_sample_factor(key_attempts(key_stats), minimum=min_attempts), ) @@ -158,9 +165,9 @@ def transition_confidence_of( transition_stats = stats.get(chr(prev_cp) + chr(next_cp)) if transition_stats is None: return 0.0 - raw = ( - transition_confidence(target, transition_stats.mean_time_ns) - * transition_accuracy_of(transition_stats) + raw = min( + transition_confidence(target, transition_stats.mean_time_ns), + transition_accuracy_of(transition_stats), ) return round_confidence( raw * confidence_sample_factor( diff --git a/src/keystrike/domain/session.py b/src/keystrike/domain/session.py index e2a3a09..3cc88fd 100644 --- a/src/keystrike/domain/session.py +++ b/src/keystrike/domain/session.py @@ -14,6 +14,41 @@ BACKSPACE = "\x7f" # normalized backspace codepoint marker used at edges +LEARN_IDLE_PAUSE_NS = 5 * 1_000_000_000 # pause learn timer after this idle gap + + +def note_keystroke_for_timer(session: Session, now_ns: int) -> None: + """Fold inter-keystroke time into active duration (capped at idle threshold).""" + if session.last_keystroke_at_ns is not None: + gap = now_ns - session.last_keystroke_at_ns + session.active_duration_ns += min(gap, LEARN_IDLE_PAUSE_NS) + session.last_keystroke_at_ns = now_ns + + +def active_typing_duration_ns( + session: Session, + now_ns: int, + *, + idle_pause_ns: int = LEARN_IDLE_PAUSE_NS, +) -> int: + """Active typing time excluding idle gaps longer than ``idle_pause_ns``.""" + if session.typing_started_at_ns is None or session.last_keystroke_at_ns is None: + return 0 + gap = now_ns - session.last_keystroke_at_ns + return session.active_duration_ns + min(gap, idle_pause_ns) + + +def is_typing_idle( + session: Session, + now_ns: int, + *, + idle_pause_ns: int = LEARN_IDLE_PAUSE_NS, +) -> bool: + """True once the learn timer would stop counting (gap >= ``idle_pause_ns``).""" + if session.last_keystroke_at_ns is None: + return False + return now_ns - session.last_keystroke_at_ns >= idle_pause_ns + @dataclass(slots=True) class Session: @@ -25,6 +60,8 @@ class Session: started_at_wall: float started_at_ns: int # when the practice screen opened (session creation) typing_started_at_ns: int | None = None # first real keystroke — the timer's true zero + last_keystroke_at_ns: int | None = None # wall clock of last real keystroke + active_duration_ns: int = 0 # accumulated active time between keystrokes keystrokes: list[Keystroke] = field(default_factory=list[Keystroke]) position: int = 0 # index into target_text of the next char to type correct_count: int = 0 # total correct keystrokes (does not decrement on backspace) diff --git a/src/keystrike/domain/version.py b/src/keystrike/domain/version.py index bc86c94..10aa336 100644 --- a/src/keystrike/domain/version.py +++ b/src/keystrike/domain/version.py @@ -1 +1 @@ -__version__ = "1.2.2" +__version__ = "1.2.3" diff --git a/src/keystrike/infrastructure/session_repo_jsonl.py b/src/keystrike/infrastructure/session_repo_jsonl.py index dbe0144..d93d031 100644 --- a/src/keystrike/infrastructure/session_repo_jsonl.py +++ b/src/keystrike/infrastructure/session_repo_jsonl.py @@ -27,9 +27,9 @@ def _session_file(paths: Paths, header: SessionResult) -> Path: class JsonlSessionRepository: def __init__(self, paths: Paths) -> None: self._paths = paths - # An in-memory index maps session_id → header so append_keystroke can - # locate the correct file without re-reading index.jsonl. Populated by - # save_header and (lazily) by iter_headers. + # In-memory index maps session_id → header so load_keystrokes can locate + # the file without re-reading index.jsonl. Populated by save_header and + # (lazily) by iter_headers. self._session_index: dict[str, SessionResult] = {} def append_keystroke(self, session_id: str, started_at: float, k: Keystroke) -> None: diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index eb3f532..8ad58cb 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -13,6 +13,7 @@ FinishSession, RecordKeystroke, StartSession, + confidence_window_session_baseline, format_session_stats_line, ) from keystrike.domain.models import Layout, SessionResult @@ -70,6 +71,8 @@ def __init__( self._focus_key = initial.focus_key self._focus_reason = initial.focus_reason self._focus_confidence = initial.focus_confidence + self._focus_speed = initial.focus_speed + self._focus_accuracy = initial.focus_accuracy self._kb_heatmap: KbHeatmap | None = None self._session = self._start( initial.target_text, @@ -109,6 +112,8 @@ def _focus_note_text(self) -> str: self._focus_key, self._focus_reason, confidence=self._focus_confidence, + speed=self._focus_speed, + accuracy=self._focus_accuracy, ) or "" def on_mount(self) -> None: @@ -130,6 +135,7 @@ def on_key(self, event: events.Key) -> None: event.stop() self._typing_area.refresh_display() + self._hud.refresh_display() if self._session.finished: self._finish_session() @@ -146,7 +152,15 @@ def _finish_session(self, *, start_next: bool = True) -> None: self._begin_session(prep) def _show_last_session_stats(self, result: SessionResult) -> None: - self.query_one("#last-session-stats", Static).update(format_session_stats_line(result)) + baseline = None + if self._finish.settings_repo is not None: + window = self._finish.settings_repo.load().confidence_session_window + baseline = confidence_window_session_baseline( + self._finish.repo, result, window=window, + ) + self.query_one("#last-session-stats", Static).update( + format_session_stats_line(result, baseline=baseline), + ) def _refresh_focus_note(self) -> None: note = self.query_one("#focus-note", Static) @@ -160,6 +174,8 @@ def _begin_session(self, prep: SessionPrep) -> None: self._focus_key = prep.focus_key self._focus_reason = prep.focus_reason self._focus_confidence = prep.focus_confidence + self._focus_speed = prep.focus_speed + self._focus_accuracy = prep.focus_accuracy self._session = self._start( prep.target_text, layout=prep.layout, diff --git a/src/keystrike/presentation/screens/stats.py b/src/keystrike/presentation/screens/stats.py index e7c3a23..12af3c7 100644 --- a/src/keystrike/presentation/screens/stats.py +++ b/src/keystrike/presentation/screens/stats.py @@ -8,13 +8,15 @@ from textual.widgets import Footer, Static from keystrike.application.session_use_cases import ( - compute_accuracy, - compute_wpm, format_focus_confidence_trend_line, - format_key_confidence_trend_line, - format_wpm_trend_line, + format_metric_trend_block, +) +from keystrike.application.stats_use_cases import ( + GetAggregateMetricTrends, + GetHeatmap, + GetHistory, + GetKeyMetricTrends, ) -from keystrike.application.stats_use_cases import GetHeatmap, GetHistory from keystrike.domain.models import Layout, SessionResult from keystrike.domain.protocols import LayoutRepository, StatsRebuilder from keystrike.presentation.bindings import BACK_BINDINGS @@ -23,6 +25,11 @@ _View = Literal["overview", "key_detail"] +def _char_label(codepoint: int) -> str: + ch = chr(codepoint) + return ch if ch.isprintable() and not ch.isspace() else f"U+{codepoint:04X}" + + class StatsScreen(Screen[None]): DEFAULT_CSS = """ StatsScreen > Vertical { @@ -42,7 +49,10 @@ def __init__( rebuild_aggregates: StatsRebuilder, get_heatmap: GetHeatmap, get_history: GetHistory, + get_key_metric_trends: GetKeyMetricTrends, + get_aggregate_metric_trends: GetAggregateMetricTrends, current_target_speed_cpm: int = 0, + confidence_session_window: int = 10, ) -> None: super().__init__() self._layout_name = layout @@ -50,7 +60,10 @@ def __init__( self._rebuild_aggregates = rebuild_aggregates self._get_heatmap = get_heatmap self._get_history = get_history + self._get_key_metric_trends = get_key_metric_trends + self._get_aggregate_metric_trends = get_aggregate_metric_trends self._current_target_speed_cpm = current_target_speed_cpm + self._confidence_session_window = confidence_session_window self._view: _View = "overview" self._selected_cp: int | None = None self._layout: Layout | None = None @@ -62,10 +75,7 @@ def __init__( def compose(self) -> ComposeResult: with Vertical(): yield Static(f"[bold]Stats — {self._layout_name}[/]", id="stats-title") - yield Static("", id="stats-wpm-trend") - yield Static("", id="stats-focus-confidence") - yield Static("", id="stats-key-detail") - yield Static("[dim]Loading…[/]", id="stats-history") + yield Static("", id="stats-trends") yield Footer() def on_mount(self) -> None: @@ -87,22 +97,17 @@ def on_mount(self) -> None: ) self.query_one(Vertical).mount( Static("[dim]vs current goal[/]", id="stats-heatmap-caption"), + Static( + "[dim]Press a key on the heatmap for letter stats · Esc to return[/]", + id="stats-heatmap-hint", + ), self._kb_heatmap, - before="#stats-history", ) - self._trend_history = self._get_history(self._layout_name, limit=20) - self._render_overview() - - history = self._get_history(self._layout_name, limit=10) - lines = [ - f"{i + 1:>2}. wpm {compute_wpm(h):5.1f} acc {compute_accuracy(h) * 100:5.1f}%" - for i, h in enumerate(history) - ] - self.query_one("#stats-history", Static).update( - "\n".join(lines) if lines else "[dim]No sessions yet for this layout.[/]" + self._trend_history = self._get_history( + self._layout_name, limit=self._confidence_session_window, ) - self.query_one("#stats-key-detail", Static).display = False + self._render_trends(mode="overview") def on_key(self, event: events.Key) -> None: codepoint = self._codepoint_from_key(event) @@ -134,27 +139,59 @@ def _codepoint_from_key(self, event: events.Key) -> int | None: return None return codepoint - def _render_overview(self) -> None: - wpm_line = format_wpm_trend_line(self._trend_history) - self.query_one("#stats-wpm-trend", Static).update( - wpm_line if wpm_line else "[dim]No sessions yet for WPM trend.[/]" - ) + def _render_trends(self, *, mode: _View, codepoint: int | None = None) -> None: + widget = self.query_one("#stats-trends", Static) + limit = self._confidence_session_window + if mode == "overview": + confidence_values, speed_values, accuracy_values = ( + self._get_aggregate_metric_trends( + self._layout_name, + current_target_speed_cpm=self._current_target_speed_cpm, + ) + ) + layout_block = format_metric_trend_block( + title="Layout", + confidence_values=confidence_values, + speed_values=speed_values, + accuracy_values=accuracy_values, + limit=limit, + ) + focus_line = format_focus_confidence_trend_line( + self._trend_history, + limit=limit, + current_target_speed_cpm=self._current_target_speed_cpm, + ) + parts = [block for block in (layout_block, focus_line) if block] + widget.update( + "\n\n".join(parts) if parts + else "[dim]No sessions yet for this layout.[/]" + ) + return - focus_line = format_focus_confidence_trend_line( - self._trend_history, + assert codepoint is not None + speed_values, accuracy_values = self._get_key_metric_trends( + self._layout_name, + codepoint, + current_target_speed_cpm=self._current_target_speed_cpm, + ) + label = _char_label(codepoint) + detail = format_metric_trend_block( + title=f"'{label}'", + headers=self._trend_history, + codepoint=codepoint, + speed_values=speed_values, + accuracy_values=accuracy_values, + limit=limit, current_target_speed_cpm=self._current_target_speed_cpm, ) - self.query_one("#stats-focus-confidence", Static).update( - focus_line if focus_line else "[dim]No sessions yet for focus confidence.[/]" + widget.update( + detail if detail else "[dim]No sessions yet for this key.[/]" ) def _show_overview(self) -> None: self._view = "overview" self._selected_cp = None - self.query_one("#stats-wpm-trend", Static).display = True - self.query_one("#stats-focus-confidence", Static).display = True - self.query_one("#stats-history", Static).display = True - self.query_one("#stats-key-detail", Static).display = False + self._render_trends(mode="overview") if self._kb_heatmap is not None and self._layout is not None: self._kb_heatmap.refresh_heatmap( self._layout, @@ -165,19 +202,7 @@ def _show_overview(self) -> None: def _show_key_detail(self, codepoint: int) -> None: self._view = "key_detail" self._selected_cp = codepoint - self.query_one("#stats-wpm-trend", Static).display = False - self.query_one("#stats-focus-confidence", Static).display = False - self.query_one("#stats-history", Static).display = False - detail_line = format_key_confidence_trend_line( - self._trend_history, - codepoint, - current_target_speed_cpm=self._current_target_speed_cpm, - ) - key_detail = self.query_one("#stats-key-detail", Static) - key_detail.update( - detail_line if detail_line else "[dim]No sessions yet for this key.[/]" - ) - key_detail.display = True + self._render_trends(mode="key_detail", codepoint=codepoint) if self._kb_heatmap is not None and self._layout is not None: self._kb_heatmap.refresh_heatmap( self._layout, diff --git a/src/keystrike/presentation/textual_app.py b/src/keystrike/presentation/textual_app.py index a9c3542..ac93da7 100644 --- a/src/keystrike/presentation/textual_app.py +++ b/src/keystrike/presentation/textual_app.py @@ -10,7 +10,12 @@ StartSession, ) from keystrike.application.settings_use_cases import CycleLayout, UpdateSettings -from keystrike.application.stats_use_cases import GetHeatmap, GetHistory +from keystrike.application.stats_use_cases import ( + GetAggregateMetricTrends, + GetHeatmap, + GetHistory, + GetKeyMetricTrends, +) from keystrike.application.wordlist_use_cases import ( ClearWordList, GetWordListCacheStatus, @@ -48,6 +53,8 @@ def __init__( rebuild_aggregates: StatsRebuilder, get_heatmap: GetHeatmap, get_history: GetHistory, + get_key_metric_trends: GetKeyMetricTrends, + get_aggregate_metric_trends: GetAggregateMetricTrends, cycle_layout: CycleLayout, update_settings: UpdateSettings, import_wordlist: ImportWordList, @@ -67,6 +74,8 @@ def __init__( self._rebuild_aggregates = rebuild_aggregates self._get_heatmap = get_heatmap self._get_history = get_history + self._get_key_metric_trends = get_key_metric_trends + self._get_aggregate_metric_trends = get_aggregate_metric_trends self._cycle_layout = cycle_layout self._update_settings = update_settings self._import_wordlist = import_wordlist @@ -113,7 +122,10 @@ def on_home_screen_open_stats(self, _: HomeScreen.OpenStats) -> None: rebuild_aggregates=self._rebuild_aggregates, get_heatmap=self._get_heatmap, get_history=self._get_history, + get_key_metric_trends=self._get_key_metric_trends, + get_aggregate_metric_trends=self._get_aggregate_metric_trends, current_target_speed_cpm=settings.target_speed_cpm, + confidence_session_window=settings.confidence_session_window, ) ) diff --git a/src/keystrike/presentation/theme.py b/src/keystrike/presentation/theme.py index bc8ee19..434be2e 100644 --- a/src/keystrike/presentation/theme.py +++ b/src/keystrike/presentation/theme.py @@ -1,7 +1,14 @@ """Color roles for the typing area. Kept in one place so themes can override.""" -STYLE_PENDING = "grey50" -STYLE_CORRECT = "bold green" -STYLE_WRONG = "bold white on red" -STYLE_CURRENT = "reverse" +STYLE_PENDING = "white" +STYLE_CORRECT = "grey42" +STYLE_WRONG = "bold red" +STYLE_CURRENT = "bold underline" +STYLE_WRONG_CURRENT = "bold red underline" STYLE_CORRECTED = "dim yellow" +STYLE_IDLE = "dim" +STYLE_TREND_CONFIDENCE = "cyan" +STYLE_TREND_SPEED = "green" +STYLE_TREND_ACCURACY = "yellow" +STYLE_DELTA_IMPROVE = "green" +STYLE_DELTA_REGRESS = "red" diff --git a/src/keystrike/presentation/widgets/hud.py b/src/keystrike/presentation/widgets/hud.py index 4e18d2b..99b0d1d 100644 --- a/src/keystrike/presentation/widgets/hud.py +++ b/src/keystrike/presentation/widgets/hud.py @@ -5,11 +5,21 @@ from keystrike.domain.daily_learn import DailyLearnBudget, format_daily_learn_display from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET from keystrike.domain.protocols import Clock, DailyLearnBudgetProvider -from keystrike.domain.session import Session +from keystrike.domain.session import Session, active_typing_duration_ns, is_typing_idle -def _format_daily_learn_segment(budget: DailyLearnBudget) -> str: - return format_daily_learn_display(budget, label=" Learn:") +def learn_timer_dimmed(session: Session, now_ns: int) -> bool: + if session.typing_started_at_ns is None or session.last_keystroke_at_ns is None: + return True + return is_typing_idle(session, now_ns) + + +def _format_daily_learn_segment(budget: DailyLearnBudget, *, dim: bool) -> str: + segment = format_daily_learn_display(budget, label=" Learn:") + if not segment: + return "" + colored = f"[green]{segment}[/]" if budget.limit_reached else segment + return f"[dim]{colored}[/]" if dim else colored def _format_focus_segment(focus_key: int | None, focus_reason: str | None) -> str: @@ -23,11 +33,12 @@ def _format_hud( daily_budget: DailyLearnBudget, *, focus_reason: str | None = None, + dim_learn: bool = True, ) -> str: accuracy = (session.correct_count / session.total_count) if session.total_count else 1.0 return ( f"Acc: [bold]{accuracy * 100:5.1f}%[/]" - f"{_format_daily_learn_segment(daily_budget)}" + f"{_format_daily_learn_segment(daily_budget, dim=dim_learn)}" f"{_format_focus_segment(session.focus_key, focus_reason)}" ) @@ -69,8 +80,8 @@ def on_mount(self) -> None: self.set_interval(0.1, self.refresh_display) def refresh_display(self) -> None: - started = self._session.typing_started_at_ns - elapsed = (self._clock.now_ns() - started) if started is not None else 0 + now_ns = self._clock.now_ns() + elapsed = active_typing_duration_ns(self._session, now_ns) daily_budget = self._get_daily_learn_budget(extra_ns=elapsed) static = self.query_one("#hud-text", Static) static.update( @@ -78,6 +89,7 @@ def refresh_display(self) -> None: self._session, daily_budget, focus_reason=self._focus_reason, + dim_learn=learn_timer_dimmed(self._session, now_ns), ), ) diff --git a/src/keystrike/presentation/widgets/kb_heatmap.py b/src/keystrike/presentation/widgets/kb_heatmap.py index 9440a03..5630271 100644 --- a/src/keystrike/presentation/widgets/kb_heatmap.py +++ b/src/keystrike/presentation/widgets/kb_heatmap.py @@ -94,13 +94,21 @@ def format_focus_note( focus_reason: str | None, *, confidence: float | None = None, + speed: float | None = None, + accuracy: float | None = None, goal: float = _CONFIDENCE_GOAL, ) -> str | None: if focus_key is None or not focus_reason: return None actual = f"{confidence:.2f}" if confidence is not None else "0.00" goal_s = f"{goal:.2f}" - metrics = f"confidence {actual} / {goal_s}" + parts: list[str] = [] + if speed is not None: + parts.append(f"speed {speed:.2f}") + if accuracy is not None: + parts.append(f"accuracy {accuracy * 100:.1f}%") + parts.append(f"confidence {actual} / {goal_s}") + metrics = ", ".join(parts) key = chr(focus_key) if focus_reason == "weak": diff --git a/src/keystrike/presentation/widgets/typing_area.py b/src/keystrike/presentation/widgets/typing_area.py index dce0e3f..7f4fb78 100644 --- a/src/keystrike/presentation/widgets/typing_area.py +++ b/src/keystrike/presentation/widgets/typing_area.py @@ -1,4 +1,5 @@ from rich.text import Text +from textual._cells import cell_len from textual.app import ComposeResult from textual.widget import Widget from textual.widgets import Static @@ -10,8 +11,11 @@ STYLE_CURRENT, STYLE_PENDING, STYLE_WRONG, + STYLE_WRONG_CURRENT, ) +WORD_DIVIDER = "·" + def render_typing_text(session: Session) -> Text: """Render target text with per-char styling based on session state.""" @@ -29,21 +33,77 @@ def render_typing_text(session: Session) -> Text: last_was_wrong = last_ks is not None and not last_ks.correct and cursor < len(target) for i, ch in enumerate(target): - display = ch if ch != " " else "·" if i < cursor: style = STYLE_CORRECTED if i in session.error_positions else STYLE_CORRECT - text.append(display, style) elif i == cursor: - if last_was_wrong: - text.append(display, STYLE_WRONG) - else: - text.append(display, STYLE_CURRENT) + style = STYLE_WRONG_CURRENT if last_was_wrong else STYLE_CURRENT else: - text.append(display, STYLE_PENDING) + style = STYLE_PENDING + text.append(WORD_DIVIDER if ch == " " else ch, style) return text +def _word_chunks(plain: str) -> list[str]: + """Split display plain text into word· chunks.""" + chunks: list[str] = [] + start = 0 + while start < len(plain): + dot = plain.find(WORD_DIVIDER, start) + if dot == -1: + chunks.append(plain[start:]) + break + chunks.append(plain[start : dot + 1]) + start = dot + 1 + return chunks + + +def wrap_typing_text(text: Text, width: int) -> Text: + """Insert hard newlines at · word boundaries for Textual display width.""" + if width <= 0 or "\n" in text.plain: + return text + + chunks = _word_chunks(text.plain) + if len(chunks) <= 1: + return text + + lines: list[list[str]] = [[]] + line_len = 0 + for chunk in chunks: + chunk_len = cell_len(chunk) + if chunk_len > width: + if lines[-1]: + lines.append([]) + line_len = 0 + lines[-1].append(chunk) + lines.append([]) + line_len = 0 + continue + if line_len and line_len + chunk_len > width: + lines.append([]) + line_len = 0 + lines[-1].append(chunk) + line_len += chunk_len + + if lines and not lines[-1]: + lines.pop() + if len(lines) <= 1: + return text + + breaks: list[int] = [] + offset = 0 + for line_chunks in lines[:-1]: + offset += sum(len(chunk) for chunk in line_chunks) + breaks.append(offset) + + wrapped = Text() + for index, part in enumerate(text.divide(breaks)): + if index: + wrapped.append("\n") + wrapped.append(part) + return wrapped + + class TypingArea(Widget): DEFAULT_CSS = """ TypingArea { @@ -62,9 +122,23 @@ def __init__(self, session: Session) -> None: def compose(self) -> ComposeResult: yield Static(render_typing_text(self._session), id="typing-text") + def on_mount(self) -> None: + self.call_after_refresh(self.refresh_display) + + def on_resize(self) -> None: + self.refresh_display() + + def _render_text(self) -> Text: + text = render_typing_text(self._session) + static = self.query_one("#typing-text", Static) + width = static.content_region.width + if width > 0: + text = wrap_typing_text(text, width) + return text + def refresh_display(self) -> None: static = self.query_one("#typing-text", Static) - static.update(render_typing_text(self._session)) + static.update(self._render_text()) def set_session(self, session: Session) -> None: self._session = session diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index ae264e3..d7acdcc 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -7,16 +7,21 @@ FinishSession, RecordKeystroke, StartSession, + SessionStatsBaseline, compute_accuracy, compute_wpm, + confidence_window_session_baseline, count_words_completed, focus_confidence_sparkline, format_focus_confidence_trend_line, format_key_confidence_trend_line, + format_metric_trend_block, + format_key_speed_trend_line, format_session_stats_line, format_wpm_trend_line, key_confidence_sparkline, key_confidence_values, + previous_session_header, wpm_sparkline, ) from keystrike.domain.aggregate import combine_sessions @@ -44,7 +49,7 @@ def _drive(text: str, keys: str, clock: FakeClock, id_gen: FakeIdGenerator, repo: FakeSessionRepository | None = None): start = StartSession(clock=clock, id_gen=id_gen) repo = repo if repo is not None else FakeSessionRepository() - record = RecordKeystroke(clock=clock, repo=repo) + record = RecordKeystroke(clock=clock) finish = FinishSession(clock=clock, repo=repo) session = start(text, layout="qwerty", mode=Mode.ADAPTIVE) for i, ch in enumerate(keys, start=1): @@ -105,6 +110,27 @@ def test_repo_receives_keystrokes(clock, id_gen, session_repo): assert len(session_repo.headers) == 1 +def test_in_progress_keystrokes_not_persisted(clock, id_gen, session_repo): + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + session = start("ab", layout="qwerty", mode=Mode.ADAPTIVE) + clock.advance(100_000_000) + record(session, "a") + assert session_repo.keystrokes == {} + assert session_repo.headers == [] + + +def test_aborted_session_not_persisted(clock, id_gen, session_repo): + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + session = start("ab", layout="qwerty", mode=Mode.ADAPTIVE) + clock.advance(100_000_000) + record(session, "a") + AbortSession()(session) + assert session_repo.keystrokes == {} + assert session_repo.headers == [] + + def test_wpm_math(clock, id_gen): # Timer starts at the first keystroke, not session creation: "hello" is 1 word, # 5 keystrokes span 4 intervals of 100ms = 0.4s → 150 wpm. @@ -144,12 +170,169 @@ def test_timer_does_not_start_until_first_keystroke(clock, id_gen): assert result.duration_ns == 100_000_000 +def test_learn_timer_pauses_after_idle(clock, id_gen): + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + finish = FinishSession(clock=clock) + session = start("ab", layout="qwerty", mode=Mode.ADAPTIVE) + + record(session, "a") + clock.advance(2_000_000_000) + record(session, "b") + clock.advance(10_000_000_000) # 10s idle — only 5s grace counts after last key + + result = finish(session) + assert result.duration_ns == 7_000_000_000 + + +def test_learn_timer_resumes_after_idle(clock, id_gen): + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + finish = FinishSession(clock=clock) + session = start("abc", layout="qwerty", mode=Mode.ADAPTIVE) + + record(session, "a") + clock.advance(2_000_000_000) + record(session, "b") + clock.advance(10_000_000_000) # idle: active frozen at 2s + 5s grace = 7s + clock.advance(1_000_000_000) + record(session, "c") + + result = finish(session) + assert result.duration_ns == 7_000_000_000 + + def test_format_session_stats_line(clock, id_gen): _, result = _drive("hello", "hello", clock, id_gen) line = format_session_stats_line(result) assert line.startswith("Last: WPM") assert "Acc" in line - assert "Keys" in line + assert "Time" in line + assert "Keys" not in line + assert "↑" not in line + assert "↓" not in line + + +def test_format_session_stats_line_shows_deltas_vs_confidence_baseline(clock, id_gen): + repo = FakeSessionRepository() + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + finish = FinishSession(clock=clock, repo=repo) + + slow = start("ab", layout="qwerty", mode=Mode.ADAPTIVE) + clock.advance(200_000_000) + record(slow, "a") + clock.advance(200_000_000) + record(slow, "b") + first = finish(slow) + + fast = start("ab", layout="qwerty", mode=Mode.ADAPTIVE) + clock.advance(100_000_000) + record(fast, "a") + clock.advance(100_000_000) + record(fast, "b") + second = finish(fast) + + baseline = confidence_window_session_baseline(repo, second, window=10) + assert baseline is not None + assert baseline.wpm == compute_wpm(first) + line = format_session_stats_line(second, baseline=baseline) + assert "[green]↑" in line + + +def test_format_session_stats_line_uses_window_not_previous_only(): + common = dict( + schema_version=3, + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=None, + words_completed=1, + ) + slow = SessionResult( + session_id="s1", + total_keystrokes=10, + correct_keystrokes=10, + **common, + ) + fast = SessionResult( + session_id="s2", + total_keystrokes=10, + correct_keystrokes=10, + **{**common, "duration_ns": 30_000_000_000}, + ) + medium = SessionResult( + session_id="s3", + total_keystrokes=10, + correct_keystrokes=10, + **{**common, "duration_ns": 33_333_333_333}, + ) + repo = FakeSessionRepository(headers=[slow, fast, medium]) + + baseline = confidence_window_session_baseline(repo, medium, window=10) + assert baseline is not None + assert baseline.wpm < compute_wpm(fast) + assert baseline.wpm > compute_wpm(slow) + + vs_previous = format_session_stats_line( + medium, baseline=SessionStatsBaseline( + wpm=compute_wpm(fast), + accuracy_pct=compute_accuracy(fast) * 100, + ), + ) + vs_window = format_session_stats_line(medium, baseline=baseline) + assert "[red]↓" in vs_previous + assert "[green]↑" in vs_window + + +def test_format_session_stats_line_shows_accuracy_regression(): + common = dict( + schema_version=3, + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=None, + words_completed=1, + ) + previous = SessionResult( + session_id="s1", + total_keystrokes=10, + correct_keystrokes=10, + **common, + ) + current = SessionResult( + session_id="s2", + total_keystrokes=10, + correct_keystrokes=8, + **common, + ) + line = format_session_stats_line( + current, + baseline=SessionStatsBaseline( + wpm=compute_wpm(previous), + accuracy_pct=compute_accuracy(previous) * 100, + ), + ) + assert "[red]↓" in line + + +def test_confidence_window_session_baseline_none_for_first_session(clock, id_gen): + repo = FakeSessionRepository() + _, result = _drive("ab", "ab", clock, id_gen, repo=repo) + assert confidence_window_session_baseline(repo, result, window=10) is None + + +def test_previous_session_header_skips_current(clock, id_gen): + repo = FakeSessionRepository() + _, first = _drive("ab", "ab", clock, id_gen, repo=repo) + clock.advance(1_000_000_000) + _, second = _drive("ab", "ab", clock, id_gen, repo=repo) + assert previous_session_header(repo, second) == first + assert previous_session_header(repo, first) is None def test_finish_session_persists_unlocked_keys(clock, id_gen): @@ -164,7 +347,7 @@ def test_finish_session_persists_unlocked_keys(clock, id_gen): layout_repo=layout_repo, ) start = StartSession(clock=clock, id_gen=id_gen) - record = RecordKeystroke(clock=clock, repo=repo) + record = RecordKeystroke(clock=clock) session = start("ab", layout="qwerty", mode=Mode.ADAPTIVE) clock.advance(100_000_000) record(session, "a") @@ -198,7 +381,7 @@ def test_finish_session_bumps_alphabet_size_when_unlocked_set_grows(clock, id_ge layout_repo=layout_repo, ) start = StartSession(clock=clock, id_gen=id_gen) - record = RecordKeystroke(clock=clock, repo=repo) + record = RecordKeystroke(clock=clock) warmup = "".join(chr(cp) for cp in order[:5] for _ in range(10)) warmup_session = start( @@ -231,7 +414,7 @@ def test_finish_session_persists_key_confidence(clock, id_gen): layout_repo=layout_repo, ) start = StartSession(clock=clock, id_gen=id_gen) - record = RecordKeystroke(clock=clock, repo=repo) + record = RecordKeystroke(clock=clock) session = start("ab", layout="qwerty", mode=Mode.ADAPTIVE, focus_key=ord("a")) clock.advance(100_000_000) record(session, "a") @@ -267,7 +450,7 @@ def test_finish_session_key_confidence_uses_confidence_session_window(clock, id_ layout_repo=layout_repo, ) start = StartSession(clock=clock, id_gen=id_gen) - record = RecordKeystroke(clock=clock, repo=repo) + record = RecordKeystroke(clock=clock) for _ in range(CONFIDENCE_SESSION_WINDOW - 1): session = start("a", layout="qwerty", mode=Mode.ADAPTIVE, focus_key=ord("a")) @@ -303,7 +486,7 @@ def test_finish_session_persists_target_speed_cpm(clock, id_gen): layout_repo=layout_repo, ) start = StartSession(clock=clock, id_gen=id_gen) - record = RecordKeystroke(clock=clock, repo=repo) + record = RecordKeystroke(clock=clock) session = start("a", layout="qwerty", mode=Mode.ADAPTIVE, focus_key=ord("a")) clock.advance(100_000_000) record(session, "a") @@ -347,6 +530,48 @@ def test_focus_confidence_sparkline_uses_focus_key_per_session(): assert spark[0] <= spark[1] # 0.5 then 1.0 +def test_format_focus_confidence_trend_line_tracks_labeled_key(): + headers = [ + SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("a"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("a"): 0.5, ord("b"): 1.0}, + ), + SessionResult( + schema_version=3, + session_id="s2", + started_at=2.0, + duration_ns=30_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("b"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("a"): 0.5, ord("b"): 1.0}, + ), + ] + focus_line = format_focus_confidence_trend_line(headers) + key_line = format_metric_trend_block( + title="'b'", + headers=headers, + codepoint=ord("b"), + confidence_values=key_confidence_values(headers, ord("b")), + speed_values=[], + accuracy_values=[], + limit=20, + ) + assert focus_line == key_line.replace("[bold]'b'[/]", "[bold]Focus 'b'[/]", 1) + + def test_format_focus_confidence_trend_line_includes_label_and_peak(): headers = [ SessionResult( @@ -364,9 +589,10 @@ def test_format_focus_confidence_trend_line_includes_label_and_peak(): ), ] line = format_focus_confidence_trend_line(headers) - assert "Focus 'e' confidence" in line - assert "latest 0.75" in line - assert "peak 0.75" in line + assert line.startswith("[bold]Focus 'e'[/] (1 sessions)") + assert "[bold cyan]confidence[/]" in line + assert "latest 0.75" in line + assert "peak 0.75" in line def test_focus_confidence_missing_key_defaults_zero(): @@ -486,8 +712,8 @@ def test_format_focus_confidence_trend_line_normalizes_to_current_goal(): ), ] line = format_focus_confidence_trend_line(headers, current_target_speed_cpm=600) - assert "latest 0.50" in line - assert "peak 0.50" in line + assert "latest 0.50" in line + assert "peak 0.50" in line def test_format_key_confidence_trend_line_includes_cumulative(): @@ -508,10 +734,64 @@ def test_format_key_confidence_trend_line_includes_cumulative(): ] line = format_key_confidence_trend_line(headers, ord("e"), cumulative=1.1) assert "'e' confidence" in line + assert "[cyan]" in line assert "latest 0.75" in line assert "cumulative 1.10" in line +def test_format_metric_trend_block_key_detail_shows_key_once_with_colors(): + headers = [ + SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("e"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("e"): 0.75}, + ), + ] + detail = format_metric_trend_block( + title="'e'", + headers=headers, + codepoint=ord("e"), + speed_values=[120.0], + accuracy_values=[0.95], + ) + assert detail.startswith("[bold]'e'[/] (1 sessions)") + assert detail.count("sessions") == 1 + assert "'e' confidence" not in detail + assert "[bold cyan]confidence[/]" in detail + assert "[bold green]speed [/]" in detail + assert "[bold yellow]accuracy [/]" in detail + assert detail.count("'e'") == 1 + assert "latest 120.00" in detail + assert "latest 95.0%" in detail + + +def test_format_metric_trend_block_aggregate_includes_title(): + detail = format_metric_trend_block( + title="Layout", + confidence_values=[0.75], + speed_values=[120.0], + accuracy_values=[0.95], + ) + assert detail.startswith("[bold]Layout[/] (1 sessions)") + assert "[bold cyan]confidence[/]" in detail + assert "[bold green]speed [/]" in detail + assert "[bold yellow]accuracy [/]" in detail + + +def test_format_key_speed_trend_line_uses_green(): + line = format_key_speed_trend_line([100.0, 120.0]) + assert "[bold green]speed[/]" in line + assert "[green]" in line + + def test_wpm_sparkline_scales_oldest_to_newest(): headers = [ SessionResult( diff --git a/tests/application/test_stats_use_cases.py b/tests/application/test_stats_use_cases.py index db3272a..2c9a8c0 100644 --- a/tests/application/test_stats_use_cases.py +++ b/tests/application/test_stats_use_cases.py @@ -1,10 +1,17 @@ from keystrike.application.stats_use_cases import ( + GetAggregateMetricTrends, GetHeatmap, GetHistory, + GetKeyMetricTrends, GetLearningRate, RebuildAggregates, ) -from keystrike.domain.confidence import CONFIDENCE_SESSION_WINDOW, MIN_CONFIDENCE_ATTEMPTS +from keystrike.domain.confidence import ( + CONFIDENCE_SESSION_WINDOW, + MIN_CONFIDENCE_ATTEMPTS, + SESSION_RECENCY_DECAY, +) +from keystrike.domain.aggregate import session_recency_weights from keystrike.domain.enums import Mode from keystrike.domain.models import KeyStats, Keystroke, LayoutAggregates, SessionResult, Settings from tests.fakes import FakeAggregatesCache, FakeSessionRepository, FakeSettingsRepository @@ -86,7 +93,8 @@ def test_rebuild_aggregates_drops_sessions_outside_window(): assert ord("a") in result assert ord("z") not in result - assert result[ord("a")].samples == CONFIDENCE_SESSION_WINDOW + weights = session_recency_weights(CONFIDENCE_SESSION_WINDOW, decay=SESSION_RECENCY_DECAY) + assert result[ord("a")].attempt_count == round(2 * sum(weights)) def test_rebuild_aggregates_respects_settings_window(): @@ -114,7 +122,8 @@ def test_rebuild_aggregates_respects_settings_window(): result = RebuildAggregates(repo=repo, cache=cache, settings_repo=settings_repo)("qwerty") assert ord("z") not in result - assert result[ord("a")].samples == window + weights = session_recency_weights(window, decay=SESSION_RECENCY_DECAY) + assert result[ord("a")].attempt_count == round(2 * sum(weights)) def test_get_heatmap_empty_cache_returns_empty_view(): @@ -238,3 +247,130 @@ def test_get_history_sorted_newest_first_and_limited(): history = get_history("qwerty", limit=3) assert [h.session_id for h in history] == ["s4", "s3", "s2"] + + +def test_get_key_metric_trends_tracks_speed_and_accuracy(): + repo = FakeSessionRepository() + settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=300)) # target 200ms + + repo.save_header(_header("s1", 1.0)) + repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), typed=ord("a"), t_ns=400_000_000, correct=True, + ), + ] + repo.save_header(_header("s2", 2.0)) + repo.keystrokes["s2"] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True, + ), + ] + + get_trends = GetKeyMetricTrends(repo=repo, settings_repo=settings_repo) + speeds, accuracies = get_trends("qwerty", ord("a")) + + assert len(speeds) == 2 + assert len(accuracies) == 2 + assert speeds[0] < speeds[1] # 400ms then 200ms vs 200ms target + assert accuracies == [1.0, 1.0] + + +def test_get_key_metric_trends_reflects_errors_in_accuracy(): + repo = FakeSessionRepository() + settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=300)) + + repo.save_header(_header("s1", 1.0)) + repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("x"), t_ns=100_000_000, correct=False), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True), + ] + + get_trends = GetKeyMetricTrends(repo=repo, settings_repo=settings_repo) + _, accuracies = get_trends("qwerty", ord("a")) + + assert accuracies == [0.5] + + +def test_get_key_metric_trends_normalizes_speed_to_current_goal(): + repo = FakeSessionRepository() + settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=600)) + + header = SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=1_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(ord("a"),), + focus_key=None, + total_keystrokes=2, + correct_keystrokes=2, + target_speed_cpm=300, + ) + repo.save_header(header) + repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True, + ), + ] + + get_trends = GetKeyMetricTrends(repo=repo, settings_repo=settings_repo) + speeds, _ = get_trends("qwerty", ord("a"), current_target_speed_cpm=600) + + assert speeds == [0.5] # 1.0 at 300 cpm → 0.5 at 600 cpm + + +def test_get_key_metric_trends_limits_to_confidence_session_window(): + window = 3 + settings_repo = FakeSettingsRepository( + Settings(confidence_session_window=window, target_speed_cpm=300), + ) + repo = FakeSessionRepository() + + for i in range(window + 5): + session_id = f"s{i}" + repo.save_header(_header(session_id, float(i))) + repo.keystrokes[session_id] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True, + ), + ] + + get_trends = GetKeyMetricTrends(repo=repo, settings_repo=settings_repo) + speeds, accuracies = get_trends("qwerty", ord("a")) + + assert len(speeds) == window + assert len(accuracies) == window + + +def test_get_aggregate_metric_trends_aggregates_all_keys(): + repo = FakeSessionRepository() + settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=300)) + + repo.save_header(_header("s1", 1.0)) + repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), typed=ord("a"), t_ns=400_000_000, correct=True, + ), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=500_000_000, correct=True), + Keystroke( + codepoint=ord("b"), typed=ord("x"), t_ns=600_000_000, correct=False, + ), + ] + + get_trends = GetAggregateMetricTrends(repo=repo, settings_repo=settings_repo) + confidences, speeds, accuracies = get_trends("qwerty") + + assert len(confidences) == 1 + assert len(speeds) == 1 + assert len(accuracies) == 1 + assert speeds[0] > 0 + assert confidences[0] > 0 + assert accuracies == [0.67] # 2 timing samples, 1 error across keys diff --git a/tests/domain/test_aggregate.py b/tests/domain/test_aggregate.py index 40f3715..9dc531e 100644 --- a/tests/domain/test_aggregate.py +++ b/tests/domain/test_aggregate.py @@ -8,8 +8,10 @@ merge_transition_stats, per_key_deltas, per_transition_deltas, + session_recency_weights, transition_key, ) +from keystrike.domain.confidence import SESSION_RECENCY_DECAY, confidence_of, target_ms_per_char from keystrike.domain.enums import Mode from keystrike.domain.models import KeyStats, Keystroke, SessionResult, TransitionStats @@ -131,7 +133,16 @@ def test_combine_multiple_maps(): assert out[ord("y")].samples == 2 -def test_combine_sessions_merges_multiple_sessions(): +def test_session_recency_weights_newest_is_one(): + assert session_recency_weights(1) == [1.0] + assert session_recency_weights(3) == [ + SESSION_RECENCY_DECAY ** 2, + SESSION_RECENCY_DECAY, + 1.0, + ] + + +def test_combine_sessions_favors_recent_session(): s1 = _session("s1", started_at=1.0) s2 = _session("s2", started_at=2.0) keys1 = [ @@ -143,8 +154,51 @@ def test_combine_sessions_merges_multiple_sessions(): Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=300_000_000, correct=True), ] out = combine_sessions([(s1, keys1), (s2, keys2)]) - assert out.keys[ord("a")].samples == 2 - assert abs(out.keys[ord("a")].mean_time_ns - 200_000_000) < 1 + w_old, w_new = session_recency_weights(2) + expected = (100_000_000 * w_old + 300_000_000 * w_new) / (w_old + w_new) + assert abs(out.keys[ord("a")].mean_time_ns - expected) < 1 + assert out.keys[ord("a")].mean_time_ns > 200_000_000 + + +def test_combine_sessions_weights_recent_attempts_more(): + s1 = _session("s1", started_at=1.0) + s2 = _session("s2", started_at=2.0) + keys1 = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), + ] + keys2 = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + ] + out = combine_sessions([(s1, keys1), (s2, keys2)]) + w_old, w_new = session_recency_weights(2) + expected_attempts = round(2 * w_old + 1 * w_new) + assert out.keys[ord("a")].attempt_count == expected_attempts + assert out.keys[ord("a")].attempt_count < 3 + + +def test_combine_sessions_recent_errors_weigh_more_on_confidence(): + s1 = _session("s1", started_at=1.0) + s2 = _session("s2", started_at=2.0) + # Old session: fast and clean. Recent session: fast but half wrong. + keys1 = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True), + ] + keys2 = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("x"), t_ns=50_000_000, correct=False), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=150_000_000, correct=True), + ] + stats = combine_sessions([(s1, keys1), (s2, keys2)]).keys + equal_weight = combine( + {ord("a"): aggregate_session(s1, keys1)[ord("a")]}, + {ord("a"): aggregate_session(s2, keys2)[ord("a")]}, + ) + assert confidence_of(ord("a"), stats, target=200.0) < confidence_of( + ord("a"), equal_weight, target=200.0, + ) def test_per_transition_deltas_tracks_prev_to_next_pair(): diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index cab914e..03547a7 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -78,8 +78,8 @@ def test_confidence_of_scales_down_with_few_attempts(): attempt_count=2, ), } - # raw speed x accuracy = 2.0 x 0.5 = 1.0; only 2 attempts -> x0.2 - assert confidence_of(ord("a"), stats, target=200.0) == 0.2 + # raw min(speed, accuracy) = min(2.0, 0.5) = 0.5; only 2 attempts -> x0.2 + assert confidence_of(ord("a"), stats, target=200.0) == 0.1 def test_confidence_of_reaches_full_value_at_minimum_attempts(): @@ -120,14 +120,14 @@ def test_transition_confidence_reaches_full_at_minimum_attempts(): def test_confidence_of_penalizes_frequent_errors(): - # Fast (2.0 speed-confidence) but wrong half the time -> 1.0, not "mastered". + # Fast (2.0 speed) but wrong half the time -> min(2.0, 0.5) = 0.5, not mastered. stats = {ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, error_count=10)} - assert confidence_of(ord("a"), stats, target=200.0) == 1.0 + assert confidence_of(ord("a"), stats, target=200.0) == 0.5 def test_compute_unlocked_stalls_on_high_error_rate_despite_fast_speed(): learn_order = (1, 2, 3, 4) - # speed-confidence 2.0, but only 50% accuracy -> combined 1.0 < threshold + # speed 2.0, but only 50% accuracy -> min(2.0, 0.5) = 0.5 < threshold stats = {1: _stats(1, mean_time_ns=100_000_000.0, error_count=10)} unlocked = compute_unlocked(learn_order, alphabet_size=1, stats=stats, target=200.0, threshold=1.5) diff --git a/tests/infrastructure/test_session_repo_jsonl.py b/tests/infrastructure/test_session_repo_jsonl.py index 7849eb1..d5e6c47 100644 --- a/tests/infrastructure/test_session_repo_jsonl.py +++ b/tests/infrastructure/test_session_repo_jsonl.py @@ -152,12 +152,11 @@ def test_round_trip_target_speed_cpm(paths): assert headers[0].target_speed_cpm == 400 -def test_keystroke_before_header_still_recoverable(paths): - # Real flow: append happens per keystroke (with started_at), save_header runs at end. +def test_keystrokes_persisted_with_header_at_finish(paths): repo = JsonlSessionRepository(paths) header = _header(sid="S2") - repo.append_keystroke(header.session_id, header.started_at, - Keystroke(codepoint=ord("x"), typed=ord("x"), t_ns=0, correct=True)) + k = Keystroke(codepoint=ord("x"), typed=ord("x"), t_ns=0, correct=True) + repo.append_keystroke(header.session_id, header.started_at, k) repo.save_header(header) repo2 = JsonlSessionRepository(paths) diff --git a/tests/presentation/test_hud.py b/tests/presentation/test_hud.py index 90f4bac..ed28265 100644 --- a/tests/presentation/test_hud.py +++ b/tests/presentation/test_hud.py @@ -4,8 +4,8 @@ from keystrike.domain.daily_learn import compute_daily_learn_budget from keystrike.domain.enums import Mode -from keystrike.domain.session import Session -from keystrike.presentation.widgets.hud import HUD, _format_hud +from keystrike.domain.session import LEARN_IDLE_PAUSE_NS, Session, active_typing_duration_ns, is_typing_idle +from keystrike.presentation.widgets.hud import HUD, _format_hud, learn_timer_dimmed from tests.fakes import FakeClock _UNLIMITED = compute_daily_learn_budget(completed_ns=0, limit_minutes=0) @@ -33,6 +33,12 @@ def _session( return session +def _active_session(**kwargs) -> Session: + session = _session(typing_started_at_ns=0, **kwargs) + session.last_keystroke_at_ns = 0 + return session + + def test_hud_shows_accuracy_without_wpm(): text = _format_hud(_session(correct=8, total=10), _UNLIMITED) assert text.startswith("Acc: [bold] 80.0%[/]") @@ -82,10 +88,21 @@ def test_hud_shows_daily_learn_goal_reached(): completed_ns=10 * 60 * 1_000_000_000, limit_minutes=10, ) - text = _format_hud(_session(), budget) + text = _format_hud(_active_session(), budget, dim_learn=False) assert "Learn:" in text assert "10.0" in text assert "/10 min" in text + assert "[green] Learn: [bold]10.0[/]/10 min[/]" in text + + +def test_hud_learn_segment_green_when_goal_reached_and_active(): + budget = compute_daily_learn_budget( + completed_ns=10 * 60 * 1_000_000_000, + limit_minutes=10, + ) + text = _format_hud(_active_session(), budget, dim_learn=False) + assert "[green] Learn: [bold]10.0[/]/10 min[/]" in text + assert "[dim] Learn:" not in text def test_hud_distinct_labels_for_daily_budget_and_focus(): @@ -121,3 +138,60 @@ async def test_hud_accuracy_updates_on_refresh(): text = str(app.screen.query_one("#hud-text", Static).content) assert "50.0%" in text assert "WPM" not in text + + +def test_hud_learn_timer_excludes_idle_beyond_pause(): + clock = FakeClock() + session = _session(typing_started_at_ns=0) + session.last_keystroke_at_ns = 2_000_000_000 + session.active_duration_ns = 2_000_000_000 + clock.advance(12_000_000_000) # 10s since last key; only 5s grace counts + + assert active_typing_duration_ns(session, clock.now_ns()) == 7_000_000_000 + assert LEARN_IDLE_PAUSE_NS == 5_000_000_000 + + +def test_hud_learn_segment_dimmed_before_first_keystroke(): + budget = compute_daily_learn_budget( + completed_ns=6 * 60 * 1_000_000_000, + limit_minutes=10, + ) + text = _format_hud(_session(), budget) + assert "[dim] Learn: [bold]6.0[/]/10 min[/]" in text + + +def test_hud_learn_segment_undimmed_while_actively_typing(): + budget = compute_daily_learn_budget( + completed_ns=6 * 60 * 1_000_000_000, + limit_minutes=10, + ) + text = _format_hud(_active_session(), budget, dim_learn=False) + assert "[dim] Learn:" not in text + assert " Learn: [bold]6.0[/]/10 min" in text + + +def test_hud_learn_segment_dimmed_while_idle(): + budget = compute_daily_learn_budget( + completed_ns=6 * 60 * 1_000_000_000, + limit_minutes=10, + ) + session = _active_session() + session.last_keystroke_at_ns = 0 + clock = FakeClock() + clock.advance(LEARN_IDLE_PAUSE_NS) + assert is_typing_idle(session, clock.now_ns()) + text = _format_hud(session, budget, dim_learn=learn_timer_dimmed(session, clock.now_ns())) + assert "[dim] Learn: [bold]6.0[/]/10 min[/]" in text + + +def test_hud_learn_segment_dim_green_when_goal_reached_but_idle(): + budget = compute_daily_learn_budget( + completed_ns=10 * 60 * 1_000_000_000, + limit_minutes=10, + ) + session = _active_session() + session.last_keystroke_at_ns = 0 + clock = FakeClock() + clock.advance(LEARN_IDLE_PAUSE_NS) + text = _format_hud(session, budget, dim_learn=learn_timer_dimmed(session, clock.now_ns())) + assert "[dim][green] Learn: [bold]10.0[/]/10 min[/][/]" in text diff --git a/tests/presentation/test_kb_heatmap.py b/tests/presentation/test_kb_heatmap.py index 32283d2..6f02b3e 100644 --- a/tests/presentation/test_kb_heatmap.py +++ b/tests/presentation/test_kb_heatmap.py @@ -92,24 +92,32 @@ def test_render_heatmap_focus_style_layers_on_review_underline(): def test_format_focus_note_weak_includes_confidence_numbers(): - note = format_focus_note(ord("a"), "weak", confidence=0.89) + note = format_focus_note(ord("a"), "weak", confidence=0.89, speed=0.95, accuracy=0.92) assert note is not None + assert "speed 0.95" in note + assert "accuracy 92.0%" in note assert "0.89" in note assert "1.00" in note assert "weak" in note def test_format_focus_note_review_includes_confidence_numbers(): - note = format_focus_note(ord("e"), "review", confidence=1.12) + note = format_focus_note(ord("e"), "review", confidence=1.12, speed=1.15, accuracy=0.98) assert note is not None + assert "speed 1.15" in note + assert "accuracy 98.0%" in note assert "1.12" in note assert "1.00" in note def test_format_focus_note_weak_transition(): - note = format_focus_note(ord("t"), "at weak transition", confidence=0.45) + note = format_focus_note( + ord("t"), "at weak transition", confidence=0.45, speed=0.50, accuracy=0.80, + ) assert note is not None assert "at" in note + assert "speed 0.50" in note + assert "accuracy 80.0%" in note assert "0.45" in note diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index 2056442..22f04bd 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -13,7 +13,13 @@ StartSession, ) from keystrike.application.settings_use_cases import CycleLayout, UpdateSettings -from keystrike.application.stats_use_cases import GetHeatmap, GetHistory, RebuildAggregates +from keystrike.application.stats_use_cases import ( + GetAggregateMetricTrends, + GetHeatmap, + GetHistory, + GetKeyMetricTrends, + RebuildAggregates, +) from keystrike.application.wordlist_use_cases import ( ClearWordList, GetWordListCacheStatus, @@ -21,12 +27,14 @@ ) from keystrike.domain.enums import Mode, SessionState from keystrike.domain.models import SessionResult, Settings +from keystrike.domain.session import LEARN_IDLE_PAUSE_NS, active_typing_duration_ns, is_typing_idle from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from keystrike.presentation.screens.home import HomeScreen from keystrike.presentation.screens.practice import PracticeScreen from keystrike.presentation.screens.settings import SettingsScreen from keystrike.presentation.screens.stats import StatsScreen from keystrike.presentation.textual_app import KeystrikeApp +from keystrike.presentation.widgets.hud import HUD from keystrike.presentation.widgets.kb_heatmap import KbHeatmap from tests.fakes import ( FakeAggregatesCache, @@ -78,8 +86,13 @@ def _build_app( app = KeystrikeApp( clock=clock, start=StartSession(clock=clock, id_gen=id_gen), - record=RecordKeystroke(clock=clock, repo=session_repo), - finish=FinishSession(clock=clock, repo=session_repo), + record=RecordKeystroke(clock=clock), + finish=FinishSession( + clock=clock, + repo=session_repo, + settings_repo=settings_repo, + layout_repo=layout_repo, + ), settings_repo=settings_repo, layout_repo=layout_repo, prepare_practice=prepare_practice, @@ -88,6 +101,14 @@ def _build_app( ), get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo), get_history=GetHistory(repo=session_repo), + get_key_metric_trends=GetKeyMetricTrends( + repo=session_repo, + settings_repo=settings_repo, + ), + get_aggregate_metric_trends=GetAggregateMetricTrends( + repo=session_repo, + settings_repo=settings_repo, + ), get_daily_learn_budget=get_daily_learn_budget, cycle_layout=CycleLayout(settings_repo=settings_repo, layout_repo=layout_repo), update_settings=UpdateSettings(repo=settings_repo), @@ -111,6 +132,7 @@ async def test_app_launches_types_and_persists_session(): await pilot.press("space" if target[0] == " " else target[0]) await pilot.pause() assert len(session_repo.headers) == 0 # session not finished yet + assert session_repo.keystrokes == {} assert isinstance(app.screen, PracticeScreen) assert app.screen._session.position >= 1 @@ -155,8 +177,8 @@ async def test_stats_are_isolated_per_layout(): assert settings_repo.settings.layout != "qwerty" await pilot.press("s") await pilot.pause() - history_text = str(app.screen.query_one("#stats-history", Static).content) - assert "No sessions yet" in history_text + trends_text = str(app.screen.query_one("#stats-trends", Static).content) + assert "No sessions yet" in trends_text @pytest.mark.asyncio @@ -183,7 +205,18 @@ async def test_adaptive_allowed_when_daily_learn_goal_reached(): @pytest.mark.asyncio -async def test_adaptive_practice_generates_lesson_text_for_focus_key(): +async def test_adaptive_practice_focus_note_shows_speed_and_accuracy(): + app, _clock, _repo, _settings = _build_app() + async with app.run_test() as pilot: + await pilot.press("enter") + await pilot.pause() + practice = app.screen + assert isinstance(practice, PracticeScreen) + note = str(practice.query_one("#focus-note", Static).content) + if practice._focus_reason: + assert "speed " in note + assert "accuracy " in note + assert "confidence " in note app, _clock, _repo, _settings = _build_app() async with app.run_test() as pilot: await pilot.press("enter") @@ -220,13 +253,42 @@ async def test_adaptive_shows_last_session_stats_after_completion(): assert len(session_repo.headers) == 1 last_stats = str(practice.query_one("#last-session-stats", Static).content) assert "Last: WPM" in last_stats + assert "Keys" not in last_stats assert practice._session.position == 0 assert practice._session.target_text +@pytest.mark.asyncio +async def test_adaptive_shows_session_deltas_after_second_completion(): + app, clock, session_repo, _ = _build_app() + async with app.run_test() as pilot: + await pilot.press("enter") + await pilot.pause() + practice = app.screen + assert isinstance(practice, PracticeScreen) + + async def complete_current(*, ms_per_key: int) -> None: + target = practice._session.target_text + for ch in target: + clock.advance(ms_per_key * 1_000_000) + await pilot.press("space" if ch == " " else ch) + await pilot.pause() + + await complete_current(ms_per_key=100) + first_stats = str(practice.query_one("#last-session-stats", Static).content) + assert "↑" not in first_stats + assert "↓" not in first_stats + + await complete_current(ms_per_key=50) + assert len(session_repo.headers) == 2 + last_stats = str(practice.query_one("#last-session-stats", Static).content) + assert "[green]" in last_stats + assert "↑" in last_stats + + @pytest.mark.asyncio async def test_escape_returns_to_home_and_cancels_session(): - app, _clock, _repo, _settings = _build_app() + app, _clock, session_repo, _settings = _build_app() async with app.run_test() as pilot: await pilot.press("enter") await pilot.pause() @@ -237,4 +299,70 @@ async def test_escape_returns_to_home_and_cancels_session(): await pilot.pause() assert practice._session.state is SessionState.CANCELLED + assert session_repo.headers == [] + assert session_repo.keystrokes == {} assert isinstance(app.screen, HomeScreen) + + +@pytest.mark.asyncio +async def test_learn_timer_pauses_while_idle_on_practice_screen(): + app, clock, _repo, settings_repo = _build_app( + settings=Settings(learn_daily_minutes=10), + ) + async with app.run_test() as pilot: + await pilot.press("enter") + await pilot.pause() + practice = app.screen + assert isinstance(practice, PracticeScreen) + target = practice._session.target_text + + clock.advance(100_000_000) + await pilot.press("space" if target[0] == " " else target[0]) + await pilot.pause() + clock.advance(2_000_000_000) + await pilot.press("space" if target[1] == " " else target[1]) + await pilot.pause() + + clock.advance(LEARN_IDLE_PAUSE_NS + 3_000_000_000) + practice.query_one(HUD).refresh_display() + await pilot.pause() + + elapsed = active_typing_duration_ns(practice._session, clock.now_ns()) + assert elapsed == 7_000_000_000 # 2s between keys + 5s idle cap, not 10s wall + + hud_text = str(practice.query_one("#hud-text", Static).content) + assert "Learn:" in hud_text + assert "0.1[/]/10" in hud_text + + +@pytest.mark.asyncio +async def test_hud_learn_dims_while_idle_on_practice_screen(): + app, clock, _repo, _settings = _build_app( + settings=Settings(learn_daily_minutes=10), + ) + async with app.run_test() as pilot: + await pilot.press("enter") + await pilot.pause() + practice = app.screen + assert isinstance(practice, PracticeScreen) + target = practice._session.target_text + + clock.advance(100_000_000) + await pilot.press("space" if target[0] == " " else target[0]) + await pilot.pause() + assert not is_typing_idle(practice._session, clock.now_ns()) + + clock.advance(LEARN_IDLE_PAUSE_NS + 1_000_000_000) + practice.query_one(HUD).refresh_display() + await pilot.pause() + assert is_typing_idle(practice._session, clock.now_ns()) + hud_text = str(practice.query_one("#hud-text", Static).content) + assert "[dim] Learn:" in hud_text + + clock.advance(100_000_000) + await pilot.press("space" if target[1] == " " else target[1]) + await pilot.pause() + assert not is_typing_idle(practice._session, clock.now_ns()) + hud_text = str(practice.query_one("#hud-text", Static).content) + assert "[dim] Learn:" not in hud_text + assert "Learn:" in hud_text diff --git a/tests/presentation/test_stats_screen.py b/tests/presentation/test_stats_screen.py index 31e336c..e7c463b 100644 --- a/tests/presentation/test_stats_screen.py +++ b/tests/presentation/test_stats_screen.py @@ -2,9 +2,16 @@ import pytest from textual.app import App +from textual.containers import Vertical from textual.widgets import Static -from keystrike.application.stats_use_cases import GetHeatmap, GetHistory, RebuildAggregates +from keystrike.application.stats_use_cases import ( + GetAggregateMetricTrends, + GetHeatmap, + GetHistory, + GetKeyMetricTrends, + RebuildAggregates, +) from keystrike.domain.enums import Mode from keystrike.domain.models import Keystroke, SessionResult, Settings from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS @@ -34,10 +41,81 @@ def _build_screen( ), get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo), get_history=GetHistory(repo=repo), + get_key_metric_trends=GetKeyMetricTrends( + repo=repo, + settings_repo=settings_repo, + ), + get_aggregate_metric_trends=GetAggregateMetricTrends( + repo=repo, + settings_repo=settings_repo, + ), current_target_speed_cpm=settings_repo.settings.target_speed_cpm, + confidence_session_window=settings_repo.settings.confidence_session_window, ) +@pytest.mark.asyncio +async def test_stats_screen_widget_order(): + app = App() + async with app.run_test() as pilot: + repo = FakeSessionRepository() + await app.push_screen(_build_screen(repo)) + await pilot.pause() + + vertical = app.screen.query_one(Vertical) + widget_ids = [ + w.id for w in vertical.children + if getattr(w, "id", None) is not None + ] + assert widget_ids == [ + "stats-title", + "stats-trends", + "stats-heatmap-caption", + "stats-heatmap-hint", + ] + + +@pytest.mark.asyncio +async def test_stats_overview_focus_shows_confidence_only(): + app = App() + async with app.run_test() as pilot: + repo = FakeSessionRepository() + header = SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(ord("e"),), + focus_key=ord("e"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("e"): 0.82}, + ) + repo.save_header(header) + repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("e"), typed=ord("e"), t_ns=400_000_000, correct=True, + ), + ] + + await app.push_screen(_build_screen(repo)) + await pilot.pause() + + focus_text = str(app.screen.query_one("#stats-trends", Static).content) + assert "[bold]Focus 'e'[/]" in focus_text + assert "Layout" in focus_text + layout_section, focus_section = focus_text.split("\n\n", 1) + assert "[bold cyan]confidence[/]" in layout_section + assert "[bold green]speed [/]" in layout_section + assert "[bold yellow]accuracy [/]" in layout_section + assert "[bold cyan]confidence[/]" in focus_section + assert "[bold green]speed [/]" not in focus_section + assert "[bold yellow]accuracy [/]" not in focus_section + + @pytest.mark.asyncio async def test_stats_screen_with_no_sessions_shows_placeholder(): app = App() @@ -45,12 +123,12 @@ async def test_stats_screen_with_no_sessions_shows_placeholder(): repo = FakeSessionRepository() await app.push_screen(_build_screen(repo)) await pilot.pause() - history_text = app.screen.query_one("#stats-history", Static).content - assert "No sessions yet" in str(history_text) + trends_text = app.screen.query_one("#stats-trends", Static).content + assert "No sessions yet" in str(trends_text) @pytest.mark.asyncio -async def test_stats_screen_with_sessions_renders_history_and_heatmap(): +async def test_stats_screen_with_sessions_renders_trends_and_heatmap(): app = App() async with app.run_test() as pilot: repo = FakeSessionRepository() @@ -74,38 +152,44 @@ async def test_stats_screen_with_sessions_renders_history_and_heatmap(): await app.push_screen(_build_screen(repo)) await pilot.pause() - history_text = str(app.screen.query_one("#stats-history", Static).content) - assert "wpm" in history_text + trends_text = str(app.screen.query_one("#stats-trends", Static).content) + assert "Layout" in trends_text assert app.screen.query_one("#kb-heatmap-text") is not None caption = str(app.screen.query_one("#stats-heatmap-caption", Static).content) assert "vs current goal" in caption + hint = str(app.screen.query_one("#stats-heatmap-hint", Static).content) + assert "Press a key on the heatmap for letter stats" in hint + assert "Esc to return" in hint @pytest.mark.asyncio -async def test_stats_screen_renders_wpm_trend(): +async def test_stats_trends_uses_confidence_session_window(): app = App() async with app.run_test() as pilot: repo = FakeSessionRepository() - header = SessionResult( - schema_version=2, - session_id="s1", - started_at=1_700_000_000.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(ord("a"),), - focus_key=None, - total_keystrokes=50, - correct_keystrokes=50, - ) - repo.save_header(header) + window = 3 + for i in range(window + 2): + repo.save_header(SessionResult( + schema_version=2, + session_id=f"s{i}", + started_at=float(i), + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(ord("a"),), + focus_key=None, + total_keystrokes=50, + correct_keystrokes=50, + )) - await app.push_screen(_build_screen(repo)) + await app.push_screen(_build_screen( + repo, + settings=Settings(confidence_session_window=window), + )) await pilot.pause() - wpm_text = str(app.screen.query_one("#stats-wpm-trend", Static).content) - assert "WPM trend" in wpm_text - assert "latest" in wpm_text + trends_text = str(app.screen.query_one("#stats-trends", Static).content) + assert f"({window} sessions)" in trends_text @pytest.mark.asyncio @@ -131,8 +215,9 @@ async def test_stats_screen_renders_focus_confidence_trend(): await app.push_screen(_build_screen(repo)) await pilot.pause() - focus_text = str(app.screen.query_one("#stats-focus-confidence", Static).content) - assert "Focus 'e' confidence" in focus_text + focus_text = str(app.screen.query_one("#stats-trends", Static).content) + assert "[bold]Focus 'e'[/]" in focus_text + assert "[bold cyan]confidence[/]" in focus_text assert "latest" in focus_text @@ -200,11 +285,23 @@ async def test_stats_key_press_shows_key_detail(): screen = cast(StatsScreen, app.screen) assert screen._view == "key_detail" assert screen._selected_cp == ord("e") - detail = str(screen.query_one("#stats-key-detail", Static).content) - assert "'e' confidence" in detail + detail = str(screen.query_one("#stats-trends", Static).content) + assert "[bold]'e'[/]" in detail + assert "confidence" in detail + assert "speed" in detail + assert "accuracy" in detail + assert "'e' confidence" not in detail + assert "'e' speed" not in detail + assert "'e' accuracy" not in detail + assert detail.count("sessions") == 1 + assert "[bold green]speed [/]" in detail + assert "[bold yellow]accuracy [/]" in detail + assert "[cyan]" in detail + assert "[green]" in detail + assert "[yellow]" in detail assert "cumulative" not in detail - assert screen.query_one("#stats-wpm-trend", Static).display is False - assert screen.query_one("#stats-history", Static).display is False + assert "Layout" not in detail + assert "Focus" not in detail @pytest.mark.asyncio @@ -225,17 +322,18 @@ async def test_stats_key_detail_switches_key_without_overview(): screen = cast(StatsScreen, app.screen) assert screen._view == "key_detail" assert screen._selected_cp == ord("a") - assert "'a' confidence" in str(screen.query_one("#stats-key-detail", Static).content) + assert "[bold]'a'[/]" in str(screen.query_one("#stats-trends", Static).content) await pilot.press("b") await pilot.pause() assert screen._view == "key_detail" assert screen._selected_cp == ord("b") - detail = str(screen.query_one("#stats-key-detail", Static).content) - assert "'b' confidence" in detail - assert "'a' confidence" not in detail - assert screen.query_one("#stats-wpm-trend", Static).display is False + detail = str(screen.query_one("#stats-trends", Static).content) + assert "[bold]'b'[/]" in detail + assert "confidence" in detail + assert "[bold]'a'[/]" not in detail + assert "'b' confidence" not in detail @pytest.mark.asyncio @@ -259,8 +357,8 @@ async def test_stats_key_detail_normalizes_confidence_to_current_goal(): await pilot.press("e") await pilot.pause() - detail = str(app.screen.query_one("#stats-key-detail", Static).content) - assert "latest 0.50" in detail + detail = str(app.screen.query_one("#stats-trends", Static).content) + assert "latest 0.50" in detail @pytest.mark.asyncio @@ -283,9 +381,105 @@ async def test_stats_key_detail_stable_when_goal_changes(): await pilot.press("e") await pilot.pause() - detail = str(app.screen.query_one("#stats-key-detail", Static).content) - assert "latest 0.82" in detail + detail = str(app.screen.query_one("#stats-trends", Static).content) + assert "latest 0.82" in detail assert "cumulative" not in detail + assert detail.count("sessions") == 1 + + +@pytest.mark.asyncio +async def test_stats_key_detail_shows_speed_and_accuracy_trends(): + app = App() + async with app.run_test() as pilot: + repo = FakeSessionRepository() + repo.save_header(_session_with_key_confidence( + session_id="s1", + started_at=1.0, + key_confidence={ord("e"): 0.55}, + )) + repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("e"), typed=ord("e"), t_ns=400_000_000, correct=True, + ), + ] + await app.push_screen(_build_screen(repo)) + await pilot.pause() + + await pilot.press("e") + await pilot.pause() + + detail = str(app.screen.query_one("#stats-trends", Static).content) + assert "speed" in detail + assert "accuracy" in detail + assert "[green]" in detail + assert "[yellow]" in detail + assert "latest" in detail + + +@pytest.mark.asyncio +async def test_stats_screen_renders_speed_and_accuracy_trends(): + app = App() + async with app.run_test() as pilot: + repo = FakeSessionRepository() + header = SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(ord("a"),), + focus_key=ord("a"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("a"): 0.82}, + ) + repo.save_header(header) + repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), typed=ord("a"), t_ns=400_000_000, correct=True, + ), + ] + + await app.push_screen(_build_screen(repo)) + await pilot.pause() + + metric_text = str(app.screen.query_one("#stats-trends", Static).content) + assert "[bold]Layout[/]" in metric_text + layout_section = metric_text.split("\n\n", 1)[0] + assert "(1 sessions)" in layout_section + assert layout_section.count("sessions") == 1 + assert "[bold cyan]confidence[/]" in metric_text + assert "[bold green]speed [/]" in metric_text + assert "[bold yellow]accuracy [/]" in metric_text + assert "[cyan]" in metric_text + assert "[green]" in metric_text + assert "[yellow]" in metric_text + assert "latest" in metric_text + + +@pytest.mark.asyncio +async def test_stats_key_press_replaces_overview_trends(): + app = App() + async with app.run_test() as pilot: + repo = FakeSessionRepository() + repo.save_header(_session_with_key_confidence( + session_id="s1", + started_at=1.0, + key_confidence={ord("e"): 0.55}, + )) + await app.push_screen(_build_screen(repo)) + await pilot.pause() + + await pilot.press("e") + await pilot.pause() + + screen = cast(StatsScreen, app.screen) + trends = str(screen.query_one("#stats-trends", Static).content) + assert "[bold]'e'[/]" in trends + assert "Layout" not in trends @pytest.mark.asyncio @@ -308,9 +502,9 @@ async def test_stats_escape_from_key_detail_returns_to_overview(): screen = cast(StatsScreen, app.screen) assert screen._view == "overview" - assert screen.query_one("#stats-key-detail", Static).display is False - assert screen.query_one("#stats-wpm-trend", Static).display is True - assert "WPM trend" in str(screen.query_one("#stats-wpm-trend", Static).content) + trends = str(screen.query_one("#stats-trends", Static).content) + assert "Layout" in trends + assert "[bold]'e'[/]" not in trends @pytest.mark.asyncio @@ -340,4 +534,4 @@ async def test_stats_ignores_key_not_in_layout(): screen = cast(StatsScreen, app.screen) assert screen._view == "overview" - assert screen.query_one("#stats-key-detail", Static).display is False + assert "[bold]'e'[/]" not in str(screen.query_one("#stats-trends", Static).content) diff --git a/tests/presentation/test_typing_area.py b/tests/presentation/test_typing_area.py index 4880a0e..0e156d4 100644 --- a/tests/presentation/test_typing_area.py +++ b/tests/presentation/test_typing_area.py @@ -1,8 +1,18 @@ from keystrike.domain.enums import Mode from keystrike.domain.models import Keystroke from keystrike.domain.session import Session -from keystrike.presentation.theme import STYLE_CORRECT, STYLE_CORRECTED -from keystrike.presentation.widgets.typing_area import render_typing_text +from keystrike.presentation.theme import ( + STYLE_CORRECT, + STYLE_CORRECTED, + STYLE_CURRENT, + STYLE_PENDING, + STYLE_WRONG, + STYLE_WRONG_CURRENT, +) +from keystrike.presentation.widgets.typing_area import ( + render_typing_text, + wrap_typing_text, +) def _session( @@ -32,6 +42,35 @@ def _style_at(session: Session, index: int) -> object: return span.style +def test_typing_color_constants(): + assert STYLE_PENDING == "white" + assert STYLE_CORRECT == "grey42" + assert STYLE_CORRECTED == "dim yellow" + assert STYLE_CURRENT == "bold underline" + assert STYLE_WRONG == "bold red" + + +def test_cursor_position_uses_underline_not_block(): + session = _session(position=1) + assert _style_at(session, 1) == STYLE_CURRENT + assert "reverse" not in str(_style_at(session, 1)) + + +def test_wrong_keystroke_at_cursor_keeps_underline(): + session = _session( + position=0, + keystrokes=[Keystroke(codepoint=ord("a"), typed=ord("x"), t_ns=0, correct=False)], + ) + assert _style_at(session, 0) == STYLE_WRONG_CURRENT + assert "underline" in str(_style_at(session, 0)) + + +def test_pending_char_uses_pending_style(): + session = _session(target_text="abc", position=0) + assert _style_at(session, 1) == STYLE_PENDING + assert _style_at(session, 2) == STYLE_PENDING + + def test_clean_correct_char_uses_correct_style(): session = _session(position=1) assert _style_at(session, 0) == STYLE_CORRECT @@ -44,3 +83,24 @@ def test_corrected_char_uses_corrected_style(): keystrokes=[Keystroke(codepoint=ord("a"), typed=ord("x"), t_ns=0, correct=False)], ) assert _style_at(session, 0) == STYLE_CORRECTED + + +def test_space_renders_as_dot_word_divider(): + session = _session(target_text="ab cd", position=0) + assert render_typing_text(session).plain == "ab·cd" + + +def test_wrap_breaks_at_word_boundaries(): + session = _session(target_text="hello world foobar", position=0) + wrapped = wrap_typing_text(render_typing_text(session), 15) + assert wrapped.plain.split("\n") == ["hello·world·", "foobar"] + + +def test_textual_render_path_avoids_midword_breaks(): + from textual.content import Content + + session = _session(target_text="st series seasons cnasa kterrorist", position=0) + wrapped = wrap_typing_text(render_typing_text(session), 26) + content = Content.from_rich_text(wrapped) + lines = [line.plain for line in content._wrap_and_format(26, overflow="fold")] + assert lines == ["st·series·seasons·cnasa·", "kterrorist"] diff --git a/uv.lock b/uv.lock index 2da5970..42371ca 100644 --- a/uv.lock +++ b/uv.lock @@ -43,7 +43,7 @@ wheels = [ [[package]] name = "keystrike" -version = "1.2.2" +version = "1.2.3" source = { editable = "." } dependencies = [ { name = "platformdirs" }, From 8497234f8dd4b0a1b4f6d0d31b421ff116d9f36d Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 29 Jul 2026 18:07:55 +0300 Subject: [PATCH 07/47] Fix lint errors after merge for PR #32. Ruff import order, line length, unused imports, and aggregate int casts. Co-authored-by: Cursor --- src/keystrike/application/session_use_cases.py | 7 ++++++- src/keystrike/domain/aggregate.py | 12 ++++++------ src/keystrike/presentation/widgets/typing_area.py | 1 - tests/application/test_session_use_cases.py | 4 ++-- tests/application/test_stats_use_cases.py | 2 +- tests/domain/test_aggregate.py | 2 +- tests/presentation/test_hud.py | 7 ++++++- tests/presentation/test_practice_screen.py | 2 +- tests/presentation/test_typing_area.py | 4 ++-- 9 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 6bc90fd..c373a3a 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -20,7 +20,12 @@ SessionRepository, SettingsRepository, ) -from keystrike.domain.session import BACKSPACE, Session, active_typing_duration_ns, note_keystroke_for_timer +from keystrike.domain.session import ( + BACKSPACE, + Session, + active_typing_duration_ns, + note_keystroke_for_timer, +) _SPARK = "▁▂▃▄▅▆▇█" diff --git a/src/keystrike/domain/aggregate.py b/src/keystrike/domain/aggregate.py index 48bebd3..90eddbc 100644 --- a/src/keystrike/domain/aggregate.py +++ b/src/keystrike/domain/aggregate.py @@ -112,11 +112,11 @@ def _combine_key_maps_weighted( mean = 0.0 out[cp] = KeyStats( codepoint=cp, - samples=int(round(weighted_samples)), + samples=round(weighted_samples), mean_time_ns=mean, - error_count=int(round(weighted_errors)), + error_count=round(weighted_errors), last_seen=max(stats.last_seen for stats, _ in entries), - attempt_count=int(round(weighted_attempts)), + attempt_count=round(weighted_attempts), ) return out @@ -146,11 +146,11 @@ def _combine_transition_maps_weighted( out[key] = TransitionStats( prev_cp=prev_cp, next_cp=next_cp, - samples=int(round(weighted_samples)), + samples=round(weighted_samples), mean_time_ns=mean, - error_count=int(round(weighted_errors)), + error_count=round(weighted_errors), last_seen=max(stats.last_seen for stats, _ in entries), - attempt_count=int(round(weighted_attempts)), + attempt_count=round(weighted_attempts), ) return out diff --git a/src/keystrike/presentation/widgets/typing_area.py b/src/keystrike/presentation/widgets/typing_area.py index 7f4fb78..cf55f38 100644 --- a/src/keystrike/presentation/widgets/typing_area.py +++ b/src/keystrike/presentation/widgets/typing_area.py @@ -10,7 +10,6 @@ STYLE_CORRECTED, STYLE_CURRENT, STYLE_PENDING, - STYLE_WRONG, STYLE_WRONG_CURRENT, ) diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index d7acdcc..8d38527 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -6,8 +6,8 @@ AbortSession, FinishSession, RecordKeystroke, - StartSession, SessionStatsBaseline, + StartSession, compute_accuracy, compute_wpm, confidence_window_session_baseline, @@ -15,8 +15,8 @@ focus_confidence_sparkline, format_focus_confidence_trend_line, format_key_confidence_trend_line, - format_metric_trend_block, format_key_speed_trend_line, + format_metric_trend_block, format_session_stats_line, format_wpm_trend_line, key_confidence_sparkline, diff --git a/tests/application/test_stats_use_cases.py b/tests/application/test_stats_use_cases.py index 2c9a8c0..4e3cc52 100644 --- a/tests/application/test_stats_use_cases.py +++ b/tests/application/test_stats_use_cases.py @@ -6,12 +6,12 @@ GetLearningRate, RebuildAggregates, ) +from keystrike.domain.aggregate import session_recency_weights from keystrike.domain.confidence import ( CONFIDENCE_SESSION_WINDOW, MIN_CONFIDENCE_ATTEMPTS, SESSION_RECENCY_DECAY, ) -from keystrike.domain.aggregate import session_recency_weights from keystrike.domain.enums import Mode from keystrike.domain.models import KeyStats, Keystroke, LayoutAggregates, SessionResult, Settings from tests.fakes import FakeAggregatesCache, FakeSessionRepository, FakeSettingsRepository diff --git a/tests/domain/test_aggregate.py b/tests/domain/test_aggregate.py index 9dc531e..634ff54 100644 --- a/tests/domain/test_aggregate.py +++ b/tests/domain/test_aggregate.py @@ -11,7 +11,7 @@ session_recency_weights, transition_key, ) -from keystrike.domain.confidence import SESSION_RECENCY_DECAY, confidence_of, target_ms_per_char +from keystrike.domain.confidence import SESSION_RECENCY_DECAY, confidence_of from keystrike.domain.enums import Mode from keystrike.domain.models import KeyStats, Keystroke, SessionResult, TransitionStats diff --git a/tests/presentation/test_hud.py b/tests/presentation/test_hud.py index ed28265..1abd5a5 100644 --- a/tests/presentation/test_hud.py +++ b/tests/presentation/test_hud.py @@ -4,7 +4,12 @@ from keystrike.domain.daily_learn import compute_daily_learn_budget from keystrike.domain.enums import Mode -from keystrike.domain.session import LEARN_IDLE_PAUSE_NS, Session, active_typing_duration_ns, is_typing_idle +from keystrike.domain.session import ( + LEARN_IDLE_PAUSE_NS, + Session, + active_typing_duration_ns, + is_typing_idle, +) from keystrike.presentation.widgets.hud import HUD, _format_hud, learn_timer_dimmed from tests.fakes import FakeClock diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index 22f04bd..5f6e05a 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -306,7 +306,7 @@ async def test_escape_returns_to_home_and_cancels_session(): @pytest.mark.asyncio async def test_learn_timer_pauses_while_idle_on_practice_screen(): - app, clock, _repo, settings_repo = _build_app( + app, clock, _repo, _settings_repo = _build_app( settings=Settings(learn_daily_minutes=10), ) async with app.run_test() as pilot: diff --git a/tests/presentation/test_typing_area.py b/tests/presentation/test_typing_area.py index 0e156d4..8721a30 100644 --- a/tests/presentation/test_typing_area.py +++ b/tests/presentation/test_typing_area.py @@ -1,3 +1,5 @@ +from textual.content import Content + from keystrike.domain.enums import Mode from keystrike.domain.models import Keystroke from keystrike.domain.session import Session @@ -97,8 +99,6 @@ def test_wrap_breaks_at_word_boundaries(): def test_textual_render_path_avoids_midword_breaks(): - from textual.content import Content - session = _session(target_text="st series seasons cnasa kterrorist", position=0) wrapped = wrap_typing_text(render_typing_text(session), 26) content = Content.from_rich_text(wrapped) From 8b9667d1f6a9aed54b93c4c5b511e460896c558b Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 29 Jul 2026 18:10:44 +0300 Subject: [PATCH 08/47] Add ruff pre-commit hook. Run `uv run pre-commit install` once per clone to enable lint on commit. Co-authored-by: Cursor --- .pre-commit-config.yaml | 5 ++ pyproject.toml | 3 +- uv.lock | 130 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..672b9c7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,5 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.0 + hooks: + - id: ruff-check diff --git a/pyproject.toml b/pyproject.toml index 1f13207..0ddaf51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keystrike" -version = "1.2.3" +version = "1.2.1" description = "Offline terminal typing tutor with keybr-style adaptive drills and per-layout stats" readme = "README.md" requires-python = ">=3.12" @@ -54,6 +54,7 @@ Changelog = "https://github.com/egno/keystrike/blob/main/docs/CHANGELOG.md" # uv sync --all-groups → + lint (Ruff; desktop only) [dependency-groups] dev = [ + "pre-commit>=4", "pytest>=8", "pytest-asyncio>=0.24", "pyright>=1.1.380", diff --git a/uv.lock b/uv.lock index 42371ca..c7ee417 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -20,6 +29,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -43,7 +79,7 @@ wheels = [ [[package]] name = "keystrike" -version = "1.2.3" +version = "1.2.1" source = { editable = "." } dependencies = [ { name = "platformdirs" }, @@ -53,6 +89,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pre-commit" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -73,6 +110,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "pre-commit", specifier = ">=4" }, { name = "pyright", specifier = ">=1.1.380" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=0.24" }, @@ -229,6 +267,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/3a/ddb78f32a0814e66b18a099377a106a2dcdce92d86a034d69d65df9b256e/pre_commit-4.6.1.tar.gz", hash = "sha256:03e809865c7d178b9979d06c761fcbfe6808fdaded8581a745bb110e52050421", size = 198646, upload-time = "2026-07-21T20:56:58.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl", hash = "sha256:0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717", size = 226186, upload-time = "2026-07-21T20:56:57.064Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -296,6 +350,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/30/c31d800f8d40d663fc84d83548b26aecf613c9c39bd6985c813d623d7b84/pytest_textual_snapshot-1.1.0-py3-none-any.whl", hash = "sha256:fdf7727d2bc444f947554308da1b08df7a45215fe49d0621cbbc24c33e8f7b8d", size = 11451, upload-time = "2025-01-23T16:11:59.389Z" }, ] +[[package]] +name = "python-discovery" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/51/276f964496a5714ab9f320896195639086881c2b39c03b5ad13de84acbb8/python_discovery-1.5.0.tar.gz", hash = "sha256:3e014c6327154d3dda27939a9a0dc9c5c000439f1906d3f303b48f984bd2ecef", size = 72483, upload-time = "2026-07-21T13:14:14.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/7b/14882602ddee241d7984a742fcb423cb4a30fb0d6efc546ac3129fba475a/python_discovery-1.5.0-py3-none-any.whl", hash = "sha256:70c4fc61b4e7404e44f01d6fc44a715c4d685ca6cea83d295922f05891877c98", size = 34205, upload-time = "2026-07-21T13:14:13.398Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -404,3 +517,18 @@ sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d wheels = [ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, ] + +[[package]] +name = "virtualenv" +version = "21.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/25/e367a7229b0914772ca8d81b41fde012d9feda68523b52644a571bb21ce8/virtualenv-21.7.0.tar.gz", hash = "sha256:7f9519b9432ff11b6e1a3e94061664efc2ff99ea21780e3cf4f6bd0a5da8b37c", size = 5527510, upload-time = "2026-07-21T13:12:14.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/7a/ae29312b1e88a22e81f5d21fc11526d2a114089776c2550d2b205b6c2a47/virtualenv-21.7.0-py3-none-any.whl", hash = "sha256:a8370c1c5530fbabf955e40b8fbbc68a431648b10f9433faa587db30a06e51dd", size = 5507078, upload-time = "2026-07-21T13:12:12.136Z" }, +] From 0ffd09711944ece85caa63d39d4d0415d1bc36c6 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 29 Jul 2026 18:11:29 +0300 Subject: [PATCH 09/47] Add pyright pre-commit hook. Matches CI: uv run pyright with pass_filenames false so pyproject.toml scope applies. Co-authored-by: Cursor --- .pre-commit-config.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 672b9c7..1e61bc7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,3 +3,12 @@ repos: rev: v0.16.0 hooks: - id: ruff-check + + - repo: local + hooks: + - id: pyright + name: pyright + entry: uv run pyright + language: system + types: [python] + pass_filenames: false From 95912b9305befa27a6ee22394f9da4bea3b21b86 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 29 Jul 2026 18:13:14 +0300 Subject: [PATCH 10/47] Fix pyright errors so pre-commit pyright hook passes. Use explicit grid/spark_width kwargs in format_metric_trend_block and TypedDict session fixtures in tests instead of untyped dict unpacking. Co-authored-by: Cursor --- .../application/session_use_cases.py | 18 ++++-- tests/application/test_session_use_cases.py | 55 ++++++++++--------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index c373a3a..26e2814 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -580,7 +580,8 @@ def format_metric_trend_block( current_target_speed_cpm: int = 0, cumulative: float | None = None, ) -> str: - grid_kwargs = {"grid": True, "spark_width": limit} + grid = True + spark_width = limit if headers is not None and codepoint is not None: ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] session_count = len(ordered) if ordered else max( @@ -593,7 +594,8 @@ def format_metric_trend_block( current_target_speed_cpm=current_target_speed_cpm, cumulative=cumulative, include_key_name=False, - **grid_kwargs, + grid=grid, + spark_width=spark_width, ) else: session_count = max( @@ -603,7 +605,9 @@ def format_metric_trend_block( 0, ) conf_line = format_confidence_trend_line( - confidence_values or [], **grid_kwargs, + confidence_values or [], + grid=grid, + spark_width=spark_width, ) header = f"[bold]{title}[/]" if session_count: @@ -613,8 +617,12 @@ def format_metric_trend_block( lines = [ header, conf_line, - format_key_speed_trend_line(speed_values, **grid_kwargs), - format_key_accuracy_trend_line(accuracy_values, **grid_kwargs), + format_key_speed_trend_line( + speed_values, grid=grid, spark_width=spark_width, + ), + format_key_accuracy_trend_line( + accuracy_values, grid=grid, spark_width=spark_width, + ), ] return "\n".join(line for line in lines if line) diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index 8d38527..cf9cd03 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import replace +from typing import TypedDict from keystrike.application.session_use_cases import ( AbortSession, @@ -45,6 +46,30 @@ ) +class _SessionStatsCommon(TypedDict): + schema_version: int + started_at: float + duration_ns: int + layout: str + mode: Mode + lesson_alphabet: tuple[()] + focus_key: None + words_completed: int + + +def _session_stats_common(*, duration_ns: int = 60_000_000_000) -> _SessionStatsCommon: + return _SessionStatsCommon( + schema_version=3, + started_at=1.0, + duration_ns=duration_ns, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=None, + words_completed=1, + ) + + def _drive(text: str, keys: str, clock: FakeClock, id_gen: FakeIdGenerator, repo: FakeSessionRepository | None = None): start = StartSession(clock=clock, id_gen=id_gen) @@ -241,33 +266,23 @@ def test_format_session_stats_line_shows_deltas_vs_confidence_baseline(clock, id def test_format_session_stats_line_uses_window_not_previous_only(): - common = dict( - schema_version=3, - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=None, - words_completed=1, - ) slow = SessionResult( session_id="s1", total_keystrokes=10, correct_keystrokes=10, - **common, + **_session_stats_common(), ) fast = SessionResult( session_id="s2", total_keystrokes=10, correct_keystrokes=10, - **{**common, "duration_ns": 30_000_000_000}, + **_session_stats_common(duration_ns=30_000_000_000), ) medium = SessionResult( session_id="s3", total_keystrokes=10, correct_keystrokes=10, - **{**common, "duration_ns": 33_333_333_333}, + **_session_stats_common(duration_ns=33_333_333_333), ) repo = FakeSessionRepository(headers=[slow, fast, medium]) @@ -288,27 +303,17 @@ def test_format_session_stats_line_uses_window_not_previous_only(): def test_format_session_stats_line_shows_accuracy_regression(): - common = dict( - schema_version=3, - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=None, - words_completed=1, - ) previous = SessionResult( session_id="s1", total_keystrokes=10, correct_keystrokes=10, - **common, + **_session_stats_common(), ) current = SessionResult( session_id="s2", total_keystrokes=10, correct_keystrokes=8, - **common, + **_session_stats_common(), ) line = format_session_stats_line( current, From 1cb3d9c4ffd4de1181001ecc2e90d580d060d661 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 29 Jul 2026 18:14:51 +0300 Subject: [PATCH 11/47] Update README for 1.2.3 stats layout and pre-commit hooks. Co-authored-by: Cursor --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d830acc..7d2babd 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ for sources and how each maps to the code. - **Adaptive engine** — row-weighted key unlock order, speed+accuracy confidence gates, Markov word drills with a guaranteed focus letter. -- **Per-layout stats** — heatmap with per-key confidence and urgency; WPM/accuracy - trends; press a key on the heatmap for that key's history. +- **Per-layout stats** — heatmap with per-key confidence and urgency; layout-wide + and focus-key trend grids (confidence, speed, accuracy); press a heatmap key for + per-letter drill-down. - **Daily learn budget** — optional cap on adaptive minutes per calendar day. - **Custom layouts** — drop `*.toml` files into your config layouts directory; no restart needed. @@ -94,8 +95,9 @@ back. Hitting your daily goal is shown in the HUD only — practice continues. ### Stats -Recent sessions are listed below the heatmap. **Press any key on the heatmap** to -drill into that key's confidence trend; `Esc` / `q` returns to the overview. +Layout and focus trend grids (confidence, speed, accuracy) sit above the heatmap. +**Press any key on the heatmap** for letter stats; `Esc` / `q` returns to the +overview. ### Settings @@ -156,7 +158,8 @@ not cmd.exe. Raw mode is not verified on every Windows build; please ## Development ```bash -uv sync # runtime + dev (pytest, pyright) +uv sync # runtime + dev (pytest, pyright, pre-commit) +uv run pre-commit install # optional: ruff + pyright hooks on commit uv run pytest -q uv run pyright uv sync --all-groups # + ruff + snapshot tests (desktop only) From 989e4382df5b64781f8c114759b40ecaee2503d4 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 29 Jul 2026 18:15:36 +0300 Subject: [PATCH 12/47] Refresh practice/stats UI snapshots and document CI checks. Baselines were stale after 1.2.3 presentation changes; README now lists what pre-commit covers vs the macOS snapshot job. Co-authored-by: Cursor --- README.md | 31 ++++++++++++++-- .../test_practice_screen_snapshot.svg | 35 ++++++++++--------- .../test_stats_screen_snapshot.svg | 10 +++--- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 7d2babd..4d789f9 100644 --- a/README.md +++ b/README.md @@ -170,11 +170,38 @@ Dependency groups: `dev` (default, pure Python — safe on Termux), `lint` (Ruff `snapshot` (`pytest-textual-snapshot`). Keep native-wheel tools out of `dev`; see `pyproject.toml` for why. -Regenerate the demo GIF after UI snapshot changes: +### Before you push (CI parity) + +Pre-commit runs **ruff** and **pyright** only. GitHub Actions also runs the full +test matrix and a separate **snapshot (macos)** job that default pytest skips. + +| Check | Command | When | +| --- | --- | --- | +| Lint (also in pre-commit) | `uv run ruff check && uv run pyright` | every commit | +| Unit tests | `uv run pytest -q` | every push | +| UI snapshots | see below | after presentation/UI changes | + +On **macOS**, match the snapshot CI job before pushing UI work: + +```bash +uv sync --group snapshot +NO_COLOR=1 TERM=xterm-256color LC_ALL=C.UTF-8 uv run pytest -m snapshot -q +``` + +Snapshots are not in pre-commit (macOS-only, extra deps). Linux/Windows devs can +rely on CI for snapshot coverage. + +Regenerate baselines after intentional UI changes: ```bash uv sync --group snapshot -uv run pytest tests/presentation/test_snapshots.py --snapshot-update +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: + +```bash uv pip install pillow cairosvg # one-off; system cairo on macOS/Linux uv run python scripts/generate_demo_gif.py ``` diff --git a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg index 3effceb..7e00743 100644 --- a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg +++ b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg @@ -36,15 +36,16 @@ .terminal-r2 { fill: #b0b0b0 } .terminal-r3 { fill: #b0b0b0;font-weight: bold } .terminal-r4 { fill: #797979 } -.terminal-r5 { fill: #121212 } -.terminal-r6 { fill: #808080 } -.terminal-r7 { fill: #5f5f5f } -.terminal-r8 { fill: #3b3b3b;font-weight: bold } -.terminal-r9 { fill: #e0e0e0 } -.terminal-r10 { fill: #3b3b3b;font-weight: bold;text-decoration: underline; } -.terminal-r11 { fill: #6f6f6f } -.terminal-r12 { fill: #6f6f6f;font-weight: bold } -.terminal-r13 { fill: #a0a0a0 } +.terminal-r5 { fill: #797979;font-weight: bold } +.terminal-r6 { fill: #e0e0e0;font-weight: bold;text-decoration: underline; } +.terminal-r7 { fill: #c4c4c4 } +.terminal-r8 { fill: #5f5f5f } +.terminal-r9 { fill: #3b3b3b;font-weight: bold } +.terminal-r10 { fill: #e0e0e0 } +.terminal-r11 { fill: #3b3b3b;font-weight: bold;text-decoration: underline; } +.terminal-r12 { fill: #6f6f6f } +.terminal-r13 { fill: #6f6f6f;font-weight: bold } +.terminal-r14 { fill: #a0a0a0 } @@ -130,20 +131,20 @@ - + -Acc: 100.0%   Learn: 0.0/10 min   Focus: aweak +Acc: 100.0%   Learn: 0.0/10 min   Focus: aweak -gsrfhgod·kfogh·skhad·ufeg·oaoft·raufjd·dda·wkgru·fkksjf·llaa·aeku·ldie +gsrfhgod·kfogh·skhad·ufeg·oaoft·raufjd·dda·wkgru·fkksjf·llaa·aeku·ldie - q  w  e  r  t  y  u  i  o  p  - a  s  d  f  g  h  j  k  l  ;  - z  x  c  v  b  n  m  ,  .  /  + q  w  e  r  t  y  u  i  o  p  + a  s  d  f  g  h  j  k  l  ;  + z  x  c  v  b  n  m  ,  .  /  -Focus a (weak): confidence 0.00 / 1.00. +Focus a (weak): speed 0.00, accuracy 0.0%, confidence 0.00 / 1.00. @@ -155,7 +156,7 @@ - esc Back  ^q Quit  + esc Back  ^q Quit  diff --git a/tests/presentation/__snapshots__/test_snapshots/test_stats_screen_snapshot.svg b/tests/presentation/__snapshots__/test_snapshots/test_stats_screen_snapshot.svg index b38a3f6..7870407 100644 --- a/tests/presentation/__snapshots__/test_snapshots/test_stats_screen_snapshot.svg +++ b/tests/presentation/__snapshots__/test_snapshots/test_stats_screen_snapshot.svg @@ -123,20 +123,20 @@ - + Stats — qwerty -No sessions yet for WPM trend. -No sessions yet for focus confidence. -vs current goal +No sessions yet for this layout. +vs current goal +Press a key on the heatmap for letter stats · Esc to return  q  w  e  r  t  y  u  i  o  p   a  s  d  f  g  h  j  k  l  ;   z  x  c  v  b  n  m  ,  .  /  -No sessions yet for this layout. + From 6de029be11c667e4028106b2ed88da6a6eaff83c Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 30 Jul 2026 10:31:36 +0300 Subject: [PATCH 13/47] ignore lead spaces --- .../application/session_use_cases.py | 9 ++++- src/keystrike/domain/session.py | 31 +++++++++++++++ .../presentation/screens/practice.py | 6 +++ tests/application/test_session_use_cases.py | 39 +++++++++++++++++++ tests/domain/test_session.py | 25 ++++++++++++ tests/presentation/test_practice_screen.py | 20 ++++++++++ 6 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 tests/domain/test_session.py diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 26e2814..a6b2f02 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -24,7 +24,9 @@ BACKSPACE, Session, active_typing_duration_ns, + normalize_newline, note_keystroke_for_timer, + skip_leading_whitespace, ) _SPARK = "▁▂▃▄▅▆▇█" @@ -82,6 +84,9 @@ def __call__(self, session: Session, char: str) -> bool: if len(char) != 1: return session.finished + if skip_leading_whitespace(session, char): + return session.finished + # The timer doesn't start until the first real keystroke — no penalty # for time spent reading the prompt before typing. now_ns = self.clock.now_ns() @@ -90,7 +95,9 @@ def __call__(self, session: Session, char: str) -> bool: note_keystroke_for_timer(session, now_ns) t_ns = active_typing_duration_ns(session, now_ns) - target_cp = ord(session.target_text[session.position]) + target_char = session.target_text[session.position] + char = normalize_newline(char, target_char) + target_cp = ord(target_char) typed_cp = ord(char) correct = typed_cp == target_cp diff --git a/src/keystrike/domain/session.py b/src/keystrike/domain/session.py index 3cc88fd..5d855ce 100644 --- a/src/keystrike/domain/session.py +++ b/src/keystrike/domain/session.py @@ -13,10 +13,41 @@ from .models import Keystroke BACKSPACE = "\x7f" # normalized backspace codepoint marker used at edges +LEADING_SKIP_KEYS = frozenset(" \t\n\r") # accidental space/enter/tab before typing starts LEARN_IDLE_PAUSE_NS = 5 * 1_000_000_000 # pause learn timer after this idle gap +def normalize_newline(char: str, target_char: str) -> str: + """Treat Enter variants as the target newline when comparing or recording.""" + if char in "\r\n" and target_char in "\r\n": + return target_char + return char + + +def leading_key_char(key: str, character: str | None = None) -> str | None: + """Map Textual key names to the char checked by ``skip_leading_whitespace``.""" + match key: + case "space": + return " " + case "tab": + return "\t" + case "enter": + return character if character in ("\r", "\n") else "\n" + case _: + return None + + +def skip_leading_whitespace(session: Session, char: str) -> bool: + """Drop space/enter/tab before the first real keystroke unless they are the target.""" + if session.typing_started_at_ns is not None or not session.target_text: + return False + if char not in LEADING_SKIP_KEYS: + return False + target = session.target_text[0] + return normalize_newline(char, target) != target + + def note_keystroke_for_timer(session: Session, now_ns: int) -> None: """Fold inter-keystroke time into active duration (capped at idle threshold).""" if session.last_keystroke_at_ns is not None: diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index 8ad58cb..fa512d6 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -19,6 +19,7 @@ from keystrike.domain.models import Layout, SessionResult from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET, NULL_STATS_REBUILDER from keystrike.domain.protocols import Clock, DailyLearnBudgetProvider, StatsRebuilder +from keystrike.domain.session import leading_key_char, skip_leading_whitespace from keystrike.presentation.bindings import BACK_BINDINGS from keystrike.presentation.widgets.hud import HUD from keystrike.presentation.widgets.kb_heatmap import KbHeatmap, format_focus_note @@ -124,6 +125,11 @@ def on_key(self, event: events.Key) -> None: key = event.key char: str | None = event.character + leading = leading_key_char(key, char) + if leading is not None and skip_leading_whitespace(self._session, leading): + event.stop() + return + if key == "backspace": self._record.backspace(self._session) elif key == "space": diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index cf9cd03..d548773 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -195,6 +195,45 @@ def test_timer_does_not_start_until_first_keystroke(clock, id_gen): assert result.duration_ns == 100_000_000 +def test_leading_space_enter_tab_ignored_before_first_keystroke(clock, id_gen): + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + session = start("abc", layout="qwerty", mode=Mode.ADAPTIVE) + + for ch in (" ", "\t", "\n", "\r"): + record(session, ch) + + assert session.typing_started_at_ns is None + assert session.keystrokes == [] + assert session.position == 0 + + record(session, "a") + assert session.typing_started_at_ns is not None + assert session.position == 1 + + +def test_leading_space_honored_when_target_starts_with_space(clock, id_gen): + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + session = start(" ab", layout="qwerty", mode=Mode.ADAPTIVE) + + record(session, " ") + assert session.position == 1 + assert len(session.keystrokes) == 1 + assert session.keystrokes[0].correct + + +def test_leading_enter_honored_when_target_starts_with_newline(clock, id_gen): + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + session = start("\nabc", layout="qwerty", mode=Mode.ADAPTIVE) + + record(session, "\r") + assert session.position == 1 + assert len(session.keystrokes) == 1 + assert session.keystrokes[0].correct + + def test_learn_timer_pauses_after_idle(clock, id_gen): start = StartSession(clock=clock, id_gen=id_gen) record = RecordKeystroke(clock=clock) diff --git a/tests/domain/test_session.py b/tests/domain/test_session.py new file mode 100644 index 0000000..5ac39cc --- /dev/null +++ b/tests/domain/test_session.py @@ -0,0 +1,25 @@ +from keystrike.domain.enums import Mode +from keystrike.domain.session import Session, skip_leading_whitespace + + +def _session(target_text: str) -> Session: + return Session( + id="s1", + target_text=target_text, + layout="qwerty", + mode=Mode.ADAPTIVE, + lang="en", + started_at_wall=0.0, + started_at_ns=0, + ) + + +def test_skip_leading_whitespace_empty_target(): + assert skip_leading_whitespace(_session(""), " ") is False + + +def test_skip_leading_whitespace_newline_enter_variants(): + session = _session("\nabc") + assert skip_leading_whitespace(session, "\r") is False + assert skip_leading_whitespace(session, "\n") is False + assert skip_leading_whitespace(session, " ") is True diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index 5f6e05a..bcdf6c0 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -137,6 +137,26 @@ async def test_app_launches_types_and_persists_session(): assert app.screen._session.position >= 1 +@pytest.mark.asyncio +async def test_leading_space_tab_ignored_before_first_keystroke(): + app, _clock, _repo, _ = _build_app() + async with app.run_test() as pilot: + await pilot.press("enter") + await pilot.pause() + practice = app.screen + assert isinstance(practice, PracticeScreen) + + await pilot.press("tab") + await pilot.pause() + if practice._session.target_text[0] != " ": + await pilot.press("space") + await pilot.pause() + + assert practice._session.typing_started_at_ns is None + assert practice._session.keystrokes == [] + assert practice._session.position == 0 + + @pytest.mark.asyncio async def test_stats_screen_reachable_from_home(): app, _clock, _repo, _settings = _build_app() From c00a18573a5c3c07ed778fe7527d763046a20c59 Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 30 Jul 2026 11:16:43 +0300 Subject: [PATCH 14/47] Document custom layouts and fix platform paths; refresh Settings layout dropdown when TOML layouts change without restart. Bump to 1.2.3 and expand layout TOML/repo tests. Co-authored-by: Cursor --- README.md | 31 +- docs/wiki/Custom-Layouts.md | 298 ++++++++++++++++++ docs/wiki/Git-sync.md | 2 +- docs/wiki/Home.md | 1 + docs/wiki/Word-Lists.md | 2 +- pyproject.toml | 2 +- .../presentation/screens/settings.py | 14 +- tests/application/test_prepare_practice.py | 51 ++- tests/application/test_settings_use_cases.py | 27 +- tests/infrastructure/test_layout_repo.py | 24 ++ tests/infrastructure/test_layout_toml.py | 83 +++++ tests/presentation/test_settings_screen.py | 56 ++++ tests/presentation/test_stats_screen.py | 22 +- uv.lock | 2 +- 14 files changed, 595 insertions(+), 20 deletions(-) create mode 100644 docs/wiki/Custom-Layouts.md diff --git a/README.md b/README.md index 4d789f9..131896a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Design follows [research-backed typing pedagogy](https://github.com/egno/keystri **Install:** `pipx install keystrike` · [PyPI](https://pypi.org/project/keystrike/) · [Discussions](https://github.com/egno/keystrike/discussions) -![Keystrike demo](https://raw.githubusercontent.com/egno/keystrike/v1.1.0/docs/assets/demo.gif) +![Keystrike demo](https://raw.githubusercontent.com/egno/keystrike/main/docs/assets/demo.gif) ## What it does @@ -44,9 +44,11 @@ for sources and how each maps to the code. - **Per-layout stats** — heatmap with per-key confidence and urgency; layout-wide and focus-key trend grids (confidence, speed, accuracy); press a heatmap key for per-letter drill-down. -- **Daily learn budget** — optional cap on adaptive minutes per calendar day. -- **Custom layouts** — drop `*.toml` files into your config layouts directory; no - restart needed. +- **Daily learn goal** — optional minutes goal tracked in the HUD; practice is not + blocked when reached. +- **Custom layouts** — drop `*.toml` files into your layouts directory; new layouts + appear when cycling layouts or opening Settings (no app restart required). See the + [Custom layouts wiki](https://github.com/egno/keystrike/wiki/Custom-Layouts). - **Git backup sync** — optional CLI to push/pull settings and sessions to a private remote (union-merge sessions, last-write-wins settings). - **Offline by default** — JSONL session logs and a local stats cache under @@ -60,6 +62,7 @@ Requires **Python 3.12+** and a terminal with **raw-mode keyboard input** ```bash pipx install keystrike # or: uv tool install keystrike keystrike # launch TUI (default) +keystrike run # same as bare keystrike keystrike --version ``` @@ -132,15 +135,19 @@ pull triggers a rebuild. ## Data locations -| Path | Contents | -| --- | --- | -| `~/.config/keystrike/settings.toml` | User settings | -| `~/.config/keystrike/layouts/` | Custom layout TOML files | -| `~/.config/keystrike/sync.toml` | Sync remote config (after `sync init`) | -| `~/.local/share/keystrike/sessions/` | Session JSONL logs (Linux) | -| `~/Library/Application Support/keystrike/` | Same on macOS | +Paths come from [platformdirs](https://github.com/tox-dev/platformdirs). On Linux, +config and data are split; on macOS and Windows, everything lives under one app +directory. + +| Contents | Linux | macOS | +| --- | --- | --- | +| User settings | `~/.config/keystrike/settings.toml` | `~/Library/Application Support/keystrike/settings.toml` | +| Custom layouts | `~/.config/keystrike/layouts/` | `~/Library/Application Support/keystrike/layouts/` | +| Sync config (after `sync init`) | `~/.config/keystrike/sync.toml` | `~/Library/Application Support/keystrike/sync.toml` | +| Session JSONL logs | `~/.local/share/keystrike/sessions/` | `~/Library/Application Support/keystrike/sessions/` | +| Stats cache | `~/.local/share/keystrike/cache/` | `~/Library/Application Support/keystrike/cache/` | -Windows uses `%APPDATA%` / `%LOCALAPPDATA%` equivalents via [platformdirs](https://github.com/tox-dev/platformdirs). +Windows uses `%LOCALAPPDATA%\keystrike\` for the same layout (single directory). ## Terminal setup diff --git a/docs/wiki/Custom-Layouts.md b/docs/wiki/Custom-Layouts.md new file mode 100644 index 0000000..84f9036 --- /dev/null +++ b/docs/wiki/Custom-Layouts.md @@ -0,0 +1,298 @@ +# Custom layouts + +Keystrike ships four bundled keyboard layouts (QWERTY, Dvorak, Colemak, Colemak +Mod-DH). Add your own by dropping a TOML file into the layouts directory — split +ortho boards, Workman, locale-specific variants, or anything else you want to +practice on its own terms. + +Each layout keeps **separate stats, unlock progress, and session history**. Switch +layouts from the home screen or Settings; practice on one layout does not affect +another. + +## Why use a custom layout + +- **Non-bundled physical layouts** — Colemak Mod-DH matrix, Workman, Norman, etc. +- **Ortholinear / split boards** — set `ortholinear = true` so the Stats heatmap + renders without ANSI row stagger. +- **Reduced or alternate key sets** — e.g. a minimal layout while learning a new + alphabet. +- **Side-by-side comparison** — cycle between QWERTY and your custom layout with + independent confidence scores. + +Bundled layouts are compiled into the app. Custom layouts are plain TOML on disk and +can be synced with [Git sync](Git-sync). + +## Quick start + +1. Create the layouts directory if it does not exist (Keystrike creates it on first + run, but you can make it yourself): + + ```bash + mkdir -p ~/.config/keystrike/layouts # Linux — see table below for your OS + ``` + +2. Add a file named `.toml` (e.g. `workman.toml`). + +3. While Keystrike is running, press **`l`** on the home screen to cycle layouts, + or open **Settings** (`o`) and pick the new name from the Layout dropdown. + +No app restart is required. New files are picked up the next time you cycle +layouts or open/resume the Settings screen. + +## File location + +Custom layouts live under `{config_dir}/layouts/` as `*.toml` files. Paths come +from [platformdirs](https://github.com/tox-dev/platformdirs) with +`appauthor=False` (same as other Keystrike config): + +| OS | Layouts directory | +| --- | --- | +| Linux | `~/.config/keystrike/layouts/` | +| macOS | `~/Library/Application Support/keystrike/layouts/` | +| Windows | `%LOCALAPPDATA%\keystrike\layouts\` | + +On macOS and Windows, config and data share one app directory; on Linux, session +logs and stats cache live under `~/.local/share/keystrike/` instead. + +The **filename stem** (without `.toml`) is the layout ID used in Settings, +`settings.toml`, and stats — e.g. `workman.toml` → layout id `workman`. + +## TOML schema + +Top-level fields: + +| Field | Required | Type | Notes | +| --- | --- | --- | --- | +| `name` | yes | string | Display/internal name stored in the `Layout` model. Should match the filename stem. | +| `learn_order` | yes | string | Unlock order: every character must appear in `[[keys]]`. | +| `ortholinear` | no | boolean | Default `false`. When `true`, Stats renders the heatmap without row stagger (for matrix/ortho boards). | +| `keys` | yes | array of tables | At least one `[[keys]]` entry. | + +Each `[[keys]]` table: + +| Field | Required | Type | Notes | +| --- | --- | --- | --- | +| `char` | yes | string | Exactly one character (the key you type). | +| `row` | yes | integer | Row index for heatmap placement (0 = top). | +| `col` | yes | integer | Column index within the row. | +| `finger` | yes | string | One of `PINKY`, `RING`, `MIDDLE`, `INDEX`, `THUMB`. | +| `hand` | yes | string | `L` or `R`. | +| `shifted` | no | boolean | Default `false`. Stored on the key model; bundled layouts leave this unset. | + +`learn_order` controls which keys unlock first after the initial +[alphabet_size](Confidence-Tuning) force-unlock. Bundled layouts use English +letter frequency (`etaoinshrdlcumwfgypbvkjxqz`, then punctuation, then space). +Custom layouts can follow the same idea or any order that matches your curriculum. + +### Minimal example + +Two keys — enough to validate the file loads: + +```toml +name = "mini" +learn_order = "ab" + +[[keys]] +char = "a" +row = 1 +col = 0 +finger = "PINKY" +hand = "L" + +[[keys]] +char = "b" +row = 1 +col = 1 +finger = "RING" +hand = "L" +``` + +Save as `{config_dir}/layouts/mini.toml`, then cycle layouts or open Settings. + +### Fuller annotated example + +Partial home-row slice showing typical fields (not a complete typing layout): + +```toml +# Layout ID in settings: "sample" (from filename sample.toml) +name = "sample" +ortholinear = false + +# Unlock order — only characters defined in [[keys]] below +learn_order = "asdf jkl;" + +[[keys]] +char = "a" +row = 1 # home row +col = 0 # left pinky column +finger = "PINKY" +hand = "L" + +[[keys]] +char = "s" +row = 1 +col = 1 +finger = "RING" +hand = "L" + +[[keys]] +char = "d" +row = 1 +col = 2 +finger = "MIDDLE" +hand = "L" + +[[keys]] +char = "f" +row = 1 +col = 3 +finger = "INDEX" +hand = "L" + +[[keys]] +char = "j" +row = 1 +col = 6 +finger = "INDEX" +hand = "R" + +[[keys]] +char = "k" +row = 1 +col = 7 +finger = "INDEX" +hand = "R" + +[[keys]] +char = "l" +row = 1 +col = 8 +finger = "RING" +hand = "R" + +[[keys]] +char = ";" +row = 1 +col = 9 +finger = "PINKY" +hand = "R" +shifted = true # optional; default false +``` + +For a full ANSI-style Latin layout, mirror the row/column grid and finger +assignments from the bundled builders (physical column → finger/hand, same as +QWERTY/Dvorak/Colemak). + +## Using a layout in the app + +| Where | How | +| --- | --- | +| **Home** | Press **`l`** to cycle to the next available layout (alphabetical order, wraps). The hero line shows `Layout: … (l switch)`. | +| **Settings** | Press **`o`** from home, choose **Layout** from the dropdown, **`Ctrl+S`** to save. | +| **Stats** | Press **`s`** — stats, heatmap, and trends are for the **currently selected** layout only. | +| **Practice** | **`Enter`** on home starts adaptive drills for the current layout. | + +**Cycle layout** is a no-op when fewer than two layouts exist (bundled-only install +with no custom files). + +Layout choice is persisted in `{config_dir}/settings.toml` as `layout = "…"`. + +### When new layouts appear + +Keystrike rescans `{config_dir}/layouts/*.toml` whenever: + +- You press **`l`** on the home screen (cycle uses a fresh `list_available()`). +- You open **Settings** (`on_mount` refreshes the Layout dropdown). +- You return to **Settings** from another screen (`on_screen_resume` refreshes again). + +You do **not** need to restart the app. Editing or removing a file while the app +is open is reflected on the next cycle or Settings visit; the dropdown keeps your +previous selection if that layout still exists. + +## Bundled layout reference + +Study these for row/column grids, learn order, and ortholinear usage: + +| Layout | Source file | +| --- | --- | +| QWERTY | [`src/keystrike/infrastructure/bundled_layouts/qwerty.py`](https://github.com/egno/keystrike/blob/main/src/keystrike/infrastructure/bundled_layouts/qwerty.py) | +| Dvorak | [`dvorak.py`](https://github.com/egno/keystrike/blob/main/src/keystrike/infrastructure/bundled_layouts/dvorak.py) | +| Colemak | [`colemak.py`](https://github.com/egno/keystrike/blob/main/src/keystrike/infrastructure/bundled_layouts/colemak.py) | +| Colemak Mod-DH (ortholinear) | [`colemak_dh.py`](https://github.com/egno/keystrike/blob/main/src/keystrike/infrastructure/bundled_layouts/colemak_dh.py) | + +Shared grid logic (finger/hand by physical column, default learn order) lives in +[`_grid.py`](https://github.com/egno/keystrike/blob/main/src/keystrike/infrastructure/bundled_layouts/_grid.py). + +The TOML parser and its docstring example are in +[`layout_toml.py`](https://github.com/egno/keystrike/blob/main/src/keystrike/infrastructure/layout_toml.py). + +Test fixtures with valid minimal files: +[`tests/infrastructure/fixtures/layouts/good.toml`](https://github.com/egno/keystrike/blob/main/tests/infrastructure/fixtures/layouts/good.toml), +[`good_ortholinear.toml`](https://github.com/egno/keystrike/blob/main/tests/infrastructure/fixtures/layouts/good_ortholinear.toml). + +## Name conflicts with bundled layouts + +Bundled layout names: `qwerty`, `dvorak`, `colemak`, `colemak_dh`. + +If you add `{config_dir}/layouts/qwerty.toml`, **the bundled QWERTY layout still +wins** — `CompositeLayoutRepository` checks bundled layouts before loading TOML. +Use a distinct filename (e.g. `my-qwerty.toml`) for a custom variant. + +Discovery lists the filename stem even when bundled takes priority on `get()`, so +avoid reusing bundled names for custom files. + +## Git sync + +Custom layout TOML files sync with [Git sync](Git-sync): + +- **Pull** — remote layouts missing locally are copied in; existing local files + with the same name are **not** overwritten. +- **Push** — all local `*.toml` files are copied to the clone. + +Bundled layouts are not synced (they ship with the app). + +## Troubleshooting + +### Layout missing from dropdown + +- Confirm the file is `{config_dir}/layouts/.toml` (not nested, not `.txt`). +- Press **`l`** or re-open **Settings** to rescan — no restart needed. +- Check the filename stem; that string is what appears in the list. + +### App errors when selecting or practicing on a custom layout + +Invalid TOML is **not** validated at discovery time — only when the layout is +loaded (`get()`). Fix the file and try again. Errors look like +`: keys[0].finger must be one of ['PINKY', 'RING', …]`. + +Common causes: + +| Symptom / error | Fix | +| --- | --- | +| `TOMLDecodeError` / parse error | Fix TOML syntax (commas, quoting, `[[keys]]` headers). | +| `missing required string field 'name'` | Add non-empty `name = "…"`. | +| `missing required non-empty array of tables 'keys'` | Add at least one `[[keys]]` block. | +| `keys[i].char must be a single character` | Use exactly one character per `char`. | +| `requires integer 'row' and 'col'` | Both must be integers. | +| `finger must be one of …` | Use `PINKY`, `RING`, `MIDDLE`, `INDEX`, or `THUMB`. | +| `hand must be one of …` | Use `L` or `R`. | +| `learn_order references undefined keys` | Every character in `learn_order` needs a matching `[[keys]]` entry. | +| `'ortholinear' must be a boolean` | Use `true` / `false`, not a string. | + +### `unknown layout` after sync or manual edit + +If `settings.toml` references a layout that no longer exists, open Settings (if +possible), pick a valid layout, and save — or edit `layout = "qwerty"` directly. + +### Filename vs `name` field mismatch + +Settings and stats use the **filename stem** as the layout id. The `name` field +inside the TOML is stored on the `Layout` object but should match the filename to +avoid confusion. + +## See also + +- [Home](Home) +- [Git sync](Git-sync) — backing up custom layouts and sessions +- [Confidence tuning](Confidence-Tuning) — per-layout unlock thresholds in `settings.toml` +- [Keystrike README — Data locations](https://github.com/egno/keystrike#data-locations) diff --git a/docs/wiki/Git-sync.md b/docs/wiki/Git-sync.md index 5335832..ea8786f 100644 --- a/docs/wiki/Git-sync.md +++ b/docs/wiki/Git-sync.md @@ -145,7 +145,7 @@ Typical `{config_dir}` / `{data_dir}`: | --- | --- | --- | | Linux | `~/.config/keystrike/` | `~/.local/share/keystrike/` | | macOS | `~/Library/Application Support/keystrike/` | same tree under Application Support | -| Windows | `%APPDATA%\keystrike\` | `%LOCALAPPDATA%\keystrike\` | +| Windows | `%LOCALAPPDATA%\keystrike\` | same tree under Local AppData | `sync.toml` contains only the remote URL, for example: diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 5fb3587..066f84b 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -5,6 +5,7 @@ Documentation for [Keystrike](https://github.com/egno/keystrike) — an offline ## Guides - **[Git sync](Git-sync)** — back up and sync settings and sessions via a private git repository (CLI-only, opt-in). +- **[Custom layouts](Custom-Layouts)** — add keyboard layouts via TOML files in your layouts directory. - **[Word lists](Word-Lists)** — import a remote dictionary for real-word adaptive drills (Settings, optional). - **[Confidence tuning](Confidence-Tuning)** — session window and attempt floors in `settings.toml` (not the Settings screen). diff --git a/docs/wiki/Word-Lists.md b/docs/wiki/Word-Lists.md index 536d41b..adf26d2 100644 --- a/docs/wiki/Word-Lists.md +++ b/docs/wiki/Word-Lists.md @@ -72,7 +72,7 @@ Typical paths (see [platformdirs](https://github.com/tox-dev/platformdirs)): | --- | --- | --- | | Linux | `~/.config/keystrike/` | `~/.local/share/keystrike/cache/` | | macOS | `~/Library/Application Support/keystrike/` | same tree | -| Windows | `%APPDATA%\keystrike\` | `%LOCALAPPDATA%\keystrike\cache\` | +| Windows | `%LOCALAPPDATA%\keystrike\` | `%LOCALAPPDATA%\keystrike\cache\` | ## Git sync caveat diff --git a/pyproject.toml b/pyproject.toml index 0ddaf51..562b540 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keystrike" -version = "1.2.1" +version = "1.2.3" description = "Offline terminal typing tutor with keybr-style adaptive drills and per-layout stats" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keystrike/presentation/screens/settings.py b/src/keystrike/presentation/screens/settings.py index 9995330..7f03282 100644 --- a/src/keystrike/presentation/screens/settings.py +++ b/src/keystrike/presentation/screens/settings.py @@ -60,7 +60,7 @@ def __init__( def compose(self) -> ComposeResult: settings = self._settings_repo.load() - layouts = [(name, name) for name in self._layout_repo.list_available()] + layouts = self._layout_select_options() with Vertical(): yield Static("[bold]Settings[/] [dim](Ctrl+S save, Esc/q back)[/]") yield Label("Layout") @@ -112,11 +112,13 @@ def compose(self) -> ComposeResult: yield Footer() def on_mount(self) -> None: + self._refresh_layout_select() self._refresh_wordlist_status() def on_screen_resume(self) -> None: settings = self._settings_repo.load() self.query_one("#settings-alphabet-size", Input).value = str(settings.alphabet_size) + self._refresh_layout_select() def action_import_wordlist(self) -> None: self._do_import() @@ -220,5 +222,15 @@ def _refresh_wordlist_status(self, message: str | None = None) -> None: text = message or self._wordlist_status(persisted) self.query_one("#settings-wordlist-status", Static).update(text) + def _layout_select_options(self) -> list[tuple[str, str]]: + return [(name, name) for name in self._layout_repo.list_available()] + + def _refresh_layout_select(self) -> None: + layout_select = cast("Select[str]", self.query_one("#settings-layout", Select)) + current = layout_select.value + layout_select.set_options(self._layout_select_options()) + if not isinstance(current, NoSelection): + layout_select.value = current + def _show_error(self, message: str) -> None: self.query_one("#settings-error", Static).update(f"[bold red]{message}[/]") diff --git a/tests/application/test_prepare_practice.py b/tests/application/test_prepare_practice.py index 6457211..24b2f9c 100644 --- a/tests/application/test_prepare_practice.py +++ b/tests/application/test_prepare_practice.py @@ -1,11 +1,14 @@ from random import Random +import pytest + from keystrike.application.build_lesson import BuildLesson from keystrike.application.learn_budget_use_cases import GetDailyLearnBudget from keystrike.application.prepare_practice import PreparePracticeSession from keystrike.domain.enums import Mode from keystrike.domain.models import SessionResult, Settings -from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS +from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS, CompositeLayoutRepository +from keystrike.infrastructure.paths import Paths from tests.fakes import ( FakeAggregatesCache, FakeClock, @@ -17,6 +20,13 @@ ) +@pytest.fixture +def paths(tmp_path): + return Paths( + config_dir=tmp_path / "config", data_dir=tmp_path / "data", log_dir=tmp_path / "log", + ) + + def _prepare(*, settings: Settings | None = None) -> PreparePracticeSession: clock = FakeClock() session_repo = FakeSessionRepository() @@ -89,3 +99,42 @@ def test_prepare_adaptive_still_builds_lesson_when_daily_goal_reached(): prep = prepare() assert prep is not None assert prep.mode is Mode.ADAPTIVE + + +def test_prepare_uses_custom_toml_layout(paths): + paths.layouts_dir.mkdir(parents=True) + (paths.layouts_dir / "myown.toml").write_text( + 'name = "myown"\nlearn_order = "ab"\n\n' + '[[keys]]\nchar = "a"\nrow = 1\ncol = 0\nfinger = "PINKY"\nhand = "L"\n\n' + '[[keys]]\nchar = "b"\nrow = 1\ncol = 1\nfinger = "RING"\nhand = "L"\n', + encoding="utf-8", + ) + layout_repo = CompositeLayoutRepository(paths) + settings_repo = FakeSettingsRepository( + Settings(layout="myown", alphabet_size=2), + ) + prepare = PreparePracticeSession( + settings_repo=settings_repo, + layout_repo=layout_repo, + build_lesson=BuildLesson( + layout_repo=layout_repo, + aggregates_cache=FakeAggregatesCache(), + settings_repo=settings_repo, + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + ), + get_daily_learn_budget=GetDailyLearnBudget( + clock=FakeClock(), + repo=FakeSessionRepository(), + settings_repo=settings_repo, + ), + ) + + prep = prepare() + + assert prep is not None + assert prep.layout_obj is not None + assert prep.layout_obj.name == "myown" + assert prep.layout_obj.learn_order == (ord("a"), ord("b")) + assert prep.lesson_heatmap is not None diff --git a/tests/application/test_settings_use_cases.py b/tests/application/test_settings_use_cases.py index d317237..b760b2e 100644 --- a/tests/application/test_settings_use_cases.py +++ b/tests/application/test_settings_use_cases.py @@ -7,10 +7,18 @@ ) from keystrike.domain.enums import TargetSpeedUnit from keystrike.domain.models import Settings -from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS +from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS, CompositeLayoutRepository +from keystrike.infrastructure.paths import Paths from tests.fakes import FakeLayoutRepository, FakeSettingsRepository +@pytest.fixture +def paths(tmp_path): + return Paths( + config_dir=tmp_path / "config", data_dir=tmp_path / "data", log_dir=tmp_path / "log", + ) + + def test_update_settings_persists_all_fields(): repo = FakeSettingsRepository(Settings()) update = UpdateSettings(repo=repo) @@ -128,3 +136,20 @@ def test_cycle_layout_noop_with_fewer_than_two_layouts(): result = cycle() assert result.layout == "qwerty" + + +def test_cycle_layout_includes_custom_toml_layout(paths): + paths.layouts_dir.mkdir(parents=True) + (paths.layouts_dir / "myown.toml").write_text( + 'name = "myown"\nlearn_order = "a"\n\n' + '[[keys]]\nchar = "a"\nrow = 1\ncol = 0\nfinger = "PINKY"\nhand = "L"\n', + encoding="utf-8", + ) + layout_repo = CompositeLayoutRepository(paths) + repo = FakeSettingsRepository(Settings(layout="qwerty")) + cycle = CycleLayout(settings_repo=repo, layout_repo=layout_repo) + + layouts_seen = {cycle().layout for _ in range(len(layout_repo.list_available()))} + + assert "myown" in layouts_seen + assert layouts_seen == set(layout_repo.list_available()) diff --git a/tests/infrastructure/test_layout_repo.py b/tests/infrastructure/test_layout_repo.py index 4c716e6..ddb6205 100644 --- a/tests/infrastructure/test_layout_repo.py +++ b/tests/infrastructure/test_layout_repo.py @@ -3,6 +3,7 @@ import pytest from keystrike.infrastructure.layout_repo import CompositeLayoutRepository +from keystrike.infrastructure.layout_toml import LayoutTomlError from keystrike.infrastructure.paths import Paths @@ -52,3 +53,26 @@ def test_bundled_takes_priority_over_same_named_toml(paths: Paths): ) repo = CompositeLayoutRepository(paths) assert repo.get("qwerty").name == "qwerty" + + +def test_get_raises_on_invalid_toml_listed_by_list_available(paths: Paths): + paths.layouts_dir.mkdir(parents=True) + (paths.layouts_dir / "broken.toml").write_text("not valid toml [[", encoding="utf-8") + repo = CompositeLayoutRepository(paths) + assert "broken" in repo.list_available() + with pytest.raises(LayoutTomlError): + repo.get("broken") + + +def test_uses_filename_stem_as_id_not_name_field(paths: Paths): + paths.layouts_dir.mkdir(parents=True) + (paths.layouts_dir / "file_stem.toml").write_text( + 'name = "display_name"\nlearn_order = "a"\n\n' + '[[keys]]\nchar = "a"\nrow = 1\ncol = 0\nfinger = "PINKY"\nhand = "L"\n', + encoding="utf-8", + ) + repo = CompositeLayoutRepository(paths) + assert "file_stem" in repo.list_available() + assert "display_name" not in repo.list_available() + layout = repo.get("file_stem") + assert layout.name == "display_name" diff --git a/tests/infrastructure/test_layout_toml.py b/tests/infrastructure/test_layout_toml.py index e3ce5bb..9abfa59 100644 --- a/tests/infrastructure/test_layout_toml.py +++ b/tests/infrastructure/test_layout_toml.py @@ -54,3 +54,86 @@ def test_missing_keys_field(tmp_path: Path): file.write_text('name = "x"\nlearn_order = "a"\n', encoding="utf-8") with pytest.raises(LayoutTomlError, match="keys"): load_layout_toml(file) + + +def _write_layout(tmp_path: Path, content: str) -> Path: + file = tmp_path / "layout.toml" + file.write_text(content, encoding="utf-8") + return file + + +_MINIMAL = ( + 'name = "x"\n' + 'learn_order = "a"\n\n' + '[[keys]]\n' + 'char = "a"\n' + 'row = 1\n' + 'col = 0\n' + 'finger = "PINKY"\n' + 'hand = "L"\n' +) + + +@pytest.mark.parametrize( + ("field", "value", "match"), + [ + ("hand", '"X"', "hand"), + ("char", '"ab"', "char"), + ("row", '"1"', "row"), + ("shifted", '"yes"', "shifted"), + ], +) +def test_rejects_invalid_key_fields( + tmp_path: Path, field: str, value: str, match: str, +): + content = _MINIMAL + if field == "shifted": + content = content + f"shifted = {value}\n" + else: + lines = _MINIMAL.splitlines() + content = "\n".join( + f"{field} = {value}" if line.startswith(f"{field} = ") else line + for line in lines + ) + "\n" + with pytest.raises(LayoutTomlError, match=match): + load_layout_toml(_write_layout(tmp_path, content)) + + +def test_rejects_non_table_keys_entry(tmp_path: Path): + content = 'name = "x"\nlearn_order = "a"\nkeys = [1]\n' + with pytest.raises(LayoutTomlError, match=r"keys\[0\] must be a table"): + load_layout_toml(_write_layout(tmp_path, content)) + + +def test_rejects_empty_learn_order(tmp_path: Path): + content = _MINIMAL.replace('learn_order = "a"\n', 'learn_order = ""\n') + with pytest.raises(LayoutTomlError, match="learn_order"): + load_layout_toml(_write_layout(tmp_path, content)) + + +def test_duplicate_keys_last_entry_wins(tmp_path: Path): + content = ( + 'name = "x"\n' + 'learn_order = "a"\n\n' + '[[keys]]\n' + 'char = "a"\n' + 'row = 1\n' + 'col = 0\n' + 'finger = "PINKY"\n' + 'hand = "L"\n\n' + '[[keys]]\n' + 'char = "a"\n' + 'row = 9\n' + 'col = 9\n' + 'finger = "THUMB"\n' + 'hand = "R"\n' + ) + layout = load_layout_toml(_write_layout(tmp_path, content)) + assert layout.keys[ord("a")].row == 9 + assert layout.keys[ord("a")].finger is Finger.THUMB + + +def test_loads_shifted_true(tmp_path: Path): + content = _MINIMAL.replace('hand = "L"\n', 'hand = "L"\nshifted = true\n') + layout = load_layout_toml(_write_layout(tmp_path, content)) + assert layout.keys[ord("a")].shifted is True diff --git a/tests/presentation/test_settings_screen.py b/tests/presentation/test_settings_screen.py index e96b9d3..113feb0 100644 --- a/tests/presentation/test_settings_screen.py +++ b/tests/presentation/test_settings_screen.py @@ -51,6 +51,35 @@ async def test_settings_screen_refreshes_alphabet_size_on_resume(): assert app.screen.query_one("#settings-alphabet-size", Input).value == "22" +@pytest.mark.asyncio +async def test_settings_screen_refreshes_layout_select_on_resume(): + app = App() + layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) + settings_repo = FakeSettingsRepository(Settings()) + store = FakeWordListStore() + screen = SettingsScreen( + settings_repo=settings_repo, + layout_repo=layout_repo, + update_settings=UpdateSettings(repo=settings_repo), + import_wordlist=ImportWordList(store=store, settings_repo=settings_repo), + clear_wordlist=ClearWordList(settings_repo=settings_repo), + get_wordlist_cache_status=GetWordListCacheStatus(store=store), + ) + async with app.run_test() as pilot: + await app.push_screen(screen) + await pilot.pause() + + layout_repo.layouts["myown"] = BUNDLED_LAYOUTS["dvorak"] + screen.on_screen_resume() + await pilot.pause() + + layout_select = app.screen.query_one("#settings-layout", Select) + option_values = sorted( + v for _, v in layout_select._options if isinstance(v, str) + ) + assert "myown" in option_values + + @pytest.mark.asyncio async def test_save_persists_changes_and_pops_screen(): app = App() @@ -80,6 +109,33 @@ async def test_save_persists_changes_and_pops_screen(): assert app.screen_stack[-1] is not screen +@pytest.mark.asyncio +async def test_save_persists_custom_layout_from_dropdown(): + app = App() + layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) + layout_repo.layouts["myown"] = BUNDLED_LAYOUTS["dvorak"] + settings_repo = FakeSettingsRepository(Settings()) + store = FakeWordListStore() + screen = SettingsScreen( + settings_repo=settings_repo, + layout_repo=layout_repo, + update_settings=UpdateSettings(repo=settings_repo), + import_wordlist=ImportWordList(store=store, settings_repo=settings_repo), + clear_wordlist=ClearWordList(settings_repo=settings_repo), + get_wordlist_cache_status=GetWordListCacheStatus(store=store), + ) + async with app.run_test() as pilot: + await app.push_screen(screen) + await pilot.pause() + + app.screen.query_one("#settings-layout", Select).value = "myown" + await pilot.press("ctrl+s") + await pilot.pause() + + assert settings_repo.settings.layout == "myown" + assert app.screen_stack[-1] is not screen + + @pytest.mark.asyncio async def test_save_rejects_non_integer_speed(): app = App() diff --git a/tests/presentation/test_stats_screen.py b/tests/presentation/test_stats_screen.py index e7c463b..e509461 100644 --- a/tests/presentation/test_stats_screen.py +++ b/tests/presentation/test_stats_screen.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import cast import pytest @@ -15,6 +16,7 @@ from keystrike.domain.enums import Mode from keystrike.domain.models import Keystroke, SessionResult, Settings from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS +from keystrike.infrastructure.layout_toml import load_layout_toml from keystrike.presentation.screens.stats import StatsScreen from tests.fakes import ( FakeAggregatesCache, @@ -23,16 +25,19 @@ FakeSettingsRepository, ) +_LAYOUT_FIXTURES = Path(__file__).parent.parent / "infrastructure" / "fixtures" / "layouts" + def _build_screen( repo: FakeSessionRepository, layout: str = "qwerty", *, settings: Settings | None = None, + layout_repo: FakeLayoutRepository | None = None, ) -> StatsScreen: cache = FakeAggregatesCache() settings_repo = FakeSettingsRepository(settings or Settings()) - layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) + layout_repo = layout_repo or FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) return StatsScreen( layout=layout, layout_repo=layout_repo, @@ -243,6 +248,21 @@ async def test_stats_title_omits_ortholinear_for_staggered_layout(): assert "ortholinear" not in title +@pytest.mark.asyncio +async def test_stats_title_flags_custom_ortholinear_toml_layout(): + custom = load_layout_toml(_LAYOUT_FIXTURES / "good_ortholinear.toml") + layout_repo = FakeLayoutRepository({**BUNDLED_LAYOUTS, "custom_ortho": custom}) + app = App() + async with app.run_test() as pilot: + repo = FakeSessionRepository() + await app.push_screen( + _build_screen(repo, layout="custom_ortho", layout_repo=layout_repo), + ) + await pilot.pause() + title = str(app.screen.query_one("#stats-title", Static).content) + assert "ortholinear" in title + + def _session_with_key_confidence( *, session_id: str, diff --git a/uv.lock b/uv.lock index c7ee417..5cd3d3b 100644 --- a/uv.lock +++ b/uv.lock @@ -79,7 +79,7 @@ wheels = [ [[package]] name = "keystrike" -version = "1.2.1" +version = "1.2.3" source = { editable = "." } dependencies = [ { name = "platformdirs" }, From d2c0bdfef02de31f886e001373ef57c785e546b4 Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 30 Jul 2026 11:31:25 +0300 Subject: [PATCH 15/47] Bias word-list drills toward weak bigrams like Markov mode. Word-list sampling now scores dictionary words with transition weights so adaptive lessons practice measured weak pairs, not just weak keys. Co-authored-by: Cursor --- src/keystrike/domain/generator.py | 53 +++++++++++++++++++++++--- tests/application/test_build_lesson.py | 46 ++++++++++++++++++++++ tests/domain/test_generator.py | 41 ++++++++++++++++++++ 3 files changed, 134 insertions(+), 6 deletions(-) diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index d2ac338..f99908d 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -16,6 +16,30 @@ DEFAULT_WORD_COUNT = 12 +def wordlist_weight_for_word( + word: str, + *, + char_weights: Mapping[str, float] | None = None, + transition_weights: Mapping[str, float] | None = None, + focus_char: str | None = None, +) -> float: + """Score a dictionary word for adaptive sampling — mirrors Markov biasing.""" + if not char_weights and not transition_weights: + return 1.0 + weight = 1.0 + if char_weights: + weight = sum(char_weights.get(ch, 1.0) for ch in word) + if transition_weights and len(word) > 1: + bigram_weight = sum( + transition_weights.get(word[i] + word[i + 1], 1.0) + for i in range(len(word) - 1) + ) + weight = bigram_weight if not char_weights else weight * bigram_weight + if focus_char and focus_char in word: + weight *= FOCUS_WORD_BOOST + return weight + + def typical_chars_per_word() -> float: """Mean chars per generated word (spaces excluded). @@ -48,7 +72,12 @@ def generate_word( wordlist_weights: list[float] | None = None, ) -> str: if words: - word = self._sample_from_wordlist(words, char_weights, wordlist_weights) + word = self._sample_from_wordlist( + words, + char_weights, + transition_weights, + wordlist_weights, + ) if ( MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN and set(word) <= alphabet @@ -74,10 +103,14 @@ def generate_lesson( words: list[str] | None = None, ) -> str: wordlist_weights: list[float] | None = None - if words and char_weights: + if words and (char_weights or transition_weights): wordlist_weights = [ - sum(char_weights.get(ch, 1.0) for ch in w) - * (FOCUS_WORD_BOOST if focus_char in w else 1.0) + wordlist_weight_for_word( + w, + char_weights=char_weights, + transition_weights=transition_weights, + focus_char=focus_char, + ) for w in words ] lesson_words = [ @@ -108,13 +141,21 @@ def _sample_from_wordlist( self, words: list[str], char_weights: Mapping[str, float] | None, + transition_weights: Mapping[str, float] | None, wordlist_weights: list[float] | None = None, ) -> str: if wordlist_weights is not None: return self.rng.choices(words, weights=wordlist_weights, k=1)[0] - if not char_weights: + if not char_weights and not transition_weights: return self.rng.choice(words) - weights = [sum(char_weights.get(ch, 1.0) for ch in w) for w in words] + weights = [ + wordlist_weight_for_word( + w, + char_weights=char_weights, + transition_weights=transition_weights, + ) + for w in words + ] return self.rng.choices(words, weights=weights, k=1)[0] def _sample_word( diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index ca1d98e..277bdc1 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -118,6 +118,52 @@ def test_lesson_uses_transition_focus_when_transitions_weak(): assert pair in lesson.text.replace(" ", "") +def test_lesson_wordlist_biases_weak_transition(): + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s = order[0], order[1] + now = 1_700_000_000.0 + five_days = 5 * 86_400.0 + fast = 100_000_000.0 + transitions = { + transition_key(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), + transition_key(a, s): TransitionStats( + a, s, 10, 400_000_000.0, 0, now - five_days, attempt_count=10, + ), + transition_key(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), + transition_key(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), + } + url = "https://example.com/words.txt" + cached = ["asa", "ass", "sas", "ssa"] + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys={}, transitions=transitions)}, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=2, wordlist_url=url)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(by_url={url: cached}), + rng=Random(0), + ) + lesson = builder("qwerty") + pair = chr(a) + chr(s) + assert lesson.focus_reason == f"{pair} weak transition" + assert set(lesson.text.split()) <= set(cached) + + ssa_count = 0 + as_word_count = 0 + for seed in range(50): + builder.rng = Random(seed) + for word in builder("qwerty").text.split(): + if word == "ssa": + ssa_count += 1 + elif pair in word: + as_word_count += 1 + assert as_word_count + ssa_count == 50 * 12 + assert as_word_count > ssa_count * 2 + + def test_lesson_uses_cached_wordlist_when_configured(): url = "https://example.com/words.txt" cached = ["the", "and", "for", "are", "but", "not", "you", "all"] diff --git a/tests/domain/test_generator.py b/tests/domain/test_generator.py index 25ca60b..4e15feb 100644 --- a/tests/domain/test_generator.py +++ b/tests/domain/test_generator.py @@ -4,6 +4,7 @@ AdaptiveGenerator, cpm_from_wpm, typical_chars_per_word, + wordlist_weight_for_word, wpm_from_cpm, ) from keystrike.domain.markov import TransitionTable @@ -83,6 +84,46 @@ def test_generate_wordlist_char_weights_bias(): assert counts["a"] > counts["b"] +def test_generate_wordlist_transition_weights_bias(): + generator = AdaptiveGenerator(table=_uniform_table("abcd"), rng=Random(0)) + words = ["cab", "cad"] + counts = {"cab": 0, "cad": 0} + for _ in range(50): + word = generator.generate_word( + frozenset("abcd"), + transition_weights={"ab": 100.0, "ad": 1.0}, + words=words, + ) + counts[word] += 1 + assert counts["cab"] > counts["cad"] + + +def test_wordlist_weight_for_word_combines_char_and_transition_weights(): + weight = wordlist_weight_for_word( + "cab", + char_weights={"a": 2.0, "b": 2.0, "c": 1.0}, + transition_weights={"ab": 3.0, "bc": 1.0}, + ) + assert weight == (2.0 + 2.0 + 1.0) * (3.0 + 1.0) + + +def test_generate_lesson_wordlist_transition_weights_bias(): + generator = AdaptiveGenerator(table=_uniform_table("as"), rng=Random(0)) + words = ["asa", "ass", "sas", "ssa"] + ssa_count = 0 + for seed in range(50): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + frozenset("as"), + focus_char="s", + word_count=12, + transition_weights={"as": 100.0, "sa": 1.0, "ss": 1.0}, + words=words, + ) + ssa_count += lesson.split().count("ssa") + assert ssa_count < 80 # uniform would land near ~150 of 600 picks + + def test_generate_lesson_focus_wordlist_overweights_focus_char(): generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) words = ["aaa", "bbb"] From 87c07d0fffff4e0c9aceefb089585d26c83e3b14 Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 30 Jul 2026 12:36:44 +0300 Subject: [PATCH 16/47] Gate unlocks on measured transition confidence and tune focus bias via settings.toml. New keys stay locked until unlocked bigrams with data meet the confidence threshold; lesson generation boosts weak focus keys and transitions using configurable multipliers (not exposed in the Settings UI). Co-authored-by: Cursor --- docs/wiki/Confidence-Tuning.md | 19 ++- src/keystrike/app.py | 1 + src/keystrike/application/build_lesson.py | 25 ++- src/keystrike/application/prepare_practice.py | 4 + .../application/session_use_cases.py | 8 +- src/keystrike/application/stats_use_cases.py | 9 ++ src/keystrike/domain/aggregate.py | 7 +- src/keystrike/domain/confidence.py | 54 ++++++- src/keystrike/domain/generator.py | 19 ++- src/keystrike/domain/models.py | 5 + src/keystrike/domain/null_adapters.py | 3 + src/keystrike/domain/protocols.py | 2 + .../infrastructure/settings_repo_toml.py | 20 +++ tests/application/test_build_lesson.py | 87 +++++++++++ tests/application/test_prepare_practice.py | 57 ++++++- tests/application/test_session_use_cases.py | 2 +- tests/application/test_stats_use_cases.py | 142 ++++++++++++++++-- tests/domain/test_aggregate.py | 10 ++ tests/domain/test_confidence.py | 44 ++++++ .../infrastructure/test_settings_repo_toml.py | 10 ++ tests/presentation/test_practice_screen.py | 58 ++++++- 21 files changed, 546 insertions(+), 40 deletions(-) diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index a15fea6..b4b41b9 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -2,11 +2,12 @@ Keystrike's adaptive engine uses **confidence** — min(speed, accuracy), scaled by how much you've practiced — to decide which keys unlock, which key or bigram gets -focus, and how the stats heatmap colors each key. Three settings control how -aggressively the engine trusts your recent performance. +focus, and how the stats heatmap colors each key. Settings in this page control how +aggressively the engine trusts your recent performance and how strongly it +biases lesson text toward the current focus key or bigram. Edit `{config_dir}/settings.toml` directly (see [Git sync](Git-sync) for the -path on your OS). These three fields are **not** on the Settings screen — saving +path on your OS). These fields are **not** on the Settings screen — saving layout, speed, or other UI settings will not change them. ## Settings @@ -16,8 +17,13 @@ layout, speed, or other UI settings will not change them. | Confidence session window | `confidence_session_window` | `10` | How many recent sessions are replayed into rolling per-key stats used for confidence, unlocks, focus, and the heatmap. | | Min key attempts | `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 bigram attempts | `min_transition_confidence_attempts` | `4` | Same ramp for letter-pair (transition) confidence. Default is lower because bigrams are practiced less often than single keys. | +| Focus char boost | `focus_char_boost` | `3.0` | Multiplier on the focus key's char weight when building lesson sampling weights. | +| Focus word boost | `focus_word_boost` | `3.0` | Extra multiplier on dictionary/Markov words that contain the focus character. | +| Focus bigram word boost | `focus_bigram_word_boost` | `4.0` | Extra multiplier on words containing the focus letter pair (when transition focus is active). | +| Focus transition boost | `focus_transition_boost` | `4.0` | Multiplier on the focus bigram's transition weight. | +| Focus weak extra boost | `focus_weak_extra_boost` | `1.5` | Additional multiplier when focus confidence is below 1.0 (weak key or weak transition). | -Valid ranges: window and both attempt floors are **1–100**. +Valid ranges: window and both attempt floors are **1–100**. Boost multipliers should be **≥ 1.0**. Confidence uses **min(speed, accuracy)**, not their product: a key must be both fast enough and accurate enough to read as mastered. Speed is `target_ms / @@ -29,6 +35,11 @@ Example (defaults shown): confidence_session_window = 10 min_confidence_attempts = 10 min_transition_confidence_attempts = 4 +focus_char_boost = 3.0 +focus_word_boost = 3.0 +focus_bigram_word_boost = 4.0 +focus_transition_boost = 4.0 +focus_weak_extra_boost = 1.5 ``` ## What each setting affects diff --git a/src/keystrike/app.py b/src/keystrike/app.py index c352117..bd0b4cf 100644 --- a/src/keystrike/app.py +++ b/src/keystrike/app.py @@ -127,6 +127,7 @@ def build() -> KeystrikeApp: layout_repo=layout_repo, build_lesson=build_lesson, get_daily_learn_budget=get_daily_learn_budget, + rebuild_aggregates=rebuild_aggregates, ) return KeystrikeApp( diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 9f26e89..2ad1afe 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -10,7 +10,6 @@ from keystrike.domain.aggregate import transition_key from keystrike.domain.confidence import ( - FOCUS_CHAR_BOOST, accuracy_of, compute_unlocked, confidence_of, @@ -155,6 +154,8 @@ def _lesson_progress( stats, target, min_attempts=settings.min_confidence_attempts, + transitions=transitions, + min_transition_attempts=settings.min_transition_confidence_attempts, ) focus_bigram = ( select_focus_transition( @@ -230,7 +231,7 @@ def __call__(self, layout_name: str) -> Lesson: ) for k in state.keys } - char_weights[chr(focus)] *= FOCUS_CHAR_BOOST + char_weights[chr(focus)] *= settings.focus_char_boost transition_weights = { transition_key(prev, nxt): transition_practice_weight( transition_confidence_of( @@ -249,6 +250,24 @@ def __call__(self, layout_name: str) -> Lesson: for prev in unlocked for nxt in unlocked } + if focus_bigram is not None: + prev_cp, next_cp = focus_bigram + pair_key = transition_key(prev_cp, next_cp) + transition_weights[pair_key] *= settings.focus_transition_boost + focus_confidence = transition_confidence_of( + prev_cp, + next_cp, + transitions, + target, + min_attempts=settings.min_transition_confidence_attempts, + ) + if focus_confidence < _CONFIDENCE_GOOD: + transition_weights[pair_key] *= settings.focus_weak_extra_boost + char_weights[chr(focus)] *= settings.focus_weak_extra_boost + elif confidence_of( + focus, stats, target, min_attempts=settings.min_confidence_attempts, + ) < _CONFIDENCE_GOOD: + char_weights[chr(focus)] *= settings.focus_weak_extra_boost dict_words: list[str] | None = None if settings.wordlist_url: cached = self.wordlist_store.load(settings.wordlist_url) @@ -265,6 +284,8 @@ def __call__(self, layout_name: str) -> Lesson: transition_weights=transition_weights, focus_bigram=focus_bigram, words=dict_words, + focus_word_boost=settings.focus_word_boost, + focus_bigram_word_boost=settings.focus_bigram_word_boost, ) urgency = { diff --git a/src/keystrike/application/prepare_practice.py b/src/keystrike/application/prepare_practice.py index 6ce33a0..e509a34 100644 --- a/src/keystrike/application/prepare_practice.py +++ b/src/keystrike/application/prepare_practice.py @@ -8,10 +8,12 @@ from keystrike.application.build_lesson import BuildLesson from keystrike.domain.enums import Mode from keystrike.domain.models import Layout +from keystrike.domain.null_adapters import NULL_STATS_REBUILDER from keystrike.domain.protocols import ( DailyLearnBudgetProvider, LayoutRepository, SettingsRepository, + StatsRebuilder, ) PrepareNextSession = Callable[[], "SessionPrep | None"] @@ -38,9 +40,11 @@ class PreparePracticeSession: layout_repo: LayoutRepository build_lesson: BuildLesson get_daily_learn_budget: DailyLearnBudgetProvider + rebuild_aggregates: StatsRebuilder = NULL_STATS_REBUILDER def __call__(self) -> SessionPrep | None: settings = self.settings_repo.load() + self.rebuild_aggregates.ensure(settings.layout) lesson = self.build_lesson(settings.layout) return SessionPrep( target_text=lesson.text, diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index a6b2f02..3c86ff7 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -144,17 +144,19 @@ def _snapshot_unlock_state( (header, repo.load_keystrokes(header.session_id)) for header in prior_headers ] sessions.append((draft, session.keystrokes)) - stats = combine_sessions(sessions).keys + combined = combine_sessions(sessions) target = target_ms_per_char(settings.target_speed_cpm) unlocked = compute_unlocked( keyboard_order(layout), settings.alphabet_size, - stats, + combined.keys, target, min_attempts=settings.min_confidence_attempts, + transitions=combined.transitions, + min_transition_attempts=settings.min_transition_confidence_attempts, ) return unlocked, { - cp: confidence_of(cp, stats, target, min_attempts=settings.min_confidence_attempts) + cp: confidence_of(cp, combined.keys, target, min_attempts=settings.min_confidence_attempts) for cp in unlocked } diff --git a/src/keystrike/application/stats_use_cases.py b/src/keystrike/application/stats_use_cases.py index 9716d71..3b21957 100644 --- a/src/keystrike/application/stats_use_cases.py +++ b/src/keystrike/application/stats_use_cases.py @@ -43,6 +43,15 @@ def __call__(self, layout: str) -> dict[int, KeyStats]: self.cache.put(layout, combined) return combined.keys + def ensure(self, layout: str) -> dict[int, KeyStats]: + """Rebuild only when cache is missing or lacks transitions despite session history.""" + cached = self.cache.get(layout) + if cached is not None and cached.transitions: + return cached.keys + if cached is not None and not any(self.repo.iter_headers(layout)): + return cached.keys + return self(layout) + @dataclass(frozen=True, slots=True) class HeatmapView: diff --git a/src/keystrike/domain/aggregate.py b/src/keystrike/domain/aggregate.py index 90eddbc..c5511c7 100644 --- a/src/keystrike/domain/aggregate.py +++ b/src/keystrike/domain/aggregate.py @@ -168,10 +168,11 @@ def combine_sessions( """ if not sessions: return LayoutAggregates(keys={}, transitions={}) - weights = session_recency_weights(len(sessions), decay=recency_decay) - key_maps = [aggregate_session(header, keystrokes) for header, keystrokes in sessions] + materialized = [(header, list(keystrokes)) for header, keystrokes in sessions] + weights = session_recency_weights(len(materialized), decay=recency_decay) + key_maps = [aggregate_session(header, keystrokes) for header, keystrokes in materialized] transition_maps = [ - aggregate_transitions(header, keystrokes) for header, keystrokes in sessions + aggregate_transitions(header, keystrokes) for header, keystrokes in materialized ] return LayoutAggregates( keys=_combine_key_maps_weighted(key_maps, weights), diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index c45cfb1..ca791d1 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -23,9 +23,12 @@ MIN_TRANSITION_CONFIDENCE_ATTEMPTS = 4 # Confidence scores are rounded before thresholds, focus, heatmap, and UI. CONFIDENCE_DECIMALS = 2 -# ponytail: fixed multipliers; upgrade to settings if users want tunable focus intensity +# Defaults for Settings and generator call sites; tunable via settings.toml. FOCUS_CHAR_BOOST = 3.0 -FOCUS_WORD_BOOST = 2.0 +FOCUS_WORD_BOOST = 3.0 +FOCUS_BIGRAM_WORD_BOOST = 4.0 +FOCUS_TRANSITION_BOOST = 4.0 +FOCUS_WEAK_EXTRA_BOOST = 1.5 def round_confidence(value: float) -> float: @@ -102,6 +105,29 @@ def confidence_of( ) +def _measured_transitions_meet_threshold( + unlocked: Sequence[int], + transitions: Mapping[str, TransitionStats], + target: float, + *, + threshold: float, + min_attempts: int, +) -> bool: + """True when every measured bigram among unlocked keys meets threshold.""" + for prev in unlocked: + for nxt in unlocked: + if chr(prev) + chr(nxt) not in transitions: + continue + if ( + transition_confidence_of( + prev, nxt, transitions, target, min_attempts=min_attempts, + ) + < threshold + ): + return False + return True + + def compute_unlocked( learn_order: Sequence[int], alphabet_size: int, @@ -110,10 +136,12 @@ def compute_unlocked( *, threshold: float = 1.0, min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, + transitions: Mapping[str, TransitionStats] | None = None, + min_transition_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, ) -> tuple[int, ...]: """The first `alphabet_size` keys are always unlocked; each further key in - `learn_order` unlocks only once every currently-unlocked key meets - `threshold`.""" + `learn_order` unlocks only once every currently-unlocked key and every + measured bigram among them meets `threshold`.""" forced_count = min(alphabet_size, len(learn_order)) unlocked = list(learn_order[:forced_count]) for codepoint in learn_order[forced_count:]: @@ -122,6 +150,17 @@ def compute_unlocked( for k in unlocked ): break + if ( + transitions is not None + and not _measured_transitions_meet_threshold( + unlocked, + transitions, + target, + threshold=threshold, + min_attempts=min_transition_attempts, + ) + ): + break unlocked.append(codepoint) return tuple(unlocked) @@ -282,7 +321,12 @@ def select_focus_transition( """Weakest unlocked bigram by transition confidence; None when no transition data.""" if not transitions: return None - pairs = [(prev, nxt) for prev in unlocked for nxt in unlocked] + pairs = [ + (prev, nxt) + for prev in unlocked + for nxt in unlocked + if chr(prev) + chr(nxt) in transitions + ] if not pairs: return None return min( diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index f99908d..087f1da 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from random import Random -from .confidence import FOCUS_WORD_BOOST +from .confidence import FOCUS_BIGRAM_WORD_BOOST, FOCUS_WORD_BOOST from .markov import TransitionTable from .models import Layout from .word_bounds import MAX_WORD_LEN, MIN_WORD_LEN @@ -22,6 +22,9 @@ def wordlist_weight_for_word( char_weights: Mapping[str, float] | None = None, transition_weights: Mapping[str, float] | None = None, focus_char: str | None = None, + focus_bigram: str | None = None, + focus_word_boost: float = FOCUS_WORD_BOOST, + focus_bigram_word_boost: float = FOCUS_BIGRAM_WORD_BOOST, ) -> float: """Score a dictionary word for adaptive sampling — mirrors Markov biasing.""" if not char_weights and not transition_weights: @@ -35,8 +38,10 @@ def wordlist_weight_for_word( for i in range(len(word) - 1) ) weight = bigram_weight if not char_weights else weight * bigram_weight - if focus_char and focus_char in word: - weight *= FOCUS_WORD_BOOST + if focus_bigram and focus_bigram in word: + weight *= focus_bigram_word_boost + elif focus_char and focus_char in word: + weight *= focus_word_boost return weight @@ -101,7 +106,12 @@ def generate_lesson( transition_weights: Mapping[str, float] | None = None, focus_bigram: tuple[int, int] | None = None, words: list[str] | None = None, + focus_word_boost: float = FOCUS_WORD_BOOST, + focus_bigram_word_boost: float = FOCUS_BIGRAM_WORD_BOOST, ) -> str: + focus_bigram_str: str | None = None + if focus_bigram is not None: + focus_bigram_str = chr(focus_bigram[0]) + chr(focus_bigram[1]) wordlist_weights: list[float] | None = None if words and (char_weights or transition_weights): wordlist_weights = [ @@ -110,6 +120,9 @@ def generate_lesson( char_weights=char_weights, transition_weights=transition_weights, focus_char=focus_char, + focus_bigram=focus_bigram_str, + focus_word_boost=focus_word_boost, + focus_bigram_word_boost=focus_bigram_word_boost, ) for w in words ] diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index 8ee1e4e..40b5b53 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -109,6 +109,11 @@ class Settings: confidence_session_window: int = 10 # sessions in rolling stats for confidence min_confidence_attempts: int = 10 # presses before key confidence reaches full weight min_transition_confidence_attempts: int = 4 # lower floor — bigrams are sparser + focus_char_boost: float = 3.0 # char weight multiplier for focus key + focus_word_boost: float = 3.0 # wordlist/Markov boost when focus char present + focus_bigram_word_boost: float = 4.0 # word boost when focus bigram present + focus_transition_boost: float = 4.0 # transition weight multiplier for focus pair + focus_weak_extra_boost: float = 1.5 # extra multiplier when focus confidence < 1.0 lang: str = "en" learn_daily_minutes: int = 10 # adaptive mode daily goal (minutes); 0 = no goal wordlist_url: str = "" # non-empty + cached file → real words; else Markov diff --git a/src/keystrike/domain/null_adapters.py b/src/keystrike/domain/null_adapters.py index 82596ec..c155b74 100644 --- a/src/keystrike/domain/null_adapters.py +++ b/src/keystrike/domain/null_adapters.py @@ -35,6 +35,9 @@ class NullStatsRebuilder: def __call__(self, layout: str) -> dict[int, KeyStats]: return {} + def ensure(self, layout: str) -> dict[int, KeyStats]: + return {} + NULL_STATS_REBUILDER = NullStatsRebuilder() diff --git a/src/keystrike/domain/protocols.py b/src/keystrike/domain/protocols.py index 2123336..d803edc 100644 --- a/src/keystrike/domain/protocols.py +++ b/src/keystrike/domain/protocols.py @@ -52,6 +52,8 @@ class StatsRebuilder(Protocol): def __call__(self, layout: str) -> dict[int, KeyStats]: ... + def ensure(self, layout: str) -> dict[int, KeyStats]: ... + class LanguageProvider(Protocol): def transitions(self, lang: str) -> TransitionTable: ... diff --git a/src/keystrike/infrastructure/settings_repo_toml.py b/src/keystrike/infrastructure/settings_repo_toml.py index f209794..0c2d4eb 100644 --- a/src/keystrike/infrastructure/settings_repo_toml.py +++ b/src/keystrike/infrastructure/settings_repo_toml.py @@ -59,6 +59,21 @@ def load(self) -> Settings: defaults.min_transition_confidence_attempts, ), ), + focus_char_boost=float( + raw.get("focus_char_boost", defaults.focus_char_boost), + ), + focus_word_boost=float( + raw.get("focus_word_boost", defaults.focus_word_boost), + ), + focus_bigram_word_boost=float( + raw.get("focus_bigram_word_boost", defaults.focus_bigram_word_boost), + ), + focus_transition_boost=float( + raw.get("focus_transition_boost", defaults.focus_transition_boost), + ), + focus_weak_extra_boost=float( + raw.get("focus_weak_extra_boost", defaults.focus_weak_extra_boost), + ), lang=str(raw.get("lang", defaults.lang)), learn_daily_minutes=int( raw.get("learn_daily_minutes", defaults.learn_daily_minutes), @@ -83,6 +98,11 @@ def save(self, settings: Settings) -> None: "min_transition_confidence_attempts", settings.min_transition_confidence_attempts, ), + ("focus_char_boost", settings.focus_char_boost), + ("focus_word_boost", settings.focus_word_boost), + ("focus_bigram_word_boost", settings.focus_bigram_word_boost), + ("focus_transition_boost", settings.focus_transition_boost), + ("focus_weak_extra_boost", settings.focus_weak_extra_boost), ("learn_daily_minutes", settings.learn_daily_minutes), ("lang", settings.lang), ] diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 277bdc1..f715cd9 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -1,4 +1,5 @@ from random import Random +from unittest.mock import patch from keystrike.application.build_lesson import BuildLesson from keystrike.domain.aggregate import transition_key @@ -118,6 +119,54 @@ def test_lesson_uses_transition_focus_when_transitions_weak(): assert pair in lesson.text.replace(" ", "") +def test_lesson_falls_back_to_key_focus_without_transitions(): + layout = BUNDLED_LAYOUTS["qwerty"] + focus = keyboard_order(layout)[0] + lesson = _build_lesson(Settings(alphabet_size=1))("qwerty") + assert lesson.focus_key == focus + assert lesson.focus_reason == "weak" + assert "transition" not in (lesson.focus_reason or "") + + +def test_lesson_uses_transition_review_when_stale_and_mastered(): + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s = order[0], order[1] + now = 1_700_000_000.0 + five_days = 5 * 86_400.0 + at_target = 200_000_000.0 + transitions = { + transition_key(a, a): TransitionStats( + a, a, 10, at_target, 0, now, attempt_count=10, + ), + transition_key(a, s): TransitionStats( + a, s, 10, at_target, 0, now - five_days, attempt_count=10, + ), + transition_key(s, a): TransitionStats( + s, a, 10, at_target, 0, now, attempt_count=10, + ), + transition_key(s, s): TransitionStats( + s, s, 10, at_target, 0, now, attempt_count=10, + ), + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys={}, transitions=transitions)}, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=2)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + ) + with patch("keystrike.application.build_lesson.time.time", return_value=now): + lesson = builder("qwerty") + pair = chr(a) + chr(s) + assert lesson.focus_key == s + assert lesson.focus_reason == f"{pair} review transition" + + def test_lesson_wordlist_biases_weak_transition(): layout = BUNDLED_LAYOUTS["qwerty"] order = keyboard_order(layout) @@ -164,6 +213,44 @@ def test_lesson_wordlist_biases_weak_transition(): assert as_word_count > ssa_count * 2 +def test_weak_transition_focus_over_represented_in_lesson_words(): + """Weak focus bigram should land in many lesson words, not just the injected one.""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s = order[0], order[1] + now = 1_700_000_000.0 + fast = 100_000_000.0 + transitions = { + transition_key(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), + transition_key(a, s): TransitionStats( + a, s, 10, 400_000_000.0, 0, now, attempt_count=10, + ), + transition_key(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), + transition_key(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys={}, transitions=transitions)}, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=2)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + ) + pair = chr(a) + chr(s) + bigram_words = 0 + total_words = 0 + for seed in range(100): + builder.rng = Random(seed) + words = builder("qwerty").text.split() + total_words += len(words) + bigram_words += sum(1 for word in words if pair in word) + rate = bigram_words / total_words + assert rate >= 0.25, f"focus bigram in {rate:.1%} of words, expected >= 25%" + + def test_lesson_uses_cached_wordlist_when_configured(): url = "https://example.com/words.txt" cached = ["the", "and", "for", "are", "but", "not", "you", "all"] diff --git a/tests/application/test_prepare_practice.py b/tests/application/test_prepare_practice.py index 24b2f9c..5a3fbcc 100644 --- a/tests/application/test_prepare_practice.py +++ b/tests/application/test_prepare_practice.py @@ -5,8 +5,9 @@ from keystrike.application.build_lesson import BuildLesson from keystrike.application.learn_budget_use_cases import GetDailyLearnBudget from keystrike.application.prepare_practice import PreparePracticeSession +from keystrike.application.stats_use_cases import RebuildAggregates from keystrike.domain.enums import Mode -from keystrike.domain.models import SessionResult, Settings +from keystrike.domain.models import Keystroke, SessionResult, Settings from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS, CompositeLayoutRepository from keystrike.infrastructure.paths import Paths from tests.fakes import ( @@ -60,6 +61,60 @@ def test_prepare_adaptive_builds_lesson(): assert prep.lesson_heatmap is not None +def test_prepare_ensures_transitions_before_lesson(): + clock = FakeClock() + session_repo = FakeSessionRepository() + settings_repo = FakeSettingsRepository(Settings(alphabet_size=2)) + layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) + cache = FakeAggregatesCache() + session_repo.save_header( + SessionResult( + schema_version=3, + session_id="s1", + started_at=clock.wall_epoch(), + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(ord("a"), ord("s")), + focus_key=ord("s"), + total_keystrokes=4, + correct_keystrokes=3, + ), + ) + session_repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("s"), typed=ord("s"), t_ns=100_000_000, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=500_000_000, correct=True), + Keystroke(codepoint=ord("s"), typed=ord("x"), t_ns=600_000_000, correct=False), + ] + rebuild = RebuildAggregates(repo=session_repo, cache=cache, settings_repo=settings_repo) + prepare = PreparePracticeSession( + settings_repo=settings_repo, + layout_repo=layout_repo, + build_lesson=BuildLesson( + layout_repo=layout_repo, + aggregates_cache=cache, + settings_repo=settings_repo, + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + ), + get_daily_learn_budget=GetDailyLearnBudget( + clock=clock, repo=session_repo, settings_repo=settings_repo, + ), + rebuild_aggregates=rebuild, + ) + + prep = prepare() + + assert prep is not None + cached = cache.get("qwerty") + assert cached is not None + assert cached.transitions + assert prep.focus_reason is not None + assert prep.focus_reason.endswith(" weak transition") + + def test_prepare_adaptive_still_builds_lesson_when_daily_goal_reached(): clock = FakeClock(wall=1_700_000_000.0) session_repo = FakeSessionRepository() diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index d548773..d425e8f 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -427,7 +427,7 @@ def test_finish_session_bumps_alphabet_size_when_unlocked_set_grows(clock, id_ge start = StartSession(clock=clock, id_gen=id_gen) record = RecordKeystroke(clock=clock) - warmup = "".join(chr(cp) for cp in order[:5] for _ in range(10)) + warmup = "".join(chr(cp) for _ in range(10) for cp in order[:5]) warmup_session = start( warmup, layout="qwerty", mode=Mode.ADAPTIVE, focus_key=order[0], ) diff --git a/tests/application/test_stats_use_cases.py b/tests/application/test_stats_use_cases.py index 4e3cc52..f814587 100644 --- a/tests/application/test_stats_use_cases.py +++ b/tests/application/test_stats_use_cases.py @@ -13,7 +13,14 @@ SESSION_RECENCY_DECAY, ) from keystrike.domain.enums import Mode -from keystrike.domain.models import KeyStats, Keystroke, LayoutAggregates, SessionResult, Settings +from keystrike.domain.models import ( + KeyStats, + Keystroke, + LayoutAggregates, + SessionResult, + Settings, + TransitionStats, +) from tests.fakes import FakeAggregatesCache, FakeSessionRepository, FakeSettingsRepository @@ -66,6 +73,91 @@ def test_rebuild_aggregates_uses_confidence_session_window(): assert cache.get("dvorak") is None +def test_rebuild_aggregates_populates_transitions(): + repo = FakeSessionRepository() + cache = FakeAggregatesCache() + repo.save_header(_header("s1", 1_700_000_000.0)) + repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), + ] + + RebuildAggregates( + repo=repo, + cache=cache, + settings_repo=FakeSettingsRepository(), + )("qwerty") + + cached = cache.get("qwerty") + assert cached is not None + ab = cached.transitions.get("ab") + assert ab is not None + assert ab.samples == 1 + assert ab.mean_time_ns == 100_000_000.0 + + +def test_ensure_skips_when_cache_has_transitions(): + repo = FakeSessionRepository() + cache = FakeAggregatesCache( + by_layout={ + "qwerty": LayoutAggregates( + keys={ord("a"): KeyStats(ord("a"), 1, 100_000_000.0, 0, 1.0, attempt_count=1)}, + transitions={ + "ab": TransitionStats( + ord("a"), + ord("b"), + 1, + 100_000_000.0, + 0, + 1.0, + attempt_count=1, + ), + }, + ), + }, + ) + rebuild = RebuildAggregates( + repo=repo, + cache=cache, + settings_repo=FakeSettingsRepository(), + ) + + keys = rebuild.ensure("qwerty") + + cached = cache.get("qwerty") + assert cached is not None + assert keys == cached.keys + assert cached.transitions + + +def test_ensure_rebuilds_when_transitions_missing_but_sessions_exist(): + repo = FakeSessionRepository() + cache = FakeAggregatesCache( + by_layout={ + "qwerty": LayoutAggregates( + keys={ord("a"): KeyStats(ord("a"), 1, 100_000_000.0, 0, 1.0, attempt_count=1)}, + transitions={}, + ), + }, + ) + repo.save_header(_header("s1", 1_700_000_000.0)) + repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), + ] + rebuild = RebuildAggregates( + repo=repo, + cache=cache, + settings_repo=FakeSettingsRepository(), + ) + + rebuild.ensure("qwerty") + + cached = cache.get("qwerty") + assert cached is not None + assert cached.transitions.get("ab") is not None + + def test_rebuild_aggregates_drops_sessions_outside_window(): repo = FakeSessionRepository() cache = FakeAggregatesCache() @@ -88,7 +180,9 @@ def test_rebuild_aggregates_drops_sessions_outside_window(): ) result = RebuildAggregates( - repo=repo, cache=cache, settings_repo=FakeSettingsRepository(), + repo=repo, + cache=cache, + settings_repo=FakeSettingsRepository(), )("qwerty") assert ord("a") in result @@ -156,7 +250,9 @@ def test_get_heatmap_confidence_ratio(monkeypatch): ], ] RebuildAggregates( - repo=repo, cache=cache, settings_repo=FakeSettingsRepository(), + repo=repo, + cache=cache, + settings_repo=FakeSettingsRepository(), )("qwerty") get_heatmap = GetHeatmap(cache=cache, settings_repo=settings_repo) @@ -209,7 +305,10 @@ def test_get_learning_rate_reads_deltas_across_sessions_chronologically(): repo.keystrokes[session_id] = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke( - codepoint=ord("a"), typed=ord("a"), t_ns=delta_ms * 1_000_000, correct=True, + codepoint=ord("a"), + typed=ord("a"), + t_ns=delta_ms * 1_000_000, + correct=True, ), ] @@ -230,7 +329,10 @@ def test_get_learning_rate_already_at_target_returns_zero(): repo.keystrokes[session_id] = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke( - codepoint=ord("a"), typed=ord("a"), t_ns=delta_ms * 1_000_000, correct=True, + codepoint=ord("a"), + typed=ord("a"), + t_ns=delta_ms * 1_000_000, + correct=True, ), ] @@ -257,14 +359,20 @@ def test_get_key_metric_trends_tracks_speed_and_accuracy(): repo.keystrokes["s1"] = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke( - codepoint=ord("a"), typed=ord("a"), t_ns=400_000_000, correct=True, + codepoint=ord("a"), + typed=ord("a"), + t_ns=400_000_000, + correct=True, ), ] repo.save_header(_header("s2", 2.0)) repo.keystrokes["s2"] = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke( - codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True, + codepoint=ord("a"), + typed=ord("a"), + t_ns=200_000_000, + correct=True, ), ] @@ -315,7 +423,10 @@ def test_get_key_metric_trends_normalizes_speed_to_current_goal(): repo.keystrokes["s1"] = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke( - codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True, + codepoint=ord("a"), + typed=ord("a"), + t_ns=200_000_000, + correct=True, ), ] @@ -338,7 +449,10 @@ def test_get_key_metric_trends_limits_to_confidence_session_window(): repo.keystrokes[session_id] = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke( - codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True, + codepoint=ord("a"), + typed=ord("a"), + t_ns=200_000_000, + correct=True, ), ] @@ -357,11 +471,17 @@ def test_get_aggregate_metric_trends_aggregates_all_keys(): repo.keystrokes["s1"] = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke( - codepoint=ord("a"), typed=ord("a"), t_ns=400_000_000, correct=True, + codepoint=ord("a"), + typed=ord("a"), + t_ns=400_000_000, + correct=True, ), Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=500_000_000, correct=True), Keystroke( - codepoint=ord("b"), typed=ord("x"), t_ns=600_000_000, correct=False, + codepoint=ord("b"), + typed=ord("x"), + t_ns=600_000_000, + correct=False, ), ] diff --git a/tests/domain/test_aggregate.py b/tests/domain/test_aggregate.py index 634ff54..75a5044 100644 --- a/tests/domain/test_aggregate.py +++ b/tests/domain/test_aggregate.py @@ -201,6 +201,16 @@ def test_combine_sessions_recent_errors_weigh_more_on_confidence(): ) +def test_combine_sessions_includes_transitions_from_iterators(): + keys = iter([ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), + ]) + out = combine_sessions([(_session(), keys)]) + assert out.keys[ord("b")].samples == 1 + assert out.transitions["ab"].samples == 1 + + def test_per_transition_deltas_tracks_prev_to_next_pair(): keys = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index 03547a7..c28df4e 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -175,6 +175,33 @@ def test_compute_unlocked_stalls_when_threshold_not_met(): assert unlocked == (1,) +def test_compute_unlocked_stalls_on_weak_measured_transition(): + learn_order = tuple(ord(c) for c in "eabcdfghm") + stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in learn_order[:8]} + transitions = { + "ee": TransitionStats( + ord("e"), ord("e"), samples=10, mean_time_ns=400_000_000.0, + error_count=0, last_seen=0.0, attempt_count=10, + ), + } + unlocked = compute_unlocked( + learn_order, alphabet_size=8, stats=stats, target=200.0, transitions=transitions, + ) + assert unlocked == learn_order[:8] + assert ord("m") not in unlocked + + +def test_compute_unlocked_advances_when_measured_transitions_meet_threshold(): + learn_order = tuple(ord(c) for c in "eabcdfghm") + stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in learn_order[:8]} + transitions = { + "ee": _transition(ord("e"), ord("e"), 100_000_000.0, attempt_count=10), + } + unlocked = compute_unlocked( + learn_order, alphabet_size=8, stats=stats, target=200.0, transitions=transitions, + ) + assert unlocked == learn_order[:9] + assert unlocked[-1] == ord("m") def test_select_focus_picks_weakest_unlocked_key(): stats = { 1: _stats(1, mean_time_ns=100_000_000.0), # confidence 2.0 @@ -274,5 +301,22 @@ def test_select_focus_transition_returns_none_without_data(): assert select_focus_transition((1, 2), {}, target=200.0, now=1000.0) is None +def test_select_focus_transition_ignores_unmeasured_pairs(): + unlocked = (ord("a"), ord("b"), ord("c")) + now = 1_700_000_000.0 + fast = 100_000_000.0 + transitions = { + "ab": _transition(ord("a"), ord("b"), 400_000_000.0, last_seen=now, attempt_count=10), + "bc": _transition(ord("b"), ord("c"), fast, last_seen=now, attempt_count=10), + } + assert select_focus_transition(unlocked, transitions, 200.0, now) == (ord("a"), ord("b")) + + +def test_select_focus_transition_returns_none_when_no_measured_unlocked_pairs(): + unlocked = (ord("a"), ord("b")) + transitions = {"yz": _transition(ord("y"), ord("z"), 100_000_000.0, last_seen=1_700_000_000.0)} + assert select_focus_transition(unlocked, transitions, 200.0, now=1_700_000_000.0) is None + + def test_focus_key_from_transition_uses_next_endpoint(): assert focus_key_from_transition(ord("t"), ord("h")) == ord("h") diff --git a/tests/infrastructure/test_settings_repo_toml.py b/tests/infrastructure/test_settings_repo_toml.py index ed84c0e..484889d 100644 --- a/tests/infrastructure/test_settings_repo_toml.py +++ b/tests/infrastructure/test_settings_repo_toml.py @@ -32,6 +32,11 @@ def test_round_trip(paths): confidence_session_window=8, min_confidence_attempts=12, min_transition_confidence_attempts=5, + focus_char_boost=2.5, + focus_word_boost=5.0, + focus_bigram_word_boost=6.0, + focus_transition_boost=3.5, + focus_weak_extra_boost=2.0, ) repo.save(original) loaded = repo.load() @@ -40,6 +45,11 @@ def test_round_trip(paths): assert loaded.confidence_session_window == 8 assert loaded.min_confidence_attempts == 12 assert loaded.min_transition_confidence_attempts == 5 + assert loaded.focus_char_boost == 2.5 + assert loaded.focus_word_boost == 5.0 + assert loaded.focus_bigram_word_boost == 6.0 + assert loaded.focus_transition_boost == 3.5 + assert loaded.focus_weak_extra_boost == 2.0 assert loaded.updated_at is not None diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index bcdf6c0..92a508c 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -26,7 +26,7 @@ ImportWordList, ) from keystrike.domain.enums import Mode, SessionState -from keystrike.domain.models import SessionResult, Settings +from keystrike.domain.models import Keystroke, SessionResult, Settings from keystrike.domain.session import LEARN_IDLE_PAUSE_NS, active_typing_duration_ns, is_typing_idle from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from keystrike.presentation.screens.home import HomeScreen @@ -54,13 +54,13 @@ def _build_app( *, clock: FakeClock | None = None, settings: Settings | None = None, - headers: list[SessionResult] | None = None, + session_repo: FakeSessionRepository | None = None, ) -> tuple[KeystrikeApp, FakeClock, FakeSessionRepository, FakeSettingsRepository]: clock = clock or FakeClock( wall=dt.datetime(2026, 7, 28, 12, 0, tzinfo=_TZ).timestamp(), ) id_gen = FakeIdGenerator() - session_repo = FakeSessionRepository(headers=headers or []) + session_repo = session_repo or FakeSessionRepository() settings_repo = FakeSettingsRepository(settings or Settings()) layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) cache = FakeAggregatesCache() @@ -76,11 +76,15 @@ def _build_app( get_daily_learn_budget = GetDailyLearnBudget( clock=clock, repo=session_repo, settings_repo=settings_repo, tz=_TZ, ) + rebuild_aggregates = RebuildAggregates( + repo=session_repo, cache=cache, settings_repo=settings_repo, + ) prepare_practice = PreparePracticeSession( settings_repo=settings_repo, layout_repo=layout_repo, build_lesson=build_lesson, get_daily_learn_budget=get_daily_learn_budget, + rebuild_aggregates=rebuild_aggregates, ) app = KeystrikeApp( @@ -96,9 +100,7 @@ def _build_app( settings_repo=settings_repo, layout_repo=layout_repo, prepare_practice=prepare_practice, - rebuild_aggregates=RebuildAggregates( - repo=session_repo, cache=cache, settings_repo=settings_repo, - ), + rebuild_aggregates=rebuild_aggregates, get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo), get_history=GetHistory(repo=session_repo), get_key_metric_trends=GetKeyMetricTrends( @@ -216,7 +218,9 @@ async def test_adaptive_allowed_when_daily_learn_goal_reached(): total_keystrokes=1, correct_keystrokes=1, ) - app, _clock, _repo, _settings = _build_app(headers=[header]) + session_repo = FakeSessionRepository() + session_repo.save_header(header) + app, _clock, _repo, _settings = _build_app(session_repo=session_repo) async with app.run_test() as pilot: await pilot.press("enter") await pilot.pause() @@ -224,6 +228,46 @@ async def test_adaptive_allowed_when_daily_learn_goal_reached(): assert isinstance(app.screen, PracticeScreen) +@pytest.mark.asyncio +async def test_adaptive_practice_shows_transition_focus_note(): + clock = FakeClock(wall=1_700_000_000.0) + session_repo = FakeSessionRepository() + session_repo.save_header( + SessionResult( + schema_version=3, + session_id="s1", + started_at=clock.wall_epoch(), + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(ord("a"), ord("s")), + focus_key=ord("s"), + total_keystrokes=4, + correct_keystrokes=3, + ), + ) + session_repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("s"), typed=ord("s"), t_ns=100_000_000, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=500_000_000, correct=True), + Keystroke(codepoint=ord("s"), typed=ord("x"), t_ns=600_000_000, correct=False), + ] + app, _clock, _repo, _settings = _build_app( + clock=clock, + settings=Settings(alphabet_size=2), + session_repo=session_repo, + ) + async with app.run_test() as pilot: + await pilot.press("enter") + await pilot.pause() + practice = app.screen + assert isinstance(practice, PracticeScreen) + note = str(practice.query_one("#focus-note", Static).content) + assert "weak transition" in note + assert "speed " in note + assert "accuracy " in note + + @pytest.mark.asyncio async def test_adaptive_practice_focus_note_shows_speed_and_accuracy(): app, _clock, _repo, _settings = _build_app() From 41fbe0b5d6ad597e79484b9bb49ec4bc8459502f Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 30 Jul 2026 12:50:36 +0300 Subject: [PATCH 17/47] Prioritize weak keys over transition focus and drop same-key bigrams. Same-letter pairs no longer enter transition stats or unlock gates; lesson building favors letter drill while any unlocked key is below mastery. Leading skip keys also apply at word boundaries for accidental spaces. Co-authored-by: Cursor --- src/keystrike/application/build_lesson.py | 38 +++++- src/keystrike/domain/aggregate.py | 43 +++++-- src/keystrike/domain/confidence.py | 25 +++- src/keystrike/domain/session.py | 14 ++- .../infrastructure/aggregates_cache.py | 33 ++--- tests/application/test_build_lesson.py | 118 ++++++++++++++++-- tests/application/test_session_use_cases.py | 36 ++++++ tests/application/test_stats_use_cases.py | 26 ++++ tests/domain/test_aggregate.py | 30 +++++ tests/domain/test_confidence.py | 67 +++++++++- tests/domain/test_session.py | 24 ++++ tests/infrastructure/test_aggregates_cache.py | 56 +++++++++ tests/presentation/test_practice_screen.py | 6 +- 13 files changed, 467 insertions(+), 49 deletions(-) diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 2ad1afe..70d5f9e 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -14,6 +14,8 @@ compute_unlocked, confidence_of, focus_key_from_transition, + has_weak_unlocked_key, + is_same_key_transition, key_confidence, practice_weight, review_urgency, @@ -157,19 +159,42 @@ def _lesson_progress( transitions=transitions, min_transition_attempts=settings.min_transition_confidence_attempts, ) - focus_bigram = ( - select_focus_transition( + keys_need_focus = has_weak_unlocked_key( + unlocked, + stats, + target, + threshold=_CONFIDENCE_GOOD, + min_attempts=settings.min_confidence_attempts, + ) + if keys_need_focus: + focus_bigram = None + focus = select_focus( + unlocked, + stats, + target, + now, + min_attempts=settings.min_confidence_attempts, + ) + elif transitions: + focus_bigram = select_focus_transition( unlocked, transitions, target, now, min_attempts=settings.min_transition_confidence_attempts, ) - if transitions else None - ) - if focus_bigram is not None: - focus = focus_key_from_transition(*focus_bigram) + if focus_bigram is not None: + focus = focus_key_from_transition(*focus_bigram) + else: + focus = select_focus( + unlocked, + stats, + target, + now, + min_attempts=settings.min_confidence_attempts, + ) else: + focus_bigram = None focus = select_focus( unlocked, stats, @@ -249,6 +274,7 @@ def __call__(self, layout_name: str) -> Lesson: ) for prev in unlocked for nxt in unlocked + if not is_same_key_transition(prev, nxt) } if focus_bigram is not None: prev_cp, next_cp = focus_bigram diff --git a/src/keystrike/domain/aggregate.py b/src/keystrike/domain/aggregate.py index c5511c7..cb8ffb0 100644 --- a/src/keystrike/domain/aggregate.py +++ b/src/keystrike/domain/aggregate.py @@ -7,10 +7,10 @@ from __future__ import annotations -from collections.abc import Iterable, Sequence +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field -from .confidence import SESSION_RECENCY_DECAY +from .confidence import SESSION_RECENCY_DECAY, is_same_key_transition from .models import KeyStats, Keystroke, LayoutAggregates, SessionResult, TransitionStats @@ -152,7 +152,7 @@ def _combine_transition_maps_weighted( last_seen=max(stats.last_seen for stats, _ in entries), attempt_count=round(weighted_attempts), ) - return out + return without_same_key_transitions(out) def combine_sessions( @@ -210,6 +210,17 @@ def transition_key(prev_cp: int, next_cp: int) -> str: return chr(prev_cp) + chr(next_cp) +def without_same_key_transitions( + transitions: Mapping[str, TransitionStats], +) -> dict[str, TransitionStats]: + """Drop same-key pairs (aa, ee) from stored transition stats.""" + return { + key: stats + for key, stats in transitions.items() + if not is_same_key_transition(stats.prev_cp, stats.next_cp) + } + + def per_transition_deltas(keystrokes: Iterable[Keystroke]) -> dict[str, list[int]]: """Inter-keystroke deltas per prev→next target pair for correct keystrokes.""" deltas: dict[str, list[int]] = {} @@ -219,7 +230,11 @@ def per_transition_deltas(keystrokes: Iterable[Keystroke]) -> dict[str, list[int for k in keystrokes: if not k.correct: continue - if last_correct_cp is not None and last_correct_t_ns is not None: + if ( + last_correct_cp is not None + and last_correct_t_ns is not None + and not is_same_key_transition(last_correct_cp, k.codepoint) + ): delta = k.t_ns - last_correct_t_ns if delta > 0: deltas.setdefault(transition_key(last_correct_cp, k.codepoint), []).append(delta) @@ -240,12 +255,16 @@ def aggregate_transitions( for i, k in enumerate(all_keystrokes): if not k.correct: if i > 0: - key = transition_key(all_keystrokes[i - 1].codepoint, k.codepoint) - entry = partial.setdefault(key, _Partial()) - entry.attempt_count += 1 - entry.error_count += 1 + prev_cp = all_keystrokes[i - 1].codepoint + if not is_same_key_transition(prev_cp, k.codepoint): + key = transition_key(prev_cp, k.codepoint) + entry = partial.setdefault(key, _Partial()) + entry.attempt_count += 1 + entry.error_count += 1 continue - if last_correct_cp is not None: + if last_correct_cp is not None and not is_same_key_transition( + last_correct_cp, k.codepoint, + ): key = transition_key(last_correct_cp, k.codepoint) partial.setdefault(key, _Partial()).attempt_count += 1 last_correct_cp = k.codepoint @@ -255,7 +274,7 @@ def aggregate_transitions( session_end_wall = result.started_at + result.duration_ns / 1e9 - return { + return without_same_key_transitions({ key: TransitionStats( prev_cp=ord(key[0]), next_cp=ord(key[1]), @@ -267,7 +286,7 @@ def aggregate_transitions( attempt_count=p.attempt_count, ) for key, p in partial.items() - } + }) def merge_transition_stats(a: TransitionStats, b: TransitionStats) -> TransitionStats: @@ -296,4 +315,4 @@ def combine_transitions(*maps: dict[str, TransitionStats]) -> dict[str, Transiti for m in maps: for key, t in m.items(): out[key] = merge_transition_stats(out[key], t) if key in out else t - return out + return without_same_key_transitions(out) diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index ca791d1..af9752d 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -62,6 +62,11 @@ def transition_attempts(transition_stats: TransitionStats) -> int: return transition_stats.attempt_count +def is_same_key_transition(prev_cp: int, next_cp: int) -> bool: + """Same physical key twice (e.g. ee, ss) — excluded from transition analysis.""" + return prev_cp == next_cp + + def confidence_sample_factor( attempts: int, *, @@ -116,6 +121,8 @@ def _measured_transitions_meet_threshold( """True when every measured bigram among unlocked keys meets threshold.""" for prev in unlocked: for nxt in unlocked: + if is_same_key_transition(prev, nxt): + continue if chr(prev) + chr(nxt) not in transitions: continue if ( @@ -270,6 +277,21 @@ def _focus_score( ) +def has_weak_unlocked_key( + unlocked: Sequence[int], + stats: Mapping[int, KeyStats], + target: float, + *, + threshold: float = 1.0, + min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, +) -> bool: + """True when any unlocked key is below mastery threshold.""" + return any( + confidence_of(cp, stats, target, min_attempts=min_attempts) < threshold + for cp in unlocked + ) + + def select_focus( unlocked: Sequence[int], stats: Mapping[int, KeyStats], @@ -325,7 +347,8 @@ def select_focus_transition( (prev, nxt) for prev in unlocked for nxt in unlocked - if chr(prev) + chr(nxt) in transitions + if not is_same_key_transition(prev, nxt) + and chr(prev) + chr(nxt) in transitions ] if not pairs: return None diff --git a/src/keystrike/domain/session.py b/src/keystrike/domain/session.py index 5d855ce..5c2dced 100644 --- a/src/keystrike/domain/session.py +++ b/src/keystrike/domain/session.py @@ -13,7 +13,7 @@ from .models import Keystroke BACKSPACE = "\x7f" # normalized backspace codepoint marker used at edges -LEADING_SKIP_KEYS = frozenset(" \t\n\r") # accidental space/enter/tab before typing starts +LEADING_SKIP_KEYS = frozenset(" \t\n\r") # accidental space/enter/tab before session/word start LEARN_IDLE_PAUSE_NS = 5 * 1_000_000_000 # pause learn timer after this idle gap @@ -39,13 +39,17 @@ def leading_key_char(key: str, character: str | None = None) -> str | None: def skip_leading_whitespace(session: Session, char: str) -> bool: - """Drop space/enter/tab before the first real keystroke unless they are the target.""" - if session.typing_started_at_ns is not None or not session.target_text: + """Drop accidental space/enter/tab before session or word start unless they are the target.""" + if not session.target_text or session.position >= len(session.target_text): return False if char not in LEADING_SKIP_KEYS: return False - target = session.target_text[0] - return normalize_newline(char, target) != target + target = session.target_text[session.position] + if normalize_newline(char, target) == target: + return False + if session.position == 0: + return True + return session.target_text[session.position - 1] in LEADING_SKIP_KEYS def note_keystroke_for_timer(session: Session, now_ns: int) -> None: diff --git a/src/keystrike/infrastructure/aggregates_cache.py b/src/keystrike/infrastructure/aggregates_cache.py index 77df329..abd38c9 100644 --- a/src/keystrike/infrastructure/aggregates_cache.py +++ b/src/keystrike/infrastructure/aggregates_cache.py @@ -7,6 +7,7 @@ from dataclasses import asdict from pathlib import Path +from keystrike.domain.aggregate import without_same_key_transitions from keystrike.domain.models import KeyStats, LayoutAggregates, TransitionStats from .atomic_write import atomic_write_text @@ -28,6 +29,20 @@ def get(self, layout: str) -> LayoutAggregates | None: data = json.loads(file.read_text(encoding="utf-8")) keys = data.get("keys", {}) transitions = data.get("transitions", {}) + parsed_transitions = { + key: TransitionStats( + prev_cp=int(entry["prev_cp"]), + next_cp=int(entry["next_cp"]), + samples=int(entry["samples"]), + mean_time_ns=float(entry["mean_time_ns"]), + error_count=int(entry["error_count"]), + last_seen=float(entry["last_seen"]), + attempt_count=int( + entry.get("attempt_count", entry["samples"] + entry["error_count"]), + ), + ) + for key, entry in transitions.items() + } return LayoutAggregates( keys={ int(cp): KeyStats( @@ -42,27 +57,15 @@ def get(self, layout: str) -> LayoutAggregates | None: ) for cp, entry in keys.items() }, - transitions={ - key: TransitionStats( - prev_cp=int(entry["prev_cp"]), - next_cp=int(entry["next_cp"]), - samples=int(entry["samples"]), - mean_time_ns=float(entry["mean_time_ns"]), - error_count=int(entry["error_count"]), - last_seen=float(entry["last_seen"]), - attempt_count=int( - entry.get("attempt_count", entry["samples"] + entry["error_count"]), - ), - ) - for key, entry in transitions.items() - }, + transitions=without_same_key_transitions(parsed_transitions), ) def put(self, layout: str, aggregates: LayoutAggregates) -> None: + transitions = without_same_key_transitions(aggregates.transitions) payload = { "schema_version": 1, "layout": layout, "keys": {str(cp): asdict(k) for cp, k in aggregates.keys.items()}, - "transitions": {key: asdict(t) for key, t in aggregates.transitions.items()}, + "transitions": {key: asdict(t) for key, t in transitions.items()}, } atomic_write_text(self._file(layout), json.dumps(payload, indent=2)) diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index f715cd9..67a3518 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -4,7 +4,7 @@ from keystrike.application.build_lesson import BuildLesson from keystrike.domain.aggregate import transition_key from keystrike.domain.learn_order import keyboard_order -from keystrike.domain.models import LayoutAggregates, Settings, TransitionStats +from keystrike.domain.models import KeyStats, LayoutAggregates, Settings, TransitionStats from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from tests.fakes import ( FakeAggregatesCache, @@ -86,6 +86,72 @@ def test_cold_start_unlocks_row_ordered_prefix_not_frequency_order(): assert row_unlocked != frequency_prefix +def test_lesson_prefers_weak_key_over_weak_transition(): + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s = order[0], order[1] + now = 1_700_000_000.0 + fast = 100_000_000.0 + slow = 400_000_000.0 + transitions = { + transition_key(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), + transition_key(a, s): TransitionStats( + a, s, 10, slow, 0, now, attempt_count=10, + ), + transition_key(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), + transition_key(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys={}, transitions=transitions)}, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=2)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + ) + lesson = builder("qwerty") + assert lesson.focus_reason == "weak" + assert "transition" not in (lesson.focus_reason or "") + + +def test_lesson_ignores_weak_same_key_transition_for_focus(): + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s = order[0], order[1] + now = 1_700_000_000.0 + at_target = 200_000_000.0 + keys = { + a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + } + transitions = { + transition_key(a, a): TransitionStats( + a, a, 10, 400_000_000.0, 0, now, attempt_count=10, + ), + transition_key(a, s): TransitionStats(a, s, 10, at_target, 0, now, attempt_count=10), + transition_key(s, a): TransitionStats(s, a, 10, at_target, 0, now, attempt_count=10), + transition_key(s, s): TransitionStats( + s, s, 10, 400_000_000.0, 0, now, attempt_count=10, + ), + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=2)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + ) + lesson = builder("qwerty") + assert "transition" not in (lesson.focus_reason or "") + + def test_lesson_uses_transition_focus_when_transitions_weak(): layout = BUNDLED_LAYOUTS["qwerty"] order = keyboard_order(layout) @@ -93,6 +159,11 @@ def test_lesson_uses_transition_focus_when_transitions_weak(): now = 1_700_000_000.0 five_days = 5 * 86_400.0 fast = 100_000_000.0 + at_target = 200_000_000.0 + keys = { + a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + } transitions = { transition_key(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), transition_key(a, s): TransitionStats( @@ -102,7 +173,7 @@ def test_lesson_uses_transition_focus_when_transitions_weak(): transition_key(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), } cache = FakeAggregatesCache( - by_layout={"qwerty": LayoutAggregates(keys={}, transitions=transitions)}, + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, ) builder = BuildLesson( layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), @@ -131,10 +202,18 @@ def test_lesson_falls_back_to_key_focus_without_transitions(): def test_lesson_uses_transition_review_when_stale_and_mastered(): layout = BUNDLED_LAYOUTS["qwerty"] order = keyboard_order(layout) - a, s = order[0], order[1] + a, s, h = order[0], order[1], order[2] now = 1_700_000_000.0 five_days = 5 * 86_400.0 at_target = 200_000_000.0 + # s→h stays weak enough to block unlocking the next key; stale a→s still + # wins transition focus via review scoring (see test_select_focus_transition_*). + slow = 380_000_000.0 + keys = { + a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + h: KeyStats(h, 10, at_target, 0, now, attempt_count=10), + } transitions = { transition_key(a, a): TransitionStats( a, a, 10, at_target, 0, now, attempt_count=10, @@ -142,20 +221,35 @@ def test_lesson_uses_transition_review_when_stale_and_mastered(): transition_key(a, s): TransitionStats( a, s, 10, at_target, 0, now - five_days, attempt_count=10, ), + transition_key(a, h): TransitionStats( + a, h, 10, at_target, 0, now, attempt_count=10, + ), transition_key(s, a): TransitionStats( s, a, 10, at_target, 0, now, attempt_count=10, ), transition_key(s, s): TransitionStats( s, s, 10, at_target, 0, now, attempt_count=10, ), + transition_key(s, h): TransitionStats( + s, h, 10, slow, 0, now, attempt_count=10, + ), + transition_key(h, a): TransitionStats( + h, a, 10, at_target, 0, now, attempt_count=10, + ), + transition_key(h, s): TransitionStats( + h, s, 10, at_target, 0, now, attempt_count=10, + ), + transition_key(h, h): TransitionStats( + h, h, 10, at_target, 0, now, attempt_count=10, + ), } cache = FakeAggregatesCache( - by_layout={"qwerty": LayoutAggregates(keys={}, transitions=transitions)}, + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, ) builder = BuildLesson( layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), aggregates_cache=cache, - settings_repo=FakeSettingsRepository(Settings(alphabet_size=2)), + settings_repo=FakeSettingsRepository(Settings(alphabet_size=3)), language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(0), @@ -174,6 +268,11 @@ def test_lesson_wordlist_biases_weak_transition(): now = 1_700_000_000.0 five_days = 5 * 86_400.0 fast = 100_000_000.0 + at_target = 200_000_000.0 + keys = { + a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + } transitions = { transition_key(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), transition_key(a, s): TransitionStats( @@ -185,7 +284,7 @@ def test_lesson_wordlist_biases_weak_transition(): url = "https://example.com/words.txt" cached = ["asa", "ass", "sas", "ssa"] cache = FakeAggregatesCache( - by_layout={"qwerty": LayoutAggregates(keys={}, transitions=transitions)}, + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, ) builder = BuildLesson( layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), @@ -220,6 +319,11 @@ def test_weak_transition_focus_over_represented_in_lesson_words(): a, s = order[0], order[1] now = 1_700_000_000.0 fast = 100_000_000.0 + at_target = 200_000_000.0 + keys = { + a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + } transitions = { transition_key(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), transition_key(a, s): TransitionStats( @@ -229,7 +333,7 @@ def test_weak_transition_focus_over_represented_in_lesson_words(): transition_key(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), } cache = FakeAggregatesCache( - by_layout={"qwerty": LayoutAggregates(keys={}, transitions=transitions)}, + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, ) builder = BuildLesson( layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index d425e8f..900ab91 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -234,6 +234,42 @@ def test_leading_enter_honored_when_target_starts_with_newline(clock, id_gen): assert session.keystrokes[0].correct +def test_repeated_leading_skip_keys_ignored_after_required_char(clock, id_gen): + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + + session = start(" ab", layout="qwerty", mode=Mode.ADAPTIVE) + record(session, " ") + record(session, " ") + record(session, " ") + assert session.position == 1 + assert session.total_count == 1 + + session = start("\nabc", layout="qwerty", mode=Mode.ADAPTIVE) + record(session, "\n") + record(session, "\n") + assert session.position == 1 + assert session.total_count == 1 + + +def test_repeated_leading_skip_keys_ignored_at_word_start(clock, id_gen): + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + session = start("hi there", layout="qwerty", mode=Mode.ADAPTIVE) + + for ch in "hi ": + record(session, ch) + record(session, " ") + record(session, "\t") + + assert session.position == 3 + assert session.total_count == 3 + assert session.error_positions == set() + + record(session, "t") + assert session.position == 4 + + def test_learn_timer_pauses_after_idle(clock, id_gen): start = StartSession(clock=clock, id_gen=id_gen) record = RecordKeystroke(clock=clock) diff --git a/tests/application/test_stats_use_cases.py b/tests/application/test_stats_use_cases.py index f814587..1364ff6 100644 --- a/tests/application/test_stats_use_cases.py +++ b/tests/application/test_stats_use_cases.py @@ -96,6 +96,32 @@ def test_rebuild_aggregates_populates_transitions(): assert ab.mean_time_ns == 100_000_000.0 +def test_rebuild_aggregates_excludes_same_key_transitions(): + repo = FakeSessionRepository() + cache = FakeAggregatesCache() + repo.save_header(_header("s1", 1_700_000_000.0)) + repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), + Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=200_000_000, correct=True), + Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=300_000_000, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=400_000_000, correct=True), + ] + + RebuildAggregates( + repo=repo, + cache=cache, + settings_repo=FakeSettingsRepository(), + )("qwerty") + + cached = cache.get("qwerty") + assert cached is not None + assert "aa" not in cached.transitions + assert "ee" not in cached.transitions + assert cached.transitions.get("ae") is not None + assert cached.transitions.get("eb") is not None + + def test_ensure_skips_when_cache_has_transitions(): repo = FakeSessionRepository() cache = FakeAggregatesCache( diff --git a/tests/domain/test_aggregate.py b/tests/domain/test_aggregate.py index 75a5044..af44413 100644 --- a/tests/domain/test_aggregate.py +++ b/tests/domain/test_aggregate.py @@ -222,6 +222,28 @@ def test_per_transition_deltas_tracks_prev_to_next_pair(): assert deltas["bc"] == [150_000_000] +def test_aggregate_transitions_skips_same_key_pairs(): + keys = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=200_000_000, correct=True), + ] + transitions = aggregate_transitions(_session(), keys) + assert "aa" not in transitions + assert "ab" in transitions + + +def test_per_transition_deltas_skips_same_key_pairs(): + keys = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=250_000_000, correct=True), + ] + deltas = per_transition_deltas(keys) + assert "aa" not in deltas + assert deltas["ab"] == [150_000_000] + + def test_aggregate_transitions_attributes_errors_to_pair(): keys = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), @@ -266,5 +288,13 @@ def test_combine_transitions_multiple_maps(): assert out["bc"].samples == 2 +def test_combine_transitions_drops_same_key_pairs(): + stale = {"aa": TransitionStats(ord("a"), ord("a"), 1, 100.0, 0, 1.0)} + valid = {"ab": TransitionStats(ord("a"), ord("b"), 1, 100.0, 0, 1.0)} + out = combine_transitions(stale, valid) + assert "aa" not in out + assert "ab" in out + + def test_transition_key_format(): assert transition_key(ord("a"), ord("b")) == "ab" diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index c28df4e..e1978c5 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -4,6 +4,7 @@ compute_unlocked, confidence_of, focus_key_from_transition, + has_weak_unlocked_key, key_attempts, key_confidence, practice_weight, @@ -175,7 +176,7 @@ def test_compute_unlocked_stalls_when_threshold_not_met(): assert unlocked == (1,) -def test_compute_unlocked_stalls_on_weak_measured_transition(): +def test_compute_unlocked_ignores_weak_same_key_transition(): learn_order = tuple(ord(c) for c in "eabcdfghm") stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in learn_order[:8]} transitions = { @@ -187,6 +188,22 @@ def test_compute_unlocked_stalls_on_weak_measured_transition(): unlocked = compute_unlocked( learn_order, alphabet_size=8, stats=stats, target=200.0, transitions=transitions, ) + assert unlocked == learn_order[:9] + assert unlocked[-1] == ord("m") + + +def test_compute_unlocked_stalls_on_weak_cross_key_transition(): + learn_order = tuple(ord(c) for c in "eabcdfghm") + stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in learn_order[:8]} + transitions = { + "ea": TransitionStats( + ord("e"), ord("a"), samples=10, mean_time_ns=400_000_000.0, + error_count=0, last_seen=0.0, attempt_count=10, + ), + } + unlocked = compute_unlocked( + learn_order, alphabet_size=8, stats=stats, target=200.0, transitions=transitions, + ) assert unlocked == learn_order[:8] assert ord("m") not in unlocked @@ -202,6 +219,8 @@ def test_compute_unlocked_advances_when_measured_transitions_meet_threshold(): ) assert unlocked == learn_order[:9] assert unlocked[-1] == ord("m") + + def test_select_focus_picks_weakest_unlocked_key(): stats = { 1: _stats(1, mean_time_ns=100_000_000.0), # confidence 2.0 @@ -210,6 +229,27 @@ def test_select_focus_picks_weakest_unlocked_key(): assert select_focus((1, 2), stats, target=200.0, now=1000.0) == 2 +def test_has_weak_unlocked_key_true_when_any_below_threshold(): + stats = { + 1: _stats(1, mean_time_ns=200_000_000.0), + 2: _stats(2, mean_time_ns=400_000_000.0), + } + assert has_weak_unlocked_key((1, 2), stats, target=200.0) is True + + +def test_has_weak_unlocked_key_false_when_all_confident(): + stats = { + 1: _stats(1, mean_time_ns=200_000_000.0), + 2: _stats(2, mean_time_ns=100_000_000.0), + } + assert has_weak_unlocked_key((1, 2), stats, target=200.0) is False + + +def test_has_weak_unlocked_key_true_for_never_practiced(): + stats = {1: _stats(1, mean_time_ns=200_000_000.0)} + assert has_weak_unlocked_key((1, 2), stats, target=200.0) is True + + def test_select_focus_prefers_never_practiced_key(): stats = {1: _stats(1, mean_time_ns=100_000_000.0)} assert select_focus((1, 2), stats, target=200.0, now=1000.0) == 2 @@ -283,6 +323,31 @@ def _transition( ) +def test_select_focus_transition_ignores_same_key_pairs(): + now = 1_700_000_000.0 + unlocked = (ord("e"), ord("a")) + transitions = { + "ee": _transition(ord("e"), ord("e"), 400_000_000.0, last_seen=now, attempt_count=10), + "aa": _transition(ord("a"), ord("a"), 400_000_000.0, last_seen=now, attempt_count=10), + } + assert select_focus_transition(unlocked, transitions, 200.0, now) is None + + +def test_select_focus_transition_never_picks_same_key_even_when_weakest(): + now = 1_700_000_000.0 + unlocked = (ord("a"), ord("b")) + fast = 100_000_000.0 + transitions = { + "aa": _transition(ord("a"), ord("a"), 400_000_000.0, last_seen=now, attempt_count=10), + "ab": _transition(ord("a"), ord("b"), fast, last_seen=now, attempt_count=10), + "ba": _transition(ord("b"), ord("a"), fast, last_seen=now, attempt_count=10), + "bb": _transition(ord("b"), ord("b"), 400_000_000.0, last_seen=now, attempt_count=10), + } + result = select_focus_transition(unlocked, transitions, 200.0, now) + assert result is not None + assert result[0] != result[1] + + def test_select_focus_transition_picks_stale_over_slightly_weaker_recent(): now = 1_000_000.0 five_days = 5 * 86_400.0 diff --git a/tests/domain/test_session.py b/tests/domain/test_session.py index 5ac39cc..f3102d9 100644 --- a/tests/domain/test_session.py +++ b/tests/domain/test_session.py @@ -23,3 +23,27 @@ def test_skip_leading_whitespace_newline_enter_variants(): assert skip_leading_whitespace(session, "\r") is False assert skip_leading_whitespace(session, "\n") is False assert skip_leading_whitespace(session, " ") is True + + +def test_skip_leading_whitespace_repeated_after_required_leading_char(): + session = _session(" ab") + session.position = 1 + assert skip_leading_whitespace(session, " ") is True + + session = _session("\nabc") + session.position = 1 + assert skip_leading_whitespace(session, "\n") is True + assert skip_leading_whitespace(session, "\r") is True + + +def test_skip_leading_whitespace_at_word_start(): + session = _session("hi there") + session.position = 3 # after "hi ", before 't' + assert skip_leading_whitespace(session, " ") is True + assert skip_leading_whitespace(session, "\t") is True + + +def test_skip_leading_whitespace_not_mid_word(): + session = _session("a b") + session.position = 1 # target space between words + assert skip_leading_whitespace(session, "\t") is False diff --git a/tests/infrastructure/test_aggregates_cache.py b/tests/infrastructure/test_aggregates_cache.py index 9a62f33..e00da1a 100644 --- a/tests/infrastructure/test_aggregates_cache.py +++ b/tests/infrastructure/test_aggregates_cache.py @@ -1,3 +1,5 @@ +import json + import pytest from keystrike.domain.models import KeyStats, LayoutAggregates, TransitionStats @@ -54,3 +56,57 @@ def test_layout_isolation(paths): assert ord("a") in qwerty.keys assert ord("z") in dvorak.keys assert ord("z") not in qwerty.keys + + +def test_put_strips_same_key_transitions(paths): + cache = FileAggregatesCache(paths) + aggregates = LayoutAggregates( + keys={ord("a"): KeyStats(ord("a"), 1, 100.0, 0, 1.0)}, + transitions={ + "aa": TransitionStats(ord("a"), ord("a"), 1, 100.0, 0, 1.0), + "ab": TransitionStats(ord("a"), ord("b"), 1, 100.0, 0, 1.0), + }, + ) + cache.put("qwerty", aggregates) + loaded = cache.get("qwerty") + assert loaded is not None + assert "aa" not in loaded.transitions + assert "ab" in loaded.transitions + raw = json.loads(cache._file("qwerty").read_text(encoding="utf-8")) + assert "aa" not in raw["transitions"] + + +def test_get_strips_same_key_transitions_from_stale_cache(paths): + cache = FileAggregatesCache(paths) + cache._file("qwerty").write_text( + json.dumps({ + "schema_version": 1, + "layout": "qwerty", + "keys": {}, + "transitions": { + "aa": { + "prev_cp": ord("a"), + "next_cp": ord("a"), + "samples": 1, + "mean_time_ns": 100.0, + "error_count": 0, + "last_seen": 1.0, + "attempt_count": 1, + }, + "ab": { + "prev_cp": ord("a"), + "next_cp": ord("b"), + "samples": 1, + "mean_time_ns": 100.0, + "error_count": 0, + "last_seen": 1.0, + "attempt_count": 1, + }, + }, + }), + encoding="utf-8", + ) + loaded = cache.get("qwerty") + assert loaded is not None + assert "aa" not in loaded.transitions + assert "ab" in loaded.transitions diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index 92a508c..3419fff 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -229,7 +229,7 @@ async def test_adaptive_allowed_when_daily_learn_goal_reached(): @pytest.mark.asyncio -async def test_adaptive_practice_shows_transition_focus_note(): +async def test_adaptive_practice_shows_weak_key_focus_note(): clock = FakeClock(wall=1_700_000_000.0) session_repo = FakeSessionRepository() session_repo.save_header( @@ -263,7 +263,9 @@ async def test_adaptive_practice_shows_transition_focus_note(): practice = app.screen assert isinstance(practice, PracticeScreen) note = str(practice.query_one("#focus-note", Static).content) - assert "weak transition" in note + assert "(weak)" in note + assert "[bold]a[/]" in note + assert "weak transition" not in note assert "speed " in note assert "accuracy " in note From 2a863bbc446c5305bac7d65263db90647440b4ba Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 30 Jul 2026 14:33:27 +0300 Subject: [PATCH 18/47] refactor --- .pre-commit-config.yaml | 2 + docs/wiki/Confidence-Tuning.md | 28 ++ docs/wiki/Custom-Layouts.md | 5 +- src/keystrike/app.py | 12 +- src/keystrike/application/build_lesson.py | 115 +++-- src/keystrike/application/prepare_practice.py | 11 +- .../application/session_use_cases.py | 400 ++--------------- src/keystrike/application/stats_use_cases.py | 50 ++- src/keystrike/application/sync_use_cases.py | 10 +- src/keystrike/domain/aggregate.py | 158 +++---- src/keystrike/domain/confidence.py | 239 ++++++---- src/keystrike/domain/enums.py | 32 +- src/keystrike/domain/generator.py | 113 +++-- src/keystrike/domain/markov.py | 12 +- src/keystrike/domain/models.py | 67 ++- src/keystrike/domain/null_adapters.py | 13 +- src/keystrike/domain/protocols.py | 15 +- src/keystrike/domain/regression.py | 3 +- src/keystrike/domain/session.py | 8 +- src/keystrike/domain/sync_merge.py | 237 +++++----- src/keystrike/domain/wordlist.py | 6 +- .../infrastructure/aggregates_cache.py | 50 ++- src/keystrike/infrastructure/atomic_write.py | 6 +- .../infrastructure/bundled_layouts/_grid.py | 12 +- .../bundled_layouts/colemak_dh.py | 4 +- src/keystrike/infrastructure/paths.py | 10 +- .../infrastructure/session_repo_jsonl.py | 28 +- .../infrastructure/settings_repo_toml.py | 44 +- src/keystrike/infrastructure/sync_git.py | 170 +++++++- .../formatting}/__init__.py | 0 .../presentation/formatting/trends.py | 409 ++++++++++++++++++ .../presentation/screens/practice.py | 59 +-- .../presentation/screens/settings.py | 28 +- src/keystrike/presentation/screens/stats.py | 103 +++-- src/keystrike/presentation/textual_app.py | 4 + src/keystrike/presentation/widgets/hud.py | 16 +- .../presentation/widgets/kb_heatmap.py | 129 ++++-- tests/application/test_build_lesson.py | 312 ++++++++++--- .../test_learn_budget_use_cases.py | 5 +- tests/application/test_prepare_practice.py | 22 +- tests/application/test_session_use_cases.py | 384 +--------------- tests/application/test_settings_use_cases.py | 4 +- tests/application/test_stats_use_cases.py | 39 +- tests/conftest.py | 3 +- tests/domain/test_aggregate.py | 172 ++++++-- tests/domain/test_confidence.py | 226 ++++++++-- tests/domain/test_enums.py | 17 + tests/domain/test_generator.py | 47 +- tests/domain/test_markov.py | 13 +- tests/domain/test_null_adapters.py | 9 +- tests/domain/test_sync_merge.py | 227 ++++++---- tests/infrastructure/test_aggregates_cache.py | 157 +++++-- tests/infrastructure/test_layout_repo.py | 4 +- tests/infrastructure/test_layout_toml.py | 40 +- .../infrastructure/test_session_repo_jsonl.py | 14 +- .../infrastructure/test_settings_repo_toml.py | 6 +- tests/infrastructure/test_sync_git.py | 270 ++++++++++++ tests/presentation/test_formatting_trends.py | 366 ++++++++++++++++ tests/presentation/test_hud.py | 28 +- tests/presentation/test_kb_heatmap.py | 53 ++- tests/presentation/test_practice_screen.py | 19 +- tests/presentation/test_settings_screen.py | 4 +- tests/presentation/test_stats_screen.py | 168 ++++--- tests/test_architecture.py | 6 +- 64 files changed, 3398 insertions(+), 1825 deletions(-) rename src/keystrike/{infrastructure/code_generators => presentation/formatting}/__init__.py (100%) create mode 100644 src/keystrike/presentation/formatting/trends.py create mode 100644 tests/domain/test_enums.py create mode 100644 tests/infrastructure/test_sync_git.py create mode 100644 tests/presentation/test_formatting_trends.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1e61bc7..236bdd7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,6 +3,8 @@ repos: rev: v0.16.0 hooks: - id: ruff-check + - id: ruff-format + args: [--check] - repo: local hooks: diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index b4b41b9..9839f13 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -62,6 +62,34 @@ focus and bigram-weighted lesson text use this floor. Keeping it lower than the key floor (default 4 vs 10) matches how sparse bigram data is in normal practice. +## Unlocks and focus + +The first **N** keys in layout `learn_order` are always unlocked, where **N** is +**Letters unlocked up front** in Settings (`alphabet_size`; see +[README — Settings](https://github.com/egno/keystrike#settings)). Each further +key in `learn_order` unlocks only when **every** currently unlocked key meets the +confidence threshold (default 1.0) **and** every **measured** cross-key bigram +among unlocked keys meets the same threshold. Unmeasured pairs do not block +unlock — you are not required to practice every possible letter pair before the +next key opens. + +Same-key repeats (double letters such as `ee`, `ss`) are excluded from unlock +checks; only prev→next pairs on **different** keys count. + +**Focus selection** is letter-first: while any unlocked key is below threshold +(`has_weak_unlocked_key`), the lesson emphasizes the weakest unlocked **key** +(by confidence, with review urgency). Transition (bigram) focus activates only +when all unlocked keys are confident; then the weakest measured cross-key bigram +among the unlocked set drives focus. If no transition data exists yet, focus +falls back to the weakest key. + +### Transition stats + +Same-key / double-letter bigrams are never aggregated into session stats, stored +in the stats cache, or used in unlock checks, focus selection, or lesson +transition weights. Only cross-key pairs (e.g. `th`, `he`) participate in +transition confidence and bigram-weighted lesson text. + ## Tradeoffs - **Window too short** — Rare keys in your unlocked set may never accumulate diff --git a/docs/wiki/Custom-Layouts.md b/docs/wiki/Custom-Layouts.md index 84f9036..2408f09 100644 --- a/docs/wiki/Custom-Layouts.md +++ b/docs/wiki/Custom-Layouts.md @@ -79,8 +79,9 @@ Each `[[keys]]` table: | `hand` | yes | string | `L` or `R`. | | `shifted` | no | boolean | Default `false`. Stored on the key model; bundled layouts leave this unset. | -`learn_order` controls which keys unlock first after the initial -[alphabet_size](Confidence-Tuning) force-unlock. Bundled layouts use English +`learn_order` controls which keys unlock first after the initial force-unlock +(**Letters unlocked up front** in Settings; see +[README — Settings](https://github.com/egno/keystrike#settings)). Bundled layouts use English letter frequency (`etaoinshrdlcumwfgypbvkjxqz`, then punctuation, then space). Custom layouts can follow the same idea or any order that matches your curriculum. diff --git a/src/keystrike/app.py b/src/keystrike/app.py index bd0b4cf..96de909 100644 --- a/src/keystrike/app.py +++ b/src/keystrike/app.py @@ -8,11 +8,13 @@ from keystrike.application.prepare_practice import PreparePracticeSession from keystrike.application.session_use_cases import ( FinishSession, + GetSessionBaseline, RecordKeystroke, StartSession, ) from keystrike.application.settings_use_cases import CycleLayout, UpdateSettings from keystrike.application.stats_use_cases import ( + EnsureAggregates, GetAggregateMetricTrends, GetHeatmap, GetHistory, @@ -83,15 +85,20 @@ def build() -> KeystrikeApp: finish = FinishSession( clock=clock, repo=session_repo, - aggregates_cache=aggregates_cache, settings_repo=settings_repo, layout_repo=layout_repo, ) + get_session_baseline = GetSessionBaseline(repo=session_repo, settings_repo=settings_repo) rebuild_aggregates = RebuildAggregates( repo=session_repo, cache=aggregates_cache, settings_repo=settings_repo, ) + ensure_aggregates = EnsureAggregates( + repo=session_repo, + cache=aggregates_cache, + rebuild=rebuild_aggregates, + ) get_heatmap = GetHeatmap(cache=aggregates_cache, settings_repo=settings_repo) get_history = GetHistory(repo=session_repo) get_key_metric_trends = GetKeyMetricTrends( @@ -127,7 +134,7 @@ def build() -> KeystrikeApp: layout_repo=layout_repo, build_lesson=build_lesson, get_daily_learn_budget=get_daily_learn_budget, - rebuild_aggregates=rebuild_aggregates, + ensure_aggregates=ensure_aggregates, ) return KeystrikeApp( @@ -138,6 +145,7 @@ def build() -> KeystrikeApp: settings_repo=settings_repo, layout_repo=layout_repo, prepare_practice=prepare_practice, + get_session_baseline=get_session_baseline, rebuild_aggregates=rebuild_aggregates, get_heatmap=get_heatmap, get_history=get_history, diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 70d5f9e..76065e1 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -5,11 +5,12 @@ from __future__ import annotations import time +from collections.abc import Mapping from dataclasses import dataclass from random import Random -from keystrike.domain.aggregate import transition_key from keystrike.domain.confidence import ( + FocusReason, accuracy_of, compute_unlocked, confidence_of, @@ -28,9 +29,11 @@ transition_confidence_of, transition_practice_weight, ) -from keystrike.domain.generator import AdaptiveGenerator +from keystrike.domain.enums import FocusKind +from keystrike.domain.generator import AdaptiveGenerator, LessonWeighting from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import ( + Bigram, KeyStats, Layout, LessonKey, @@ -53,7 +56,7 @@ def _key_focus_metrics( focus: int, - stats: dict[int, KeyStats], + stats: Mapping[int, KeyStats], target: float, ) -> tuple[float, float]: key_stats = stats.get(focus) @@ -68,10 +71,10 @@ def _key_focus_metrics( def _transition_focus_metrics( prev_cp: int, next_cp: int, - transitions: dict[str, TransitionStats], + transitions: Mapping[Bigram, TransitionStats], target: float, ) -> tuple[float, float]: - t_stats = transitions.get(transition_key(prev_cp, next_cp)) + t_stats = transitions.get(Bigram(prev_cp, next_cp)) if t_stats is None: return 0.0, 0.0 return ( @@ -82,41 +85,45 @@ def _transition_focus_metrics( def _focus_reason( focus: int, - stats: dict[int, KeyStats], + stats: Mapping[int, KeyStats], target: float, now: float, *, min_attempts: int, -) -> str | None: +) -> FocusReason | None: key_stats = stats.get(focus) urgency = review_urgency(key_stats.last_seen if key_stats else 0.0, now) confidence = confidence_of(focus, stats, target, min_attempts=min_attempts) if urgency > 0 and confidence >= _CONFIDENCE_GOOD: - return "review" + return FocusReason(kind=FocusKind.KEY_REVIEW) if confidence < _CONFIDENCE_GOOD: - return "weak" + return FocusReason(kind=FocusKind.KEY_WEAK) return None def _focus_reason_transition( prev_cp: int, next_cp: int, - transitions: dict[str, TransitionStats], + transitions: Mapping[Bigram, TransitionStats], target: float, now: float, *, min_attempts: int, -) -> str | None: - pair = chr(prev_cp) + chr(next_cp) - t_stats = transitions.get(transition_key(prev_cp, next_cp)) +) -> FocusReason | None: + pair = Bigram(prev_cp, next_cp) + t_stats = transitions.get(pair) urgency = review_urgency(t_stats.last_seen if t_stats else 0.0, now) confidence = transition_confidence_of( - prev_cp, next_cp, transitions, target, min_attempts=min_attempts, + prev_cp, + next_cp, + transitions, + target, + min_attempts=min_attempts, ) if urgency > 0 and confidence >= _CONFIDENCE_GOOD: - return f"{pair} review transition" + return FocusReason(kind=FocusKind.TRANSITION_REVIEW, pair=pair) if confidence < _CONFIDENCE_GOOD: - return f"{pair} weak transition" + return FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=pair) return None @@ -125,7 +132,7 @@ class Lesson: text: str state: LessonState urgency: dict[int, float] - focus_reason: str | None + focus_reason: FocusReason | None focus_confidence: float | None = None focus_speed: float | None = None focus_accuracy: float | None = None @@ -142,12 +149,12 @@ def heatmap(self) -> dict[int, float]: def _lesson_progress( layout_name: str, layout: Layout, - stats: dict[int, KeyStats], + stats: Mapping[int, KeyStats], settings: Settings, now: float, *, - transitions: dict[str, TransitionStats] | None = None, -) -> tuple[tuple[int, ...], int, LessonState, tuple[int, int] | None]: + transitions: Mapping[Bigram, TransitionStats] | None = None, +) -> tuple[tuple[int, ...], int, LessonState, Bigram | None]: target = target_ms_per_char(settings.target_speed_cpm) order = keyboard_order(layout) unlocked = compute_unlocked( @@ -208,7 +215,10 @@ def _lesson_progress( codepoint=cp, unlocked=True, confidence=confidence_of( - cp, stats, target, min_attempts=settings.min_confidence_attempts, + cp, + stats, + target, + min_attempts=settings.min_confidence_attempts, ), is_focus=(cp == focus), ) @@ -236,11 +246,16 @@ def __call__(self, layout_name: str) -> Lesson: settings = self.settings_repo.load() layout = self.layout_repo.get(layout_name) aggregates = self.aggregates_cache.get(layout_name) - stats = aggregates.keys if aggregates else {} - transitions = aggregates.transitions if aggregates else {} + stats: Mapping[int, KeyStats] = aggregates.keys if aggregates else {} + transitions: Mapping[Bigram, TransitionStats] = aggregates.transitions if aggregates else {} now = time.time() unlocked, focus, state, focus_bigram = _lesson_progress( - layout_name, layout, stats, settings, now, transitions=transitions, + layout_name, + layout, + stats, + settings, + now, + transitions=transitions, ) target = target_ms_per_char(settings.target_speed_cpm) @@ -251,14 +266,15 @@ def __call__(self, layout_name: str) -> Lesson: chr(k.codepoint): practice_weight( k.confidence, urgency=review_urgency( - stats[k.codepoint].last_seen if k.codepoint in stats else 0.0, now, + stats[k.codepoint].last_seen if k.codepoint in stats else 0.0, + now, ), ) for k in state.keys } char_weights[chr(focus)] *= settings.focus_char_boost transition_weights = { - transition_key(prev, nxt): transition_practice_weight( + Bigram(prev, nxt): transition_practice_weight( transition_confidence_of( prev, nxt, @@ -267,8 +283,9 @@ def __call__(self, layout_name: str) -> Lesson: min_attempts=settings.min_transition_confidence_attempts, ), urgency=review_urgency( - transitions[transition_key(prev, nxt)].last_seen - if transition_key(prev, nxt) in transitions else 0.0, + transitions[Bigram(prev, nxt)].last_seen + if Bigram(prev, nxt) in transitions + else 0.0, now, ), ) @@ -278,7 +295,7 @@ def __call__(self, layout_name: str) -> Lesson: } if focus_bigram is not None: prev_cp, next_cp = focus_bigram - pair_key = transition_key(prev_cp, next_cp) + pair_key = Bigram(prev_cp, next_cp) transition_weights[pair_key] *= settings.focus_transition_boost focus_confidence = transition_confidence_of( prev_cp, @@ -290,9 +307,15 @@ def __call__(self, layout_name: str) -> Lesson: if focus_confidence < _CONFIDENCE_GOOD: transition_weights[pair_key] *= settings.focus_weak_extra_boost char_weights[chr(focus)] *= settings.focus_weak_extra_boost - elif confidence_of( - focus, stats, target, min_attempts=settings.min_confidence_attempts, - ) < _CONFIDENCE_GOOD: + elif ( + confidence_of( + focus, + stats, + target, + min_attempts=settings.min_confidence_attempts, + ) + < _CONFIDENCE_GOOD + ): char_weights[chr(focus)] *= settings.focus_weak_extra_boost dict_words: list[str] | None = None if settings.wordlist_url: @@ -301,22 +324,24 @@ def __call__(self, layout_name: str) -> Lesson: filtered = words_for_alphabet(cached, alphabet_chars) if filtered: dict_words = filtered - text = generator.generate_lesson( - alphabet_chars, - chr(focus), - word_count=WORD_COUNT, + weighting = LessonWeighting( char_weights=char_weights, - layout=layout, transition_weights=transition_weights, - focus_bigram=focus_bigram, + layout=layout, words=dict_words, focus_word_boost=settings.focus_word_boost, focus_bigram_word_boost=settings.focus_bigram_word_boost, ) + text = generator.generate_lesson( + alphabet_chars, + chr(focus), + word_count=WORD_COUNT, + weighting=weighting, + focus_bigram=focus_bigram, + ) urgency = { - cp: review_urgency(stats[cp].last_seen if cp in stats else 0.0, now) - for cp in unlocked + cp: review_urgency(stats[cp].last_seen if cp in stats else 0.0, now) for cp in unlocked } focus_speed: float | None = None focus_accuracy: float | None = None @@ -339,7 +364,10 @@ def __call__(self, layout_name: str) -> Lesson: ) if reason: focus_speed, focus_accuracy = _transition_focus_metrics( - prev_cp, next_cp, transitions, target, + prev_cp, + next_cp, + transitions, + target, ) else: reason = _focus_reason( @@ -350,7 +378,10 @@ def __call__(self, layout_name: str) -> Lesson: min_attempts=settings.min_confidence_attempts, ) focus_confidence = confidence_of( - focus, stats, target, min_attempts=settings.min_confidence_attempts, + focus, + stats, + target, + min_attempts=settings.min_confidence_attempts, ) if reason: focus_speed, focus_accuracy = _key_focus_metrics(focus, stats, target) diff --git a/src/keystrike/application/prepare_practice.py b/src/keystrike/application/prepare_practice.py index e509a34..54cee4f 100644 --- a/src/keystrike/application/prepare_practice.py +++ b/src/keystrike/application/prepare_practice.py @@ -6,14 +6,15 @@ from dataclasses import dataclass from keystrike.application.build_lesson import BuildLesson +from keystrike.domain.confidence import FocusReason from keystrike.domain.enums import Mode from keystrike.domain.models import Layout -from keystrike.domain.null_adapters import NULL_STATS_REBUILDER +from keystrike.domain.null_adapters import NULL_AGGREGATES_ENSURER from keystrike.domain.protocols import ( + AggregatesEnsurer, DailyLearnBudgetProvider, LayoutRepository, SettingsRepository, - StatsRebuilder, ) PrepareNextSession = Callable[[], "SessionPrep | None"] @@ -25,7 +26,7 @@ class SessionPrep: layout: str mode: Mode focus_key: int | None - focus_reason: str | None + focus_reason: FocusReason | None focus_confidence: float | None focus_speed: float | None focus_accuracy: float | None @@ -40,11 +41,11 @@ class PreparePracticeSession: layout_repo: LayoutRepository build_lesson: BuildLesson get_daily_learn_budget: DailyLearnBudgetProvider - rebuild_aggregates: StatsRebuilder = NULL_STATS_REBUILDER + ensure_aggregates: AggregatesEnsurer = NULL_AGGREGATES_ENSURER def __call__(self) -> SessionPrep | None: settings = self.settings_repo.load() - self.rebuild_aggregates.ensure(settings.layout) + self.ensure_aggregates(settings.layout) lesson = self.build_lesson(settings.layout) return SessionPrep( target_text=lesson.text, diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 3c86ff7..26619fb 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -1,4 +1,4 @@ -from collections.abc import Callable, Iterable, Sequence +from collections.abc import Iterable from dataclasses import dataclass, field, replace from keystrike.domain.aggregate import combine_sessions, session_recency_weights @@ -13,7 +13,6 @@ from keystrike.domain.models import Keystroke, SessionResult from keystrike.domain.null_adapters import NullSessionRepository from keystrike.domain.protocols import ( - AggregatesCache, Clock, IdGenerator, LayoutRepository, @@ -29,8 +28,6 @@ skip_leading_whitespace, ) -_SPARK = "▁▂▃▄▅▆▇█" - @dataclass(slots=True) class StartSession: @@ -139,7 +136,7 @@ def _snapshot_unlock_state( prior_headers = sorted( repo.iter_headers(session.layout), key=lambda h: h.started_at, - )[-(settings.confidence_session_window - 1):] + )[-(settings.confidence_session_window - 1) :] sessions: list[tuple[SessionResult, Iterable[Keystroke]]] = [ (header, repo.load_keystrokes(header.session_id)) for header in prior_headers ] @@ -175,7 +172,6 @@ def _sync_alphabet_size(unlocked_keys: tuple[int, ...], settings_repo: SettingsR class FinishSession: clock: Clock repo: SessionRepository = field(default_factory=NullSessionRepository) - aggregates_cache: AggregatesCache | None = None settings_repo: SettingsRepository | None = None layout_repo: LayoutRepository | None = None @@ -190,10 +186,7 @@ def __call__(self, session: Session) -> SessionResult: target_speed_cpm = 0 unlocked_keys: tuple[int, ...] = () key_confidence: dict[int, float] = {} - if ( - self.settings_repo is not None - and self.layout_repo is not None - ): + if self.settings_repo is not None and self.layout_repo is not None: settings = self.settings_repo.load() target_speed_cpm = settings.target_speed_cpm unlocked_keys, key_confidence = _snapshot_unlock_state( @@ -278,364 +271,11 @@ def compute_accuracy(result: SessionResult) -> float: return result.correct_keystrokes / result.total_keystrokes -def value_sparkline(values: Sequence[float]) -> str: - """Unicode sparkline for numeric values, oldest→newest.""" - if not values: - return "" - lo, hi = min(values), max(values) - if hi == lo: - level = len(_SPARK) - 1 if hi > 0 else 0 - return _SPARK[level] * len(values) - span = hi - lo - return "".join( - _SPARK[min(len(_SPARK) - 1, int((v - lo) / span * (len(_SPARK) - 1)))] - for v in values - ) - - -def wpm_sparkline(headers: Sequence[SessionResult], *, limit: int = 20) -> str: - """Unicode sparkline of WPM per session, oldest→newest.""" - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] - if not ordered: - return "" - return value_sparkline([compute_wpm(h) for h in ordered]) - - -def _display_confidence( - stored_conf: float, - stored_target_cpm: int, - current_target_cpm: int, -) -> float: - if stored_target_cpm <= 0 or current_target_cpm <= 0: - return stored_conf - return stored_conf * ( - target_ms_per_char(current_target_cpm) - / target_ms_per_char(stored_target_cpm) - ) - - -def focus_confidence_values( - headers: Sequence[SessionResult], - *, - limit: int = 20, - current_target_speed_cpm: int = 0, -) -> list[float]: - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] - return [ - _display_confidence( - h.key_confidence.get(h.focus_key, 0.0) if h.focus_key is not None else 0.0, - h.target_speed_cpm, - current_target_speed_cpm, - ) - for h in ordered - ] - - -def focus_confidence_sparkline( - headers: Sequence[SessionResult], - *, - limit: int = 20, - current_target_speed_cpm: int = 0, -) -> str: - """Unicode sparkline of focus-key confidence per session, oldest→newest.""" - values = focus_confidence_values( - headers, limit=limit, current_target_speed_cpm=current_target_speed_cpm, - ) - if not values: - return "" - return value_sparkline(values) - - -def key_confidence_values( - headers: Sequence[SessionResult], - codepoint: int, - *, - limit: int = 20, - current_target_speed_cpm: int = 0, -) -> list[float]: - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] - return [ - _display_confidence( - h.key_confidence.get(codepoint, 0.0), - h.target_speed_cpm, - current_target_speed_cpm, - ) - for h in ordered - ] - - -def key_confidence_sparkline( - headers: Sequence[SessionResult], - codepoint: int, - *, - limit: int = 20, - current_target_speed_cpm: int = 0, -) -> str: - values = key_confidence_values( - headers, codepoint, limit=limit, current_target_speed_cpm=current_target_speed_cpm, - ) - if not values: - return "" - return value_sparkline(values) - - -def _char_label(codepoint: int) -> str: - ch = chr(codepoint) - return ch if ch.isprintable() and not ch.isspace() else f"U+{codepoint:04X}" - - -def _focus_char_label(focus_key: int | None) -> str: - if focus_key is None: - return "?" - return _char_label(focus_key) - - -def format_wpm_trend_line(headers: Sequence[SessionResult], *, limit: int = 20) -> str: - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] - if not ordered: - return "" - wpms = [compute_wpm(h) for h in ordered] - spark = wpm_sparkline(headers, limit=limit) - return ( - f"[bold]WPM trend[/] ({len(wpms)} sessions) {spark} " - f"[dim]latest {wpms[-1]:.0f} peak {max(wpms):.0f}[/]" - ) - - -def format_focus_confidence_trend_line( - headers: Sequence[SessionResult], - *, - limit: int = 20, - current_target_speed_cpm: int = 0, -) -> str: - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] - if not ordered: - return "" - focus_key = ordered[-1].focus_key - if focus_key is None: - return "" - values = key_confidence_values( - headers, - focus_key, - limit=limit, - current_target_speed_cpm=current_target_speed_cpm, - ) - label = _focus_char_label(focus_key) - return format_metric_trend_block( - title=f"Focus '{label}'", - confidence_values=values, - speed_values=[], - accuracy_values=[], - limit=limit, - ) - - -# Keep in sync with presentation/theme.py STYLE_TREND_* / STYLE_DELTA_*. -_TREND_CONFIDENCE_COLOR = "cyan" -_TREND_SPEED_COLOR = "green" -_TREND_ACCURACY_COLOR = "yellow" +# Keep in sync with presentation/theme.py STYLE_DELTA_*. _DELTA_IMPROVE_COLOR = "green" _DELTA_REGRESS_COLOR = "red" -def _colored_sparkline(spark: str, color: str) -> str: - return f"[{color}]{spark}[/]" if spark else "" - - -def _default_metric_value(value: float) -> str: - return f"{value:.2f}" - - -_GRID_LABEL_WIDTH = 10 -_GRID_VALUE_WIDTH = 8 - - -def _format_metric_trend_line( - label: str, - color: str, - values: Sequence[float], - spark: str, - *, - format_value: Callable[[float], str] | None = None, - suffix: str = "", - show_sessions: bool = True, - grid: bool = False, - spark_width: int = 20, -) -> str: - if not values: - return "" - fmt = format_value or _default_metric_value - label_text = label.ljust(_GRID_LABEL_WIDTH) if grid else label - spark_text = spark.ljust(spark_width) if grid else spark - session_part = f" ({len(values)} sessions) " if show_sessions else " " - latest_str = fmt(values[-1]) - peak_str = fmt(max(values)) - if grid: - values_part = ( - f"latest {latest_str:>{_GRID_VALUE_WIDTH}} " - f"peak {peak_str:>{_GRID_VALUE_WIDTH}}" - ) - else: - values_part = f"latest {latest_str} peak {peak_str}" - line = ( - f"[bold {color}]{label_text}[/]{session_part}" - f"{_colored_sparkline(spark_text, color)} " - f"[dim {color}]{values_part}[/]" - ) - if suffix: - line += f" {suffix}" - return line - - -def format_confidence_trend_line( - values: Sequence[float], - *, - grid: bool = False, - spark_width: int = 20, -) -> str: - return _format_metric_trend_line( - "confidence", - _TREND_CONFIDENCE_COLOR, - values, - value_sparkline(values), - show_sessions=not grid, - grid=grid, - spark_width=spark_width, - ) - - -def format_key_confidence_trend_line( - headers: Sequence[SessionResult], - codepoint: int, - *, - limit: int = 20, - current_target_speed_cpm: int = 0, - cumulative: float | None = None, - include_key_name: bool = True, - grid: bool = False, - spark_width: int = 20, -) -> str: - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] - if not ordered: - return "" - values = key_confidence_values( - headers, codepoint, limit=limit, current_target_speed_cpm=current_target_speed_cpm, - ) - spark = key_confidence_sparkline( - headers, codepoint, limit=limit, current_target_speed_cpm=current_target_speed_cpm, - ) - char = _char_label(codepoint) - label = f"'{char}' confidence" if include_key_name else "confidence" - suffix = "" - if cumulative is not None: - suffix = f"[dim {_TREND_CONFIDENCE_COLOR}]cumulative {cumulative:.2f}[/]" - return _format_metric_trend_line( - label, - _TREND_CONFIDENCE_COLOR, - values, - spark, - suffix=suffix, - show_sessions=not grid, - grid=grid, - spark_width=spark_width, - ) - - -def format_key_speed_trend_line( - values: Sequence[float], - *, - grid: bool = False, - spark_width: int = 20, -) -> str: - return _format_metric_trend_line( - "speed", - _TREND_SPEED_COLOR, - values, - value_sparkline(values), - show_sessions=not grid, - grid=grid, - spark_width=spark_width, - ) - - -def format_key_accuracy_trend_line( - values: Sequence[float], - *, - grid: bool = False, - spark_width: int = 20, -) -> str: - pct_values = [v * 100 for v in values] - return _format_metric_trend_line( - "accuracy", - _TREND_ACCURACY_COLOR, - pct_values, - value_sparkline(pct_values), - format_value=lambda v: f"{v:.1f}%", - show_sessions=not grid, - grid=grid, - spark_width=spark_width, - ) - - -def format_metric_trend_block( - title: str, - *, - speed_values: Sequence[float], - accuracy_values: Sequence[float], - limit: int = 20, - confidence_values: Sequence[float] | None = None, - headers: Sequence[SessionResult] | None = None, - codepoint: int | None = None, - current_target_speed_cpm: int = 0, - cumulative: float | None = None, -) -> str: - grid = True - spark_width = limit - if headers is not None and codepoint is not None: - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] - session_count = len(ordered) if ordered else max( - len(speed_values), len(accuracy_values), 0, - ) - conf_line = format_key_confidence_trend_line( - headers, - codepoint, - limit=limit, - current_target_speed_cpm=current_target_speed_cpm, - cumulative=cumulative, - include_key_name=False, - grid=grid, - spark_width=spark_width, - ) - else: - session_count = max( - len(confidence_values or []), - len(speed_values), - len(accuracy_values), - 0, - ) - conf_line = format_confidence_trend_line( - confidence_values or [], - grid=grid, - spark_width=spark_width, - ) - header = f"[bold]{title}[/]" - if session_count: - header += f" ({session_count} sessions)" - else: - return "" - lines = [ - header, - conf_line, - format_key_speed_trend_line( - speed_values, grid=grid, spark_width=spark_width, - ), - format_key_accuracy_trend_line( - accuracy_values, grid=grid, spark_width=spark_width, - ), - ] - return "\n".join(line for line in lines if line) - - def previous_session_header( repo: SessionRepository, result: SessionResult, @@ -669,20 +309,38 @@ def confidence_window_session_baseline( for i, header in enumerate(ordered): if header.session_id != result.session_id: continue - prior = ordered[max(0, i - window + 1):i] + prior = ordered[max(0, i - window + 1) : i] if not prior: return None weights = session_recency_weights(len(prior)) total = sum(weights) wpm = sum(compute_wpm(h) * w for h, w in zip(prior, weights, strict=True)) / total - acc = sum( - compute_accuracy(h) * 100 * w - for h, w in zip(prior, weights, strict=True) - ) / total + acc = ( + sum(compute_accuracy(h) * 100 * w for h, w in zip(prior, weights, strict=True)) / total + ) return SessionStatsBaseline(wpm=wpm, accuracy_pct=acc) return None +@dataclass(slots=True) +class GetSessionBaseline: + """Recency-weighted WPM/accuracy baseline for a just-finished session. + + Wraps ``confidence_window_session_baseline`` behind its own collaborators + so callers (e.g. ``PracticeScreen``) don't need to reach into another use + case's private ``repo``/``settings_repo`` to compute it. + """ + + repo: SessionRepository + settings_repo: SettingsRepository | None = None + + def __call__(self, result: SessionResult) -> SessionStatsBaseline | None: + if self.settings_repo is None: + return None + window = self.settings_repo.load().confidence_session_window + return confidence_window_session_baseline(self.repo, result, window=window) + + def _format_metric_delta( current: float, previous: float, @@ -712,7 +370,9 @@ def format_session_stats_line( if baseline is not None: wpm_delta = _format_metric_delta(wpm, baseline.wpm) acc_delta = _format_metric_delta( - acc, baseline.accuracy_pct, suffix="%", + acc, + baseline.accuracy_pct, + suffix="%", ) return ( f"Last: WPM [bold]{wpm:5.1f}[/]{wpm_delta} " diff --git a/src/keystrike/application/stats_use_cases.py b/src/keystrike/application/stats_use_cases.py index 3b21957..29a67ce 100644 --- a/src/keystrike/application/stats_use_cases.py +++ b/src/keystrike/application/stats_use_cases.py @@ -25,13 +25,13 @@ @dataclass(slots=True) class RebuildAggregates: - """Replay the last N sessions (from settings) into the cache.""" + """Command: replay the last N sessions (from settings) into the cache.""" repo: SessionRepository cache: AggregatesCache settings_repo: SettingsRepository - def __call__(self, layout: str) -> dict[int, KeyStats]: + def __call__(self, layout: str) -> None: window = self.settings_repo.load().confidence_session_window headers = sorted( self.repo.iter_headers(layout), @@ -41,16 +41,30 @@ def __call__(self, layout: str) -> dict[int, KeyStats]: [(header, self.repo.load_keystrokes(header.session_id)) for header in headers], ) self.cache.put(layout, combined) - return combined.keys - def ensure(self, layout: str) -> dict[int, KeyStats]: - """Rebuild only when cache is missing or lacks transitions despite session history.""" + +@dataclass(slots=True) +class EnsureAggregates: + """Query: current per-key aggregates for a layout. + + Rebuilds first (via ``rebuild``) only when the cache is missing or lacks + transitions despite session history — callers never have to decide + whether a rebuild is needed themselves. + """ + + repo: SessionRepository + cache: AggregatesCache + rebuild: RebuildAggregates + + def __call__(self, layout: str) -> Mapping[int, KeyStats]: cached = self.cache.get(layout) if cached is not None and cached.transitions: return cached.keys if cached is not None and not any(self.repo.iter_headers(layout)): return cached.keys - return self(layout) + self.rebuild(layout) + rebuilt = self.cache.get(layout) + return rebuilt.keys if rebuilt is not None else {} @dataclass(frozen=True, slots=True) @@ -128,10 +142,7 @@ def _normalize_speed_to_current_goal( ) -> float: if stored_target_cpm <= 0 or current_target_cpm <= 0: return speed - return speed * ( - target_ms_per_char(current_target_cpm) - / target_ms_per_char(stored_target_cpm) - ) + return speed * (target_ms_per_char(current_target_cpm) / target_ms_per_char(stored_target_cpm)) def _aggregate_speed_accuracy( @@ -205,10 +216,8 @@ def _metric_trends( confidences: list[float] = [] for rel_i, header in enumerate(ordered): abs_i = start_offset + rel_i - window_headers = all_headers[max(0, abs_i - window + 1):abs_i + 1] - sessions = [ - (h, repo.load_keystrokes(h.session_id)) for h in window_headers - ] + window_headers = all_headers[max(0, abs_i - window + 1) : abs_i + 1] + sessions = [(h, repo.load_keystrokes(h.session_id)) for h in window_headers] combined = combine_sessions(sessions).keys if header.target_speed_cpm > 0: @@ -218,9 +227,7 @@ def _metric_trends( if codepoint is not None: key_stats = combined.get(codepoint) - if key_stats is None or ( - key_stats.samples == 0 and key_stats.error_count == 0 - ): + if key_stats is None or (key_stats.samples == 0 and key_stats.error_count == 0): speeds.append(0.0) accuracies.append(0.0) confidences.append(0.0) @@ -237,7 +244,10 @@ def _metric_trends( ) accuracy = round_confidence(accuracy_of(key_stats)) confidence = confidence_of( - codepoint, combined, session_target, min_attempts=min_attempts, + codepoint, + combined, + session_target, + min_attempts=min_attempts, ) else: speed, accuracy = _aggregate_speed_accuracy(combined, session_target) @@ -249,7 +259,9 @@ def _metric_trends( ), ) confidence = _aggregate_confidence( - combined, session_target, min_attempts, + combined, + session_target, + min_attempts, ) speeds.append(speed) diff --git a/src/keystrike/application/sync_use_cases.py b/src/keystrike/application/sync_use_cases.py index f1a1568..117f97c 100644 --- a/src/keystrike/application/sync_use_cases.py +++ b/src/keystrike/application/sync_use_cases.py @@ -5,12 +5,12 @@ from dataclasses import dataclass from keystrike.domain.models import SyncStatusReport -from keystrike.domain.protocols import StatsRebuilder, SyncGateway +from keystrike.domain.protocols import StatsRebuilder, SyncStore @dataclass(slots=True) class InitSync: - gateway: SyncGateway + gateway: SyncStore def __call__(self, remote_url: str) -> None: self.gateway.init(remote_url) @@ -18,7 +18,7 @@ def __call__(self, remote_url: str) -> None: @dataclass(slots=True) class PullSync: - gateway: SyncGateway + gateway: SyncStore rebuild: StatsRebuilder def __call__(self) -> int: @@ -27,7 +27,7 @@ def __call__(self) -> int: @dataclass(slots=True) class PushSync: - gateway: SyncGateway + gateway: SyncStore def __call__(self) -> bool: return self.gateway.push() @@ -35,7 +35,7 @@ def __call__(self) -> bool: @dataclass(slots=True) class GetSyncStatus: - gateway: SyncGateway + gateway: SyncStore def __call__(self) -> SyncStatusReport: return self.gateway.status() diff --git a/src/keystrike/domain/aggregate.py b/src/keystrike/domain/aggregate.py index cb8ffb0..b9e2701 100644 --- a/src/keystrike/domain/aggregate.py +++ b/src/keystrike/domain/aggregate.py @@ -10,8 +10,8 @@ from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field -from .confidence import SESSION_RECENCY_DECAY, is_same_key_transition -from .models import KeyStats, Keystroke, LayoutAggregates, SessionResult, TransitionStats +from .confidence import SESSION_RECENCY_DECAY, HasConfidenceFields, is_same_key_transition +from .models import Bigram, KeyStats, Keystroke, LayoutAggregates, SessionResult, TransitionStats @dataclass(slots=True) @@ -66,8 +66,7 @@ def aggregate_session( cp: KeyStats( codepoint=cp, samples=len(p.time_samples), - mean_time_ns=(sum(p.time_samples) / len(p.time_samples)) - if p.time_samples else 0.0, + mean_time_ns=(sum(p.time_samples) / len(p.time_samples)) if p.time_samples else 0.0, error_count=p.error_count, last_seen=session_end_wall, attempt_count=p.attempt_count, @@ -88,6 +87,39 @@ def session_recency_weights( return [decay ** (session_count - 1 - i) for i in range(session_count)] +def _rounded_weighted_count(weighted: float) -> int: + """Round recency-weighted counts without zeroing fractional evidence.""" + if weighted <= 0: + return 0 + rounded = round(weighted) + return max(1, rounded) if rounded == 0 else rounded + + +def _weighted_merge_fields( + entries: Sequence[tuple[HasConfidenceFields, float]], +) -> tuple[int, float, int, int, float]: + """Shared recency-weighted merge math for key stats and transition stats: + returns (samples, mean_time_ns, error_count, attempt_count, last_seen).""" + weighted_samples = sum(weight * stats.samples for stats, weight in entries) + weighted_errors = sum(weight * stats.error_count for stats, weight in entries) + weighted_attempts = sum(weight * stats.attempt_count for stats, weight in entries) + if weighted_samples > 0: + mean = ( + sum(stats.mean_time_ns * weight * stats.samples for stats, weight in entries) + / weighted_samples + ) + else: + mean = 0.0 + last_seen = max(stats.last_seen for stats, _ in entries) + return ( + _rounded_weighted_count(weighted_samples), + mean, + round(weighted_errors), + _rounded_weighted_count(weighted_attempts), + last_seen, + ) + + def _combine_key_maps_weighted( maps: Sequence[dict[int, KeyStats]], weights: Sequence[float], @@ -100,57 +132,38 @@ def _combine_key_maps_weighted( out: dict[int, KeyStats] = {} for cp, entries in by_cp.items(): - weighted_samples = sum(weight * stats.samples for stats, weight in entries) - weighted_errors = sum(weight * stats.error_count for stats, weight in entries) - weighted_attempts = sum(weight * stats.attempt_count for stats, weight in entries) - if weighted_samples > 0: - mean = sum( - stats.mean_time_ns * weight * stats.samples - for stats, weight in entries - ) / weighted_samples - else: - mean = 0.0 + samples, mean, errors, attempts, last_seen = _weighted_merge_fields(entries) out[cp] = KeyStats( codepoint=cp, - samples=round(weighted_samples), + samples=samples, mean_time_ns=mean, - error_count=round(weighted_errors), - last_seen=max(stats.last_seen for stats, _ in entries), - attempt_count=round(weighted_attempts), + error_count=errors, + last_seen=last_seen, + attempt_count=attempts, ) return out def _combine_transition_maps_weighted( - maps: Sequence[dict[str, TransitionStats]], + maps: Sequence[dict[Bigram, TransitionStats]], weights: Sequence[float], -) -> dict[str, TransitionStats]: - by_key: dict[str, list[tuple[TransitionStats, float]]] = {} +) -> dict[Bigram, TransitionStats]: + by_key: dict[Bigram, list[tuple[TransitionStats, float]]] = {} for m, weight in zip(maps, weights, strict=True): for key, stats in m.items(): by_key.setdefault(key, []).append((stats, weight)) - out: dict[str, TransitionStats] = {} + out: dict[Bigram, TransitionStats] = {} for key, entries in by_key.items(): - weighted_samples = sum(weight * stats.samples for stats, weight in entries) - weighted_errors = sum(weight * stats.error_count for stats, weight in entries) - weighted_attempts = sum(weight * stats.attempt_count for stats, weight in entries) - if weighted_samples > 0: - mean = sum( - stats.mean_time_ns * weight * stats.samples - for stats, weight in entries - ) / weighted_samples - else: - mean = 0.0 - prev_cp, next_cp = entries[0][0].prev_cp, entries[0][0].next_cp + samples, mean, errors, attempts, last_seen = _weighted_merge_fields(entries) out[key] = TransitionStats( - prev_cp=prev_cp, - next_cp=next_cp, - samples=round(weighted_samples), + prev_cp=key.prev_cp, + next_cp=key.next_cp, + samples=samples, mean_time_ns=mean, - error_count=round(weighted_errors), - last_seen=max(stats.last_seen for stats, _ in entries), - attempt_count=round(weighted_attempts), + error_count=errors, + last_seen=last_seen, + attempt_count=attempts, ) return without_same_key_transitions(out) @@ -184,10 +197,7 @@ def merge_key_stats(a: KeyStats, b: KeyStats) -> KeyStats: if a.codepoint != b.codepoint: raise ValueError(f"codepoint mismatch: {a.codepoint} vs {b.codepoint}") total = a.samples + b.samples - mean = ( - (a.mean_time_ns * a.samples + b.mean_time_ns * b.samples) / total - if total > 0 else 0.0 - ) + mean = (a.mean_time_ns * a.samples + b.mean_time_ns * b.samples) / total if total > 0 else 0.0 return KeyStats( codepoint=a.codepoint, samples=total, @@ -207,12 +217,15 @@ def combine(*maps: dict[int, KeyStats]) -> dict[int, KeyStats]: def transition_key(prev_cp: int, next_cp: int) -> str: - return chr(prev_cp) + chr(next_cp) + """Display form of a bigram, e.g. `transition_key(ord("a"), ord("b")) == + "ab"`. Internal transition dicts are keyed by `Bigram` instances, not this + string — use this only for display/logging.""" + return Bigram(prev_cp, next_cp).chars() def without_same_key_transitions( - transitions: Mapping[str, TransitionStats], -) -> dict[str, TransitionStats]: + transitions: Mapping[Bigram, TransitionStats], +) -> dict[Bigram, TransitionStats]: """Drop same-key pairs (aa, ee) from stored transition stats.""" return { key: stats @@ -221,9 +234,9 @@ def without_same_key_transitions( } -def per_transition_deltas(keystrokes: Iterable[Keystroke]) -> dict[str, list[int]]: +def per_transition_deltas(keystrokes: Iterable[Keystroke]) -> dict[Bigram, list[int]]: """Inter-keystroke deltas per prev→next target pair for correct keystrokes.""" - deltas: dict[str, list[int]] = {} + deltas: dict[Bigram, list[int]] = {} last_correct_cp: int | None = None last_correct_t_ns: int | None = None @@ -237,7 +250,7 @@ def per_transition_deltas(keystrokes: Iterable[Keystroke]) -> dict[str, list[int ): delta = k.t_ns - last_correct_t_ns if delta > 0: - deltas.setdefault(transition_key(last_correct_cp, k.codepoint), []).append(delta) + deltas.setdefault(Bigram(last_correct_cp, k.codepoint), []).append(delta) last_correct_cp = k.codepoint last_correct_t_ns = k.t_ns @@ -247,8 +260,8 @@ def per_transition_deltas(keystrokes: Iterable[Keystroke]) -> dict[str, list[int def aggregate_transitions( result: SessionResult, keystrokes: Iterable[Keystroke], -) -> dict[str, TransitionStats]: - partial: dict[str, _Partial] = {} +) -> dict[Bigram, TransitionStats]: + partial: dict[Bigram, _Partial] = {} all_keystrokes = list(keystrokes) last_correct_cp: int | None = None @@ -257,15 +270,16 @@ def aggregate_transitions( if i > 0: prev_cp = all_keystrokes[i - 1].codepoint if not is_same_key_transition(prev_cp, k.codepoint): - key = transition_key(prev_cp, k.codepoint) + key = Bigram(prev_cp, k.codepoint) entry = partial.setdefault(key, _Partial()) entry.attempt_count += 1 entry.error_count += 1 continue if last_correct_cp is not None and not is_same_key_transition( - last_correct_cp, k.codepoint, + last_correct_cp, + k.codepoint, ): - key = transition_key(last_correct_cp, k.codepoint) + key = Bigram(last_correct_cp, k.codepoint) partial.setdefault(key, _Partial()).attempt_count += 1 last_correct_cp = k.codepoint @@ -274,19 +288,20 @@ def aggregate_transitions( session_end_wall = result.started_at + result.duration_ns / 1e9 - return without_same_key_transitions({ - key: TransitionStats( - prev_cp=ord(key[0]), - next_cp=ord(key[1]), - samples=len(p.time_samples), - mean_time_ns=(sum(p.time_samples) / len(p.time_samples)) - if p.time_samples else 0.0, - error_count=p.error_count, - last_seen=session_end_wall, - attempt_count=p.attempt_count, - ) - for key, p in partial.items() - }) + return without_same_key_transitions( + { + key: TransitionStats( + prev_cp=key.prev_cp, + next_cp=key.next_cp, + samples=len(p.time_samples), + mean_time_ns=(sum(p.time_samples) / len(p.time_samples)) if p.time_samples else 0.0, + error_count=p.error_count, + last_seen=session_end_wall, + attempt_count=p.attempt_count, + ) + for key, p in partial.items() + } + ) def merge_transition_stats(a: TransitionStats, b: TransitionStats) -> TransitionStats: @@ -295,10 +310,7 @@ def merge_transition_stats(a: TransitionStats, b: TransitionStats) -> Transition f"transition mismatch: {a.prev_cp}→{a.next_cp} vs {b.prev_cp}→{b.next_cp}", ) total = a.samples + b.samples - mean = ( - (a.mean_time_ns * a.samples + b.mean_time_ns * b.samples) / total - if total > 0 else 0.0 - ) + mean = (a.mean_time_ns * a.samples + b.mean_time_ns * b.samples) / total if total > 0 else 0.0 return TransitionStats( prev_cp=a.prev_cp, next_cp=a.next_cp, @@ -310,8 +322,8 @@ def merge_transition_stats(a: TransitionStats, b: TransitionStats) -> Transition ) -def combine_transitions(*maps: dict[str, TransitionStats]) -> dict[str, TransitionStats]: - out: dict[str, TransitionStats] = {} +def combine_transitions(*maps: dict[Bigram, TransitionStats]) -> dict[Bigram, TransitionStats]: + out: dict[Bigram, TransitionStats] = {} for m in maps: for key, t in m.items(): out[key] = merge_transition_stats(out[key], t) if key in out else t diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index af9752d..78b137a 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -7,8 +7,11 @@ from __future__ import annotations from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Protocol -from .models import KeyStats, TransitionStats +from .enums import FocusKind +from .models import Bigram, KeyStats, TransitionStats _SECONDS_PER_DAY = 86_400.0 _REVIEW_URGENCY_FULL_DAYS = 3.0 @@ -31,6 +34,17 @@ FOCUS_WEAK_EXTRA_BOOST = 1.5 +@dataclass(frozen=True, slots=True) +class FocusReason: + """Why the adaptive engine is emphasizing today's lesson focus. Replaces + the old ad-hoc strings/formatted-string focus reasons; `pair` is set only + for the TRANSITION_* kinds. Presentation code pattern-matches on `kind` + to render its own display text instead of parsing suffixes/substrings.""" + + kind: FocusKind + pair: Bigram | None = None + + def round_confidence(value: float) -> float: """Round confidence for comparisons and display so they stay aligned.""" return round(value, CONFIDENCE_DECIMALS) @@ -47,19 +61,79 @@ def key_confidence(target_ms_per_char: float, mean_time_ns: float) -> float: return target_ms_per_char / (mean_time_ns / 1e6) +class HasConfidenceFields(Protocol): + """Structural shape shared by `KeyStats` and `TransitionStats`: everything + the confidence/accuracy/attempts math needs, regardless of whether the + stat is keyed by a single codepoint or a `Bigram`. Lets the functions + below have one body each instead of a key-variant and a transition-variant. + + Declared as read-only properties (not plain attributes) so the frozen + `KeyStats`/`TransitionStats` dataclasses structurally satisfy it — a + protocol with mutable attribute requirements would reject them.""" + + @property + def samples(self) -> int: ... + @property + def error_count(self) -> int: ... + @property + def attempt_count(self) -> int: ... + @property + def mean_time_ns(self) -> float: ... + @property + def last_seen(self) -> float: ... + + +def _accuracy(stats: HasConfidenceFields) -> float: + """Fraction of attempts that were correct. 0.0 for a stat with no correct + attempts yet, including one that's been missed but never hit.""" + samples = stats.samples + if samples <= 0 and stats.mean_time_ns > 0: + samples = 1 + total = samples + stats.error_count + return samples / total if total > 0 else 0.0 + + def accuracy_of(key_stats: KeyStats) -> float: - """Fraction of attempts on this key that were correct. 0.0 for a key with - no correct attempts yet, including one that's been missed but never hit.""" - total = key_stats.samples + key_stats.error_count - return key_stats.samples / total if total > 0 else 0.0 + return _accuracy(key_stats) + + +def _effective_attempt_count( + samples: int, + error_count: int, + attempt_count: int, + *, + mean_time_ns: float = 0.0, +) -> int: + """Use samples+errors when attempt_count was zeroed (stale cache / old merge). + + When mean_time_ns is measured but all counts were zeroed, infer at least one + attempt so confidence stays aligned with `_accuracy` (same fallback rule). + """ + if attempt_count > 0: + return attempt_count + inferred = samples + error_count + if inferred > 0: + return inferred + if mean_time_ns > 0: + return 1 + return 0 + + +def _attempts(stats: HasConfidenceFields) -> int: + return _effective_attempt_count( + stats.samples, + stats.error_count, + stats.attempt_count, + mean_time_ns=stats.mean_time_ns, + ) def key_attempts(key_stats: KeyStats) -> int: - return key_stats.attempt_count + return _attempts(key_stats) def transition_attempts(transition_stats: TransitionStats) -> int: - return transition_stats.attempt_count + return _attempts(transition_stats) def is_same_key_transition(prev_cp: int, next_cp: int) -> bool: @@ -81,6 +155,23 @@ def confidence_sample_factor( return min(1.0, attempts / minimum) +def _confidence_from_stats( + stats: HasConfidenceFields | None, + target: float, + *, + min_attempts: int, +) -> float: + """Shared body for `confidence_of`/`transition_confidence_of`: min(speed, + accuracy) so fast-but-sloppy or slow-but-accurate cannot read as mastered, + scaled down until `min_attempts` so a lucky first session can't read as + mastered (see docs/research/typing-pedagogy.md). 0.0 when never practiced. + """ + if stats is None: + return 0.0 + raw = min(key_confidence(target, stats.mean_time_ns), _accuracy(stats)) + return round_confidence(raw * confidence_sample_factor(_attempts(stats), minimum=min_attempts)) + + def confidence_of( codepoint: int, stats: Mapping[int, KeyStats], @@ -89,30 +180,14 @@ def confidence_of( min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, ) -> float: """Live confidence for one key from aggregated stats (typically the last - `CONFIDENCE_SESSION_WINDOW` sessions, recency-weighted), recomputed from the - current target. - 0.0 for a never-practiced key. - - Confidence is min(speed, accuracy) so fast-but-sloppy or slow-but-accurate - cannot read as mastered. Both must clear the bar. Scaled down until - `MIN_CONFIDENCE_ATTEMPTS` presses on the key so a lucky first session - can't read as mastered (see docs/research/typing-pedagogy.md). - """ - key_stats = stats.get(codepoint) - if key_stats is None: - return 0.0 - raw = min( - key_confidence(target, key_stats.mean_time_ns), - accuracy_of(key_stats), - ) - return round_confidence( - raw * confidence_sample_factor(key_attempts(key_stats), minimum=min_attempts), - ) + `CONFIDENCE_SESSION_WINDOW` sessions, recency-weighted), recomputed from + the current target. 0.0 for a never-practiced key.""" + return _confidence_from_stats(stats.get(codepoint), target, min_attempts=min_attempts) def _measured_transitions_meet_threshold( unlocked: Sequence[int], - transitions: Mapping[str, TransitionStats], + transitions: Mapping[Bigram, TransitionStats], target: float, *, threshold: float, @@ -123,11 +198,15 @@ def _measured_transitions_meet_threshold( for nxt in unlocked: if is_same_key_transition(prev, nxt): continue - if chr(prev) + chr(nxt) not in transitions: + if Bigram(prev, nxt) not in transitions: continue if ( transition_confidence_of( - prev, nxt, transitions, target, min_attempts=min_attempts, + prev, + nxt, + transitions, + target, + min_attempts=min_attempts, ) < threshold ): @@ -143,7 +222,7 @@ def compute_unlocked( *, threshold: float = 1.0, min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, - transitions: Mapping[str, TransitionStats] | None = None, + transitions: Mapping[Bigram, TransitionStats] | None = None, min_transition_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, ) -> tuple[int, ...]: """The first `alphabet_size` keys are always unlocked; each further key in @@ -157,15 +236,12 @@ def compute_unlocked( for k in unlocked ): break - if ( - transitions is not None - and not _measured_transitions_meet_threshold( - unlocked, - transitions, - target, - threshold=threshold, - min_attempts=min_transition_attempts, - ) + if transitions is not None and not _measured_transitions_meet_threshold( + unlocked, + transitions, + target, + threshold=threshold, + min_attempts=min_transition_attempts, ): break unlocked.append(codepoint) @@ -189,37 +265,28 @@ def review_urgency(last_seen: float, now: float) -> float: def transition_accuracy_of(transition_stats: TransitionStats) -> float: - total = transition_stats.samples + transition_stats.error_count - return transition_stats.samples / total if total > 0 else 0.0 + return _accuracy(transition_stats) def transition_confidence(target_ms_per_char: float, mean_time_ns: float) -> float: - if mean_time_ns <= 0: - return 0.0 + """Speed confidence for a transition — identical math to `key_confidence`, + kept as a separate name so call sites read as transition- vs key-specific.""" return key_confidence(target_ms_per_char, mean_time_ns) def transition_confidence_of( prev_cp: int, next_cp: int, - stats: Mapping[str, TransitionStats], + stats: Mapping[Bigram, TransitionStats], target: float, *, min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, ) -> float: """Live confidence for one bigram transition. 0.0 when never practiced.""" - transition_stats = stats.get(chr(prev_cp) + chr(next_cp)) - if transition_stats is None: - return 0.0 - raw = min( - transition_confidence(target, transition_stats.mean_time_ns), - transition_accuracy_of(transition_stats), - ) - return round_confidence( - raw * confidence_sample_factor( - transition_attempts(transition_stats), - minimum=min_attempts, - ), + return _confidence_from_stats( + stats.get(Bigram(prev_cp, next_cp)), + target, + min_attempts=min_attempts, ) @@ -260,6 +327,22 @@ def practice_weight( return base * (1.0 + review_bias * urgency) +def _focus_score_from_entry( + stats: HasConfidenceFields | None, + target: float, + now: float, + *, + review_penalty: float, + min_attempts: int, +) -> float: + """Shared body for `_focus_score`/`_transition_focus_score`: confidence, + discounted further the longer it's been since last practiced, so a stale + mastered key/pair can still outrank a merely-weak recent one.""" + urgency = review_urgency(stats.last_seen if stats else 0.0, now) + confidence = _confidence_from_stats(stats, target, min_attempts=min_attempts) + return confidence * (1.0 - review_penalty * urgency) + + def _focus_score( codepoint: int, stats: Mapping[int, KeyStats], @@ -269,11 +352,12 @@ def _focus_score( review_penalty: float, min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, ) -> float: - key_stats = stats.get(codepoint) - urgency = review_urgency(key_stats.last_seen if key_stats else 0.0, now) - return ( - confidence_of(codepoint, stats, target, min_attempts=min_attempts) - * (1.0 - review_penalty * urgency) + return _focus_score_from_entry( + stats.get(codepoint), + target, + now, + review_penalty=review_penalty, + min_attempts=min_attempts, ) @@ -287,8 +371,7 @@ def has_weak_unlocked_key( ) -> bool: """True when any unlocked key is below mastery threshold.""" return any( - confidence_of(cp, stats, target, min_attempts=min_attempts) < threshold - for cp in unlocked + confidence_of(cp, stats, target, min_attempts=min_attempts) < threshold for cp in unlocked ) @@ -307,7 +390,12 @@ def select_focus( return min( unlocked, key=lambda cp: _focus_score( - cp, stats, target, now, review_penalty=review_penalty, min_attempts=min_attempts, + cp, + stats, + target, + now, + review_penalty=review_penalty, + min_attempts=min_attempts, ), ) @@ -315,40 +403,39 @@ def select_focus( def _transition_focus_score( prev_cp: int, next_cp: int, - stats: Mapping[str, TransitionStats], + stats: Mapping[Bigram, TransitionStats], target: float, now: float, *, review_penalty: float, min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, ) -> float: - key = chr(prev_cp) + chr(next_cp) - t_stats = stats.get(key) - urgency = review_urgency(t_stats.last_seen if t_stats else 0.0, now) - confidence = transition_confidence_of( - prev_cp, next_cp, stats, target, min_attempts=min_attempts, + return _focus_score_from_entry( + stats.get(Bigram(prev_cp, next_cp)), + target, + now, + review_penalty=review_penalty, + min_attempts=min_attempts, ) - return confidence * (1.0 - review_penalty * urgency) def select_focus_transition( unlocked: Sequence[int], - transitions: Mapping[str, TransitionStats], + transitions: Mapping[Bigram, TransitionStats], target: float, now: float, *, review_penalty: float = 0.5, min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, -) -> tuple[int, int] | None: +) -> Bigram | None: """Weakest unlocked bigram by transition confidence; None when no transition data.""" if not transitions: return None pairs = [ - (prev, nxt) + Bigram(prev, nxt) for prev in unlocked for nxt in unlocked - if not is_same_key_transition(prev, nxt) - and chr(prev) + chr(nxt) in transitions + if not is_same_key_transition(prev, nxt) and Bigram(prev, nxt) in transitions ] if not pairs: return None diff --git a/src/keystrike/domain/enums.py b/src/keystrike/domain/enums.py index b54b208..b7deae1 100644 --- a/src/keystrike/domain/enums.py +++ b/src/keystrike/domain/enums.py @@ -1,10 +1,29 @@ -from enum import IntEnum, StrEnum +from enum import Enum, IntEnum, StrEnum, auto class Mode(StrEnum): ADAPTIVE = "adaptive" +# Modes retired when code-practice/freeform/sample-text modes were dropped in +# favor of adaptive-only drills. Old persisted sessions still carry these +# strings, so they need to keep resolving to something valid. +_LEGACY_MODES = frozenset({"free", "code", "sample"}) + + +def migrate_legacy_mode(raw: str) -> Mode: + """Map a persisted mode string to the current `Mode` enum, translating + retired legacy modes (from pre-adaptive-only builds) to `Mode.ADAPTIVE`. + + This is a data-migration policy decision, not a persistence-format + concern, so it lives in the domain layer rather than in a repository + adapter. + """ + if raw in _LEGACY_MODES: + return Mode.ADAPTIVE + return Mode(raw) + + class Finger(IntEnum): PINKY = 1 RING = 2 @@ -27,3 +46,14 @@ class SessionState(StrEnum): class TargetSpeedUnit(StrEnum): WPM = "wpm" CPM = "cpm" + + +class FocusKind(Enum): + """Why the adaptive engine picked today's lesson focus. Replaces the old + ad-hoc strings ("weak", "review", f"{pair} weak transition", ...) that + presentation code had to parse via substring/suffix matching.""" + + KEY_WEAK = auto() + KEY_REVIEW = auto() + TRANSITION_WEAK = auto() + TRANSITION_REVIEW = auto() diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index 087f1da..76c00c1 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -9,24 +9,41 @@ from .confidence import FOCUS_BIGRAM_WORD_BOOST, FOCUS_WORD_BOOST from .markov import TransitionTable -from .models import Layout +from .models import Bigram, Layout from .word_bounds import MAX_WORD_LEN, MIN_WORD_LEN MAX_RETRIES = 5 DEFAULT_WORD_COUNT = 12 +@dataclass(frozen=True, slots=True) +class LessonWeighting: + """Practice-weighting knobs threaded through lesson generation. + + Bundles the char/transition weighting inputs and the focus-word boosts so + ``generate_lesson`` and its helpers take one object instead of a long, + mutually-dependent parameter list. + """ + + char_weights: Mapping[str, float] | None = None + transition_weights: Mapping[Bigram, float] | None = None + layout: Layout | None = None + words: list[str] | None = None + focus_word_boost: float = FOCUS_WORD_BOOST + focus_bigram_word_boost: float = FOCUS_BIGRAM_WORD_BOOST + + def wordlist_weight_for_word( word: str, *, - char_weights: Mapping[str, float] | None = None, - transition_weights: Mapping[str, float] | None = None, + weighting: LessonWeighting | None = None, focus_char: str | None = None, focus_bigram: str | None = None, - focus_word_boost: float = FOCUS_WORD_BOOST, - focus_bigram_word_boost: float = FOCUS_BIGRAM_WORD_BOOST, ) -> float: """Score a dictionary word for adaptive sampling — mirrors Markov biasing.""" + weighting = weighting or LessonWeighting() + char_weights = weighting.char_weights + transition_weights = weighting.transition_weights if not char_weights and not transition_weights: return 1.0 weight = 1.0 @@ -34,14 +51,14 @@ def wordlist_weight_for_word( weight = sum(char_weights.get(ch, 1.0) for ch in word) if transition_weights and len(word) > 1: bigram_weight = sum( - transition_weights.get(word[i] + word[i + 1], 1.0) + transition_weights.get(Bigram(ord(word[i]), ord(word[i + 1])), 1.0) for i in range(len(word) - 1) ) weight = bigram_weight if not char_weights else weight * bigram_weight if focus_bigram and focus_bigram in word: - weight *= focus_bigram_word_boost + weight *= weighting.focus_bigram_word_boost elif focus_char and focus_char in word: - weight *= focus_word_boost + weight *= weighting.focus_word_boost return weight @@ -69,28 +86,18 @@ class AdaptiveGenerator: def generate_word( self, alphabet: frozenset[str], - char_weights: Mapping[str, float] | None = None, - layout: Layout | None = None, - transition_weights: Mapping[str, float] | None = None, + weighting: LessonWeighting | None = None, *, - words: list[str] | None = None, wordlist_weights: list[float] | None = None, ) -> str: - if words: - word = self._sample_from_wordlist( - words, - char_weights, - transition_weights, - wordlist_weights, - ) - if ( - MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN - and set(word) <= alphabet - ): + weighting = weighting or LessonWeighting() + if weighting.words: + word = self._sample_from_wordlist(weighting, wordlist_weights) + if MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN and set(word) <= alphabet: return word word = "" for _ in range(MAX_RETRIES): - word = self._sample_word(alphabet, char_weights, layout, transition_weights) + word = self._sample_word(alphabet, weighting) if MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN: return word return word @@ -101,40 +108,26 @@ def generate_lesson( focus_char: str, *, word_count: int = DEFAULT_WORD_COUNT, - char_weights: Mapping[str, float] | None = None, - layout: Layout | None = None, - transition_weights: Mapping[str, float] | None = None, + weighting: LessonWeighting | None = None, focus_bigram: tuple[int, int] | None = None, - words: list[str] | None = None, - focus_word_boost: float = FOCUS_WORD_BOOST, - focus_bigram_word_boost: float = FOCUS_BIGRAM_WORD_BOOST, ) -> str: + weighting = weighting or LessonWeighting() focus_bigram_str: str | None = None if focus_bigram is not None: focus_bigram_str = chr(focus_bigram[0]) + chr(focus_bigram[1]) wordlist_weights: list[float] | None = None - if words and (char_weights or transition_weights): + if weighting.words and (weighting.char_weights or weighting.transition_weights): wordlist_weights = [ wordlist_weight_for_word( w, - char_weights=char_weights, - transition_weights=transition_weights, + weighting=weighting, focus_char=focus_char, focus_bigram=focus_bigram_str, - focus_word_boost=focus_word_boost, - focus_bigram_word_boost=focus_bigram_word_boost, ) - for w in words + for w in weighting.words ] lesson_words = [ - self.generate_word( - alphabet, - char_weights, - layout, - transition_weights, - words=words, - wordlist_weights=wordlist_weights, - ) + self.generate_word(alphabet, weighting, wordlist_weights=wordlist_weights) for _ in range(word_count) ] if focus_bigram is not None: @@ -143,7 +136,9 @@ def generate_lesson( if not any(bigram in w for w in lesson_words): idx = self.rng.randrange(len(lesson_words)) lesson_words[idx] = self._inject_focus_bigram( - lesson_words[idx], prev_char, next_char, + lesson_words[idx], + prev_char, + next_char, ) elif not any(focus_char in w for w in lesson_words): idx = self.rng.randrange(len(lesson_words)) @@ -152,44 +147,34 @@ def generate_lesson( def _sample_from_wordlist( self, - words: list[str], - char_weights: Mapping[str, float] | None, - transition_weights: Mapping[str, float] | None, + weighting: LessonWeighting, wordlist_weights: list[float] | None = None, ) -> str: + words = weighting.words or [] if wordlist_weights is not None: return self.rng.choices(words, weights=wordlist_weights, k=1)[0] - if not char_weights and not transition_weights: + if not weighting.char_weights and not weighting.transition_weights: return self.rng.choice(words) - weights = [ - wordlist_weight_for_word( - w, - char_weights=char_weights, - transition_weights=transition_weights, - ) - for w in words - ] + weights = [wordlist_weight_for_word(w, weighting=weighting) for w in words] return self.rng.choices(words, weights=weights, k=1)[0] def _sample_word( self, alphabet: frozenset[str], - char_weights: Mapping[str, float] | None, - layout: Layout | None, - transition_weights: Mapping[str, float] | None, + weighting: LessonWeighting, ) -> str: chars: list[str] = [] while len(chars) < MAX_WORD_LEN: - p_stop = min(1.0, 1.3**len(chars) / MAX_WORD_LEN) + p_stop = min(1.0, 1.3 ** len(chars) / MAX_WORD_LEN) if chars and self.rng.random() < p_stop: break ch = self.table.sample( "".join(chars), alphabet, self.rng, - char_weights=char_weights, - layout=layout, - transition_weights=transition_weights, + char_weights=weighting.char_weights, + layout=weighting.layout, + transition_weights=weighting.transition_weights, ) if ch is None: ch = self.rng.choice(sorted(alphabet)) diff --git a/src/keystrike/domain/markov.py b/src/keystrike/domain/markov.py index d70a01a..332d90c 100644 --- a/src/keystrike/domain/markov.py +++ b/src/keystrike/domain/markov.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from random import Random -from .models import Layout +from .models import Bigram, Layout _DIFFERENT_HAND_BOOST = 1.5 _DIFFERENT_FINGER_SAME_HAND_BOOST = 1.2 @@ -39,7 +39,7 @@ def sample( *, char_weights: Mapping[str, float] | None = None, layout: Layout | None = None, - transition_weights: Mapping[str, float] | None = None, + transition_weights: Mapping[Bigram, float] | None = None, ) -> str | None: """Sample the next char given `context`, restricted to `alphabet`. @@ -55,13 +55,13 @@ def sample( When `layout` is set, each candidate is also scaled by `transition_practice_weight` from the last char in `context`. - `transition_weights` (keyed by prev+next, e.g. ``"ab"``) multiplies each - candidate by the learner's measured confidence on that pair. + `transition_weights` (keyed by `Bigram(prev_cp, next_cp)`) multiplies + each candidate by the learner's measured confidence on that pair. """ prev_cp = ord(context[-1]) if context else None max_len = min(self.order, len(context)) for length in range(max_len, -1, -1): - ctx = context[len(context) - length:] if length else "" + ctx = context[len(context) - length :] if length else "" row = self.transitions.get(ctx) if not row: continue @@ -76,7 +76,7 @@ def sample( if layout is not None and prev_cp is not None: weight *= transition_practice_weight(prev_cp, ord(c), layout) if transition_weights is not None and prev_cp is not None: - weight *= transition_weights.get(chr(prev_cp) + c, 1.0) + weight *= transition_weights.get(Bigram(prev_cp, ord(c)), 1.0) weights.append(weight) else: weights = [w for _, w in candidates] diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index 40b5b53..74bc557 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -1,13 +1,16 @@ +from collections.abc import Mapping from dataclasses import dataclass, field +from types import MappingProxyType +from typing import NamedTuple from .enums import Finger, Hand, Mode, TargetSpeedUnit @dataclass(frozen=True, slots=True) class Keystroke: - codepoint: int # target char (0 if user typed past end) - typed: int # actual char typed - t_ns: int # monotonic ns since session start + codepoint: int # target char (0 if user typed past end) + typed: int # actual char typed + t_ns: int # monotonic ns since session start correct: bool @@ -19,7 +22,7 @@ def _empty_key_confidence() -> dict[int, float]: class SessionResult: schema_version: int session_id: str - started_at: float # unix epoch + started_at: float # unix epoch duration_ns: int layout: str mode: Mode @@ -30,6 +33,10 @@ class SessionResult: words_completed: int = 0 lang: str = "en" unlocked_keys: tuple[int, ...] = () + # Left as a plain (mutable-in-place) dict rather than MappingProxyType-wrapped + # like Layout.keys/LayoutAggregates: infrastructure.session_repo_jsonl's + # `asdict(header)` deep-copies every field, and copy.deepcopy cannot pickle + # a mappingproxy. Freezing this one would break header persistence. key_confidence: dict[int, float] = field(default_factory=_empty_key_confidence) target_speed_cpm: int = 0 # goal active at finish; 0 = legacy sessions @@ -44,6 +51,19 @@ class KeyStats: attempt_count: int = 0 +class Bigram(NamedTuple): + """A directed pair of codepoints: the key pressed before, then the key + pressed after. The value-type key for `TransitionStats` maps, replacing + the old ad-hoc `chr(prev) + chr(next)` string keys.""" + + prev_cp: int + next_cp: int + + def chars(self) -> str: + """Display form, e.g. `Bigram(ord("a"), ord("b")).chars() == "ab"`.""" + return chr(self.prev_cp) + chr(self.next_cp) + + @dataclass(frozen=True, slots=True) class TransitionStats: prev_cp: int @@ -55,14 +75,20 @@ class TransitionStats: attempt_count: int = 0 -def _empty_transitions() -> dict[str, TransitionStats]: +def _empty_transitions() -> dict[Bigram, TransitionStats]: return {} @dataclass(frozen=True, slots=True) class LayoutAggregates: - keys: dict[int, KeyStats] - transitions: dict[str, TransitionStats] = field(default_factory=_empty_transitions) + keys: Mapping[int, KeyStats] + transitions: Mapping[Bigram, TransitionStats] = field(default_factory=_empty_transitions) + + def __post_init__(self) -> None: + # Freezing the dataclass only blocks attribute rebinding — wrap the + # dict fields too so in-place mutation of their contents also raises. + object.__setattr__(self, "keys", MappingProxyType(dict(self.keys))) + object.__setattr__(self, "transitions", MappingProxyType(dict(self.transitions))) @dataclass(frozen=True, slots=True) @@ -94,30 +120,33 @@ class KeyPos: @dataclass(frozen=True, slots=True) class Layout: name: str - keys: dict[int, KeyPos] + keys: Mapping[int, KeyPos] learn_order: tuple[int, ...] ortholinear: bool = False # no row stagger — affects only how Stats renders the heatmap + def __post_init__(self) -> None: + object.__setattr__(self, "keys", MappingProxyType(dict(self.keys))) + @dataclass(frozen=True, slots=True) class Settings: schema_version: int = 1 layout: str = "qwerty" - target_speed_cpm: int = 300 # ~46 wpm at typical generated word length + target_speed_cpm: int = 300 # ~46 wpm at typical generated word length target_speed_unit: TargetSpeedUnit = TargetSpeedUnit.WPM - alphabet_size: int = 16 # letters force-unlocked from cold start + alphabet_size: int = 16 # letters force-unlocked from cold start confidence_session_window: int = 10 # sessions in rolling stats for confidence - min_confidence_attempts: int = 10 # presses before key confidence reaches full weight + min_confidence_attempts: int = 10 # presses before key confidence reaches full weight min_transition_confidence_attempts: int = 4 # lower floor — bigrams are sparser - focus_char_boost: float = 3.0 # char weight multiplier for focus key - focus_word_boost: float = 3.0 # wordlist/Markov boost when focus char present - focus_bigram_word_boost: float = 4.0 # word boost when focus bigram present - focus_transition_boost: float = 4.0 # transition weight multiplier for focus pair - focus_weak_extra_boost: float = 1.5 # extra multiplier when focus confidence < 1.0 + focus_char_boost: float = 3.0 # char weight multiplier for focus key + focus_word_boost: float = 3.0 # wordlist/Markov boost when focus char present + focus_bigram_word_boost: float = 4.0 # word boost when focus bigram present + focus_transition_boost: float = 4.0 # transition weight multiplier for focus pair + focus_weak_extra_boost: float = 1.5 # extra multiplier when focus confidence < 1.0 lang: str = "en" - learn_daily_minutes: int = 10 # adaptive mode daily goal (minutes); 0 = no goal - wordlist_url: str = "" # non-empty + cached file → real words; else Markov - updated_at: str | None = None # ISO-8601 UTC; sync LWW + learn_daily_minutes: int = 10 # adaptive mode daily goal (minutes); 0 = no goal + wordlist_url: str = "" # non-empty + cached file → real words; else Markov + updated_at: str | None = None # ISO-8601 UTC; sync LWW @dataclass(frozen=True, slots=True) diff --git a/src/keystrike/domain/null_adapters.py b/src/keystrike/domain/null_adapters.py index c155b74..a721213 100644 --- a/src/keystrike/domain/null_adapters.py +++ b/src/keystrike/domain/null_adapters.py @@ -32,14 +32,19 @@ def load_keystrokes(self, session_id: str) -> Iterator[Keystroke]: class NullStatsRebuilder: - def __call__(self, layout: str) -> dict[int, KeyStats]: - return {} + def __call__(self, layout: str) -> None: + return None - def ensure(self, layout: str) -> dict[int, KeyStats]: + +NULL_STATS_REBUILDER = NullStatsRebuilder() + + +class NullAggregatesEnsurer: + def __call__(self, layout: str) -> dict[int, KeyStats]: return {} -NULL_STATS_REBUILDER = NullStatsRebuilder() +NULL_AGGREGATES_ENSURER = NullAggregatesEnsurer() class NullLearningRateEstimator: diff --git a/src/keystrike/domain/protocols.py b/src/keystrike/domain/protocols.py index d803edc..e23d9f7 100644 --- a/src/keystrike/domain/protocols.py +++ b/src/keystrike/domain/protocols.py @@ -1,4 +1,4 @@ -from collections.abc import Iterator +from collections.abc import Iterator, Mapping from typing import Protocol from .daily_learn import DailyLearnBudget @@ -50,9 +50,14 @@ class StatsRebuilder(Protocol): """Shape of `application.stats_use_cases.RebuildAggregates` — lets callers (e.g. PracticeScreen) depend on the behavior without importing application code.""" - def __call__(self, layout: str) -> dict[int, KeyStats]: ... + def __call__(self, layout: str) -> None: ... - def ensure(self, layout: str) -> dict[int, KeyStats]: ... + +class AggregatesEnsurer(Protocol): + """Shape of `application.stats_use_cases.EnsureAggregates` — lets callers + depend on the behavior without importing application code.""" + + def __call__(self, layout: str) -> Mapping[int, KeyStats]: ... class LanguageProvider(Protocol): @@ -85,7 +90,3 @@ def init(self, remote_url: str) -> None: ... def pull(self, rebuild: StatsRebuilder) -> int: ... def push(self) -> bool: ... def status(self) -> SyncStatusReport: ... - - -class SyncGateway(SyncStore, Protocol): - """Alias kept for application-layer typing.""" diff --git a/src/keystrike/domain/regression.py b/src/keystrike/domain/regression.py index 9383313..f86b37e 100644 --- a/src/keystrike/domain/regression.py +++ b/src/keystrike/domain/regression.py @@ -28,7 +28,8 @@ def _degree_for(sample_count: int) -> int: def estimate_sessions_to_goal( - recent_time_ns: Sequence[float], target_time_ns: float, + recent_time_ns: Sequence[float], + target_time_ns: float, ) -> int | None: """`recent_time_ns` is per-attempt time, oldest first (most recent 30 kept). diff --git a/src/keystrike/domain/session.py b/src/keystrike/domain/session.py index 5c2dced..e6f4714 100644 --- a/src/keystrike/domain/session.py +++ b/src/keystrike/domain/session.py @@ -93,14 +93,14 @@ class Session: mode: Mode lang: str started_at_wall: float - started_at_ns: int # when the practice screen opened (session creation) + started_at_ns: int # when the practice screen opened (session creation) typing_started_at_ns: int | None = None # first real keystroke — the timer's true zero last_keystroke_at_ns: int | None = None # wall clock of last real keystroke active_duration_ns: int = 0 # accumulated active time between keystrokes keystrokes: list[Keystroke] = field(default_factory=list[Keystroke]) - position: int = 0 # index into target_text of the next char to type - correct_count: int = 0 # total correct keystrokes (does not decrement on backspace) - total_count: int = 0 # total keystrokes recorded (including errors and corrections) + position: int = 0 # index into target_text of the next char to type + correct_count: int = 0 # total correct keystrokes (does not decrement on backspace) + total_count: int = 0 # total keystrokes recorded (including errors and corrections) error_positions: set[int] = field(default_factory=set[int]) # positions that needed correction state: SessionState = SessionState.RUNNING focus_key: int | None = None # adaptive mode: the key this lesson emphasized diff --git a/src/keystrike/domain/sync_merge.py b/src/keystrike/domain/sync_merge.py index 3abfc45..d8bb3ba 100644 --- a/src/keystrike/domain/sync_merge.py +++ b/src/keystrike/domain/sync_merge.py @@ -1,156 +1,117 @@ -"""Pure merge rules for git-backed sync (sessions union, settings LWW).""" +"""Pure merge-decision rules for git-backed sync (sessions union, settings LWW). + +Everything here takes already-loaded data — parsed index entries, filename +sets, timestamps, file contents as text — and returns a description of what +should happen (which sessions to import, which settings file wins, which +layout files are missing). No `Path`/`shutil`/disk I/O of any kind happens in +this module. `infrastructure/sync_git.py` reads the real inputs off disk, +calls the functions below to decide what to do, and performs the actual copy/ +write operations against the resulting plan. +""" from __future__ import annotations import datetime as dt -import json -import shutil import tomllib -from pathlib import Path +from dataclasses import dataclass +from typing import Any -def read_index_session_ids(index_path: Path) -> set[str]: - return _index_session_ids(index_path) +def index_session_ids(entries: list[dict[str, Any]]) -> set[str]: + """Session ids present in a parsed sessions index.""" + return {str(entry["session_id"]) for entry in entries} -def iter_layouts_from_index(index_path: Path) -> set[str]: - if not index_path.is_file(): - return set() - layouts: set[str] = set() - with index_path.open(encoding="utf-8") as fh: - for raw in fh: - line = raw.strip() - if not line: - continue - data = json.loads(line) - layouts.add(str(data["layout"])) - return layouts +def index_layouts(entries: list[dict[str, Any]]) -> set[str]: + """Distinct layout names referenced by a parsed sessions index.""" + return {str(entry["layout"]) for entry in entries} -def import_missing_sessions( - *, - local_sessions_dir: Path, - remote_sessions_dir: Path, - local_index: Path, - remote_index: Path, -) -> list[str]: - """Copy session files and append index entries present remotely but not locally.""" - before = _index_session_ids(local_index) - if not remote_index.is_file(): - return [] - local_sessions_dir.mkdir(parents=True, exist_ok=True) - imported: list[str] = [] - with remote_index.open(encoding="utf-8") as fh: - for raw in fh: - line = raw.strip() - if not line: - continue - data = json.loads(line) - session_id = str(data["session_id"]) - if session_id in before: - continue - started_at = float(data["started_at"]) - month = dt.datetime.fromtimestamp(started_at, tz=dt.UTC).strftime("%Y-%m") - remote_file = remote_sessions_dir / month / f"{session_id}.jsonl" - if not remote_file.is_file(): - continue - dest_dir = local_sessions_dir / month - dest_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(remote_file, dest_dir / remote_file.name) - with local_index.open("a", encoding="utf-8") as out: - out.write(line) - out.write("\n") - before.add(session_id) - imported.append(session_id) - return imported - - -def resolve_settings_lww(*, local_path: Path, remote_path: Path) -> str: - """Copy the newer settings file to both sides. Returns 'local', 'remote', or 'none'.""" - if not local_path.is_file() and not remote_path.is_file(): - return "none" - if not remote_path.is_file(): - return "local" - if not local_path.is_file(): - local_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(remote_path, local_path) - return "remote" +@dataclass(frozen=True, slots=True) +class SessionImportPlan: + """One remote session that should be imported locally.""" - local_epoch = settings_effective_epoch(local_path) - remote_epoch = settings_effective_epoch(remote_path) - if local_epoch >= remote_epoch: - remote_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(local_path, remote_path) - return "local" - local_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(remote_path, local_path) - return "remote" - - -def copy_layouts_missing(*, local_layouts: Path, remote_layouts: Path) -> int: - """Copy remote layout TOML files missing locally.""" - if not remote_layouts.is_dir(): - return 0 - local_layouts.mkdir(parents=True, exist_ok=True) - copied = 0 - for remote_file in remote_layouts.glob("*.toml"): - local_file = local_layouts / remote_file.name - if not local_file.is_file(): - shutil.copy2(remote_file, local_file) - copied += 1 - return copied - - -def copy_layouts_to_remote(*, local_layouts: Path, remote_layouts: Path) -> None: - if not local_layouts.is_dir(): - return - remote_layouts.mkdir(parents=True, exist_ok=True) - for src in local_layouts.glob("*.toml"): - shutil.copy2(src, remote_layouts / src.name) - - -def copy_file_if_exists(src: Path, dest: Path) -> bool: - if not src.is_file(): - return False - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, dest) - return True - - -def settings_effective_epoch(path: Path) -> float: - if not path.is_file(): - return 0.0 - raw = tomllib.loads(path.read_text(encoding="utf-8")) - updated = raw.get("updated_at") + session_id: str + month: str + filename: str + index_line: str + + +def plan_missing_sessions( + *, + local_session_ids: set[str], + remote_entries: list[dict[str, Any]], + remote_lines: list[str], +) -> list[SessionImportPlan]: + """Decide which remote sessions are missing locally and need importing. + + `remote_entries`/`remote_lines` are the parsed-JSON and matching raw + (stripped) text of each line in the remote index, in file order. This does + not check whether the session's `.jsonl` file actually exists on disk — + the infrastructure executor skips any plan entry whose source file is + missing. + """ + seen = set(local_session_ids) + plans: list[SessionImportPlan] = [] + for entry, line in zip(remote_entries, remote_lines, strict=True): + session_id = str(entry["session_id"]) + if session_id in seen: + continue + started_at = float(entry["started_at"]) + month = dt.datetime.fromtimestamp(started_at, tz=dt.UTC).strftime("%Y-%m") + plans.append( + SessionImportPlan( + session_id=session_id, + month=month, + filename=f"{session_id}.jsonl", + index_line=line, + ), + ) + seen.add(session_id) + return plans + + +def settings_epoch_from_toml(raw: str, mtime: float) -> float: + """Effective LWW timestamp for settings TOML text already read from disk. + + Parsing TOML text is pure (`tomllib.loads` operates on a string, not a + file). `mtime` is the caller-supplied `st_mtime` fallback used when the + document has no `updated_at` field. + """ + data = tomllib.loads(raw) + updated = data.get("updated_at") if updated is not None: text = str(updated).replace("Z", "+00:00") parsed = dt.datetime.fromisoformat(text) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=dt.UTC) return parsed.timestamp() - return path.stat().st_mtime - - -def merge_sessions_union(local_sessions: Path, remote_sessions: Path) -> int: - return len( - import_missing_sessions( - local_sessions_dir=local_sessions, - remote_sessions_dir=remote_sessions, - local_index=local_sessions / "index.jsonl", - remote_index=remote_sessions / "index.jsonl", - ), - ) - - -def _index_session_ids(index_file: Path) -> set[str]: - if not index_file.is_file(): - return set() - ids: set[str] = set() - with index_file.open(encoding="utf-8") as fh: - for raw in fh: - line = raw.strip() - if not line: - continue - data = json.loads(line) - ids.add(str(data["session_id"])) - return ids + return mtime + + +def decide_settings_winner( + *, + local_exists: bool, + remote_exists: bool, + local_epoch: float, + remote_epoch: float, +) -> str: + """Which settings file should win: `'local'`, `'remote'`, or `'none'`. + + Callers copy the winning file over the loser: when the result is + `'remote'`, remote always exists (copy remote -> local); when it's + `'local'`, propagate local -> remote only if a remote file existed to + overwrite in the first place. + """ + if not local_exists and not remote_exists: + return "none" + if not remote_exists: + return "local" + if not local_exists: + return "remote" + return "local" if local_epoch >= remote_epoch else "remote" + + +def plan_layouts_to_copy(*, source_names: set[str], dest_names: set[str]) -> set[str]: + """Layout TOML filenames present in source but missing from dest.""" + return source_names - dest_names diff --git a/src/keystrike/domain/wordlist.py b/src/keystrike/domain/wordlist.py index 291373b..6082039 100644 --- a/src/keystrike/domain/wordlist.py +++ b/src/keystrike/domain/wordlist.py @@ -11,8 +11,4 @@ def parse_wordlist_text(text: str) -> list[str]: def words_for_alphabet(words: list[str], alphabet: frozenset[str]) -> list[str]: - return [ - w - for w in words - if MIN_WORD_LEN <= len(w) <= MAX_WORD_LEN and set(w) <= alphabet - ] + return [w for w in words if MIN_WORD_LEN <= len(w) <= MAX_WORD_LEN and set(w) <= alphabet] diff --git a/src/keystrike/infrastructure/aggregates_cache.py b/src/keystrike/infrastructure/aggregates_cache.py index abd38c9..e3692d0 100644 --- a/src/keystrike/infrastructure/aggregates_cache.py +++ b/src/keystrike/infrastructure/aggregates_cache.py @@ -8,12 +8,37 @@ from pathlib import Path from keystrike.domain.aggregate import without_same_key_transitions -from keystrike.domain.models import KeyStats, LayoutAggregates, TransitionStats +from keystrike.domain.models import Bigram, KeyStats, LayoutAggregates, TransitionStats from .atomic_write import atomic_write_text from .paths import Paths +def _as_int(v: object) -> int: + return int(v) # type: ignore[arg-type] + + +def _as_float(v: object) -> float: + return float(v) # type: ignore[arg-type] + + +def _coerce_samples(entry: dict[str, object]) -> int: + samples = _as_int(entry["samples"]) + if samples <= 0 and _as_float(entry["mean_time_ns"]) > 0: + return 1 + return samples + + +def _coerce_attempt_count(entry: dict[str, object]) -> int: + samples = _coerce_samples(entry) + errors = _as_int(entry["error_count"]) + inferred = samples + errors + stored = _as_int(entry.get("attempt_count", inferred)) + if stored <= 0 and inferred > 0: + return inferred + return stored + + class FileAggregatesCache: def __init__(self, paths: Paths) -> None: self._paths = paths @@ -26,34 +51,33 @@ def get(self, layout: str) -> LayoutAggregates | None: file = self._file(layout) if not file.exists(): return None - data = json.loads(file.read_text(encoding="utf-8")) + try: + data = json.loads(file.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None keys = data.get("keys", {}) transitions = data.get("transitions", {}) parsed_transitions = { - key: TransitionStats( + Bigram(int(entry["prev_cp"]), int(entry["next_cp"])): TransitionStats( prev_cp=int(entry["prev_cp"]), next_cp=int(entry["next_cp"]), - samples=int(entry["samples"]), + samples=_coerce_samples(entry), mean_time_ns=float(entry["mean_time_ns"]), error_count=int(entry["error_count"]), last_seen=float(entry["last_seen"]), - attempt_count=int( - entry.get("attempt_count", entry["samples"] + entry["error_count"]), - ), + attempt_count=_coerce_attempt_count(entry), ) - for key, entry in transitions.items() + for entry in transitions.values() } return LayoutAggregates( keys={ int(cp): KeyStats( codepoint=int(cp), - samples=int(entry["samples"]), + samples=_coerce_samples(entry), mean_time_ns=float(entry["mean_time_ns"]), error_count=int(entry["error_count"]), last_seen=float(entry["last_seen"]), - attempt_count=int( - entry.get("attempt_count", entry["samples"] + entry["error_count"]), - ), + attempt_count=_coerce_attempt_count(entry), ) for cp, entry in keys.items() }, @@ -66,6 +90,6 @@ def put(self, layout: str, aggregates: LayoutAggregates) -> None: "schema_version": 1, "layout": layout, "keys": {str(cp): asdict(k) for cp, k in aggregates.keys.items()}, - "transitions": {key: asdict(t) for key, t in transitions.items()}, + "transitions": {key.chars(): asdict(t) for key, t in transitions.items()}, } atomic_write_text(self._file(layout), json.dumps(payload, indent=2)) diff --git a/src/keystrike/infrastructure/atomic_write.py b/src/keystrike/infrastructure/atomic_write.py index cc02bb4..e570a66 100644 --- a/src/keystrike/infrastructure/atomic_write.py +++ b/src/keystrike/infrastructure/atomic_write.py @@ -9,5 +9,9 @@ def atomic_write_text(path: Path, contents: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(contents, encoding="utf-8") + try: + tmp.write_text(contents, encoding="utf-8") + except BaseException: + tmp.unlink(missing_ok=True) + raise os.replace(tmp, path) diff --git a/src/keystrike/infrastructure/bundled_layouts/_grid.py b/src/keystrike/infrastructure/bundled_layouts/_grid.py index f0aa92e..c1b3213 100644 --- a/src/keystrike/infrastructure/bundled_layouts/_grid.py +++ b/src/keystrike/infrastructure/bundled_layouts/_grid.py @@ -44,13 +44,21 @@ def build_layout(name: str, rows: tuple[str, str, str], *, ortholinear: bool = F for col, ch in enumerate(row): finger, hand = _FINGER_HAND_BY_COL[col] keys[ord(ch)] = KeyPos( - codepoint=ord(ch), row=row_index, col=col, finger=finger, hand=hand, + codepoint=ord(ch), + row=row_index, + col=col, + finger=finger, + hand=hand, ) if not ch.isalpha(): punctuation.append(ch) keys[ord(" ")] = KeyPos( - codepoint=ord(" "), row=_SPACE_ROW, col=_SPACE_COL, finger=Finger.THUMB, hand=Hand.L, + codepoint=ord(" "), + row=_SPACE_ROW, + col=_SPACE_COL, + finger=Finger.THUMB, + hand=Hand.L, ) learn_order = ( diff --git a/src/keystrike/infrastructure/bundled_layouts/colemak_dh.py b/src/keystrike/infrastructure/bundled_layouts/colemak_dh.py index 0771f2e..62095e1 100644 --- a/src/keystrike/infrastructure/bundled_layouts/colemak_dh.py +++ b/src/keystrike/infrastructure/bundled_layouts/colemak_dh.py @@ -7,5 +7,7 @@ # home row's inner columns down to the bottom row (curl down instead of an # inward stretch), and G reclaims its QWERTY home-row position. LAYOUT = build_layout( - "colemak_dh", ("qwfpbjluy;", "arstgmneio", "zxcdvkh,./"), ortholinear=True, + "colemak_dh", + ("qwfpbjluy;", "arstgmneio", "zxcdvkh,./"), + ortholinear=True, ) diff --git a/src/keystrike/infrastructure/paths.py b/src/keystrike/infrastructure/paths.py index 54109ef..cf35f86 100644 --- a/src/keystrike/infrastructure/paths.py +++ b/src/keystrike/infrastructure/paths.py @@ -51,6 +51,12 @@ def default_paths() -> Paths: def ensure_dirs(paths: Paths) -> None: - for p in (paths.config_dir, paths.data_dir, paths.log_dir, - paths.layouts_dir, paths.sessions_dir, paths.cache_dir): + for p in ( + paths.config_dir, + paths.data_dir, + paths.log_dir, + paths.layouts_dir, + paths.sessions_dir, + paths.cache_dir, + ): p.mkdir(parents=True, exist_ok=True) diff --git a/src/keystrike/infrastructure/session_repo_jsonl.py b/src/keystrike/infrastructure/session_repo_jsonl.py index d93d031..57e1510 100644 --- a/src/keystrike/infrastructure/session_repo_jsonl.py +++ b/src/keystrike/infrastructure/session_repo_jsonl.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import cast -from keystrike.domain.enums import Mode +from keystrike.domain.enums import Mode, migrate_legacy_mode from keystrike.domain.models import Keystroke, SessionResult from .paths import Paths @@ -36,12 +36,16 @@ def append_keystroke(self, session_id: str, started_at: float, k: Keystroke) -> file = self._paths.sessions_dir / _month_dir(started_at) / f"{session_id}.jsonl" file.parent.mkdir(parents=True, exist_ok=True) with file.open("a", encoding="utf-8") as fh: - fh.write(json.dumps({ - "codepoint": k.codepoint, - "typed": k.typed, - "t_ns": k.t_ns, - "correct": k.correct, - })) + fh.write( + json.dumps( + { + "codepoint": k.codepoint, + "typed": k.typed, + "t_ns": k.t_ns, + "correct": k.correct, + } + ) + ) fh.write("\n") def save_header(self, header: SessionResult) -> None: @@ -120,14 +124,8 @@ def _as_float(v: object) -> float: return float(v) # type: ignore[arg-type] -_LEGACY_MODES = frozenset({"free", "code", "sample"}) - - def _parse_mode(raw: object) -> Mode: - value = str(raw) - if value in _LEGACY_MODES: - return Mode.ADAPTIVE - return Mode(value) + return migrate_legacy_mode(str(raw)) def _parse_key_confidence(raw: object) -> dict[int, float]: @@ -158,5 +156,3 @@ def _header_from_dict(d: dict[str, object]) -> SessionResult: key_confidence=_parse_key_confidence(d.get("key_confidence", {})), target_speed_cpm=_as_int(d.get("target_speed_cpm", 0)), ) - - diff --git a/src/keystrike/infrastructure/settings_repo_toml.py b/src/keystrike/infrastructure/settings_repo_toml.py index 0c2d4eb..0d3b72c 100644 --- a/src/keystrike/infrastructure/settings_repo_toml.py +++ b/src/keystrike/infrastructure/settings_repo_toml.py @@ -3,6 +3,7 @@ from __future__ import annotations +import dataclasses import tomllib from datetime import UTC, datetime @@ -33,7 +34,10 @@ def __init__(self, paths: Paths) -> None: def load(self) -> Settings: if not self._paths.settings_file.exists(): return Settings() - raw = tomllib.loads(self._paths.settings_file.read_text(encoding="utf-8")) + try: + raw = tomllib.loads(self._paths.settings_file.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError: + return Settings() defaults = Settings() unit_raw = str(raw.get("target_speed_unit", defaults.target_speed_unit)) @@ -79,36 +83,20 @@ def load(self) -> Settings: raw.get("learn_daily_minutes", defaults.learn_daily_minutes), ), wordlist_url=str(raw.get("wordlist_url", defaults.wordlist_url)), - updated_at=( - str(raw["updated_at"]) if raw.get("updated_at") is not None else None - ), + updated_at=(str(raw["updated_at"]) if raw.get("updated_at") is not None else None), ) def save(self, settings: Settings) -> None: + # Field list is derived from the Settings dataclass itself (single + # source of truth) rather than hand-maintained here, so it can't + # silently drift when a field is added/removed from Settings. lines = ["# keystrike settings — edit with care, or use the Settings screen.\n"] - fields: list[tuple[str, object]] = [ - ("schema_version", settings.schema_version), - ("layout", settings.layout), - ("target_speed_cpm", settings.target_speed_cpm), - ("target_speed_unit", settings.target_speed_unit), - ("alphabet_size", settings.alphabet_size), - ("confidence_session_window", settings.confidence_session_window), - ("min_confidence_attempts", settings.min_confidence_attempts), - ( - "min_transition_confidence_attempts", - settings.min_transition_confidence_attempts, - ), - ("focus_char_boost", settings.focus_char_boost), - ("focus_word_boost", settings.focus_word_boost), - ("focus_bigram_word_boost", settings.focus_bigram_word_boost), - ("focus_transition_boost", settings.focus_transition_boost), - ("focus_weak_extra_boost", settings.focus_weak_extra_boost), - ("learn_daily_minutes", settings.learn_daily_minutes), - ("lang", settings.lang), - ] - if settings.wordlist_url: - fields.append(("wordlist_url", settings.wordlist_url)) - for key, value in fields: - lines.append(f"{key} = {_fmt_scalar(value)}\n") + for field in dataclasses.fields(settings): + if field.name == "updated_at": + continue # written fresh below, not round-tripped from the input + value = getattr(settings, field.name) + if field.name == "wordlist_url" and not value: + continue # omit when unset, matching prior hand-rolled behavior + lines.append(f"{field.name} = {_fmt_scalar(value)}\n") lines.append(f"updated_at = {_fmt_scalar(datetime.now(UTC).isoformat())}\n") atomic_write_text(self._paths.settings_file, "".join(lines)) diff --git a/src/keystrike/infrastructure/sync_git.py b/src/keystrike/infrastructure/sync_git.py index 883aef9..b218327 100644 --- a/src/keystrike/infrastructure/sync_git.py +++ b/src/keystrike/infrastructure/sync_git.py @@ -1,23 +1,31 @@ -"""Git CLI adapter and merge orchestration for opt-in backup sync.""" +"""Git CLI adapter and merge orchestration for opt-in backup sync. + +The actual filesystem I/O for merging (copying session files, settings, +layouts) lives here; `domain/sync_merge.py` only decides *what* to do given +already-loaded data. Each `*_execute`-style helper below reads inputs off +disk, calls the matching pure domain function, and performs the resulting +copy/write plan. +""" from __future__ import annotations +import json import shutil import subprocess import tomllib from dataclasses import dataclass from pathlib import Path +from typing import Any from keystrike.domain.models import SyncStatusReport from keystrike.domain.protocols import StatsRebuilder from keystrike.domain.sync_merge import ( - copy_file_if_exists, - copy_layouts_missing, - copy_layouts_to_remote, - import_missing_sessions, - iter_layouts_from_index, - read_index_session_ids, - resolve_settings_lww, + decide_settings_winner, + index_layouts, + index_session_ids, + plan_layouts_to_copy, + plan_missing_sessions, + settings_epoch_from_toml, ) from keystrike.infrastructure.paths import Paths @@ -25,12 +33,135 @@ _SYNC_REL_PATHS = ("settings.toml", "layouts", "sessions") +# Network-bound git ops (clone/pull/push) can otherwise hang indefinitely +# waiting on credentials, freezing the whole TUI. +_GIT_TIMEOUT_S = 30 + + +class GitSyncError(RuntimeError): + """Raised when a git subprocess used for backup sync fails to run — + either it hung past the timeout or the `git` binary isn't installed.""" + @dataclass(frozen=True, slots=True) class SyncConfig: remote_url: str +def _read_index(path: Path) -> tuple[list[dict[str, Any]], list[str]]: + """Load a sessions index file into parsed entries + matching raw lines.""" + if not path.is_file(): + return [], [] + entries: list[dict[str, Any]] = [] + lines: list[str] = [] + with path.open(encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line: + continue + entries.append(json.loads(line)) + lines.append(line) + return entries, lines + + +def read_index_session_ids(index_path: Path) -> set[str]: + entries, _ = _read_index(index_path) + return index_session_ids(entries) + + +def iter_layouts_from_index(index_path: Path) -> set[str]: + entries, _ = _read_index(index_path) + return index_layouts(entries) + + +def import_missing_sessions( + *, + local_sessions_dir: Path, + remote_sessions_dir: Path, + local_index: Path, + remote_index: Path, +) -> list[str]: + """Copy session files and append index entries present remotely but not locally.""" + local_entries, _ = _read_index(local_index) + remote_entries, remote_lines = _read_index(remote_index) + plans = plan_missing_sessions( + local_session_ids=index_session_ids(local_entries), + remote_entries=remote_entries, + remote_lines=remote_lines, + ) + if not plans: + return [] + local_sessions_dir.mkdir(parents=True, exist_ok=True) + imported: list[str] = [] + for plan in plans: + remote_file = remote_sessions_dir / plan.month / plan.filename + if not remote_file.is_file(): + continue + dest_dir = local_sessions_dir / plan.month + dest_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(remote_file, dest_dir / plan.filename) + with local_index.open("a", encoding="utf-8") as out: + out.write(plan.index_line) + out.write("\n") + imported.append(plan.session_id) + return imported + + +def _settings_epoch(path: Path) -> float: + return settings_epoch_from_toml(path.read_text(encoding="utf-8"), path.stat().st_mtime) + + +def resolve_settings_lww(*, local_path: Path, remote_path: Path) -> str: + """Copy the newer settings file to both sides. Returns 'local', 'remote', or 'none'.""" + local_exists = local_path.is_file() + remote_exists = remote_path.is_file() + winner = decide_settings_winner( + local_exists=local_exists, + remote_exists=remote_exists, + local_epoch=_settings_epoch(local_path) if local_exists else 0.0, + remote_epoch=_settings_epoch(remote_path) if remote_exists else 0.0, + ) + if winner == "remote": + local_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(remote_path, local_path) + elif winner == "local" and remote_exists: + remote_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(local_path, remote_path) + return winner + + +def copy_layouts_missing(*, local_layouts: Path, remote_layouts: Path) -> int: + """Copy remote layout TOML files missing locally.""" + if not remote_layouts.is_dir(): + return 0 + remote_names = {p.name for p in remote_layouts.glob("*.toml")} + local_names: set[str] = ( + {p.name for p in local_layouts.glob("*.toml")} if local_layouts.is_dir() else set() + ) + missing = plan_layouts_to_copy(source_names=remote_names, dest_names=local_names) + if missing: + local_layouts.mkdir(parents=True, exist_ok=True) + for name in missing: + shutil.copy2(remote_layouts / name, local_layouts / name) + return len(missing) + + +def copy_layouts_to_remote(*, local_layouts: Path, remote_layouts: Path) -> None: + if not local_layouts.is_dir(): + return + remote_layouts.mkdir(parents=True, exist_ok=True) + for src in local_layouts.glob("*.toml"): + shutil.copy2(src, remote_layouts / src.name) + + +def copy_file_if_exists(src: Path, dest: Path) -> bool: + if not src.is_file(): + return False + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + return True + + class GitSyncGateway: def __init__(self, paths: Paths) -> None: self._paths = paths @@ -187,11 +318,20 @@ def _rebuild_all_layouts(self, rebuild: StatsRebuilder) -> None: def _git(*args: str, cwd: Path | None = None) -> str: - result = subprocess.run( - ["git", *args], - cwd=cwd, - check=True, - capture_output=True, - text=True, - ) + try: + result = subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT_S, + ) + except subprocess.TimeoutExpired as exc: + raise GitSyncError( + f"git {args[0]} timed out after {_GIT_TIMEOUT_S}s " + "(hung waiting on network/credentials?)", + ) from exc + except FileNotFoundError as exc: + raise GitSyncError("git executable not found — is git installed?") from exc return result.stdout diff --git a/src/keystrike/infrastructure/code_generators/__init__.py b/src/keystrike/presentation/formatting/__init__.py similarity index 100% rename from src/keystrike/infrastructure/code_generators/__init__.py rename to src/keystrike/presentation/formatting/__init__.py diff --git a/src/keystrike/presentation/formatting/trends.py b/src/keystrike/presentation/formatting/trends.py new file mode 100644 index 0000000..0ea4889 --- /dev/null +++ b/src/keystrike/presentation/formatting/trends.py @@ -0,0 +1,409 @@ +"""Rich-markup sparkline/trend-line formatting for stats and practice screens. + +Pure display logic: no I/O, no use cases — just numeric-values-in, +Rich-markup-strings-out. Lives in presentation because it renders to Rich +markup, not because it needs a screen. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence + +from keystrike.application.session_use_cases import compute_wpm +from keystrike.domain.confidence import target_ms_per_char +from keystrike.domain.models import SessionResult + +_SPARK = "▁▂▃▄▅▆▇█" + + +def value_sparkline(values: Sequence[float]) -> str: + """Unicode sparkline for numeric values, oldest→newest.""" + if not values: + return "" + lo, hi = min(values), max(values) + if hi == lo: + level = len(_SPARK) - 1 if hi > 0 else 0 + return _SPARK[level] * len(values) + span = hi - lo + return "".join( + _SPARK[min(len(_SPARK) - 1, int((v - lo) / span * (len(_SPARK) - 1)))] for v in values + ) + + +def wpm_sparkline(headers: Sequence[SessionResult], *, limit: int = 20) -> str: + """Unicode sparkline of WPM per session, oldest→newest.""" + ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + if not ordered: + return "" + return value_sparkline([compute_wpm(h) for h in ordered]) + + +def _display_confidence( + stored_conf: float, + stored_target_cpm: int, + current_target_cpm: int, +) -> float: + if stored_target_cpm <= 0 or current_target_cpm <= 0: + return stored_conf + return stored_conf * ( + target_ms_per_char(current_target_cpm) / target_ms_per_char(stored_target_cpm) + ) + + +def focus_confidence_values( + headers: Sequence[SessionResult], + *, + limit: int = 20, + current_target_speed_cpm: int = 0, +) -> list[float]: + ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + return [ + _display_confidence( + h.key_confidence.get(h.focus_key, 0.0) if h.focus_key is not None else 0.0, + h.target_speed_cpm, + current_target_speed_cpm, + ) + for h in ordered + ] + + +def focus_confidence_sparkline( + headers: Sequence[SessionResult], + *, + limit: int = 20, + current_target_speed_cpm: int = 0, +) -> str: + """Unicode sparkline of focus-key confidence per session, oldest→newest.""" + values = focus_confidence_values( + headers, + limit=limit, + current_target_speed_cpm=current_target_speed_cpm, + ) + if not values: + return "" + return value_sparkline(values) + + +def key_confidence_values( + headers: Sequence[SessionResult], + codepoint: int, + *, + limit: int = 20, + current_target_speed_cpm: int = 0, +) -> list[float]: + ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + return [ + _display_confidence( + h.key_confidence.get(codepoint, 0.0), + h.target_speed_cpm, + current_target_speed_cpm, + ) + for h in ordered + ] + + +def key_confidence_sparkline( + headers: Sequence[SessionResult], + codepoint: int, + *, + limit: int = 20, + current_target_speed_cpm: int = 0, +) -> str: + values = key_confidence_values( + headers, + codepoint, + limit=limit, + current_target_speed_cpm=current_target_speed_cpm, + ) + if not values: + return "" + return value_sparkline(values) + + +def _char_label(codepoint: int) -> str: + ch = chr(codepoint) + return ch if ch.isprintable() and not ch.isspace() else f"U+{codepoint:04X}" + + +def _focus_char_label(focus_key: int | None) -> str: + if focus_key is None: + return "?" + return _char_label(focus_key) + + +def format_wpm_trend_line(headers: Sequence[SessionResult], *, limit: int = 20) -> str: + ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + if not ordered: + return "" + wpms = [compute_wpm(h) for h in ordered] + spark = wpm_sparkline(headers, limit=limit) + return ( + f"[bold]WPM trend[/] ({len(wpms)} sessions) {spark} " + f"[dim]latest {wpms[-1]:.0f} peak {max(wpms):.0f}[/]" + ) + + +def format_focus_confidence_trend_line( + headers: Sequence[SessionResult], + *, + limit: int = 20, + current_target_speed_cpm: int = 0, +) -> str: + ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + if not ordered: + return "" + focus_key = ordered[-1].focus_key + if focus_key is None: + return "" + values = key_confidence_values( + headers, + focus_key, + limit=limit, + current_target_speed_cpm=current_target_speed_cpm, + ) + label = _focus_char_label(focus_key) + return format_aggregate_metric_trend_block( + title=f"Focus '{label}'", + confidence_values=values, + speed_values=[], + accuracy_values=[], + limit=limit, + ) + + +# Keep in sync with presentation/theme.py STYLE_TREND_*. +_TREND_CONFIDENCE_COLOR = "cyan" +_TREND_SPEED_COLOR = "green" +_TREND_ACCURACY_COLOR = "yellow" + + +def _colored_sparkline(spark: str, color: str) -> str: + return f"[{color}]{spark}[/]" if spark else "" + + +def _default_metric_value(value: float) -> str: + return f"{value:.2f}" + + +_GRID_LABEL_WIDTH = 10 +_GRID_VALUE_WIDTH = 8 + + +def _format_metric_trend_line( + label: str, + color: str, + values: Sequence[float], + spark: str, + *, + format_value: Callable[[float], str] | None = None, + suffix: str = "", + show_sessions: bool = True, + grid: bool = False, + spark_width: int = 20, +) -> str: + if not values: + return "" + fmt = format_value or _default_metric_value + label_text = label.ljust(_GRID_LABEL_WIDTH) if grid else label + spark_text = spark.ljust(spark_width) if grid else spark + session_part = f" ({len(values)} sessions) " if show_sessions else " " + latest_str = fmt(values[-1]) + peak_str = fmt(max(values)) + if grid: + values_part = ( + f"latest {latest_str:>{_GRID_VALUE_WIDTH}} peak {peak_str:>{_GRID_VALUE_WIDTH}}" + ) + else: + values_part = f"latest {latest_str} peak {peak_str}" + line = ( + f"[bold {color}]{label_text}[/]{session_part}" + f"{_colored_sparkline(spark_text, color)} " + f"[dim {color}]{values_part}[/]" + ) + if suffix: + line += f" {suffix}" + return line + + +def format_confidence_trend_line( + values: Sequence[float], + *, + grid: bool = False, + spark_width: int = 20, +) -> str: + return _format_metric_trend_line( + "confidence", + _TREND_CONFIDENCE_COLOR, + values, + value_sparkline(values), + show_sessions=not grid, + grid=grid, + spark_width=spark_width, + ) + + +def format_key_confidence_trend_line( + headers: Sequence[SessionResult], + codepoint: int, + *, + limit: int = 20, + current_target_speed_cpm: int = 0, + cumulative: float | None = None, + include_key_name: bool = True, + grid: bool = False, + spark_width: int = 20, +) -> str: + ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + if not ordered: + return "" + values = key_confidence_values( + headers, + codepoint, + limit=limit, + current_target_speed_cpm=current_target_speed_cpm, + ) + spark = key_confidence_sparkline( + headers, + codepoint, + limit=limit, + current_target_speed_cpm=current_target_speed_cpm, + ) + char = _char_label(codepoint) + label = f"'{char}' confidence" if include_key_name else "confidence" + suffix = "" + if cumulative is not None: + suffix = f"[dim {_TREND_CONFIDENCE_COLOR}]cumulative {cumulative:.2f}[/]" + return _format_metric_trend_line( + label, + _TREND_CONFIDENCE_COLOR, + values, + spark, + suffix=suffix, + show_sessions=not grid, + grid=grid, + spark_width=spark_width, + ) + + +def format_key_speed_trend_line( + values: Sequence[float], + *, + grid: bool = False, + spark_width: int = 20, +) -> str: + return _format_metric_trend_line( + "speed", + _TREND_SPEED_COLOR, + values, + value_sparkline(values), + show_sessions=not grid, + grid=grid, + spark_width=spark_width, + ) + + +def format_key_accuracy_trend_line( + values: Sequence[float], + *, + grid: bool = False, + spark_width: int = 20, +) -> str: + pct_values = [v * 100 for v in values] + return _format_metric_trend_line( + "accuracy", + _TREND_ACCURACY_COLOR, + pct_values, + value_sparkline(pct_values), + format_value=lambda v: f"{v:.1f}%", + show_sessions=not grid, + grid=grid, + spark_width=spark_width, + ) + + +def _assemble_trend_block( + title: str, + session_count: int, + conf_line: str, + speed_values: Sequence[float], + accuracy_values: Sequence[float], + *, + grid: bool, + spark_width: int, +) -> str: + header = f"[bold]{title}[/]" + if not session_count: + return "" + header += f" ({session_count} sessions)" + lines = [ + header, + conf_line, + format_key_speed_trend_line(speed_values, grid=grid, spark_width=spark_width), + format_key_accuracy_trend_line(accuracy_values, grid=grid, spark_width=spark_width), + ] + return "\n".join(line for line in lines if line) + + +def format_key_metric_trend_block( + title: str, + *, + headers: Sequence[SessionResult], + codepoint: int, + speed_values: Sequence[float], + accuracy_values: Sequence[float], + limit: int = 20, + current_target_speed_cpm: int = 0, + cumulative: float | None = None, +) -> str: + """Trend block for a single key: confidence line driven by session headers.""" + grid = True + spark_width = limit + ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + session_count = len(ordered) if ordered else max(len(speed_values), len(accuracy_values), 0) + conf_line = format_key_confidence_trend_line( + headers, + codepoint, + limit=limit, + current_target_speed_cpm=current_target_speed_cpm, + cumulative=cumulative, + include_key_name=False, + grid=grid, + spark_width=spark_width, + ) + return _assemble_trend_block( + title, + session_count, + conf_line, + speed_values, + accuracy_values, + grid=grid, + spark_width=spark_width, + ) + + +def format_aggregate_metric_trend_block( + title: str, + *, + confidence_values: Sequence[float], + speed_values: Sequence[float], + accuracy_values: Sequence[float], + limit: int = 20, +) -> str: + """Trend block for layout-wide (or other pre-computed) confidence values.""" + grid = True + spark_width = limit + session_count = max(len(confidence_values), len(speed_values), len(accuracy_values), 0) + conf_line = format_confidence_trend_line( + confidence_values, + grid=grid, + spark_width=spark_width, + ) + return _assemble_trend_block( + title, + session_count, + conf_line, + speed_values, + accuracy_values, + grid=grid, + spark_width=spark_width, + ) diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index fa512d6..5d282aa 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -11,18 +11,23 @@ from keystrike.application.session_use_cases import ( AbortSession, FinishSession, + GetSessionBaseline, RecordKeystroke, StartSession, - confidence_window_session_baseline, format_session_stats_line, ) +from keystrike.domain.confidence import FocusReason from keystrike.domain.models import Layout, SessionResult from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET, NULL_STATS_REBUILDER from keystrike.domain.protocols import Clock, DailyLearnBudgetProvider, StatsRebuilder from keystrike.domain.session import leading_key_char, skip_leading_whitespace from keystrike.presentation.bindings import BACK_BINDINGS from keystrike.presentation.widgets.hud import HUD -from keystrike.presentation.widgets.kb_heatmap import KbHeatmap, format_focus_note +from keystrike.presentation.widgets.kb_heatmap import ( + KbHeatmap, + focus_transition_pair, + format_focus_note, +) from keystrike.presentation.widgets.typing_area import TypingArea @@ -56,6 +61,7 @@ def __init__( clock: Clock, initial: SessionPrep, prepare_next: PrepareNextSession, + get_session_baseline: GetSessionBaseline, rebuild_aggregates: StatsRebuilder = NULL_STATS_REBUILDER, get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET, ) -> None: @@ -65,12 +71,13 @@ def __init__( self._finish = finish self._clock = clock self._prepare_next = prepare_next + self._get_session_baseline = get_session_baseline self._rebuild_aggregates = rebuild_aggregates self._get_daily_learn_budget = get_daily_learn_budget self._layout_obj: Layout | None = initial.layout_obj self._lesson_heatmap = initial.lesson_heatmap self._focus_key = initial.focus_key - self._focus_reason = initial.focus_reason + self._focus_reason: FocusReason | None = initial.focus_reason self._focus_confidence = initial.focus_confidence self._focus_speed = initial.focus_speed self._focus_accuracy = initial.focus_accuracy @@ -97,8 +104,9 @@ def compose(self) -> ComposeResult: self._kb_heatmap = KbHeatmap( self._layout_obj, self._lesson_heatmap, - self._focus_key, + focus=self._focus_key, urgency=None, + focus_transition=focus_transition_pair(self._focus_reason), ) yield self._kb_heatmap yield Static( @@ -109,13 +117,16 @@ def compose(self) -> ComposeResult: yield Footer() def _focus_note_text(self) -> str: - return format_focus_note( - self._focus_key, - self._focus_reason, - confidence=self._focus_confidence, - speed=self._focus_speed, - accuracy=self._focus_accuracy, - ) or "" + return ( + format_focus_note( + self._focus_key, + self._focus_reason, + confidence=self._focus_confidence, + speed=self._focus_speed, + accuracy=self._focus_accuracy, + ) + or "" + ) def on_mount(self) -> None: self._refresh_focus_note() @@ -146,24 +157,17 @@ def on_key(self, event: events.Key) -> None: if self._session.finished: self._finish_session() - def _finish_session(self, *, start_next: bool = True) -> None: + def _finish_session(self) -> None: result = self._finish(self._session) self._rebuild_aggregates(result.layout) self._show_last_session_stats(result) - if not start_next: - return prep = self._prepare_next() if prep is None: return self._begin_session(prep) def _show_last_session_stats(self, result: SessionResult) -> None: - baseline = None - if self._finish.settings_repo is not None: - window = self._finish.settings_repo.load().confidence_session_window - baseline = confidence_window_session_baseline( - self._finish.repo, result, window=window, - ) + baseline = self._get_session_baseline(result) self.query_one("#last-session-stats", Static).update( format_session_stats_line(result, baseline=baseline), ) @@ -193,13 +197,14 @@ def _begin_session(self, prep: SessionPrep) -> None: self._session, focus_reason=prep.focus_reason, ) - if self._kb_heatmap is not None and prep.layout_obj and prep.lesson_heatmap is not None: - self._kb_heatmap.refresh_heatmap( - prep.layout_obj, - prep.lesson_heatmap, - prep.focus_key, - urgency=None, - ) + KbHeatmap.update_or_none( + self._kb_heatmap, + prep.layout_obj, + prep.lesson_heatmap, + focus=prep.focus_key, + urgency=None, + focus_transition=focus_transition_pair(prep.focus_reason), + ) self._refresh_focus_note() def action_back(self) -> None: diff --git a/src/keystrike/presentation/screens/settings.py b/src/keystrike/presentation/screens/settings.py index 7f03282..001ff4f 100644 --- a/src/keystrike/presentation/screens/settings.py +++ b/src/keystrike/presentation/screens/settings.py @@ -128,10 +128,8 @@ def action_clear_wordlist(self) -> None: def action_save(self) -> None: speed_raw = self.query_one("#settings-speed", Input).value - try: - target_speed_value = int(speed_raw) - except ValueError: - self._show_error("Target speed must be an integer.") + target_speed_value = self._parse_int_field(speed_raw, "Target speed") + if target_speed_value is None: return speed_unit_select = cast( @@ -148,17 +146,16 @@ def action_save(self) -> None: ) alphabet_size_raw = self.query_one("#settings-alphabet-size", Input).value - try: - alphabet_size = int(alphabet_size_raw) - except ValueError: - self._show_error("Number of letters must be an integer.") + alphabet_size = self._parse_int_field(alphabet_size_raw, "Number of letters") + if alphabet_size is None: return learn_daily_minutes_raw = self.query_one("#settings-learn-daily-minutes", Input).value - try: - learn_daily_minutes = int(learn_daily_minutes_raw) - except ValueError: - self._show_error("Daily learn minutes must be an integer.") + learn_daily_minutes = self._parse_int_field( + learn_daily_minutes_raw, + "Daily learn minutes", + ) + if learn_daily_minutes is None: return layout_select = cast("Select[str]", self.query_one("#settings-layout", Select)) @@ -234,3 +231,10 @@ def _refresh_layout_select(self) -> None: def _show_error(self, message: str) -> None: self.query_one("#settings-error", Static).update(f"[bold red]{message}[/]") + + def _parse_int_field(self, raw: str, field_label: str) -> int | None: + try: + return int(raw) + except ValueError: + self._show_error(f"{field_label} must be an integer.") + return None diff --git a/src/keystrike/presentation/screens/stats.py b/src/keystrike/presentation/screens/stats.py index 12af3c7..26ff6db 100644 --- a/src/keystrike/presentation/screens/stats.py +++ b/src/keystrike/presentation/screens/stats.py @@ -7,10 +7,6 @@ from textual.screen import Screen from textual.widgets import Footer, Static -from keystrike.application.session_use_cases import ( - format_focus_confidence_trend_line, - format_metric_trend_block, -) from keystrike.application.stats_use_cases import ( GetAggregateMetricTrends, GetHeatmap, @@ -20,6 +16,11 @@ from keystrike.domain.models import Layout, SessionResult from keystrike.domain.protocols import LayoutRepository, StatsRebuilder from keystrike.presentation.bindings import BACK_BINDINGS +from keystrike.presentation.formatting.trends import ( + format_aggregate_metric_trend_block, + format_focus_confidence_trend_line, + format_key_metric_trend_block, +) from keystrike.presentation.widgets.kb_heatmap import KbHeatmap _View = Literal["overview", "key_detail"] @@ -93,7 +94,9 @@ def on_mount(self) -> None: self.query_one("#stats-title", Static).update(title) self._kb_heatmap = KbHeatmap( - layout, heatmap_view.confidence, urgency=heatmap_view.urgency, + layout, + heatmap_view.confidence, + urgency=heatmap_view.urgency, ) self.query_one(Vertical).mount( Static("[dim]vs current goal[/]", id="stats-heatmap-caption"), @@ -105,9 +108,10 @@ def on_mount(self) -> None: ) self._trend_history = self._get_history( - self._layout_name, limit=self._confidence_session_window, + self._layout_name, + limit=self._confidence_session_window, ) - self._render_trends(mode="overview") + self._render_overview() def on_key(self, event: events.Key) -> None: codepoint = self._codepoint_from_key(event) @@ -139,43 +143,38 @@ def _codepoint_from_key(self, event: events.Key) -> int | None: return None return codepoint - def _render_trends(self, *, mode: _View, codepoint: int | None = None) -> None: + def _render_overview(self) -> None: widget = self.query_one("#stats-trends", Static) limit = self._confidence_session_window - if mode == "overview": - confidence_values, speed_values, accuracy_values = ( - self._get_aggregate_metric_trends( - self._layout_name, - current_target_speed_cpm=self._current_target_speed_cpm, - ) - ) - layout_block = format_metric_trend_block( - title="Layout", - confidence_values=confidence_values, - speed_values=speed_values, - accuracy_values=accuracy_values, - limit=limit, - ) - focus_line = format_focus_confidence_trend_line( - self._trend_history, - limit=limit, - current_target_speed_cpm=self._current_target_speed_cpm, - ) - parts = [block for block in (layout_block, focus_line) if block] - widget.update( - "\n\n".join(parts) if parts - else "[dim]No sessions yet for this layout.[/]" - ) - return + confidence_values, speed_values, accuracy_values = self._get_aggregate_metric_trends( + self._layout_name, + current_target_speed_cpm=self._current_target_speed_cpm, + ) + layout_block = format_aggregate_metric_trend_block( + title="Layout", + confidence_values=confidence_values, + speed_values=speed_values, + accuracy_values=accuracy_values, + limit=limit, + ) + focus_line = format_focus_confidence_trend_line( + self._trend_history, + limit=limit, + current_target_speed_cpm=self._current_target_speed_cpm, + ) + parts = [block for block in (layout_block, focus_line) if block] + widget.update("\n\n".join(parts) if parts else "[dim]No sessions yet for this layout.[/]") - assert codepoint is not None + def _render_key_detail(self, codepoint: int) -> None: + widget = self.query_one("#stats-trends", Static) + limit = self._confidence_session_window speed_values, accuracy_values = self._get_key_metric_trends( self._layout_name, codepoint, current_target_speed_cpm=self._current_target_speed_cpm, ) label = _char_label(codepoint) - detail = format_metric_trend_block( + detail = format_key_metric_trend_block( title=f"'{label}'", headers=self._trend_history, codepoint=codepoint, @@ -184,29 +183,27 @@ def _render_trends(self, *, mode: _View, codepoint: int | None = None) -> None: limit=limit, current_target_speed_cpm=self._current_target_speed_cpm, ) - widget.update( - detail if detail else "[dim]No sessions yet for this key.[/]" - ) + widget.update(detail if detail else "[dim]No sessions yet for this key.[/]") def _show_overview(self) -> None: self._view = "overview" self._selected_cp = None - self._render_trends(mode="overview") - if self._kb_heatmap is not None and self._layout is not None: - self._kb_heatmap.refresh_heatmap( - self._layout, - self._heatmap_confidence, - urgency=self._heatmap_urgency, - ) + self._render_overview() + KbHeatmap.update_or_none( + self._kb_heatmap, + self._layout, + self._heatmap_confidence, + urgency=self._heatmap_urgency, + ) def _show_key_detail(self, codepoint: int) -> None: self._view = "key_detail" self._selected_cp = codepoint - self._render_trends(mode="key_detail", codepoint=codepoint) - if self._kb_heatmap is not None and self._layout is not None: - self._kb_heatmap.refresh_heatmap( - self._layout, - self._heatmap_confidence, - focus=codepoint, - urgency=self._heatmap_urgency, - ) + self._render_key_detail(codepoint) + KbHeatmap.update_or_none( + self._kb_heatmap, + self._layout, + self._heatmap_confidence, + focus=codepoint, + urgency=self._heatmap_urgency, + ) diff --git a/src/keystrike/presentation/textual_app.py b/src/keystrike/presentation/textual_app.py index ac93da7..b864945 100644 --- a/src/keystrike/presentation/textual_app.py +++ b/src/keystrike/presentation/textual_app.py @@ -6,6 +6,7 @@ from keystrike.application.prepare_practice import PreparePracticeSession from keystrike.application.session_use_cases import ( FinishSession, + GetSessionBaseline, RecordKeystroke, StartSession, ) @@ -50,6 +51,7 @@ def __init__( settings_repo: SettingsRepository, layout_repo: LayoutRepository, prepare_practice: PreparePracticeSession, + get_session_baseline: GetSessionBaseline, rebuild_aggregates: StatsRebuilder, get_heatmap: GetHeatmap, get_history: GetHistory, @@ -71,6 +73,7 @@ def __init__( self._settings_repo = settings_repo self._layout_repo = layout_repo self._prepare_practice = prepare_practice + self._get_session_baseline = get_session_baseline self._rebuild_aggregates = rebuild_aggregates self._get_heatmap = get_heatmap self._get_history = get_history @@ -108,6 +111,7 @@ def on_home_screen_start_practice(self, _: HomeScreen.StartPractice) -> None: clock=self._clock, initial=initial, prepare_next=self._prepare_practice, + get_session_baseline=self._get_session_baseline, rebuild_aggregates=self._rebuild_aggregates, get_daily_learn_budget=self._get_daily_learn_budget, ) diff --git a/src/keystrike/presentation/widgets/hud.py b/src/keystrike/presentation/widgets/hud.py index 99b0d1d..a888ff5 100644 --- a/src/keystrike/presentation/widgets/hud.py +++ b/src/keystrike/presentation/widgets/hud.py @@ -2,10 +2,12 @@ from textual.widget import Widget from textual.widgets import Static +from keystrike.domain.confidence import FocusReason from keystrike.domain.daily_learn import DailyLearnBudget, format_daily_learn_display from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET from keystrike.domain.protocols import Clock, DailyLearnBudgetProvider from keystrike.domain.session import Session, active_typing_duration_ns, is_typing_idle +from keystrike.presentation.widgets.kb_heatmap import focus_reason_label, focus_transition_pair def learn_timer_dimmed(session: Session, now_ns: int) -> bool: @@ -22,17 +24,19 @@ def _format_daily_learn_segment(budget: DailyLearnBudget, *, dim: bool) -> str: return f"[dim]{colored}[/]" if dim else colored -def _format_focus_segment(focus_key: int | None, focus_reason: str | None) -> str: - if focus_key is None or not focus_reason: +def _format_focus_segment(focus_key: int | None, focus_reason: FocusReason | None) -> str: + if focus_key is None or focus_reason is None: return "" - return f" Focus: [bold]{chr(focus_key)}[/] [dim]{focus_reason}[/]" + transition = focus_transition_pair(focus_reason) + label = transition.chars() if transition is not None else chr(focus_key) + return f" Focus: [bold]{label}[/] [dim]{focus_reason_label(focus_reason)}[/]" def _format_hud( session: Session, daily_budget: DailyLearnBudget, *, - focus_reason: str | None = None, + focus_reason: FocusReason | None = None, dim_learn: bool = True, ) -> str: accuracy = (session.correct_count / session.total_count) if session.total_count else 1.0 @@ -58,7 +62,7 @@ def __init__( clock: Clock, *, get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET, - focus_reason: str | None = None, + focus_reason: FocusReason | None = None, ) -> None: super().__init__() self._session = session @@ -97,7 +101,7 @@ def set_session( self, session: Session, *, - focus_reason: str | None = None, + focus_reason: FocusReason | None = None, ) -> None: self._session = session self._focus_reason = focus_reason diff --git a/src/keystrike/presentation/widgets/kb_heatmap.py b/src/keystrike/presentation/widgets/kb_heatmap.py index 5630271..fa17c11 100644 --- a/src/keystrike/presentation/widgets/kb_heatmap.py +++ b/src/keystrike/presentation/widgets/kb_heatmap.py @@ -3,7 +3,9 @@ from textual.widget import Widget from textual.widgets import Static -from keystrike.domain.models import Layout +from keystrike.domain.confidence import FocusReason +from keystrike.domain.enums import FocusKind +from keystrike.domain.models import Bigram, Layout _ROWS = 3 _COLS = 10 @@ -14,6 +16,32 @@ _FOCUS_STYLE = "underline" _REVIEW_STYLE = "underline magenta" +_TRANSITION_KINDS = (FocusKind.TRANSITION_WEAK, FocusKind.TRANSITION_REVIEW) + + +def focus_transition_pair(focus_reason: FocusReason | None) -> Bigram | None: + """The bigram a transition-kind focus reason is about, or None for a + key-kind reason (or no reason at all).""" + if focus_reason is None or focus_reason.kind not in _TRANSITION_KINDS: + return None + return focus_reason.pair + + +def focus_reason_label(focus_reason: FocusReason) -> str: + """Display text for a focus reason, e.g. "weak", "review", or + "eo weak transition" — matches the old ad-hoc focus-reason strings.""" + match focus_reason.kind: + case FocusKind.KEY_WEAK: + return "weak" + case FocusKind.KEY_REVIEW: + return "review" + case FocusKind.TRANSITION_WEAK: + assert focus_reason.pair is not None + return f"{focus_reason.pair.chars()} weak transition" + case FocusKind.TRANSITION_REVIEW: + assert focus_reason.pair is not None + return f"{focus_reason.pair.chars()} review transition" + def _confidence_style(confidence: float | None) -> str: if confidence is None: @@ -49,6 +77,8 @@ def render_heatmap( heatmap: dict[int, float], focus: int | None = None, urgency: dict[int, float] | None = None, + *, + focus_transition: tuple[int, int] | None = None, ) -> Text: """Render the layout's alpha rows as a 3x10 ASCII grid, colored by confidence. @@ -68,10 +98,11 @@ def render_heatmap( if pos.row >= _ROWS: continue ch = "_" if cp == ord(" ") else chr(cp) + is_focus = cp == focus or (focus_transition is not None and cp in focus_transition) style = _key_style( heatmap.get(cp), (urgency or {}).get(cp, 0.0), - is_focus=(cp == focus), + is_focus=is_focus, ) grid[pos.row][pos.col] = (ch, style) @@ -91,14 +122,14 @@ def render_heatmap( def format_focus_note( focus_key: int | None, - focus_reason: str | None, + focus_reason: FocusReason | None, *, confidence: float | None = None, speed: float | None = None, accuracy: float | None = None, goal: float = _CONFIDENCE_GOAL, ) -> str | None: - if focus_key is None or not focus_reason: + if focus_key is None or focus_reason is None: return None actual = f"{confidence:.2f}" if confidence is not None else "0.00" goal_s = f"{goal:.2f}" @@ -109,28 +140,27 @@ def format_focus_note( parts.append(f"accuracy {accuracy * 100:.1f}%") parts.append(f"confidence {actual} / {goal_s}") metrics = ", ".join(parts) - key = chr(focus_key) - - if focus_reason == "weak": - return f"[dim]Focus [bold]{key}[/] ({focus_reason}): {metrics}.[/]" - if focus_reason == "review": - return ( - f"[dim]Focus [bold]{key}[/] ({focus_reason}): {metrics}. " - "Resurfacing before it fades.[/]" - ) - if focus_reason.endswith(" weak transition"): - pair = focus_reason.removesuffix(" weak transition") - return ( - f"[dim]Focus [bold]{pair}[/] ({focus_reason}): {metrics}. " - "Practice text favors this pair.[/]" - ) - if focus_reason.endswith(" review transition"): - pair = focus_reason.removesuffix(" review transition") - return ( - f"[dim]Focus [bold]{pair}[/] ({focus_reason}): {metrics}. " - "Transition due for review.[/]" - ) - return None + label = focus_reason_label(focus_reason) + + match focus_reason.kind: + case FocusKind.KEY_WEAK: + return f"[dim]Focus [bold]{chr(focus_key)}[/] ({label}): {metrics}.[/]" + case FocusKind.KEY_REVIEW: + return ( + f"[dim]Focus [bold]{chr(focus_key)}[/] ({label}): {metrics}. " + "Resurfacing before it fades.[/]" + ) + case FocusKind.TRANSITION_WEAK: + assert focus_reason.pair is not None + pair = focus_reason.pair.chars() + return ( + f"[dim]Focus [bold]{pair}[/] ({label}): {metrics}. " + "Practice text favors this pair.[/]" + ) + case FocusKind.TRANSITION_REVIEW: + assert focus_reason.pair is not None + pair = focus_reason.pair.chars() + return f"[dim]Focus [bold]{pair}[/] ({label}): {metrics}. Transition due for review.[/]" class KbHeatmap(Widget): @@ -145,18 +175,27 @@ def __init__( self, layout: Layout, heatmap: dict[int, float], + *, focus: int | None = None, urgency: dict[int, float] | None = None, + focus_transition: tuple[int, int] | None = None, ) -> None: super().__init__() self._layout = layout self._heatmap = heatmap self._focus = focus self._urgency = urgency + self._focus_transition = focus_transition def compose(self) -> ComposeResult: yield Static( - render_heatmap(self._layout, self._heatmap, self._focus, self._urgency), + render_heatmap( + self._layout, + self._heatmap, + self._focus, + self._urgency, + focus_transition=self._focus_transition, + ), id="kb-heatmap-text", ) @@ -164,13 +203,47 @@ def refresh_heatmap( self, layout: Layout, heatmap: dict[int, float], + *, focus: int | None = None, urgency: dict[int, float] | None = None, + focus_transition: tuple[int, int] | None = None, ) -> None: self._layout = layout self._heatmap = heatmap self._focus = focus self._urgency = urgency + self._focus_transition = focus_transition self.query_one("#kb-heatmap-text", Static).update( - render_heatmap(layout, heatmap, focus, urgency), + render_heatmap( + layout, + heatmap, + focus, + urgency, + focus_transition=focus_transition, + ), + ) + + @staticmethod + def update_or_none( + widget: "KbHeatmap | None", + layout: Layout | None, + heatmap: dict[int, float] | None, + *, + focus: int | None = None, + urgency: dict[int, float] | None = None, + focus_transition: tuple[int, int] | None = None, + ) -> None: + """Refresh `widget` if it and the required layout/heatmap data are present. + + Centralizes the "if self._kb_heatmap is not None and layout/heatmap + present" guard that would otherwise be duplicated at every call site. + """ + if widget is None or layout is None or heatmap is None: + return + widget.refresh_heatmap( + layout, + heatmap, + focus=focus, + urgency=urgency, + focus_transition=focus_transition, ) diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 67a3518..7a8e4d4 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -1,10 +1,12 @@ from random import Random from unittest.mock import patch -from keystrike.application.build_lesson import BuildLesson -from keystrike.domain.aggregate import transition_key +from keystrike.application.build_lesson import BuildLesson, _transition_focus_metrics +from keystrike.domain.aggregate import _combine_transition_maps_weighted, session_recency_weights +from keystrike.domain.confidence import FocusReason, target_ms_per_char, transition_confidence_of +from keystrike.domain.enums import FocusKind from keystrike.domain.learn_order import keyboard_order -from keystrike.domain.models import KeyStats, LayoutAggregates, Settings, TransitionStats +from keystrike.domain.models import Bigram, KeyStats, LayoutAggregates, Settings, TransitionStats from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from tests.fakes import ( FakeAggregatesCache, @@ -94,12 +96,18 @@ def test_lesson_prefers_weak_key_over_weak_transition(): fast = 100_000_000.0 slow = 400_000_000.0 transitions = { - transition_key(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), - transition_key(a, s): TransitionStats( - a, s, 10, slow, 0, now, attempt_count=10, + Bigram(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), + Bigram(a, s): TransitionStats( + a, + s, + 10, + slow, + 0, + now, + attempt_count=10, ), - transition_key(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), - transition_key(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), + Bigram(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), + Bigram(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), } cache = FakeAggregatesCache( by_layout={"qwerty": LayoutAggregates(keys={}, transitions=transitions)}, @@ -113,8 +121,7 @@ def test_lesson_prefers_weak_key_over_weak_transition(): rng=Random(0), ) lesson = builder("qwerty") - assert lesson.focus_reason == "weak" - assert "transition" not in (lesson.focus_reason or "") + assert lesson.focus_reason == FocusReason(kind=FocusKind.KEY_WEAK) def test_lesson_ignores_weak_same_key_transition_for_focus(): @@ -128,13 +135,25 @@ def test_lesson_ignores_weak_same_key_transition_for_focus(): s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), } transitions = { - transition_key(a, a): TransitionStats( - a, a, 10, 400_000_000.0, 0, now, attempt_count=10, + Bigram(a, a): TransitionStats( + a, + a, + 10, + 400_000_000.0, + 0, + now, + attempt_count=10, ), - transition_key(a, s): TransitionStats(a, s, 10, at_target, 0, now, attempt_count=10), - transition_key(s, a): TransitionStats(s, a, 10, at_target, 0, now, attempt_count=10), - transition_key(s, s): TransitionStats( - s, s, 10, 400_000_000.0, 0, now, attempt_count=10, + Bigram(a, s): TransitionStats(a, s, 10, at_target, 0, now, attempt_count=10), + Bigram(s, a): TransitionStats(s, a, 10, at_target, 0, now, attempt_count=10), + Bigram(s, s): TransitionStats( + s, + s, + 10, + 400_000_000.0, + 0, + now, + attempt_count=10, ), } cache = FakeAggregatesCache( @@ -149,7 +168,10 @@ def test_lesson_ignores_weak_same_key_transition_for_focus(): rng=Random(0), ) lesson = builder("qwerty") - assert "transition" not in (lesson.focus_reason or "") + assert lesson.focus_reason is None or lesson.focus_reason.kind in ( + FocusKind.KEY_WEAK, + FocusKind.KEY_REVIEW, + ) def test_lesson_uses_transition_focus_when_transitions_weak(): @@ -165,12 +187,18 @@ def test_lesson_uses_transition_focus_when_transitions_weak(): s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), } transitions = { - transition_key(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), - transition_key(a, s): TransitionStats( - a, s, 10, 400_000_000.0, 0, now - five_days, attempt_count=10, + Bigram(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), + Bigram(a, s): TransitionStats( + a, + s, + 10, + 400_000_000.0, + 0, + now - five_days, + attempt_count=10, ), - transition_key(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), - transition_key(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), + Bigram(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), + Bigram(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), } cache = FakeAggregatesCache( by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, @@ -184,19 +212,121 @@ def test_lesson_uses_transition_focus_when_transitions_weak(): rng=Random(0), ) lesson = builder("qwerty") - pair = chr(a) + chr(s) + pair_bigram = Bigram(a, s) + pair = pair_bigram.chars() assert lesson.focus_key == s - assert lesson.focus_reason == f"{pair} weak transition" + assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=pair_bigram) assert pair in lesson.text.replace(" ", "") +def test_transition_focus_metrics_reports_accuracy_when_speed_measured(): + target = target_ms_per_char(300) + eo_key = Bigram(ord("e"), ord("o")) + transitions = { + eo_key: TransitionStats( + ord("e"), + ord("o"), + 0, + 196_000_000.0, + 0, + 1.0, + attempt_count=1, + ), + } + speed, accuracy = _transition_focus_metrics( + ord("e"), + ord("o"), + transitions, + target, + ) + assert speed > 0 + assert accuracy > 0 + + +def test_build_lesson_eo_not_zero_confidence_when_counts_zeroed(): + """Full BuildLesson path: stale samples=0 must not show 0.00 with speed/acc.""" + layout_name = "colemak_dh" + layout = BUNDLED_LAYOUTS[layout_name] + order = keyboard_order(layout) + now = 1_700_000_000.0 + at_target = 200_000_000.0 + keys = {cp: KeyStats(cp, 10, at_target, 0, now, attempt_count=10) for cp in order[:4]} + eo_key = Bigram(ord("e"), ord("o")) + transitions = { + eo_key: TransitionStats( + ord("e"), + ord("o"), + 0, + 566_631_000.0, + 0, + now, + attempt_count=0, + ), + } + cache = FakeAggregatesCache( + by_layout={layout_name: LayoutAggregates(keys=keys, transitions=transitions)}, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository( + Settings(layout=layout_name, alphabet_size=4, target_speed_cpm=104), + ), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + ) + lesson = builder(layout_name) + assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=eo_key) + assert lesson.focus_speed is not None + assert lesson.focus_speed >= 1.0 + assert lesson.focus_accuracy == 1.0 + assert lesson.focus_confidence is not None + assert lesson.focus_confidence > 0.0 + + +def test_weak_transition_focus_confidence_matches_displayed_speed(): + """Sparse recency-weighted eo must not show 0.00 confidence with good speed/acc.""" + target = target_ms_per_char(300) + eo_key = Bigram(ord("e"), ord("o")) + old = TransitionStats( + ord("e"), + ord("o"), + 1, + 196_000_000.0, + 0, + 1.0, + attempt_count=1, + ) + empty = TransitionStats(ord("e"), ord("o"), 0, 0.0, 0, 2.0, attempt_count=0) + merged = _combine_transition_maps_weighted( + [{eo_key: old}, {eo_key: empty}, {eo_key: empty}], + session_recency_weights(3), + )[eo_key] + speed, accuracy = _transition_focus_metrics( + ord("e"), + ord("o"), + {eo_key: merged}, + target, + ) + confidence = transition_confidence_of( + ord("e"), + ord("o"), + {eo_key: merged}, + target, + ) + assert speed >= 1.0 + assert accuracy == 1.0 + assert confidence > 0.0 + assert confidence < 1.0 # still weak due to sample ramp + + def test_lesson_falls_back_to_key_focus_without_transitions(): layout = BUNDLED_LAYOUTS["qwerty"] focus = keyboard_order(layout)[0] lesson = _build_lesson(Settings(alphabet_size=1))("qwerty") assert lesson.focus_key == focus - assert lesson.focus_reason == "weak" - assert "transition" not in (lesson.focus_reason or "") + assert lesson.focus_reason == FocusReason(kind=FocusKind.KEY_WEAK) def test_lesson_uses_transition_review_when_stale_and_mastered(): @@ -215,32 +345,86 @@ def test_lesson_uses_transition_review_when_stale_and_mastered(): h: KeyStats(h, 10, at_target, 0, now, attempt_count=10), } transitions = { - transition_key(a, a): TransitionStats( - a, a, 10, at_target, 0, now, attempt_count=10, + Bigram(a, a): TransitionStats( + a, + a, + 10, + at_target, + 0, + now, + attempt_count=10, ), - transition_key(a, s): TransitionStats( - a, s, 10, at_target, 0, now - five_days, attempt_count=10, + Bigram(a, s): TransitionStats( + a, + s, + 10, + at_target, + 0, + now - five_days, + attempt_count=10, ), - transition_key(a, h): TransitionStats( - a, h, 10, at_target, 0, now, attempt_count=10, + Bigram(a, h): TransitionStats( + a, + h, + 10, + at_target, + 0, + now, + attempt_count=10, ), - transition_key(s, a): TransitionStats( - s, a, 10, at_target, 0, now, attempt_count=10, + Bigram(s, a): TransitionStats( + s, + a, + 10, + at_target, + 0, + now, + attempt_count=10, ), - transition_key(s, s): TransitionStats( - s, s, 10, at_target, 0, now, attempt_count=10, + Bigram(s, s): TransitionStats( + s, + s, + 10, + at_target, + 0, + now, + attempt_count=10, ), - transition_key(s, h): TransitionStats( - s, h, 10, slow, 0, now, attempt_count=10, + Bigram(s, h): TransitionStats( + s, + h, + 10, + slow, + 0, + now, + attempt_count=10, ), - transition_key(h, a): TransitionStats( - h, a, 10, at_target, 0, now, attempt_count=10, + Bigram(h, a): TransitionStats( + h, + a, + 10, + at_target, + 0, + now, + attempt_count=10, ), - transition_key(h, s): TransitionStats( - h, s, 10, at_target, 0, now, attempt_count=10, + Bigram(h, s): TransitionStats( + h, + s, + 10, + at_target, + 0, + now, + attempt_count=10, ), - transition_key(h, h): TransitionStats( - h, h, 10, at_target, 0, now, attempt_count=10, + Bigram(h, h): TransitionStats( + h, + h, + 10, + at_target, + 0, + now, + attempt_count=10, ), } cache = FakeAggregatesCache( @@ -256,9 +440,8 @@ def test_lesson_uses_transition_review_when_stale_and_mastered(): ) with patch("keystrike.application.build_lesson.time.time", return_value=now): lesson = builder("qwerty") - pair = chr(a) + chr(s) assert lesson.focus_key == s - assert lesson.focus_reason == f"{pair} review transition" + assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_REVIEW, pair=Bigram(a, s)) def test_lesson_wordlist_biases_weak_transition(): @@ -274,12 +457,18 @@ def test_lesson_wordlist_biases_weak_transition(): s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), } transitions = { - transition_key(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), - transition_key(a, s): TransitionStats( - a, s, 10, 400_000_000.0, 0, now - five_days, attempt_count=10, + Bigram(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), + Bigram(a, s): TransitionStats( + a, + s, + 10, + 400_000_000.0, + 0, + now - five_days, + attempt_count=10, ), - transition_key(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), - transition_key(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), + Bigram(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), + Bigram(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), } url = "https://example.com/words.txt" cached = ["asa", "ass", "sas", "ssa"] @@ -296,7 +485,7 @@ def test_lesson_wordlist_biases_weak_transition(): ) lesson = builder("qwerty") pair = chr(a) + chr(s) - assert lesson.focus_reason == f"{pair} weak transition" + assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=Bigram(a, s)) assert set(lesson.text.split()) <= set(cached) ssa_count = 0 @@ -325,12 +514,18 @@ def test_weak_transition_focus_over_represented_in_lesson_words(): s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), } transitions = { - transition_key(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), - transition_key(a, s): TransitionStats( - a, s, 10, 400_000_000.0, 0, now, attempt_count=10, + Bigram(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), + Bigram(a, s): TransitionStats( + a, + s, + 10, + 400_000_000.0, + 0, + now, + attempt_count=10, ), - transition_key(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), - transition_key(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), + Bigram(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), + Bigram(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), } cache = FakeAggregatesCache( by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, @@ -430,6 +625,5 @@ def test_lesson_uses_markov_when_url_saved_without_cache(): lesson = builder("qwerty") assert lesson.text assert all( - set(word) <= {chr(k.codepoint) for k in lesson.state.keys} - for word in lesson.text.split() + set(word) <= {chr(k.codepoint) for k in lesson.state.keys} for word in lesson.text.split() ) diff --git a/tests/application/test_learn_budget_use_cases.py b/tests/application/test_learn_budget_use_cases.py index 0ec090e..76f94be 100644 --- a/tests/application/test_learn_budget_use_cases.py +++ b/tests/application/test_learn_budget_use_cases.py @@ -41,7 +41,10 @@ def test_get_daily_learn_budget_includes_extra_ns_for_active_session(): settings_repo = FakeSettingsRepository(Settings(learn_daily_minutes=10)) budget = GetDailyLearnBudget( - clock=clock, repo=repo, settings_repo=settings_repo, tz=_TZ, + clock=clock, + repo=repo, + settings_repo=settings_repo, + tz=_TZ, )(extra_ns=2 * 60 * 1_000_000_000) assert budget.limit_reached diff --git a/tests/application/test_prepare_practice.py b/tests/application/test_prepare_practice.py index 5a3fbcc..27e59b5 100644 --- a/tests/application/test_prepare_practice.py +++ b/tests/application/test_prepare_practice.py @@ -5,7 +5,7 @@ from keystrike.application.build_lesson import BuildLesson from keystrike.application.learn_budget_use_cases import GetDailyLearnBudget from keystrike.application.prepare_practice import PreparePracticeSession -from keystrike.application.stats_use_cases import RebuildAggregates +from keystrike.application.stats_use_cases import EnsureAggregates, RebuildAggregates from keystrike.domain.enums import Mode from keystrike.domain.models import Keystroke, SessionResult, Settings from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS, CompositeLayoutRepository @@ -24,7 +24,9 @@ @pytest.fixture def paths(tmp_path): return Paths( - config_dir=tmp_path / "config", data_dir=tmp_path / "data", log_dir=tmp_path / "log", + config_dir=tmp_path / "config", + data_dir=tmp_path / "data", + log_dir=tmp_path / "log", ) @@ -35,7 +37,9 @@ def _prepare(*, settings: Settings | None = None) -> PreparePracticeSession: layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) cache = FakeAggregatesCache() get_daily_learn_budget = GetDailyLearnBudget( - clock=clock, repo=session_repo, settings_repo=settings_repo, + clock=clock, + repo=session_repo, + settings_repo=settings_repo, ) return PreparePracticeSession( settings_repo=settings_repo, @@ -88,6 +92,7 @@ def test_prepare_ensures_transitions_before_lesson(): Keystroke(codepoint=ord("s"), typed=ord("x"), t_ns=600_000_000, correct=False), ] rebuild = RebuildAggregates(repo=session_repo, cache=cache, settings_repo=settings_repo) + ensure = EnsureAggregates(repo=session_repo, cache=cache, rebuild=rebuild) prepare = PreparePracticeSession( settings_repo=settings_repo, layout_repo=layout_repo, @@ -100,9 +105,11 @@ def test_prepare_ensures_transitions_before_lesson(): rng=Random(0), ), get_daily_learn_budget=GetDailyLearnBudget( - clock=clock, repo=session_repo, settings_repo=settings_repo, + clock=clock, + repo=session_repo, + settings_repo=settings_repo, ), - rebuild_aggregates=rebuild, + ensure_aggregates=ensure, ) prep = prepare() @@ -112,7 +119,6 @@ def test_prepare_ensures_transitions_before_lesson(): assert cached is not None assert cached.transitions assert prep.focus_reason is not None - assert prep.focus_reason.endswith(" weak transition") def test_prepare_adaptive_still_builds_lesson_when_daily_goal_reached(): @@ -148,7 +154,9 @@ def test_prepare_adaptive_still_builds_lesson_when_daily_goal_reached(): rng=Random(0), ), get_daily_learn_budget=GetDailyLearnBudget( - clock=clock, repo=session_repo, settings_repo=settings_repo, + clock=clock, + repo=session_repo, + settings_repo=settings_repo, ), ) prep = prepare() diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index 900ab91..9f287ec 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -13,17 +13,8 @@ compute_wpm, confidence_window_session_baseline, count_words_completed, - focus_confidence_sparkline, - format_focus_confidence_trend_line, - format_key_confidence_trend_line, - format_key_speed_trend_line, - format_metric_trend_block, format_session_stats_line, - format_wpm_trend_line, - key_confidence_sparkline, - key_confidence_values, previous_session_header, - wpm_sparkline, ) from keystrike.domain.aggregate import combine_sessions from keystrike.domain.confidence import ( @@ -37,7 +28,6 @@ from keystrike.domain.models import SessionResult, Settings from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from tests.fakes import ( - FakeAggregatesCache, FakeClock, FakeIdGenerator, FakeLayoutRepository, @@ -70,8 +60,13 @@ def _session_stats_common(*, duration_ns: int = 60_000_000_000) -> _SessionStats ) -def _drive(text: str, keys: str, clock: FakeClock, id_gen: FakeIdGenerator, - repo: FakeSessionRepository | None = None): +def _drive( + text: str, + keys: str, + clock: FakeClock, + id_gen: FakeIdGenerator, + repo: FakeSessionRepository | None = None, +): start = StartSession(clock=clock, id_gen=id_gen) repo = repo if repo is not None else FakeSessionRepository() record = RecordKeystroke(clock=clock) @@ -367,7 +362,8 @@ def test_format_session_stats_line_uses_window_not_previous_only(): assert baseline.wpm > compute_wpm(slow) vs_previous = format_session_stats_line( - medium, baseline=SessionStatsBaseline( + medium, + baseline=SessionStatsBaseline( wpm=compute_wpm(fast), accuracy_pct=compute_accuracy(fast) * 100, ), @@ -422,7 +418,6 @@ def test_finish_session_persists_unlocked_keys(clock, id_gen): finish = FinishSession( clock=clock, repo=repo, - aggregates_cache=FakeAggregatesCache(), settings_repo=settings_repo, layout_repo=layout_repo, ) @@ -465,7 +460,10 @@ def test_finish_session_bumps_alphabet_size_when_unlocked_set_grows(clock, id_ge warmup = "".join(chr(cp) for _ in range(10) for cp in order[:5]) warmup_session = start( - warmup, layout="qwerty", mode=Mode.ADAPTIVE, focus_key=order[0], + warmup, + layout="qwerty", + mode=Mode.ADAPTIVE, + focus_key=order[0], ) for ch in warmup: clock.advance(50_000_000) @@ -489,7 +487,6 @@ def test_finish_session_persists_key_confidence(clock, id_gen): finish = FinishSession( clock=clock, repo=repo, - aggregates_cache=FakeAggregatesCache(), settings_repo=settings_repo, layout_repo=layout_repo, ) @@ -545,7 +542,7 @@ def test_finish_session_key_confidence_uses_confidence_session_window(clock, id_ settings = settings_repo.load() target = target_ms_per_char(settings.target_speed_cpm) - prior = sorted(repo.headers, key=lambda h: h.started_at)[-(CONFIDENCE_SESSION_WINDOW - 1):] + prior = sorted(repo.headers, key=lambda h: h.started_at)[-(CONFIDENCE_SESSION_WINDOW - 1) :] stats = combine_sessions( [(header, repo.keystrokes[header.session_id]) for header in prior] + [(result, session.keystrokes)], @@ -561,7 +558,6 @@ def test_finish_session_persists_target_speed_cpm(clock, id_gen): finish = FinishSession( clock=clock, repo=repo, - aggregates_cache=FakeAggregatesCache(), settings_repo=settings_repo, layout_repo=layout_repo, ) @@ -574,355 +570,3 @@ def test_finish_session_persists_target_speed_cpm(clock, id_gen): assert result.target_speed_cpm == 400 assert repo.headers[0].target_speed_cpm == 400 - - -def test_focus_confidence_sparkline_uses_focus_key_per_session(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("a"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("a"): 0.5, ord("b"): 1.0}, - ), - SessionResult( - schema_version=3, - session_id="s2", - started_at=2.0, - duration_ns=30_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("b"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("a"): 0.5, ord("b"): 1.0}, - ), - ] - spark = focus_confidence_sparkline(headers) - assert len(spark) == 2 - assert spark[0] <= spark[1] # 0.5 then 1.0 - - -def test_format_focus_confidence_trend_line_tracks_labeled_key(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("a"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("a"): 0.5, ord("b"): 1.0}, - ), - SessionResult( - schema_version=3, - session_id="s2", - started_at=2.0, - duration_ns=30_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("b"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("a"): 0.5, ord("b"): 1.0}, - ), - ] - focus_line = format_focus_confidence_trend_line(headers) - key_line = format_metric_trend_block( - title="'b'", - headers=headers, - codepoint=ord("b"), - confidence_values=key_confidence_values(headers, ord("b")), - speed_values=[], - accuracy_values=[], - limit=20, - ) - assert focus_line == key_line.replace("[bold]'b'[/]", "[bold]Focus 'b'[/]", 1) - - -def test_format_focus_confidence_trend_line_includes_label_and_peak(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("e"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("e"): 0.75}, - ), - ] - line = format_focus_confidence_trend_line(headers) - assert line.startswith("[bold]Focus 'e'[/] (1 sessions)") - assert "[bold cyan]confidence[/]" in line - assert "latest 0.75" in line - assert "peak 0.75" in line - - -def test_focus_confidence_missing_key_defaults_zero(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("z"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={}, - ), - ] - spark = focus_confidence_sparkline(headers) - assert len(spark) == 1 - assert spark[0] == "▁" - - -def test_key_confidence_values_tracks_codepoint_across_sessions(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("a"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("e"): 0.4, ord("a"): 0.5}, - ), - SessionResult( - schema_version=3, - session_id="s2", - started_at=2.0, - duration_ns=30_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("e"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("e"): 0.9, ord("a"): 0.5}, - ), - ] - values = key_confidence_values(headers, ord("e")) - assert values == [0.4, 0.9] - spark = key_confidence_sparkline(headers, ord("e")) - assert len(spark) == 2 - assert spark[0] <= spark[1] - - -def test_key_confidence_values_normalize_to_current_goal(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("e"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("e"): 1.0}, - target_speed_cpm=300, - ), - ] - values = key_confidence_values(headers, ord("e"), current_target_speed_cpm=600) - assert values == [0.5] - - -def test_key_confidence_values_legacy_session_unnormalized(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("e"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("e"): 0.82}, - target_speed_cpm=0, - ), - ] - values = key_confidence_values(headers, ord("e"), current_target_speed_cpm=600) - assert values == [0.82] - - -def test_format_focus_confidence_trend_line_normalizes_to_current_goal(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("e"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("e"): 1.0}, - target_speed_cpm=300, - ), - ] - line = format_focus_confidence_trend_line(headers, current_target_speed_cpm=600) - assert "latest 0.50" in line - assert "peak 0.50" in line - - -def test_format_key_confidence_trend_line_includes_cumulative(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("e"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("e"): 0.75}, - ), - ] - line = format_key_confidence_trend_line(headers, ord("e"), cumulative=1.1) - assert "'e' confidence" in line - assert "[cyan]" in line - assert "latest 0.75" in line - assert "cumulative 1.10" in line - - -def test_format_metric_trend_block_key_detail_shows_key_once_with_colors(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("e"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("e"): 0.75}, - ), - ] - detail = format_metric_trend_block( - title="'e'", - headers=headers, - codepoint=ord("e"), - speed_values=[120.0], - accuracy_values=[0.95], - ) - assert detail.startswith("[bold]'e'[/] (1 sessions)") - assert detail.count("sessions") == 1 - assert "'e' confidence" not in detail - assert "[bold cyan]confidence[/]" in detail - assert "[bold green]speed [/]" in detail - assert "[bold yellow]accuracy [/]" in detail - assert detail.count("'e'") == 1 - assert "latest 120.00" in detail - assert "latest 95.0%" in detail - - -def test_format_metric_trend_block_aggregate_includes_title(): - detail = format_metric_trend_block( - title="Layout", - confidence_values=[0.75], - speed_values=[120.0], - accuracy_values=[0.95], - ) - assert detail.startswith("[bold]Layout[/] (1 sessions)") - assert "[bold cyan]confidence[/]" in detail - assert "[bold green]speed [/]" in detail - assert "[bold yellow]accuracy [/]" in detail - - -def test_format_key_speed_trend_line_uses_green(): - line = format_key_speed_trend_line([100.0, 120.0]) - assert "[bold green]speed[/]" in line - assert "[green]" in line - - -def test_wpm_sparkline_scales_oldest_to_newest(): - headers = [ - SessionResult( - schema_version=2, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=None, - total_keystrokes=50, - correct_keystrokes=50, - words_completed=10, - ), - SessionResult( - schema_version=2, - session_id="s2", - started_at=2.0, - duration_ns=30_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=None, - total_keystrokes=50, - correct_keystrokes=50, - words_completed=10, - ), - ] - spark = wpm_sparkline(headers) - assert len(spark) == 2 - assert spark[0] <= spark[1] # 50 wpm then 100 wpm - - -def test_format_wpm_trend_line_includes_latest_and_peak(): - headers = [ - SessionResult( - schema_version=2, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=None, - total_keystrokes=50, - correct_keystrokes=50, - words_completed=10, - ), - ] - line = format_wpm_trend_line(headers) - assert "WPM trend" in line - assert "latest 10" in line - assert "peak 10" in line diff --git a/tests/application/test_settings_use_cases.py b/tests/application/test_settings_use_cases.py index b760b2e..305e86e 100644 --- a/tests/application/test_settings_use_cases.py +++ b/tests/application/test_settings_use_cases.py @@ -15,7 +15,9 @@ @pytest.fixture def paths(tmp_path): return Paths( - config_dir=tmp_path / "config", data_dir=tmp_path / "data", log_dir=tmp_path / "log", + config_dir=tmp_path / "config", + data_dir=tmp_path / "data", + log_dir=tmp_path / "log", ) diff --git a/tests/application/test_stats_use_cases.py b/tests/application/test_stats_use_cases.py index 1364ff6..7c5668f 100644 --- a/tests/application/test_stats_use_cases.py +++ b/tests/application/test_stats_use_cases.py @@ -1,4 +1,5 @@ from keystrike.application.stats_use_cases import ( + EnsureAggregates, GetAggregateMetricTrends, GetHeatmap, GetHistory, @@ -14,6 +15,7 @@ ) from keystrike.domain.enums import Mode from keystrike.domain.models import ( + Bigram, KeyStats, Keystroke, LayoutAggregates, @@ -63,13 +65,12 @@ def test_rebuild_aggregates_uses_confidence_session_window(): ] rebuild = RebuildAggregates(repo=repo, cache=cache, settings_repo=FakeSettingsRepository()) - result = rebuild("qwerty") + assert rebuild("qwerty") is None - assert set(result) == {ord("a")} - assert result[ord("a")].samples == 2 cached = cache.get("qwerty") assert cached is not None - assert cached.keys == result + assert set(cached.keys) == {ord("a")} + assert cached.keys[ord("a")].samples == 2 assert cache.get("dvorak") is None @@ -90,7 +91,7 @@ def test_rebuild_aggregates_populates_transitions(): cached = cache.get("qwerty") assert cached is not None - ab = cached.transitions.get("ab") + ab = cached.transitions.get(Bigram(ord("a"), ord("b"))) assert ab is not None assert ab.samples == 1 assert ab.mean_time_ns == 100_000_000.0 @@ -116,10 +117,10 @@ def test_rebuild_aggregates_excludes_same_key_transitions(): cached = cache.get("qwerty") assert cached is not None - assert "aa" not in cached.transitions - assert "ee" not in cached.transitions - assert cached.transitions.get("ae") is not None - assert cached.transitions.get("eb") is not None + assert Bigram(ord("a"), ord("a")) not in cached.transitions + assert Bigram(ord("e"), ord("e")) not in cached.transitions + assert cached.transitions.get(Bigram(ord("a"), ord("e"))) is not None + assert cached.transitions.get(Bigram(ord("e"), ord("b"))) is not None def test_ensure_skips_when_cache_has_transitions(): @@ -129,7 +130,7 @@ def test_ensure_skips_when_cache_has_transitions(): "qwerty": LayoutAggregates( keys={ord("a"): KeyStats(ord("a"), 1, 100_000_000.0, 0, 1.0, attempt_count=1)}, transitions={ - "ab": TransitionStats( + Bigram(ord("a"), ord("b")): TransitionStats( ord("a"), ord("b"), 1, @@ -147,8 +148,9 @@ def test_ensure_skips_when_cache_has_transitions(): cache=cache, settings_repo=FakeSettingsRepository(), ) + ensure = EnsureAggregates(repo=repo, cache=cache, rebuild=rebuild) - keys = rebuild.ensure("qwerty") + keys = ensure("qwerty") cached = cache.get("qwerty") assert cached is not None @@ -176,12 +178,13 @@ def test_ensure_rebuilds_when_transitions_missing_but_sessions_exist(): cache=cache, settings_repo=FakeSettingsRepository(), ) + ensure = EnsureAggregates(repo=repo, cache=cache, rebuild=rebuild) - rebuild.ensure("qwerty") + ensure("qwerty") cached = cache.get("qwerty") assert cached is not None - assert cached.transitions.get("ab") is not None + assert cached.transitions.get(Bigram(ord("a"), ord("b"))) is not None def test_rebuild_aggregates_drops_sessions_outside_window(): @@ -205,11 +208,14 @@ def test_rebuild_aggregates_drops_sessions_outside_window(): Keystroke(codepoint=ord("z"), typed=ord("z"), t_ns=300_000_000, correct=True), ) - result = RebuildAggregates( + RebuildAggregates( repo=repo, cache=cache, settings_repo=FakeSettingsRepository(), )("qwerty") + cached = cache.get("qwerty") + assert cached is not None + result = cached.keys assert ord("a") in result assert ord("z") not in result @@ -239,7 +245,10 @@ def test_rebuild_aggregates_respects_settings_window(): Keystroke(codepoint=ord("z"), typed=ord("z"), t_ns=100_000_000, correct=True), ) - result = RebuildAggregates(repo=repo, cache=cache, settings_repo=settings_repo)("qwerty") + RebuildAggregates(repo=repo, cache=cache, settings_repo=settings_repo)("qwerty") + cached = cache.get("qwerty") + assert cached is not None + result = cached.keys assert ord("z") not in result weights = session_recency_weights(window, decay=SESSION_RECENCY_DECAY) diff --git a/tests/conftest.py b/tests/conftest.py index 95a80a1..71705f9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,8 @@ @pytest.fixture(autouse=True) def _snapshot_no_color( # pyright: ignore[reportUnusedFunction] - request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Match CI snapshot job: baselines assume NO_COLOR=1 (grey, not theme accent).""" if request.node.get_closest_marker("snapshot") is not None: diff --git a/tests/domain/test_aggregate.py b/tests/domain/test_aggregate.py index af44413..df9575b 100644 --- a/tests/domain/test_aggregate.py +++ b/tests/domain/test_aggregate.py @@ -1,4 +1,5 @@ from keystrike.domain.aggregate import ( + _combine_transition_maps_weighted, aggregate_session, aggregate_transitions, combine, @@ -11,13 +12,22 @@ session_recency_weights, transition_key, ) -from keystrike.domain.confidence import SESSION_RECENCY_DECAY, confidence_of +from keystrike.domain.confidence import ( + SESSION_RECENCY_DECAY, + confidence_of, + transition_accuracy_of, + transition_confidence_of, +) from keystrike.domain.enums import Mode -from keystrike.domain.models import KeyStats, Keystroke, SessionResult, TransitionStats +from keystrike.domain.models import Bigram, KeyStats, Keystroke, SessionResult, TransitionStats -def _session(session_id: str = "s1", started_at: float = 1000.0, - duration_ns: int = 1_000_000_000, layout: str = "qwerty") -> SessionResult: +def _session( + session_id: str = "s1", + started_at: float = 1000.0, + duration_ns: int = 1_000_000_000, + layout: str = "qwerty", +) -> SessionResult: return SessionResult( schema_version=1, session_id=session_id, @@ -52,7 +62,7 @@ def test_aggregate_single_session_mean_time(): ] stats = aggregate_session(_session(), keys) ks = stats[ord("a")] - assert ks.samples == 2 # 3 correct → 2 intervals + assert ks.samples == 2 # 3 correct → 2 intervals assert abs(ks.mean_time_ns - 100_000_000) < 1 assert ks.error_count == 0 @@ -66,7 +76,7 @@ def test_aggregate_counts_errors_per_target(): ] stats = aggregate_session(_session(), keys) assert stats[ord("b")].error_count == 2 - assert stats[ord("b")].samples == 0 # no interval measured (single correct) + assert stats[ord("b")].samples == 0 # no interval measured (single correct) def test_aggregate_last_seen_matches_session_end(): @@ -111,10 +121,8 @@ def test_per_key_deltas_separates_by_codepoint(): def test_merge_weighted_mean(): - a = KeyStats(codepoint=ord("x"), samples=2, mean_time_ns=100.0, - error_count=1, last_seen=100.0) - b = KeyStats(codepoint=ord("x"), samples=3, mean_time_ns=200.0, - error_count=2, last_seen=200.0) + a = KeyStats(codepoint=ord("x"), samples=2, mean_time_ns=100.0, error_count=1, last_seen=100.0) + b = KeyStats(codepoint=ord("x"), samples=3, mean_time_ns=200.0, error_count=2, last_seen=200.0) m = merge_key_stats(a, b) assert m.samples == 5 # (2*100 + 3*200) / 5 = 160 @@ -125,8 +133,10 @@ def test_merge_weighted_mean(): def test_combine_multiple_maps(): a = {ord("x"): KeyStats(ord("x"), 1, 100.0, 0, 1.0)} - b = {ord("x"): KeyStats(ord("x"), 1, 300.0, 1, 2.0), - ord("y"): KeyStats(ord("y"), 2, 50.0, 0, 3.0)} + b = { + ord("x"): KeyStats(ord("x"), 1, 300.0, 1, 2.0), + ord("y"): KeyStats(ord("y"), 2, 50.0, 0, 3.0), + } out = combine(a, b) assert out[ord("x")].samples == 2 assert abs(out[ord("x")].mean_time_ns - 200.0) < 1e-9 @@ -136,7 +146,7 @@ def test_combine_multiple_maps(): def test_session_recency_weights_newest_is_one(): assert session_recency_weights(1) == [1.0] assert session_recency_weights(3) == [ - SESSION_RECENCY_DECAY ** 2, + SESSION_RECENCY_DECAY**2, SESSION_RECENCY_DECAY, 1.0, ] @@ -197,18 +207,22 @@ def test_combine_sessions_recent_errors_weigh_more_on_confidence(): {ord("a"): aggregate_session(s2, keys2)[ord("a")]}, ) assert confidence_of(ord("a"), stats, target=200.0) < confidence_of( - ord("a"), equal_weight, target=200.0, + ord("a"), + equal_weight, + target=200.0, ) def test_combine_sessions_includes_transitions_from_iterators(): - keys = iter([ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), - ]) + keys = iter( + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), + ] + ) out = combine_sessions([(_session(), keys)]) assert out.keys[ord("b")].samples == 1 - assert out.transitions["ab"].samples == 1 + assert out.transitions[Bigram(ord("a"), ord("b"))].samples == 1 def test_per_transition_deltas_tracks_prev_to_next_pair(): @@ -218,8 +232,8 @@ def test_per_transition_deltas_tracks_prev_to_next_pair(): Keystroke(codepoint=ord("c"), typed=ord("c"), t_ns=250_000_000, correct=True), ] deltas = per_transition_deltas(keys) - assert deltas["ab"] == [100_000_000] - assert deltas["bc"] == [150_000_000] + assert deltas[Bigram(ord("a"), ord("b"))] == [100_000_000] + assert deltas[Bigram(ord("b"), ord("c"))] == [150_000_000] def test_aggregate_transitions_skips_same_key_pairs(): @@ -229,8 +243,8 @@ def test_aggregate_transitions_skips_same_key_pairs(): Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=200_000_000, correct=True), ] transitions = aggregate_transitions(_session(), keys) - assert "aa" not in transitions - assert "ab" in transitions + assert Bigram(ord("a"), ord("a")) not in transitions + assert Bigram(ord("a"), ord("b")) in transitions def test_per_transition_deltas_skips_same_key_pairs(): @@ -240,8 +254,8 @@ def test_per_transition_deltas_skips_same_key_pairs(): Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=250_000_000, correct=True), ] deltas = per_transition_deltas(keys) - assert "aa" not in deltas - assert deltas["ab"] == [150_000_000] + assert Bigram(ord("a"), ord("a")) not in deltas + assert deltas[Bigram(ord("a"), ord("b"))] == [150_000_000] def test_aggregate_transitions_attributes_errors_to_pair(): @@ -251,7 +265,7 @@ def test_aggregate_transitions_attributes_errors_to_pair(): Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), ] transitions = aggregate_transitions(_session(), keys) - ab = transitions["ab"] + ab = transitions[Bigram(ord("a"), ord("b"))] assert ab.error_count == 1 assert ab.samples == 1 assert abs(ab.mean_time_ns - 100_000_000) < 1 @@ -263,7 +277,7 @@ def test_aggregate_transitions_skips_error_on_first_keystroke(): Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=50_000_000, correct=True), ] transitions = aggregate_transitions(_session(), keys) - assert "aa" not in transitions + assert Bigram(ord("a"), ord("a")) not in transitions assert transitions == {} @@ -277,24 +291,108 @@ def test_merge_transition_stats_weighted_mean(): def test_combine_transitions_multiple_maps(): - a = {"ab": TransitionStats(ord("a"), ord("b"), 1, 100.0, 0, 1.0)} + ab_key = Bigram(ord("a"), ord("b")) + bc_key = Bigram(ord("b"), ord("c")) + a = {ab_key: TransitionStats(ord("a"), ord("b"), 1, 100.0, 0, 1.0)} b = { - "ab": TransitionStats(ord("a"), ord("b"), 1, 300.0, 1, 2.0), - "bc": TransitionStats(ord("b"), ord("c"), 2, 50.0, 0, 3.0), + ab_key: TransitionStats(ord("a"), ord("b"), 1, 300.0, 1, 2.0), + bc_key: TransitionStats(ord("b"), ord("c"), 2, 50.0, 0, 3.0), } out = combine_transitions(a, b) - assert out["ab"].samples == 2 - assert abs(out["ab"].mean_time_ns - 200.0) < 1e-9 - assert out["bc"].samples == 2 + assert out[ab_key].samples == 2 + assert abs(out[ab_key].mean_time_ns - 200.0) < 1e-9 + assert out[bc_key].samples == 2 def test_combine_transitions_drops_same_key_pairs(): - stale = {"aa": TransitionStats(ord("a"), ord("a"), 1, 100.0, 0, 1.0)} - valid = {"ab": TransitionStats(ord("a"), ord("b"), 1, 100.0, 0, 1.0)} + stale = {Bigram(ord("a"), ord("a")): TransitionStats(ord("a"), ord("a"), 1, 100.0, 0, 1.0)} + valid = {Bigram(ord("a"), ord("b")): TransitionStats(ord("a"), ord("b"), 1, 100.0, 0, 1.0)} out = combine_transitions(stale, valid) - assert "aa" not in out - assert "ab" in out + assert Bigram(ord("a"), ord("a")) not in out + assert Bigram(ord("a"), ord("b")) in out def test_transition_key_format(): assert transition_key(ord("a"), ord("b")) == "ab" + + +def test_combine_sessions_keeps_transition_samples_when_weight_rounds_down(): + s1 = _session("s1", started_at=1.0) + s2 = _session("s2", started_at=2.0) + s3 = _session("s3", started_at=3.0) + keys1 = [ + Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=0, correct=True), + Keystroke(codepoint=ord("o"), typed=ord("o"), t_ns=196_000_000, correct=True), + ] + keys2 = [ + Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=0, correct=True), + ] + keys3 = [ + Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=0, correct=True), + ] + out = combine_sessions([(s1, keys1), (s2, keys2), (s3, keys3)]) + eo = out.transitions[Bigram(ord("e"), ord("o"))] + assert eo.samples > 0 + assert eo.mean_time_ns > 0 + assert transition_accuracy_of(eo) > 0 + + +def test_weighted_transition_merge_never_zeros_samples_with_mean(): + eo_key = Bigram(ord("e"), ord("o")) + old = TransitionStats( + ord("e"), + ord("o"), + 1, + 196_000_000.0, + 0, + 1.0, + attempt_count=1, + ) + recent = TransitionStats( + ord("e"), + ord("o"), + 0, + 0.0, + 0, + 2.0, + attempt_count=0, + ) + merged = _combine_transition_maps_weighted( + [{eo_key: old}, {eo_key: recent}], + session_recency_weights(2), + )[eo_key] + assert merged.samples > 0 + assert merged.mean_time_ns > 0 + assert transition_accuracy_of(merged) > 0 + + +def test_weighted_transition_merge_keeps_attempt_count_with_samples(): + """Recency-weighted attempt_count must not round to 0 while samples stay > 0.""" + eo_key = Bigram(ord("e"), ord("o")) + old = TransitionStats( + ord("e"), + ord("o"), + 1, + 196_000_000.0, + 0, + 1.0, + attempt_count=1, + ) + recent = TransitionStats( + ord("e"), + ord("o"), + 0, + 0.0, + 0, + 2.0, + attempt_count=0, + ) + # Oldest session weight 0.49 — round(0.49) would zero attempt_count without bump. + merged = _combine_transition_maps_weighted( + [{eo_key: old}, {eo_key: recent}, {eo_key: recent}], + session_recency_weights(3), + )[eo_key] + assert merged.samples > 0 + assert merged.attempt_count > 0 + target = 60_000 / 300 # default 300 cpm + assert transition_confidence_of(ord("e"), ord("o"), {eo_key: merged}, target) > 0 diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index e1978c5..c917d8f 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -12,9 +12,10 @@ select_focus, select_focus_transition, target_ms_per_char, + transition_accuracy_of, transition_confidence_of, ) -from keystrike.domain.models import KeyStats, TransitionStats +from keystrike.domain.models import Bigram, KeyStats, TransitionStats def test_target_ms_per_char(): @@ -68,6 +69,19 @@ def test_accuracy_of_never_correct_is_zero(): assert accuracy_of(stats) == 0.0 +def test_transition_accuracy_of_uses_mean_when_samples_rounded_to_zero(): + stats = TransitionStats( + ord("e"), + ord("o"), + 0, + 196_000_000.0, + 0, + 1.0, + attempt_count=1, + ) + assert transition_accuracy_of(stats) == 1.0 + + def test_confidence_of_scales_down_with_few_attempts(): stats = { ord("a"): KeyStats( @@ -94,7 +108,11 @@ def test_compute_unlocked_stalls_when_sparse_key_looks_fast(): stats = { 1: _stats(1, mean_time_ns=100_000_000.0), 2: KeyStats( - 2, samples=1, mean_time_ns=100_000_000.0, error_count=1, last_seen=0.0, + 2, + samples=1, + mean_time_ns=100_000_000.0, + error_count=1, + last_seen=0.0, attempt_count=2, ), } @@ -104,9 +122,14 @@ def test_compute_unlocked_stalls_when_sparse_key_looks_fast(): def test_transition_confidence_scales_down_with_few_attempts(): stats = { - "ab": TransitionStats( - ord("a"), ord("b"), samples=3, mean_time_ns=200_000_000.0, - error_count=0, last_seen=0.0, attempt_count=2, + Bigram(ord("a"), ord("b")): TransitionStats( + ord("a"), + ord("b"), + samples=3, + mean_time_ns=200_000_000.0, + error_count=0, + last_seen=0.0, + attempt_count=2, ), } # raw 1.0 x (2/4) = 0.5 @@ -115,11 +138,64 @@ def test_transition_confidence_scales_down_with_few_attempts(): def test_transition_confidence_reaches_full_at_minimum_attempts(): stats = { - "ab": _transition(ord("a"), ord("b"), 200_000_000.0, attempt_count=4), + Bigram(ord("a"), ord("b")): _transition(ord("a"), ord("b"), 200_000_000.0, attempt_count=4), } assert transition_confidence_of(ord("a"), ord("b"), stats, target=200.0) == 1.0 +def test_transition_confidence_infers_attempts_when_samples_zeroed_but_timed(): + """Stale cache may zero samples while mean_time_ns remains measured.""" + stats = { + Bigram(ord("e"), ord("o")): TransitionStats( + ord("e"), + ord("o"), + 0, + 196_000_000.0, + 0, + 1.0, + attempt_count=0, + ), + } + target = target_ms_per_char(300) + assert transition_confidence_of(ord("e"), ord("o"), stats, target) > 0.0 + + +def test_transition_confidence_infers_attempts_from_samples_when_count_zeroed(): + """Stale cache may store attempt_count=0 while samples/mean remain measured.""" + stats = { + Bigram(ord("e"), ord("o")): TransitionStats( + ord("e"), + ord("o"), + 1, + 196_000_000.0, + 0, + 1.0, + attempt_count=0, + ), + } + target = target_ms_per_char(300) + assert transition_confidence_of(ord("e"), ord("o"), stats, target) > 0.0 + + +def test_transition_confidence_infers_attempts_from_mean_when_all_counts_zeroed(): + """Pre-fix cache wrote samples=0 and attempt_count=0 while mean_time_ns stayed.""" + stats = { + Bigram(ord("e"), ord("o")): TransitionStats( + ord("e"), + ord("o"), + 0, + 196_000_000.0, + 0, + 1.0, + attempt_count=0, + ), + } + target = target_ms_per_char(300) + confidence = transition_confidence_of(ord("e"), ord("o"), stats, target) + assert confidence > 0.0 + assert confidence < 1.0 + + def test_confidence_of_penalizes_frequent_errors(): # Fast (2.0 speed) but wrong half the time -> min(2.0, 0.5) = 0.5, not mastered. stats = {ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, error_count=10)} @@ -130,8 +206,9 @@ def test_compute_unlocked_stalls_on_high_error_rate_despite_fast_speed(): learn_order = (1, 2, 3, 4) # speed 2.0, but only 50% accuracy -> min(2.0, 0.5) = 0.5 < threshold stats = {1: _stats(1, mean_time_ns=100_000_000.0, error_count=10)} - unlocked = compute_unlocked(learn_order, alphabet_size=1, stats=stats, target=200.0, - threshold=1.5) + unlocked = compute_unlocked( + learn_order, alphabet_size=1, stats=stats, target=200.0, threshold=1.5 + ) assert unlocked == (1,) @@ -180,13 +257,22 @@ def test_compute_unlocked_ignores_weak_same_key_transition(): learn_order = tuple(ord(c) for c in "eabcdfghm") stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in learn_order[:8]} transitions = { - "ee": TransitionStats( - ord("e"), ord("e"), samples=10, mean_time_ns=400_000_000.0, - error_count=0, last_seen=0.0, attempt_count=10, + Bigram(ord("e"), ord("e")): TransitionStats( + ord("e"), + ord("e"), + samples=10, + mean_time_ns=400_000_000.0, + error_count=0, + last_seen=0.0, + attempt_count=10, ), } unlocked = compute_unlocked( - learn_order, alphabet_size=8, stats=stats, target=200.0, transitions=transitions, + learn_order, + alphabet_size=8, + stats=stats, + target=200.0, + transitions=transitions, ) assert unlocked == learn_order[:9] assert unlocked[-1] == ord("m") @@ -196,13 +282,22 @@ def test_compute_unlocked_stalls_on_weak_cross_key_transition(): learn_order = tuple(ord(c) for c in "eabcdfghm") stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in learn_order[:8]} transitions = { - "ea": TransitionStats( - ord("e"), ord("a"), samples=10, mean_time_ns=400_000_000.0, - error_count=0, last_seen=0.0, attempt_count=10, + Bigram(ord("e"), ord("a")): TransitionStats( + ord("e"), + ord("a"), + samples=10, + mean_time_ns=400_000_000.0, + error_count=0, + last_seen=0.0, + attempt_count=10, ), } unlocked = compute_unlocked( - learn_order, alphabet_size=8, stats=stats, target=200.0, transitions=transitions, + learn_order, + alphabet_size=8, + stats=stats, + target=200.0, + transitions=transitions, ) assert unlocked == learn_order[:8] assert ord("m") not in unlocked @@ -212,10 +307,19 @@ def test_compute_unlocked_advances_when_measured_transitions_meet_threshold(): learn_order = tuple(ord(c) for c in "eabcdfghm") stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in learn_order[:8]} transitions = { - "ee": _transition(ord("e"), ord("e"), 100_000_000.0, attempt_count=10), + Bigram(ord("e"), ord("e")): _transition( + ord("e"), + ord("e"), + 100_000_000.0, + attempt_count=10, + ), } unlocked = compute_unlocked( - learn_order, alphabet_size=8, stats=stats, target=200.0, transitions=transitions, + learn_order, + alphabet_size=8, + stats=stats, + target=200.0, + transitions=transitions, ) assert unlocked == learn_order[:9] assert unlocked[-1] == ord("m") @@ -327,8 +431,20 @@ def test_select_focus_transition_ignores_same_key_pairs(): now = 1_700_000_000.0 unlocked = (ord("e"), ord("a")) transitions = { - "ee": _transition(ord("e"), ord("e"), 400_000_000.0, last_seen=now, attempt_count=10), - "aa": _transition(ord("a"), ord("a"), 400_000_000.0, last_seen=now, attempt_count=10), + Bigram(ord("e"), ord("e")): _transition( + ord("e"), + ord("e"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + Bigram(ord("a"), ord("a")): _transition( + ord("a"), + ord("a"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), } assert select_focus_transition(unlocked, transitions, 200.0, now) is None @@ -338,10 +454,34 @@ def test_select_focus_transition_never_picks_same_key_even_when_weakest(): unlocked = (ord("a"), ord("b")) fast = 100_000_000.0 transitions = { - "aa": _transition(ord("a"), ord("a"), 400_000_000.0, last_seen=now, attempt_count=10), - "ab": _transition(ord("a"), ord("b"), fast, last_seen=now, attempt_count=10), - "ba": _transition(ord("b"), ord("a"), fast, last_seen=now, attempt_count=10), - "bb": _transition(ord("b"), ord("b"), 400_000_000.0, last_seen=now, attempt_count=10), + Bigram(ord("a"), ord("a")): _transition( + ord("a"), + ord("a"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + Bigram(ord("a"), ord("b")): _transition( + ord("a"), + ord("b"), + fast, + last_seen=now, + attempt_count=10, + ), + Bigram(ord("b"), ord("a")): _transition( + ord("b"), + ord("a"), + fast, + last_seen=now, + attempt_count=10, + ), + Bigram(ord("b"), ord("b")): _transition( + ord("b"), + ord("b"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), } result = select_focus_transition(unlocked, transitions, 200.0, now) assert result is not None @@ -354,10 +494,15 @@ def test_select_focus_transition_picks_stale_over_slightly_weaker_recent(): unlocked = (ord("a"), ord("b")) fast = 100_000_000.0 transitions = { - "aa": _transition(ord("a"), ord("a"), fast, last_seen=now), - "ab": _transition(ord("a"), ord("b"), 210_000_000.0, last_seen=now - five_days), - "ba": _transition(ord("b"), ord("a"), 235_000_000.0, last_seen=now), - "bb": _transition(ord("b"), ord("b"), fast, last_seen=now), + Bigram(ord("a"), ord("a")): _transition(ord("a"), ord("a"), fast, last_seen=now), + Bigram(ord("a"), ord("b")): _transition( + ord("a"), + ord("b"), + 210_000_000.0, + last_seen=now - five_days, + ), + Bigram(ord("b"), ord("a")): _transition(ord("b"), ord("a"), 235_000_000.0, last_seen=now), + Bigram(ord("b"), ord("b")): _transition(ord("b"), ord("b"), fast, last_seen=now), } assert select_focus_transition(unlocked, transitions, 200.0, now) == (ord("a"), ord("b")) @@ -371,15 +516,34 @@ def test_select_focus_transition_ignores_unmeasured_pairs(): now = 1_700_000_000.0 fast = 100_000_000.0 transitions = { - "ab": _transition(ord("a"), ord("b"), 400_000_000.0, last_seen=now, attempt_count=10), - "bc": _transition(ord("b"), ord("c"), fast, last_seen=now, attempt_count=10), + Bigram(ord("a"), ord("b")): _transition( + ord("a"), + ord("b"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + Bigram(ord("b"), ord("c")): _transition( + ord("b"), + ord("c"), + fast, + last_seen=now, + attempt_count=10, + ), } assert select_focus_transition(unlocked, transitions, 200.0, now) == (ord("a"), ord("b")) def test_select_focus_transition_returns_none_when_no_measured_unlocked_pairs(): unlocked = (ord("a"), ord("b")) - transitions = {"yz": _transition(ord("y"), ord("z"), 100_000_000.0, last_seen=1_700_000_000.0)} + transitions = { + Bigram(ord("y"), ord("z")): _transition( + ord("y"), + ord("z"), + 100_000_000.0, + last_seen=1_700_000_000.0, + ), + } assert select_focus_transition(unlocked, transitions, 200.0, now=1_700_000_000.0) is None diff --git a/tests/domain/test_enums.py b/tests/domain/test_enums.py new file mode 100644 index 0000000..cdd29bc --- /dev/null +++ b/tests/domain/test_enums.py @@ -0,0 +1,17 @@ +import pytest + +from keystrike.domain.enums import Mode, migrate_legacy_mode + + +@pytest.mark.parametrize("legacy", ["free", "code", "sample"]) +def test_migrate_legacy_mode_maps_retired_modes_to_adaptive(legacy): + assert migrate_legacy_mode(legacy) is Mode.ADAPTIVE + + +def test_migrate_legacy_mode_passes_through_current_mode(): + assert migrate_legacy_mode("adaptive") is Mode.ADAPTIVE + + +def test_migrate_legacy_mode_raises_on_unknown_value(): + with pytest.raises(ValueError, match="not-a-real-mode"): + migrate_legacy_mode("not-a-real-mode") diff --git a/tests/domain/test_generator.py b/tests/domain/test_generator.py index 4e15feb..c9924f5 100644 --- a/tests/domain/test_generator.py +++ b/tests/domain/test_generator.py @@ -2,12 +2,14 @@ from keystrike.domain.generator import ( AdaptiveGenerator, + LessonWeighting, cpm_from_wpm, typical_chars_per_word, wordlist_weight_for_word, wpm_from_cpm, ) from keystrike.domain.markov import TransitionTable +from keystrike.domain.models import Bigram from keystrike.domain.word_bounds import MAX_WORD_LEN, MIN_WORD_LEN @@ -47,7 +49,10 @@ def test_generate_lesson_always_includes_focus_char(): def test_generate_lesson_char_weights_bias_toward_weak_key(): generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) lesson = generator.generate_lesson( - frozenset("ab"), focus_char="a", word_count=20, char_weights={"a": 50.0}, + frozenset("ab"), + focus_char="a", + word_count=20, + weighting=LessonWeighting(char_weights={"a": 50.0}), ) assert lesson.count("a") > lesson.count("b") @@ -56,7 +61,7 @@ def test_generate_word_uses_wordlist_when_provided(): generator = AdaptiveGenerator(table=_uniform_table("xyz"), rng=Random(0)) words = ["cab", "bad", "dab"] for _ in range(10): - word = generator.generate_word(frozenset("abcd"), words=words) + word = generator.generate_word(frozenset("abcd"), LessonWeighting(words=words)) assert word in words @@ -68,7 +73,10 @@ def test_generate_word_falls_back_to_markov_without_wordlist(): def test_generate_word_falls_back_when_wordlist_outside_alphabet(): generator = AdaptiveGenerator(table=_uniform_table("abc"), rng=Random(0)) - word = generator.generate_word(frozenset("abc"), words=["xyz", "qrs"]) + word = generator.generate_word( + frozenset("abc"), + LessonWeighting(words=["xyz", "qrs"]), + ) assert set(word) <= {"a", "b", "c"} @@ -78,7 +86,8 @@ def test_generate_wordlist_char_weights_bias(): counts = {"a": 0, "b": 0} for _ in range(50): word = generator.generate_word( - frozenset("ab"), char_weights={"a": 100.0, "b": 1.0}, words=words, + frozenset("ab"), + LessonWeighting(char_weights={"a": 100.0, "b": 1.0}, words=words), ) counts[word[0]] += 1 assert counts["a"] > counts["b"] @@ -91,8 +100,13 @@ def test_generate_wordlist_transition_weights_bias(): for _ in range(50): word = generator.generate_word( frozenset("abcd"), - transition_weights={"ab": 100.0, "ad": 1.0}, - words=words, + LessonWeighting( + transition_weights={ + Bigram(ord("a"), ord("b")): 100.0, + Bigram(ord("a"), ord("d")): 1.0, + }, + words=words, + ), ) counts[word] += 1 assert counts["cab"] > counts["cad"] @@ -101,8 +115,10 @@ def test_generate_wordlist_transition_weights_bias(): def test_wordlist_weight_for_word_combines_char_and_transition_weights(): weight = wordlist_weight_for_word( "cab", - char_weights={"a": 2.0, "b": 2.0, "c": 1.0}, - transition_weights={"ab": 3.0, "bc": 1.0}, + weighting=LessonWeighting( + char_weights={"a": 2.0, "b": 2.0, "c": 1.0}, + transition_weights={Bigram(ord("a"), ord("b")): 3.0, Bigram(ord("b"), ord("c")): 1.0}, + ), ) assert weight == (2.0 + 2.0 + 1.0) * (3.0 + 1.0) @@ -117,8 +133,14 @@ def test_generate_lesson_wordlist_transition_weights_bias(): frozenset("as"), focus_char="s", word_count=12, - transition_weights={"as": 100.0, "sa": 1.0, "ss": 1.0}, - words=words, + weighting=LessonWeighting( + transition_weights={ + Bigram(ord("a"), ord("s")): 100.0, + Bigram(ord("s"), ord("a")): 1.0, + Bigram(ord("s"), ord("s")): 1.0, + }, + words=words, + ), ) ssa_count += lesson.split().count("ssa") assert ssa_count < 80 # uniform would land near ~150 of 600 picks @@ -134,8 +156,7 @@ def test_generate_lesson_focus_wordlist_overweights_focus_char(): frozenset("ab"), focus_char="a", word_count=8, - char_weights={"a": 1.75, "b": 1.0}, - words=words, + weighting=LessonWeighting(char_weights={"a": 1.75, "b": 1.0}, words=words), ) if lesson.count("a") > lesson.count("b"): focus_counts += 1 @@ -151,7 +172,7 @@ def test_generate_lesson_markov_overweights_focus_char(): frozenset("ab"), focus_char="a", word_count=12, - char_weights={"a": 5.25, "b": 1.0}, + weighting=LessonWeighting(char_weights={"a": 5.25, "b": 1.0}), ) if lesson.count("a") > lesson.count("b"): focus_counts += 1 diff --git a/tests/domain/test_markov.py b/tests/domain/test_markov.py index d8dbfc7..cbfc4e5 100644 --- a/tests/domain/test_markov.py +++ b/tests/domain/test_markov.py @@ -1,7 +1,7 @@ from random import Random from keystrike.domain.markov import TransitionTable, transition_practice_weight -from keystrike.domain.models import Layout +from keystrike.domain.models import Bigram, Layout from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS @@ -43,9 +43,7 @@ def test_sample_char_weights_bias_toward_weighted_char(): rng = Random(1) # "b" would be a coin flip against "c" on raw weight alone; a large # char_weights bias should make it win consistently. - results = { - table.sample("", frozenset("bc"), rng, char_weights={"b": 100.0}) for _ in range(20) - } + results = {table.sample("", frozenset("bc"), rng, char_weights={"b": 100.0}) for _ in range(20)} assert results == {"b"} @@ -93,7 +91,12 @@ def test_sample_transition_weights_bias_weak_pair(): rng = Random(0) counts = {"b": 0, "c": 0} for _ in range(200): - ch = table.sample("a", frozenset("abc"), rng, transition_weights={"ab": 100.0}) + ch = table.sample( + "a", + frozenset("abc"), + rng, + transition_weights={Bigram(ord("a"), ord("b")): 100.0}, + ) assert ch is not None counts[ch] += 1 assert counts["b"] > counts["c"] diff --git a/tests/domain/test_null_adapters.py b/tests/domain/test_null_adapters.py index d596091..f848bc0 100644 --- a/tests/domain/test_null_adapters.py +++ b/tests/domain/test_null_adapters.py @@ -1,6 +1,7 @@ from keystrike.domain.enums import Mode from keystrike.domain.models import Keystroke, SessionResult from keystrike.domain.null_adapters import ( + NULL_AGGREGATES_ENSURER, NULL_DAILY_LEARN_BUDGET, NULL_STATS_REBUILDER, NullSessionRepository, @@ -30,8 +31,12 @@ def test_null_session_repository_is_inert(): assert list(repo.load_keystrokes("s1")) == [] -def test_null_stats_rebuilder_returns_empty_dict(): - assert NULL_STATS_REBUILDER("qwerty") == {} +def test_null_stats_rebuilder_returns_none(): + assert NULL_STATS_REBUILDER("qwerty") is None + + +def test_null_aggregates_ensurer_returns_empty_dict(): + assert NULL_AGGREGATES_ENSURER("qwerty") == {} def test_null_daily_learn_budget_is_unlimited(): diff --git a/tests/domain/test_sync_merge.py b/tests/domain/test_sync_merge.py index fb2c3e1..61fc8bb 100644 --- a/tests/domain/test_sync_merge.py +++ b/tests/domain/test_sync_merge.py @@ -1,45 +1,20 @@ -import json -import os -import time -from pathlib import Path - -import pytest - from keystrike.domain.sync_merge import ( - import_missing_sessions, - read_index_session_ids, - resolve_settings_lww, + decide_settings_winner, + index_layouts, + index_session_ids, + plan_layouts_to_copy, + plan_missing_sessions, + settings_epoch_from_toml, ) -@pytest.fixture -def tree(tmp_path: Path) -> dict[str, Path]: - local = tmp_path / "local" - remote = tmp_path / "remote" - local_sessions = local / "sessions" - remote_sessions = remote / "sessions" - for d in (local, remote, local_sessions, remote_sessions): - d.mkdir(parents=True) - return { - "local_settings": local / "settings.toml", - "remote_settings": remote / "settings.toml", - "local_sessions": local_sessions, - "remote_sessions": remote_sessions, - "local_index": local_sessions / "index.jsonl", - "remote_index": remote_sessions / "index.jsonl", - } - - -def _write_index(path: Path, *entries: dict[str, object]) -> None: - lines = [json.dumps(e) for e in entries] - path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - -def _header(sid: str, layout: str = "qwerty") -> dict[str, object]: +def _entry( + sid: str, started_at: float = 1_700_000_000.0, layout: str = "qwerty" +) -> dict[str, object]: return { "schema_version": 1, "session_id": sid, - "started_at": 1_700_000_000.0, + "started_at": started_at, "duration_ns": 1, "layout": layout, "mode": "adaptive", @@ -50,77 +25,153 @@ def _header(sid: str, layout: str = "qwerty") -> dict[str, object]: } -def test_session_union_imports_missing_remote_only(tree: dict[str, Path]) -> None: - _write_index(tree["local_index"], _header("A")) - _write_index(tree["remote_index"], _header("A"), _header("B")) - month = "2023-11" - (tree["remote_sessions"] / month).mkdir() - (tree["remote_sessions"] / month / "B.jsonl").write_text( - '{"codepoint": 97, "typed": 97, "t_ns": 0, "correct": true}\n', - encoding="utf-8", - ) +def test_index_session_ids_extracts_ids(): + entries = [_entry("A"), _entry("B")] + assert index_session_ids(entries) == {"A", "B"} + - imported = import_missing_sessions( - local_sessions_dir=tree["local_sessions"], - remote_sessions_dir=tree["remote_sessions"], - local_index=tree["local_index"], - remote_index=tree["remote_index"], +def test_index_layouts_extracts_distinct_layouts(): + entries = [ + _entry("A", layout="qwerty"), + _entry("B", layout="dvorak"), + _entry("C", layout="qwerty"), + ] + assert index_layouts(entries) == {"qwerty", "dvorak"} + + +def test_plan_missing_sessions_skips_ids_already_local(): + remote_entries = [_entry("A"), _entry("B")] + remote_lines = ['{"session_id": "A"}', '{"session_id": "B"}'] + + plans = plan_missing_sessions( + local_session_ids={"A"}, + remote_entries=remote_entries, + remote_lines=remote_lines, ) - assert imported == ["B"] - assert read_index_session_ids(tree["local_index"]) == {"A", "B"} - assert (tree["local_sessions"] / month / "B.jsonl").exists() + assert [p.session_id for p in plans] == ["B"] + assert plans[0].index_line == '{"session_id": "B"}' + assert plans[0].filename == "B.jsonl" -def test_session_union_skips_duplicate_local_ids(tree: dict[str, Path]) -> None: - _write_index(tree["local_index"], _header("A")) - _write_index(tree["remote_index"], _header("A"), _header("B")) - month = "2023-11" - (tree["remote_sessions"] / month).mkdir() - (tree["remote_sessions"] / month / "B.jsonl").write_text("{}\n", encoding="utf-8") +def test_plan_missing_sessions_derives_month_from_started_at(): + started_at = 1_700_000_000.0 # 2023-11-14 ~ + remote_entries = [_entry("B", started_at=started_at)] + remote_lines = ["line"] - import_missing_sessions( - local_sessions_dir=tree["local_sessions"], - remote_sessions_dir=tree["remote_sessions"], - local_index=tree["local_index"], - remote_index=tree["remote_index"], + plans = plan_missing_sessions( + local_session_ids=set(), + remote_entries=remote_entries, + remote_lines=remote_lines, ) - lines = tree["local_index"].read_text().splitlines() - assert lines.count(json.dumps(_header("A"))) == 1 - assert json.dumps(_header("B")) in lines + assert plans[0].month == "2023-11" -def test_settings_lww_prefers_updated_at(tree: dict[str, Path]) -> None: - tree["local_settings"].write_text( - 'layout = "qwerty"\nupdated_at = "2024-01-01T00:00:00+00:00"\n', - encoding="utf-8", +def test_plan_missing_sessions_ignores_duplicate_remote_entries(): + remote_entries = [_entry("A"), _entry("A")] + remote_lines = ["first", "second"] + + plans = plan_missing_sessions( + local_session_ids=set(), + remote_entries=remote_entries, + remote_lines=remote_lines, ) - tree["remote_settings"].write_text( - 'layout = "dvorak"\nupdated_at = "2024-06-01T00:00:00+00:00"\n', - encoding="utf-8", + + assert [p.session_id for p in plans] == ["A"] + assert plans[0].index_line == "first" + + +def test_settings_epoch_prefers_updated_at_field(): + raw = 'layout = "qwerty"\nupdated_at = "2024-06-01T00:00:00+00:00"\n' + epoch = settings_epoch_from_toml(raw, mtime=0.0) + assert epoch > 0.0 + assert epoch != 0.0 + + +def test_settings_epoch_falls_back_to_mtime_when_no_updated_at(): + raw = 'layout = "qwerty"\n' + assert settings_epoch_from_toml(raw, mtime=123.0) == 123.0 + + +def test_settings_epoch_handles_naive_and_zulu_timestamps(): + naive = settings_epoch_from_toml('updated_at = "2024-01-01T00:00:00"\n', mtime=0.0) + zulu = settings_epoch_from_toml('updated_at = "2024-01-01T00:00:00Z"\n', mtime=0.0) + assert naive == zulu + + +def test_decide_settings_winner_none_when_neither_exists(): + winner = decide_settings_winner( + local_exists=False, + remote_exists=False, + local_epoch=0.0, + remote_epoch=0.0, ) + assert winner == "none" + - winner = resolve_settings_lww( - local_path=tree["local_settings"], - remote_path=tree["remote_settings"], +def test_decide_settings_winner_local_when_remote_missing(): + winner = decide_settings_winner( + local_exists=True, + remote_exists=False, + local_epoch=5.0, + remote_epoch=0.0, ) + assert winner == "local" - assert winner == "remote" - assert 'layout = "dvorak"' in tree["local_settings"].read_text() +def test_decide_settings_winner_remote_when_local_missing(): + winner = decide_settings_winner( + local_exists=False, + remote_exists=True, + local_epoch=0.0, + remote_epoch=5.0, + ) + assert winner == "remote" -def test_settings_lww_falls_back_to_mtime(tree: dict[str, Path]) -> None: - tree["local_settings"].write_text('layout = "qwerty"\n', encoding="utf-8") - tree["remote_settings"].write_text('layout = "dvorak"\n', encoding="utf-8") - now = time.time() - os.utime(tree["remote_settings"], (now - 100, now - 100)) - os.utime(tree["local_settings"], (now, now)) - winner = resolve_settings_lww( - local_path=tree["local_settings"], - remote_path=tree["remote_settings"], +def test_decide_settings_winner_prefers_newer_epoch(): + assert ( + decide_settings_winner( + local_exists=True, + remote_exists=True, + local_epoch=10.0, + remote_epoch=20.0, + ) + == "remote" ) + assert ( + decide_settings_winner( + local_exists=True, + remote_exists=True, + local_epoch=20.0, + remote_epoch=10.0, + ) + == "local" + ) + +def test_decide_settings_winner_ties_favor_local(): + winner = decide_settings_winner( + local_exists=True, + remote_exists=True, + local_epoch=10.0, + remote_epoch=10.0, + ) assert winner == "local" - assert tree["remote_settings"].read_text() == tree["local_settings"].read_text() + + +def test_plan_layouts_to_copy_returns_names_missing_from_dest(): + missing = plan_layouts_to_copy( + source_names={"qwerty.toml", "dvorak.toml"}, + dest_names={"qwerty.toml"}, + ) + assert missing == {"dvorak.toml"} + + +def test_plan_layouts_to_copy_empty_when_nothing_missing(): + missing = plan_layouts_to_copy( + source_names={"qwerty.toml"}, + dest_names={"qwerty.toml", "dvorak.toml"}, + ) + assert missing == set() diff --git a/tests/infrastructure/test_aggregates_cache.py b/tests/infrastructure/test_aggregates_cache.py index e00da1a..b5c3cb0 100644 --- a/tests/infrastructure/test_aggregates_cache.py +++ b/tests/infrastructure/test_aggregates_cache.py @@ -2,7 +2,8 @@ import pytest -from keystrike.domain.models import KeyStats, LayoutAggregates, TransitionStats +from keystrike.domain.confidence import target_ms_per_char, transition_confidence_of +from keystrike.domain.models import Bigram, KeyStats, LayoutAggregates, TransitionStats from keystrike.infrastructure.aggregates_cache import FileAggregatesCache from keystrike.infrastructure.paths import Paths @@ -25,11 +26,19 @@ def test_get_returns_none_when_missing(paths): def test_put_then_get(paths): cache = FileAggregatesCache(paths) stats = { - ord("a"): KeyStats(ord("a"), 10, 120_000_000.0, 1, 1_700_000_000.0), - ord("b"): KeyStats(ord("b"), 5, 200_000_000.0, 3, 1_700_000_100.0), + ord("a"): KeyStats(ord("a"), 10, 120_000_000.0, 1, 1_700_000_000.0, attempt_count=11), + ord("b"): KeyStats(ord("b"), 5, 200_000_000.0, 3, 1_700_000_100.0, attempt_count=8), } transitions = { - "ab": TransitionStats(ord("a"), ord("b"), 4, 150_000_000.0, 2, 1_700_000_050.0), + Bigram(ord("a"), ord("b")): TransitionStats( + ord("a"), + ord("b"), + 4, + 150_000_000.0, + 2, + 1_700_000_050.0, + attempt_count=6, + ), } aggregates = LayoutAggregates(keys=stats, transitions=transitions) cache.put("qwerty", aggregates) @@ -63,15 +72,15 @@ def test_put_strips_same_key_transitions(paths): aggregates = LayoutAggregates( keys={ord("a"): KeyStats(ord("a"), 1, 100.0, 0, 1.0)}, transitions={ - "aa": TransitionStats(ord("a"), ord("a"), 1, 100.0, 0, 1.0), - "ab": TransitionStats(ord("a"), ord("b"), 1, 100.0, 0, 1.0), + Bigram(ord("a"), ord("a")): TransitionStats(ord("a"), ord("a"), 1, 100.0, 0, 1.0), + Bigram(ord("a"), ord("b")): TransitionStats(ord("a"), ord("b"), 1, 100.0, 0, 1.0), }, ) cache.put("qwerty", aggregates) loaded = cache.get("qwerty") assert loaded is not None - assert "aa" not in loaded.transitions - assert "ab" in loaded.transitions + assert Bigram(ord("a"), ord("a")) not in loaded.transitions + assert Bigram(ord("a"), ord("b")) in loaded.transitions raw = json.loads(cache._file("qwerty").read_text(encoding="utf-8")) assert "aa" not in raw["transitions"] @@ -79,34 +88,116 @@ def test_put_strips_same_key_transitions(paths): def test_get_strips_same_key_transitions_from_stale_cache(paths): cache = FileAggregatesCache(paths) cache._file("qwerty").write_text( - json.dumps({ - "schema_version": 1, - "layout": "qwerty", - "keys": {}, - "transitions": { - "aa": { - "prev_cp": ord("a"), - "next_cp": ord("a"), - "samples": 1, - "mean_time_ns": 100.0, - "error_count": 0, - "last_seen": 1.0, - "attempt_count": 1, + json.dumps( + { + "schema_version": 1, + "layout": "qwerty", + "keys": {}, + "transitions": { + "aa": { + "prev_cp": ord("a"), + "next_cp": ord("a"), + "samples": 1, + "mean_time_ns": 100.0, + "error_count": 0, + "last_seen": 1.0, + "attempt_count": 1, + }, + "ab": { + "prev_cp": ord("a"), + "next_cp": ord("b"), + "samples": 1, + "mean_time_ns": 100.0, + "error_count": 0, + "last_seen": 1.0, + "attempt_count": 1, + }, }, - "ab": { - "prev_cp": ord("a"), - "next_cp": ord("b"), - "samples": 1, - "mean_time_ns": 100.0, - "error_count": 0, - "last_seen": 1.0, - "attempt_count": 1, + } + ), + encoding="utf-8", + ) + loaded = cache.get("qwerty") + assert loaded is not None + assert Bigram(ord("a"), ord("a")) not in loaded.transitions + assert Bigram(ord("a"), ord("b")) in loaded.transitions + + +def test_get_repairs_zero_samples_when_mean_time_present(paths): + """Pre-fix cache wrote samples=0 while mean_time_ns stayed measured.""" + cache = FileAggregatesCache(paths) + cache._file("qwerty").write_text( + json.dumps( + { + "schema_version": 1, + "layout": "qwerty", + "keys": {}, + "transitions": { + "eo": { + "prev_cp": ord("e"), + "next_cp": ord("o"), + "samples": 0, + "mean_time_ns": 196_000_000.0, + "error_count": 0, + "last_seen": 1.0, + "attempt_count": 0, + }, }, - }, - }), + } + ), encoding="utf-8", ) loaded = cache.get("qwerty") assert loaded is not None - assert "aa" not in loaded.transitions - assert "ab" in loaded.transitions + eo = loaded.transitions[Bigram(ord("e"), ord("o"))] + assert eo.samples == 1 + assert eo.attempt_count == 1 + assert ( + transition_confidence_of( + ord("e"), + ord("o"), + loaded.transitions, + target_ms_per_char(300), + ) + > 0.0 + ) + + +def test_get_repairs_zero_attempt_count_when_samples_present(paths): + """Pre-fix cache wrote attempt_count=0 while samples stayed > 0.""" + cache = FileAggregatesCache(paths) + cache._file("qwerty").write_text( + json.dumps( + { + "schema_version": 1, + "layout": "qwerty", + "keys": {}, + "transitions": { + "eo": { + "prev_cp": ord("e"), + "next_cp": ord("o"), + "samples": 1, + "mean_time_ns": 196_000_000.0, + "error_count": 0, + "last_seen": 1.0, + "attempt_count": 0, + }, + }, + } + ), + encoding="utf-8", + ) + loaded = cache.get("qwerty") + assert loaded is not None + eo = loaded.transitions[Bigram(ord("e"), ord("o"))] + assert eo.attempt_count == 1 + + assert ( + transition_confidence_of( + ord("e"), + ord("o"), + loaded.transitions, + target_ms_per_char(300), + ) + > 0.0 + ) diff --git a/tests/infrastructure/test_layout_repo.py b/tests/infrastructure/test_layout_repo.py index ddb6205..40942f5 100644 --- a/tests/infrastructure/test_layout_repo.py +++ b/tests/infrastructure/test_layout_repo.py @@ -10,7 +10,9 @@ @pytest.fixture def paths(tmp_path: Path) -> Paths: return Paths( - config_dir=tmp_path / "config", data_dir=tmp_path / "data", log_dir=tmp_path / "log", + config_dir=tmp_path / "config", + data_dir=tmp_path / "data", + log_dir=tmp_path / "log", ) diff --git a/tests/infrastructure/test_layout_toml.py b/tests/infrastructure/test_layout_toml.py index 9abfa59..9498027 100644 --- a/tests/infrastructure/test_layout_toml.py +++ b/tests/infrastructure/test_layout_toml.py @@ -43,8 +43,11 @@ def test_rejects_bad_fixtures(fixture: str): def test_missing_name_field(tmp_path: Path): file = tmp_path / "no_name.toml" - file.write_text('learn_order = "a"\n\n[[keys]]\nchar = "a"\nrow = 1\ncol = 0\n' - 'finger = "PINKY"\nhand = "L"\n', encoding="utf-8") + file.write_text( + 'learn_order = "a"\n\n[[keys]]\nchar = "a"\nrow = 1\ncol = 0\n' + 'finger = "PINKY"\nhand = "L"\n', + encoding="utf-8", + ) with pytest.raises(LayoutTomlError, match="name"): load_layout_toml(file) @@ -65,10 +68,10 @@ def _write_layout(tmp_path: Path, content: str) -> Path: _MINIMAL = ( 'name = "x"\n' 'learn_order = "a"\n\n' - '[[keys]]\n' + "[[keys]]\n" 'char = "a"\n' - 'row = 1\n' - 'col = 0\n' + "row = 1\n" + "col = 0\n" 'finger = "PINKY"\n' 'hand = "L"\n' ) @@ -84,17 +87,22 @@ def _write_layout(tmp_path: Path, content: str) -> Path: ], ) def test_rejects_invalid_key_fields( - tmp_path: Path, field: str, value: str, match: str, + tmp_path: Path, + field: str, + value: str, + match: str, ): content = _MINIMAL if field == "shifted": content = content + f"shifted = {value}\n" else: lines = _MINIMAL.splitlines() - content = "\n".join( - f"{field} = {value}" if line.startswith(f"{field} = ") else line - for line in lines - ) + "\n" + content = ( + "\n".join( + f"{field} = {value}" if line.startswith(f"{field} = ") else line for line in lines + ) + + "\n" + ) with pytest.raises(LayoutTomlError, match=match): load_layout_toml(_write_layout(tmp_path, content)) @@ -115,16 +123,16 @@ def test_duplicate_keys_last_entry_wins(tmp_path: Path): content = ( 'name = "x"\n' 'learn_order = "a"\n\n' - '[[keys]]\n' + "[[keys]]\n" 'char = "a"\n' - 'row = 1\n' - 'col = 0\n' + "row = 1\n" + "col = 0\n" 'finger = "PINKY"\n' 'hand = "L"\n\n' - '[[keys]]\n' + "[[keys]]\n" 'char = "a"\n' - 'row = 9\n' - 'col = 9\n' + "row = 9\n" + "col = 9\n" 'finger = "THUMB"\n' 'hand = "R"\n' ) diff --git a/tests/infrastructure/test_session_repo_jsonl.py b/tests/infrastructure/test_session_repo_jsonl.py index d5e6c47..05ca622 100644 --- a/tests/infrastructure/test_session_repo_jsonl.py +++ b/tests/infrastructure/test_session_repo_jsonl.py @@ -37,10 +37,16 @@ def test_round_trip_single_session(paths): repo = JsonlSessionRepository(paths) header = _header() repo.save_header(header) - repo.append_keystroke(header.session_id, header.started_at, - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True)) - repo.append_keystroke(header.session_id, header.started_at, - Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100, correct=True)) + repo.append_keystroke( + header.session_id, + header.started_at, + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + ) + repo.append_keystroke( + header.session_id, + header.started_at, + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100, correct=True), + ) # Fresh repo instance — read must survive across process restart. repo2 = JsonlSessionRepository(paths) diff --git a/tests/infrastructure/test_settings_repo_toml.py b/tests/infrastructure/test_settings_repo_toml.py index 484889d..a83cc50 100644 --- a/tests/infrastructure/test_settings_repo_toml.py +++ b/tests/infrastructure/test_settings_repo_toml.py @@ -63,9 +63,7 @@ def test_save_uses_atomic_replace(paths): def test_ignores_unknown_keys_for_forward_compat(paths): paths.settings_file.write_text( - 'schema_version = 1\n' - 'layout = "qwerty"\n' - 'unknown_future_key = 42\n', + 'schema_version = 1\nlayout = "qwerty"\nunknown_future_key = 42\n', encoding="utf-8", ) s = TomlSettingsRepository(paths).load() @@ -74,7 +72,7 @@ def test_ignores_unknown_keys_for_forward_compat(paths): def test_ignores_removed_settings_keys(paths): paths.settings_file.write_text( - 'schema_version = 1\n' + "schema_version = 1\n" 'layout = "qwerty"\n' 'freeform_path = "/tmp/old.txt"\n' 'code_language = "python"\n', diff --git a/tests/infrastructure/test_sync_git.py b/tests/infrastructure/test_sync_git.py new file mode 100644 index 0000000..cb429f9 --- /dev/null +++ b/tests/infrastructure/test_sync_git.py @@ -0,0 +1,270 @@ +import json +import os +import time +from pathlib import Path + +import pytest + +from keystrike.infrastructure.sync_git import ( + copy_file_if_exists, + copy_layouts_missing, + copy_layouts_to_remote, + import_missing_sessions, + iter_layouts_from_index, + read_index_session_ids, + resolve_settings_lww, +) + + +@pytest.fixture +def tree(tmp_path: Path) -> dict[str, Path]: + local = tmp_path / "local" + remote = tmp_path / "remote" + local_sessions = local / "sessions" + remote_sessions = remote / "sessions" + for d in (local, remote, local_sessions, remote_sessions): + d.mkdir(parents=True) + return { + "local": local, + "remote": remote, + "local_settings": local / "settings.toml", + "remote_settings": remote / "settings.toml", + "local_layouts": local / "layouts", + "remote_layouts": remote / "layouts", + "local_sessions": local_sessions, + "remote_sessions": remote_sessions, + "local_index": local_sessions / "index.jsonl", + "remote_index": remote_sessions / "index.jsonl", + } + + +def _write_index(path: Path, *entries: dict[str, object]) -> None: + lines = [json.dumps(e) for e in entries] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _header(sid: str, layout: str = "qwerty") -> dict[str, object]: + return { + "schema_version": 1, + "session_id": sid, + "started_at": 1_700_000_000.0, + "duration_ns": 1, + "layout": layout, + "mode": "adaptive", + "lesson_alphabet": [97], + "focus_key": None, + "total_keystrokes": 1, + "correct_keystrokes": 1, + } + + +def test_read_index_session_ids_missing_file_is_empty(tree: dict[str, Path]) -> None: + assert read_index_session_ids(tree["local_index"]) == set() + + +def test_read_index_session_ids_reads_existing_ids(tree: dict[str, Path]) -> None: + _write_index(tree["local_index"], _header("A"), _header("B")) + assert read_index_session_ids(tree["local_index"]) == {"A", "B"} + + +def test_iter_layouts_from_index(tree: dict[str, Path]) -> None: + _write_index(tree["local_index"], _header("A", layout="qwerty"), _header("B", layout="dvorak")) + assert iter_layouts_from_index(tree["local_index"]) == {"qwerty", "dvorak"} + + +def test_session_union_imports_missing_remote_only(tree: dict[str, Path]) -> None: + _write_index(tree["local_index"], _header("A")) + _write_index(tree["remote_index"], _header("A"), _header("B")) + month = "2023-11" + (tree["remote_sessions"] / month).mkdir() + (tree["remote_sessions"] / month / "B.jsonl").write_text( + '{"codepoint": 97, "typed": 97, "t_ns": 0, "correct": true}\n', + encoding="utf-8", + ) + + imported = import_missing_sessions( + local_sessions_dir=tree["local_sessions"], + remote_sessions_dir=tree["remote_sessions"], + local_index=tree["local_index"], + remote_index=tree["remote_index"], + ) + + assert imported == ["B"] + assert read_index_session_ids(tree["local_index"]) == {"A", "B"} + assert (tree["local_sessions"] / month / "B.jsonl").exists() + + +def test_session_union_skips_duplicate_local_ids(tree: dict[str, Path]) -> None: + _write_index(tree["local_index"], _header("A")) + _write_index(tree["remote_index"], _header("A"), _header("B")) + month = "2023-11" + (tree["remote_sessions"] / month).mkdir() + (tree["remote_sessions"] / month / "B.jsonl").write_text("{}\n", encoding="utf-8") + + import_missing_sessions( + local_sessions_dir=tree["local_sessions"], + remote_sessions_dir=tree["remote_sessions"], + local_index=tree["local_index"], + remote_index=tree["remote_index"], + ) + + lines = tree["local_index"].read_text().splitlines() + assert lines.count(json.dumps(_header("A"))) == 1 + assert json.dumps(_header("B")) in lines + + +def test_session_union_skips_entries_whose_file_is_missing(tree: dict[str, Path]) -> None: + _write_index(tree["remote_index"], _header("B")) + # No B.jsonl on disk anywhere. + + imported = import_missing_sessions( + local_sessions_dir=tree["local_sessions"], + remote_sessions_dir=tree["remote_sessions"], + local_index=tree["local_index"], + remote_index=tree["remote_index"], + ) + + assert imported == [] + + +def test_session_union_no_remote_index_is_noop(tree: dict[str, Path]) -> None: + imported = import_missing_sessions( + local_sessions_dir=tree["local_sessions"], + remote_sessions_dir=tree["remote_sessions"], + local_index=tree["local_index"], + remote_index=tree["remote_index"], + ) + assert imported == [] + + +def test_settings_lww_prefers_updated_at(tree: dict[str, Path]) -> None: + tree["local_settings"].write_text( + 'layout = "qwerty"\nupdated_at = "2024-01-01T00:00:00+00:00"\n', + encoding="utf-8", + ) + tree["remote_settings"].write_text( + 'layout = "dvorak"\nupdated_at = "2024-06-01T00:00:00+00:00"\n', + encoding="utf-8", + ) + + winner = resolve_settings_lww( + local_path=tree["local_settings"], + remote_path=tree["remote_settings"], + ) + + assert winner == "remote" + assert 'layout = "dvorak"' in tree["local_settings"].read_text() + + +def test_settings_lww_falls_back_to_mtime(tree: dict[str, Path]) -> None: + tree["local_settings"].write_text('layout = "qwerty"\n', encoding="utf-8") + tree["remote_settings"].write_text('layout = "dvorak"\n', encoding="utf-8") + now = time.time() + os.utime(tree["remote_settings"], (now - 100, now - 100)) + os.utime(tree["local_settings"], (now, now)) + + winner = resolve_settings_lww( + local_path=tree["local_settings"], + remote_path=tree["remote_settings"], + ) + + assert winner == "local" + assert tree["remote_settings"].read_text() == tree["local_settings"].read_text() + + +def test_settings_lww_neither_exists(tree: dict[str, Path]) -> None: + winner = resolve_settings_lww( + local_path=tree["local_settings"], + remote_path=tree["remote_settings"], + ) + assert winner == "none" + assert not tree["local_settings"].exists() + assert not tree["remote_settings"].exists() + + +def test_settings_lww_only_local_exists_does_not_touch_remote(tree: dict[str, Path]) -> None: + tree["local_settings"].write_text('layout = "qwerty"\n', encoding="utf-8") + + winner = resolve_settings_lww( + local_path=tree["local_settings"], + remote_path=tree["remote_settings"], + ) + + assert winner == "local" + assert not tree["remote_settings"].exists() + + +def test_settings_lww_only_remote_exists_copies_to_local(tree: dict[str, Path]) -> None: + tree["remote_settings"].write_text('layout = "dvorak"\n', encoding="utf-8") + + winner = resolve_settings_lww( + local_path=tree["local_settings"], + remote_path=tree["remote_settings"], + ) + + assert winner == "remote" + assert tree["local_settings"].read_text() == 'layout = "dvorak"\n' + + +def test_copy_layouts_missing_copies_only_absent_files(tree: dict[str, Path]) -> None: + tree["remote_layouts"].mkdir() + tree["local_layouts"].mkdir() + (tree["remote_layouts"] / "qwerty.toml").write_text("a", encoding="utf-8") + (tree["remote_layouts"] / "dvorak.toml").write_text("b", encoding="utf-8") + (tree["local_layouts"] / "qwerty.toml").write_text("existing", encoding="utf-8") + + copied = copy_layouts_missing( + local_layouts=tree["local_layouts"], + remote_layouts=tree["remote_layouts"], + ) + + assert copied == 1 + assert (tree["local_layouts"] / "dvorak.toml").read_text() == "b" + assert (tree["local_layouts"] / "qwerty.toml").read_text() == "existing" + + +def test_copy_layouts_missing_no_remote_dir(tree: dict[str, Path]) -> None: + assert ( + copy_layouts_missing( + local_layouts=tree["local_layouts"], + remote_layouts=tree["remote_layouts"], + ) + == 0 + ) + + +def test_copy_layouts_to_remote_copies_everything(tree: dict[str, Path]) -> None: + tree["local_layouts"].mkdir() + (tree["local_layouts"] / "qwerty.toml").write_text("a", encoding="utf-8") + + copy_layouts_to_remote( + local_layouts=tree["local_layouts"], + remote_layouts=tree["remote_layouts"], + ) + + assert (tree["remote_layouts"] / "qwerty.toml").read_text() == "a" + + +def test_copy_layouts_to_remote_no_local_dir_is_noop(tree: dict[str, Path]) -> None: + copy_layouts_to_remote( + local_layouts=tree["local_layouts"], + remote_layouts=tree["remote_layouts"], + ) + assert not tree["remote_layouts"].exists() + + +def test_copy_file_if_exists_true_when_present(tree: dict[str, Path]) -> None: + src = tree["local"] / "settings.toml" + src.write_text("data", encoding="utf-8") + dest = tree["remote"] / "nested" / "settings.toml" + + assert copy_file_if_exists(src, dest) is True + assert dest.read_text() == "data" + + +def test_copy_file_if_exists_false_when_missing(tree: dict[str, Path]) -> None: + src = tree["local"] / "missing.toml" + dest = tree["remote"] / "settings.toml" + + assert copy_file_if_exists(src, dest) is False + assert not dest.exists() diff --git a/tests/presentation/test_formatting_trends.py b/tests/presentation/test_formatting_trends.py new file mode 100644 index 0000000..6f7de89 --- /dev/null +++ b/tests/presentation/test_formatting_trends.py @@ -0,0 +1,366 @@ +from __future__ import annotations + +from keystrike.domain.enums import Mode +from keystrike.domain.models import SessionResult +from keystrike.presentation.formatting.trends import ( + focus_confidence_sparkline, + format_aggregate_metric_trend_block, + format_focus_confidence_trend_line, + format_key_confidence_trend_line, + format_key_metric_trend_block, + format_key_speed_trend_line, + format_wpm_trend_line, + key_confidence_sparkline, + key_confidence_values, + wpm_sparkline, +) + + +def test_focus_confidence_sparkline_uses_focus_key_per_session(): + headers = [ + SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("a"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("a"): 0.5, ord("b"): 1.0}, + ), + SessionResult( + schema_version=3, + session_id="s2", + started_at=2.0, + duration_ns=30_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("b"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("a"): 0.5, ord("b"): 1.0}, + ), + ] + spark = focus_confidence_sparkline(headers) + assert len(spark) == 2 + assert spark[0] <= spark[1] # 0.5 then 1.0 + + +def test_format_focus_confidence_trend_line_tracks_labeled_key(): + headers = [ + SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("a"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("a"): 0.5, ord("b"): 1.0}, + ), + SessionResult( + schema_version=3, + session_id="s2", + started_at=2.0, + duration_ns=30_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("b"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("a"): 0.5, ord("b"): 1.0}, + ), + ] + focus_line = format_focus_confidence_trend_line(headers) + key_line = format_key_metric_trend_block( + title="'b'", + headers=headers, + codepoint=ord("b"), + speed_values=[], + accuracy_values=[], + limit=20, + ) + assert focus_line == key_line.replace("[bold]'b'[/]", "[bold]Focus 'b'[/]", 1) + + +def test_format_focus_confidence_trend_line_includes_label_and_peak(): + headers = [ + SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("e"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("e"): 0.75}, + ), + ] + line = format_focus_confidence_trend_line(headers) + assert line.startswith("[bold]Focus 'e'[/] (1 sessions)") + assert "[bold cyan]confidence[/]" in line + assert "latest 0.75" in line + assert "peak 0.75" in line + + +def test_focus_confidence_missing_key_defaults_zero(): + headers = [ + SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("z"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={}, + ), + ] + spark = focus_confidence_sparkline(headers) + assert len(spark) == 1 + assert spark[0] == "▁" + + +def test_key_confidence_values_tracks_codepoint_across_sessions(): + headers = [ + SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("a"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("e"): 0.4, ord("a"): 0.5}, + ), + SessionResult( + schema_version=3, + session_id="s2", + started_at=2.0, + duration_ns=30_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("e"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("e"): 0.9, ord("a"): 0.5}, + ), + ] + values = key_confidence_values(headers, ord("e")) + assert values == [0.4, 0.9] + spark = key_confidence_sparkline(headers, ord("e")) + assert len(spark) == 2 + assert spark[0] <= spark[1] + + +def test_key_confidence_values_normalize_to_current_goal(): + headers = [ + SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("e"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("e"): 1.0}, + target_speed_cpm=300, + ), + ] + values = key_confidence_values(headers, ord("e"), current_target_speed_cpm=600) + assert values == [0.5] + + +def test_key_confidence_values_legacy_session_unnormalized(): + headers = [ + SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("e"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("e"): 0.82}, + target_speed_cpm=0, + ), + ] + values = key_confidence_values(headers, ord("e"), current_target_speed_cpm=600) + assert values == [0.82] + + +def test_format_focus_confidence_trend_line_normalizes_to_current_goal(): + headers = [ + SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("e"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("e"): 1.0}, + target_speed_cpm=300, + ), + ] + line = format_focus_confidence_trend_line(headers, current_target_speed_cpm=600) + assert "latest 0.50" in line + assert "peak 0.50" in line + + +def test_format_key_confidence_trend_line_includes_cumulative(): + headers = [ + SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("e"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("e"): 0.75}, + ), + ] + line = format_key_confidence_trend_line(headers, ord("e"), cumulative=1.1) + assert "'e' confidence" in line + assert "[cyan]" in line + assert "latest 0.75" in line + assert "cumulative 1.10" in line + + +def test_format_key_metric_trend_block_shows_key_once_with_colors(): + headers = [ + SessionResult( + schema_version=3, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=ord("e"), + total_keystrokes=50, + correct_keystrokes=50, + key_confidence={ord("e"): 0.75}, + ), + ] + detail = format_key_metric_trend_block( + title="'e'", + headers=headers, + codepoint=ord("e"), + speed_values=[120.0], + accuracy_values=[0.95], + ) + assert detail.startswith("[bold]'e'[/] (1 sessions)") + assert detail.count("sessions") == 1 + assert "'e' confidence" not in detail + assert "[bold cyan]confidence[/]" in detail + assert "[bold green]speed [/]" in detail + assert "[bold yellow]accuracy [/]" in detail + assert detail.count("'e'") == 1 + assert "latest 120.00" in detail + assert "latest 95.0%" in detail + + +def test_format_aggregate_metric_trend_block_includes_title(): + detail = format_aggregate_metric_trend_block( + title="Layout", + confidence_values=[0.75], + speed_values=[120.0], + accuracy_values=[0.95], + ) + assert detail.startswith("[bold]Layout[/] (1 sessions)") + assert "[bold cyan]confidence[/]" in detail + assert "[bold green]speed [/]" in detail + assert "[bold yellow]accuracy [/]" in detail + + +def test_format_key_speed_trend_line_uses_green(): + line = format_key_speed_trend_line([100.0, 120.0]) + assert "[bold green]speed[/]" in line + assert "[green]" in line + + +def test_wpm_sparkline_scales_oldest_to_newest(): + headers = [ + SessionResult( + schema_version=2, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=None, + total_keystrokes=50, + correct_keystrokes=50, + words_completed=10, + ), + SessionResult( + schema_version=2, + session_id="s2", + started_at=2.0, + duration_ns=30_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=None, + total_keystrokes=50, + correct_keystrokes=50, + words_completed=10, + ), + ] + spark = wpm_sparkline(headers) + assert len(spark) == 2 + assert spark[0] <= spark[1] # 50 wpm then 100 wpm + + +def test_format_wpm_trend_line_includes_latest_and_peak(): + headers = [ + SessionResult( + schema_version=2, + session_id="s1", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=None, + total_keystrokes=50, + correct_keystrokes=50, + words_completed=10, + ), + ] + line = format_wpm_trend_line(headers) + assert "WPM trend" in line + assert "latest 10" in line diff --git a/tests/presentation/test_hud.py b/tests/presentation/test_hud.py index 1abd5a5..8309648 100644 --- a/tests/presentation/test_hud.py +++ b/tests/presentation/test_hud.py @@ -2,8 +2,10 @@ from textual.app import App from textual.widgets import Static +from keystrike.domain.confidence import FocusReason from keystrike.domain.daily_learn import compute_daily_learn_budget -from keystrike.domain.enums import Mode +from keystrike.domain.enums import FocusKind, Mode +from keystrike.domain.models import Bigram from keystrike.domain.session import ( LEARN_IDLE_PAUSE_NS, Session, @@ -64,12 +66,32 @@ def test_hud_omits_daily_goal_when_limit_disabled(): assert "Learn:" not in text +def test_hud_shows_transition_pair_in_focus_label(): + session = _session() + session.focus_key = ord("o") + reason = FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=Bigram(ord("e"), ord("o"))) + text = _format_hud(session, _UNLIMITED, focus_reason=reason) + assert "Focus:" in text + assert "[bold]eo[/]" in text + assert "[bold]o[/]" not in text + assert "eo weak transition" in text + + def test_hud_shows_focus_reason_when_given(): - text = _format_hud(_session(), _UNLIMITED, focus_reason="review") + reason = FocusReason(kind=FocusKind.KEY_REVIEW) + text = _format_hud(_session(), _UNLIMITED, focus_reason=reason) assert "Focus:" in text assert "review" in text +def test_hud_shows_single_key_focus_for_non_transition(): + session = _session() + session.focus_key = ord("a") + reason = FocusReason(kind=FocusKind.KEY_WEAK) + text = _format_hud(session, _UNLIMITED, focus_reason=reason) + assert "[bold]a[/]" in text + + def test_hud_omits_focus_when_reason_missing(): text = _format_hud(_session(), _UNLIMITED, focus_reason=None) assert "Focus:" not in text @@ -120,7 +142,7 @@ def test_hud_distinct_labels_for_daily_budget_and_focus(): text = _format_hud( session, budget, - focus_reason="weak", + focus_reason=FocusReason(kind=FocusKind.KEY_WEAK), ) assert "Learn:" in text assert "9.0" in text diff --git a/tests/presentation/test_kb_heatmap.py b/tests/presentation/test_kb_heatmap.py index 6f02b3e..aa66a0d 100644 --- a/tests/presentation/test_kb_heatmap.py +++ b/tests/presentation/test_kb_heatmap.py @@ -1,6 +1,13 @@ +from keystrike.domain.confidence import FocusReason +from keystrike.domain.enums import FocusKind +from keystrike.domain.models import Bigram from keystrike.infrastructure.bundled_layouts.colemak_dh import LAYOUT as COLEMAK_DH from keystrike.infrastructure.bundled_layouts.qwerty import LAYOUT as QWERTY -from keystrike.presentation.widgets.kb_heatmap import format_focus_note, render_heatmap +from keystrike.presentation.widgets.kb_heatmap import ( + focus_transition_pair, + format_focus_note, + render_heatmap, +) def test_render_heatmap_includes_every_alpha_key_once(): @@ -18,8 +25,8 @@ def test_render_heatmap_excludes_space_row(): def test_render_heatmap_colors_by_confidence(): high = render_heatmap(QWERTY, {ord("a"): 1.5}) low = render_heatmap(QWERTY, {ord("a"): 0.1}) - a_span_high = next(s for s in high.spans if high.plain[s.start:s.end].strip() == "a") - a_span_low = next(s for s in low.spans if low.plain[s.start:s.end].strip() == "a") + a_span_high = next(s for s in high.spans if high.plain[s.start : s.end].strip() == "a") + a_span_low = next(s for s in low.spans if low.plain[s.start : s.end].strip() == "a") assert a_span_high.style != a_span_low.style @@ -82,7 +89,10 @@ def test_render_heatmap_weak_key_without_urgency_stays_red_only(): def test_render_heatmap_focus_style_layers_on_review_underline(): text = render_heatmap( - QWERTY, {ord("a"): 1.5}, focus=ord("a"), urgency={ord("a"): 1.0}, + QWERTY, + {ord("a"): 1.5}, + focus=ord("a"), + urgency={ord("a"): 1.0}, ) span = next(s for s in text.spans if text.plain[s.start : s.end].strip() == "a") style = str(span.style) @@ -92,7 +102,8 @@ def test_render_heatmap_focus_style_layers_on_review_underline(): def test_format_focus_note_weak_includes_confidence_numbers(): - note = format_focus_note(ord("a"), "weak", confidence=0.89, speed=0.95, accuracy=0.92) + reason = FocusReason(kind=FocusKind.KEY_WEAK) + note = format_focus_note(ord("a"), reason, confidence=0.89, speed=0.95, accuracy=0.92) assert note is not None assert "speed 0.95" in note assert "accuracy 92.0%" in note @@ -102,7 +113,8 @@ def test_format_focus_note_weak_includes_confidence_numbers(): def test_format_focus_note_review_includes_confidence_numbers(): - note = format_focus_note(ord("e"), "review", confidence=1.12, speed=1.15, accuracy=0.98) + reason = FocusReason(kind=FocusKind.KEY_REVIEW) + note = format_focus_note(ord("e"), reason, confidence=1.12, speed=1.15, accuracy=0.98) assert note is not None assert "speed 1.15" in note assert "accuracy 98.0%" in note @@ -111,8 +123,13 @@ def test_format_focus_note_review_includes_confidence_numbers(): def test_format_focus_note_weak_transition(): + reason = FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=Bigram(ord("a"), ord("t"))) note = format_focus_note( - ord("t"), "at weak transition", confidence=0.45, speed=0.50, accuracy=0.80, + ord("t"), + reason, + confidence=0.45, + speed=0.50, + accuracy=0.80, ) assert note is not None assert "at" in note @@ -123,3 +140,25 @@ def test_format_focus_note_weak_transition(): def test_format_focus_note_none_without_reason(): assert format_focus_note(ord("a"), None, confidence=0.5) is None + + +def test_focus_transition_pair_returns_pair_for_transition_kinds(): + weak = FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=Bigram(ord("e"), ord("o"))) + review = FocusReason(kind=FocusKind.TRANSITION_REVIEW, pair=Bigram(ord("a"), ord("t"))) + assert focus_transition_pair(weak) == (ord("e"), ord("o")) + assert focus_transition_pair(review) == (ord("a"), ord("t")) + assert focus_transition_pair(FocusReason(kind=FocusKind.KEY_WEAK)) is None + assert focus_transition_pair(FocusReason(kind=FocusKind.KEY_REVIEW)) is None + assert focus_transition_pair(None) is None + + +def test_render_heatmap_highlights_both_transition_keys(): + text = render_heatmap( + QWERTY, + {ord("e"): 0.89, ord("o"): 0.89}, + focus=ord("o"), + focus_transition=(ord("e"), ord("o")), + ) + for ch in "eo": + span = next(s for s in text.spans if text.plain[s.start : s.end].strip() == ch) + assert "underline" in str(span.style) diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index 3419fff..58b625b 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -9,11 +9,13 @@ from keystrike.application.prepare_practice import PreparePracticeSession from keystrike.application.session_use_cases import ( FinishSession, + GetSessionBaseline, RecordKeystroke, StartSession, ) from keystrike.application.settings_use_cases import CycleLayout, UpdateSettings from keystrike.application.stats_use_cases import ( + EnsureAggregates, GetAggregateMetricTrends, GetHeatmap, GetHistory, @@ -74,17 +76,27 @@ def _build_app( rng=Random(0), ) get_daily_learn_budget = GetDailyLearnBudget( - clock=clock, repo=session_repo, settings_repo=settings_repo, tz=_TZ, + clock=clock, + repo=session_repo, + settings_repo=settings_repo, + tz=_TZ, ) rebuild_aggregates = RebuildAggregates( - repo=session_repo, cache=cache, settings_repo=settings_repo, + repo=session_repo, + cache=cache, + settings_repo=settings_repo, + ) + ensure_aggregates = EnsureAggregates( + repo=session_repo, + cache=cache, + rebuild=rebuild_aggregates, ) prepare_practice = PreparePracticeSession( settings_repo=settings_repo, layout_repo=layout_repo, build_lesson=build_lesson, get_daily_learn_budget=get_daily_learn_budget, - rebuild_aggregates=rebuild_aggregates, + ensure_aggregates=ensure_aggregates, ) app = KeystrikeApp( @@ -100,6 +112,7 @@ def _build_app( settings_repo=settings_repo, layout_repo=layout_repo, prepare_practice=prepare_practice, + get_session_baseline=GetSessionBaseline(repo=session_repo, settings_repo=settings_repo), rebuild_aggregates=rebuild_aggregates, get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo), get_history=GetHistory(repo=session_repo), diff --git a/tests/presentation/test_settings_screen.py b/tests/presentation/test_settings_screen.py index 113feb0..752b034 100644 --- a/tests/presentation/test_settings_screen.py +++ b/tests/presentation/test_settings_screen.py @@ -74,9 +74,7 @@ async def test_settings_screen_refreshes_layout_select_on_resume(): await pilot.pause() layout_select = app.screen.query_one("#settings-layout", Select) - option_values = sorted( - v for _, v in layout_select._options if isinstance(v, str) - ) + option_values = sorted(v for _, v in layout_select._options if isinstance(v, str)) assert "myown" in option_values diff --git a/tests/presentation/test_stats_screen.py b/tests/presentation/test_stats_screen.py index e509461..4d2b6fe 100644 --- a/tests/presentation/test_stats_screen.py +++ b/tests/presentation/test_stats_screen.py @@ -42,7 +42,9 @@ def _build_screen( layout=layout, layout_repo=layout_repo, rebuild_aggregates=RebuildAggregates( - repo=repo, cache=cache, settings_repo=settings_repo, + repo=repo, + cache=cache, + settings_repo=settings_repo, ), get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo), get_history=GetHistory(repo=repo), @@ -68,10 +70,7 @@ async def test_stats_screen_widget_order(): await pilot.pause() vertical = app.screen.query_one(Vertical) - widget_ids = [ - w.id for w in vertical.children - if getattr(w, "id", None) is not None - ] + widget_ids = [w.id for w in vertical.children if getattr(w, "id", None) is not None] assert widget_ids == [ "stats-title", "stats-trends", @@ -102,7 +101,10 @@ async def test_stats_overview_focus_shows_confidence_only(): repo.keystrokes["s1"] = [ Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=0, correct=True), Keystroke( - codepoint=ord("e"), typed=ord("e"), t_ns=400_000_000, correct=True, + codepoint=ord("e"), + typed=ord("e"), + t_ns=400_000_000, + correct=True, ), ] @@ -174,23 +176,27 @@ async def test_stats_trends_uses_confidence_session_window(): repo = FakeSessionRepository() window = 3 for i in range(window + 2): - repo.save_header(SessionResult( - schema_version=2, - session_id=f"s{i}", - started_at=float(i), - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(ord("a"),), - focus_key=None, - total_keystrokes=50, - correct_keystrokes=50, - )) - - await app.push_screen(_build_screen( - repo, - settings=Settings(confidence_session_window=window), - )) + repo.save_header( + SessionResult( + schema_version=2, + session_id=f"s{i}", + started_at=float(i), + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(ord("a"),), + focus_key=None, + total_keystrokes=50, + correct_keystrokes=50, + ) + ) + + await app.push_screen( + _build_screen( + repo, + settings=Settings(confidence_session_window=window), + ) + ) await pilot.pause() trends_text = str(app.screen.query_one("#stats-trends", Static).content) @@ -291,11 +297,13 @@ async def test_stats_key_press_shows_key_detail(): app = App() async with app.run_test() as pilot: repo = FakeSessionRepository() - repo.save_header(_session_with_key_confidence( - session_id="s1", - started_at=1.0, - key_confidence={ord("e"): 0.55}, - )) + repo.save_header( + _session_with_key_confidence( + session_id="s1", + started_at=1.0, + key_confidence={ord("e"): 0.55}, + ) + ) await app.push_screen(_build_screen(repo)) await pilot.pause() @@ -329,11 +337,13 @@ async def test_stats_key_detail_switches_key_without_overview(): app = App() async with app.run_test() as pilot: repo = FakeSessionRepository() - repo.save_header(_session_with_key_confidence( - session_id="s1", - started_at=1.0, - key_confidence={ord("a"): 0.40, ord("b"): 0.70}, - )) + repo.save_header( + _session_with_key_confidence( + session_id="s1", + started_at=1.0, + key_confidence={ord("a"): 0.40, ord("b"): 0.70}, + ) + ) await app.push_screen(_build_screen(repo)) await pilot.pause() @@ -362,16 +372,20 @@ async def test_stats_key_detail_normalizes_confidence_to_current_goal(): app = App() async with app.run_test() as pilot: repo = FakeSessionRepository() - repo.save_header(_session_with_key_confidence( - session_id="s1", - started_at=1.0, - key_confidence={ord("e"): 1.0}, - target_speed_cpm=300, - )) - await app.push_screen(_build_screen( - repo, - settings=Settings(target_speed_cpm=600), - )) + repo.save_header( + _session_with_key_confidence( + session_id="s1", + started_at=1.0, + key_confidence={ord("e"): 1.0}, + target_speed_cpm=300, + ) + ) + await app.push_screen( + _build_screen( + repo, + settings=Settings(target_speed_cpm=600), + ) + ) await pilot.pause() await pilot.press("e") @@ -387,15 +401,19 @@ async def test_stats_key_detail_stable_when_goal_changes(): app = App() async with app.run_test() as pilot: repo = FakeSessionRepository() - repo.save_header(_session_with_key_confidence( - session_id="s1", - started_at=1.0, - key_confidence={ord("e"): 0.82}, - )) - await app.push_screen(_build_screen( - repo, - settings=Settings(target_speed_cpm=600), - )) + repo.save_header( + _session_with_key_confidence( + session_id="s1", + started_at=1.0, + key_confidence={ord("e"): 0.82}, + ) + ) + await app.push_screen( + _build_screen( + repo, + settings=Settings(target_speed_cpm=600), + ) + ) await pilot.pause() await pilot.press("e") @@ -412,15 +430,20 @@ async def test_stats_key_detail_shows_speed_and_accuracy_trends(): app = App() async with app.run_test() as pilot: repo = FakeSessionRepository() - repo.save_header(_session_with_key_confidence( - session_id="s1", - started_at=1.0, - key_confidence={ord("e"): 0.55}, - )) + repo.save_header( + _session_with_key_confidence( + session_id="s1", + started_at=1.0, + key_confidence={ord("e"): 0.55}, + ) + ) repo.keystrokes["s1"] = [ Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=0, correct=True), Keystroke( - codepoint=ord("e"), typed=ord("e"), t_ns=400_000_000, correct=True, + codepoint=ord("e"), + typed=ord("e"), + t_ns=400_000_000, + correct=True, ), ] await app.push_screen(_build_screen(repo)) @@ -459,7 +482,10 @@ async def test_stats_screen_renders_speed_and_accuracy_trends(): repo.keystrokes["s1"] = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke( - codepoint=ord("a"), typed=ord("a"), t_ns=400_000_000, correct=True, + codepoint=ord("a"), + typed=ord("a"), + t_ns=400_000_000, + correct=True, ), ] @@ -485,11 +511,13 @@ async def test_stats_key_press_replaces_overview_trends(): app = App() async with app.run_test() as pilot: repo = FakeSessionRepository() - repo.save_header(_session_with_key_confidence( - session_id="s1", - started_at=1.0, - key_confidence={ord("e"): 0.55}, - )) + repo.save_header( + _session_with_key_confidence( + session_id="s1", + started_at=1.0, + key_confidence={ord("e"): 0.55}, + ) + ) await app.push_screen(_build_screen(repo)) await pilot.pause() @@ -507,11 +535,13 @@ async def test_stats_escape_from_key_detail_returns_to_overview(): app = App() async with app.run_test() as pilot: repo = FakeSessionRepository() - repo.save_header(_session_with_key_confidence( - session_id="s1", - started_at=1.0, - key_confidence={ord("e"): 0.55}, - )) + repo.save_header( + _session_with_key_confidence( + session_id="s1", + started_at=1.0, + key_confidence={ord("e"): 0.55}, + ) + ) await app.push_screen(_build_screen(repo)) await pilot.pause() diff --git a/tests/test_architecture.py b/tests/test_architecture.py index 738389a..386b633 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -34,11 +34,7 @@ def _imported_modules(path: Path) -> set[str]: def _layer_files(layer: str) -> list[Path]: - return [ - path - for path in (_SRC / layer).rglob("*.py") - if path.name not in _EXEMPT_FILES - ] + return [path for path in (_SRC / layer).rglob("*.py") if path.name not in _EXEMPT_FILES] def test_layers_only_depend_on_allowed_keystrike_layers(): From f2f9e5aaffd8f19414b4f5bf24b1428d906f8e2e Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 30 Jul 2026 15:24:15 +0300 Subject: [PATCH 19/47] refactoring --- src/keystrike/app.py | 7 +- src/keystrike/application/build_lesson.py | 316 ++++++++++-------- src/keystrike/application/prepare_practice.py | 2 +- .../application/session_use_cases.py | 40 ++- .../application/settings_use_cases.py | 40 ++- src/keystrike/application/stats_use_cases.py | 13 +- .../application/wordlist_use_cases.py | 2 +- src/keystrike/domain/confidence.py | 265 +-------------- src/keystrike/domain/daily_learn.py | 31 +- src/keystrike/domain/focus.py | 215 ++++++++++++ src/keystrike/domain/generator.py | 16 +- src/keystrike/domain/markov.py | 6 + src/keystrike/domain/models.py | 14 +- src/keystrike/domain/null_adapters.py | 31 +- src/keystrike/domain/protocols.py | 2 +- src/keystrike/domain/sync_merge.py | 14 +- src/keystrike/domain/unlock.py | 78 +++++ .../infrastructure/aggregates_cache.py | 75 ++--- src/keystrike/infrastructure/json_coerce.py | 95 ++++++ src/keystrike/infrastructure/layout_repo.py | 11 +- .../infrastructure/session_repo_jsonl.py | 64 ++-- .../infrastructure/settings_repo_toml.py | 76 ++--- src/keystrike/infrastructure/sync_git.py | 5 +- .../presentation/formatting/trends.py | 63 ++-- src/keystrike/presentation/screens/home.py | 7 +- .../presentation/screens/practice.py | 2 +- .../presentation/screens/settings.py | 37 +- src/keystrike/presentation/screens/stats.py | 8 +- src/keystrike/presentation/theme.py | 3 - src/keystrike/presentation/widgets/hud.py | 14 +- .../presentation/widgets/kb_heatmap.py | 2 +- tests/application/test_build_lesson.py | 21 +- tests/application/test_prepare_practice.py | 8 +- tests/application/test_session_use_cases.py | 2 +- tests/application/test_settings_use_cases.py | 61 ++-- tests/application/test_stats_use_cases.py | 25 +- tests/application/test_sync_use_cases.py | 88 +++++ tests/application/test_wordlist_use_cases.py | 5 +- tests/domain/test_confidence.py | 14 +- tests/domain/test_daily_learn.py | 28 +- tests/domain/test_generator.py | 14 +- tests/fakes.py | 51 +++ tests/presentation/test_hud.py | 2 +- tests/presentation/test_kb_heatmap.py | 2 +- tests/presentation/test_practice_screen.py | 7 +- tests/presentation/test_settings_screen.py | 2 +- tests/presentation/test_stats_screen.py | 3 +- tests/presentation/test_typing_area.py | 2 - 48 files changed, 1165 insertions(+), 724 deletions(-) create mode 100644 src/keystrike/domain/focus.py create mode 100644 src/keystrike/domain/unlock.py create mode 100644 src/keystrike/infrastructure/json_coerce.py create mode 100644 tests/application/test_sync_use_cases.py diff --git a/src/keystrike/app.py b/src/keystrike/app.py index 96de909..77d7cd2 100644 --- a/src/keystrike/app.py +++ b/src/keystrike/app.py @@ -14,11 +14,11 @@ ) from keystrike.application.settings_use_cases import CycleLayout, UpdateSettings from keystrike.application.stats_use_cases import ( - EnsureAggregates, GetAggregateMetricTrends, GetHeatmap, GetHistory, GetKeyMetricTrends, + GetOrRebuildAggregates, RebuildAggregates, ) from keystrike.application.sync_use_cases import GetSyncStatus, InitSync, PullSync, PushSync @@ -94,12 +94,12 @@ def build() -> KeystrikeApp: cache=aggregates_cache, settings_repo=settings_repo, ) - ensure_aggregates = EnsureAggregates( + ensure_aggregates = GetOrRebuildAggregates( repo=session_repo, cache=aggregates_cache, rebuild=rebuild_aggregates, ) - get_heatmap = GetHeatmap(cache=aggregates_cache, settings_repo=settings_repo) + get_heatmap = GetHeatmap(cache=aggregates_cache, settings_repo=settings_repo, clock=clock) get_history = GetHistory(repo=session_repo) get_key_metric_trends = GetKeyMetricTrends( repo=session_repo, @@ -128,6 +128,7 @@ def build() -> KeystrikeApp: language_provider=language_provider, wordlist_store=wordlist_store, rng=Random(), + clock=clock, ) prepare_practice = PreparePracticeSession( settings_repo=settings_repo, diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 76065e1..11c957a 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -4,32 +4,32 @@ from __future__ import annotations -import time from collections.abc import Mapping from dataclasses import dataclass from random import Random from keystrike.domain.confidence import ( - FocusReason, accuracy_of, - compute_unlocked, confidence_of, - focus_key_from_transition, - has_weak_unlocked_key, is_same_key_transition, key_confidence, - practice_weight, review_urgency, round_confidence, - select_focus, - select_focus_transition, target_ms_per_char, transition_accuracy_of, transition_confidence, transition_confidence_of, - transition_practice_weight, ) from keystrike.domain.enums import FocusKind +from keystrike.domain.focus import ( + FocusReason, + focus_key_from_transition, + has_weak_unlocked_key, + practice_weight, + select_focus, + select_focus_transition, + transition_practice_weight, +) from keystrike.domain.generator import AdaptiveGenerator, LessonWeighting from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import ( @@ -43,11 +43,13 @@ ) from keystrike.domain.protocols import ( AggregatesCache, + Clock, LanguageProvider, LayoutRepository, SettingsRepository, WordListStore, ) +from keystrike.domain.unlock import compute_unlocked from keystrike.domain.wordlist import words_for_alphabet WORD_COUNT = 12 @@ -83,48 +85,91 @@ def _transition_focus_metrics( ) -def _focus_reason( - focus: int, - stats: Mapping[int, KeyStats], - target: float, - now: float, +def _focus_reason_for_confidence( + confidence: float, + urgency: float, *, - min_attempts: int, + weak_kind: FocusKind, + review_kind: FocusKind, + pair: Bigram | None, ) -> FocusReason | None: - key_stats = stats.get(focus) - urgency = review_urgency(key_stats.last_seen if key_stats else 0.0, now) - confidence = confidence_of(focus, stats, target, min_attempts=min_attempts) + """Shared body for key- and transition-focus reasons — the two only + differ in which FocusKind pair applies and whether a Bigram is attached.""" if urgency > 0 and confidence >= _CONFIDENCE_GOOD: - return FocusReason(kind=FocusKind.KEY_REVIEW) + return FocusReason(kind=review_kind, pair=pair) if confidence < _CONFIDENCE_GOOD: - return FocusReason(kind=FocusKind.KEY_WEAK) + return FocusReason(kind=weak_kind, pair=pair) return None -def _focus_reason_transition( - prev_cp: int, - next_cp: int, +def _resolve_focus_confidence( + focus: int, + focus_bigram: Bigram | None, + *, + stats: Mapping[int, KeyStats], transitions: Mapping[Bigram, TransitionStats], target: float, - now: float, + settings: Settings, +) -> float: + """The one confidence value the rest of lesson-building keys off of — + computed once so weighting and the focus-reason explanation agree.""" + if focus_bigram is not None: + return transition_confidence_of( + focus_bigram.prev_cp, + focus_bigram.next_cp, + transitions, + target, + min_attempts=settings.min_transition_confidence_attempts, + ) + return confidence_of(focus, stats, target, min_attempts=settings.min_confidence_attempts) + + +def _compute_focus_explanation( + focus: int, + focus_bigram: Bigram | None, + focus_confidence: float, *, - min_attempts: int, -) -> FocusReason | None: - pair = Bigram(prev_cp, next_cp) - t_stats = transitions.get(pair) - urgency = review_urgency(t_stats.last_seen if t_stats else 0.0, now) - confidence = transition_confidence_of( - prev_cp, - next_cp, - transitions, - target, - min_attempts=min_attempts, + stats: Mapping[int, KeyStats], + transitions: Mapping[Bigram, TransitionStats], + target: float, + now: float, +) -> tuple[FocusReason | None, float | None, float | None]: + """Why (if at all) today's focus is being called out, plus the raw + speed/accuracy to show alongside it. Reuses `focus_confidence` rather + than recomputing it (it was already needed for weighting).""" + if focus_bigram is not None: + t_stats = transitions.get(focus_bigram) + urgency = review_urgency(t_stats.last_seen if t_stats else 0.0, now) + reason = _focus_reason_for_confidence( + focus_confidence, + urgency, + weak_kind=FocusKind.TRANSITION_WEAK, + review_kind=FocusKind.TRANSITION_REVIEW, + pair=focus_bigram, + ) + if reason is None: + return None, None, None + speed, accuracy = _transition_focus_metrics( + focus_bigram.prev_cp, + focus_bigram.next_cp, + transitions, + target, + ) + return reason, speed, accuracy + + key_stats = stats.get(focus) + urgency = review_urgency(key_stats.last_seen if key_stats else 0.0, now) + reason = _focus_reason_for_confidence( + focus_confidence, + urgency, + weak_kind=FocusKind.KEY_WEAK, + review_kind=FocusKind.KEY_REVIEW, + pair=None, ) - if urgency > 0 and confidence >= _CONFIDENCE_GOOD: - return FocusReason(kind=FocusKind.TRANSITION_REVIEW, pair=pair) - if confidence < _CONFIDENCE_GOOD: - return FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=pair) - return None + if reason is None: + return None, None, None + speed, accuracy = _key_focus_metrics(focus, stats, target) + return reason, speed, accuracy @dataclass(slots=True) @@ -233,6 +278,63 @@ def _lesson_progress( return unlocked, focus, state, focus_bigram +def _compute_weights( + state: LessonState, + focus: int, + focus_bigram: Bigram | None, + focus_confidence: float, + *, + unlocked: tuple[int, ...], + stats: Mapping[int, KeyStats], + transitions: Mapping[Bigram, TransitionStats], + target: float, + settings: Settings, + now: float, +) -> tuple[dict[str, float], dict[Bigram, float]]: + """Per-char and per-transition sampling weights for practice-text + generation, biased toward weak/stale keys and boosted further for + today's focus (see `domain.focus.practice_weight`).""" + char_weights = { + chr(k.codepoint): practice_weight( + k.confidence, + urgency=review_urgency( + stats[k.codepoint].last_seen if k.codepoint in stats else 0.0, + now, + ), + ) + for k in state.keys + } + char_weights[chr(focus)] *= settings.focus_char_boost + transition_weights = { + Bigram(prev, nxt): transition_practice_weight( + transition_confidence_of( + prev, + nxt, + transitions, + target, + min_attempts=settings.min_transition_confidence_attempts, + ), + urgency=review_urgency( + transitions[Bigram(prev, nxt)].last_seen + if Bigram(prev, nxt) in transitions + else 0.0, + now, + ), + ) + for prev in unlocked + for nxt in unlocked + if not is_same_key_transition(prev, nxt) + } + if focus_bigram is not None: + transition_weights[focus_bigram] *= settings.focus_transition_boost + if focus_confidence < _CONFIDENCE_GOOD: + transition_weights[focus_bigram] *= settings.focus_weak_extra_boost + char_weights[chr(focus)] *= settings.focus_weak_extra_boost + elif focus_confidence < _CONFIDENCE_GOOD: + char_weights[chr(focus)] *= settings.focus_weak_extra_boost + return char_weights, transition_weights + + @dataclass(slots=True) class BuildLesson: layout_repo: LayoutRepository @@ -241,6 +343,7 @@ class BuildLesson: language_provider: LanguageProvider wordlist_store: WordListStore rng: Random + clock: Clock def __call__(self, layout_name: str) -> Lesson: settings = self.settings_repo.load() @@ -248,7 +351,7 @@ def __call__(self, layout_name: str) -> Lesson: aggregates = self.aggregates_cache.get(layout_name) stats: Mapping[int, KeyStats] = aggregates.keys if aggregates else {} transitions: Mapping[Bigram, TransitionStats] = aggregates.transitions if aggregates else {} - now = time.time() + now = self.clock.wall_epoch() unlocked, focus, state, focus_bigram = _lesson_progress( layout_name, layout, @@ -258,72 +361,38 @@ def __call__(self, layout_name: str) -> Lesson: transitions=transitions, ) target = target_ms_per_char(settings.target_speed_cpm) + focus_confidence = _resolve_focus_confidence( + focus, + focus_bigram, + stats=stats, + transitions=transitions, + target=target, + settings=settings, + ) table = self.language_provider.transitions(settings.lang) generator = AdaptiveGenerator(table=table, rng=self.rng) alphabet_chars = frozenset(chr(cp) for cp in unlocked) - char_weights = { - chr(k.codepoint): practice_weight( - k.confidence, - urgency=review_urgency( - stats[k.codepoint].last_seen if k.codepoint in stats else 0.0, - now, - ), - ) - for k in state.keys - } - char_weights[chr(focus)] *= settings.focus_char_boost - transition_weights = { - Bigram(prev, nxt): transition_practice_weight( - transition_confidence_of( - prev, - nxt, - transitions, - target, - min_attempts=settings.min_transition_confidence_attempts, - ), - urgency=review_urgency( - transitions[Bigram(prev, nxt)].last_seen - if Bigram(prev, nxt) in transitions - else 0.0, - now, - ), - ) - for prev in unlocked - for nxt in unlocked - if not is_same_key_transition(prev, nxt) - } - if focus_bigram is not None: - prev_cp, next_cp = focus_bigram - pair_key = Bigram(prev_cp, next_cp) - transition_weights[pair_key] *= settings.focus_transition_boost - focus_confidence = transition_confidence_of( - prev_cp, - next_cp, - transitions, - target, - min_attempts=settings.min_transition_confidence_attempts, - ) - if focus_confidence < _CONFIDENCE_GOOD: - transition_weights[pair_key] *= settings.focus_weak_extra_boost - char_weights[chr(focus)] *= settings.focus_weak_extra_boost - elif ( - confidence_of( - focus, - stats, - target, - min_attempts=settings.min_confidence_attempts, - ) - < _CONFIDENCE_GOOD - ): - char_weights[chr(focus)] *= settings.focus_weak_extra_boost - dict_words: list[str] | None = None + char_weights, transition_weights = _compute_weights( + state, + focus, + focus_bigram, + focus_confidence, + unlocked=unlocked, + stats=stats, + transitions=transitions, + target=target, + settings=settings, + now=now, + ) + + dict_words: tuple[str, ...] | None = None if settings.wordlist_url: cached = self.wordlist_store.load(settings.wordlist_url) if cached: filtered = words_for_alphabet(cached, alphabet_chars) if filtered: - dict_words = filtered + dict_words = tuple(filtered) weighting = LessonWeighting( char_weights=char_weights, transition_weights=transition_weights, @@ -343,48 +412,15 @@ def __call__(self, layout_name: str) -> Lesson: urgency = { cp: review_urgency(stats[cp].last_seen if cp in stats else 0.0, now) for cp in unlocked } - focus_speed: float | None = None - focus_accuracy: float | None = None - if focus_bigram is not None: - prev_cp, next_cp = focus_bigram - reason = _focus_reason_transition( - prev_cp, - next_cp, - transitions, - target, - now, - min_attempts=settings.min_transition_confidence_attempts, - ) - focus_confidence = transition_confidence_of( - prev_cp, - next_cp, - transitions, - target, - min_attempts=settings.min_transition_confidence_attempts, - ) - if reason: - focus_speed, focus_accuracy = _transition_focus_metrics( - prev_cp, - next_cp, - transitions, - target, - ) - else: - reason = _focus_reason( - focus, - stats, - target, - now, - min_attempts=settings.min_confidence_attempts, - ) - focus_confidence = confidence_of( - focus, - stats, - target, - min_attempts=settings.min_confidence_attempts, - ) - if reason: - focus_speed, focus_accuracy = _key_focus_metrics(focus, stats, target) + reason, focus_speed, focus_accuracy = _compute_focus_explanation( + focus, + focus_bigram, + focus_confidence, + stats=stats, + transitions=transitions, + target=target, + now=now, + ) return Lesson( text=text, state=state, diff --git a/src/keystrike/application/prepare_practice.py b/src/keystrike/application/prepare_practice.py index 54cee4f..9e24683 100644 --- a/src/keystrike/application/prepare_practice.py +++ b/src/keystrike/application/prepare_practice.py @@ -6,8 +6,8 @@ from dataclasses import dataclass from keystrike.application.build_lesson import BuildLesson -from keystrike.domain.confidence import FocusReason from keystrike.domain.enums import Mode +from keystrike.domain.focus import FocusReason from keystrike.domain.models import Layout from keystrike.domain.null_adapters import NULL_AGGREGATES_ENSURER from keystrike.domain.protocols import ( diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 26619fb..5d93830 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -3,7 +3,6 @@ from keystrike.domain.aggregate import combine_sessions, session_recency_weights from keystrike.domain.confidence import ( - compute_unlocked, confidence_of, target_ms_per_char, ) @@ -11,7 +10,11 @@ from keystrike.domain.generator import typical_chars_per_word from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import Keystroke, SessionResult -from keystrike.domain.null_adapters import NullSessionRepository +from keystrike.domain.null_adapters import ( + NULL_LAYOUT_REPOSITORY, + NULL_SETTINGS_REPOSITORY, + NullSessionRepository, +) from keystrike.domain.protocols import ( Clock, IdGenerator, @@ -27,6 +30,7 @@ note_keystroke_for_timer, skip_leading_whitespace, ) +from keystrike.domain.unlock import compute_unlocked @dataclass(slots=True) @@ -172,8 +176,8 @@ def _sync_alphabet_size(unlocked_keys: tuple[int, ...], settings_repo: SettingsR class FinishSession: clock: Clock repo: SessionRepository = field(default_factory=NullSessionRepository) - settings_repo: SettingsRepository | None = None - layout_repo: LayoutRepository | None = None + settings_repo: SettingsRepository = NULL_SETTINGS_REPOSITORY + layout_repo: LayoutRepository = NULL_LAYOUT_REPOSITORY def __call__(self, session: Session) -> SessionResult: session.state = SessionState.COMPLETE @@ -183,20 +187,16 @@ def __call__(self, session: Session) -> SessionResult: else 0 ) - target_speed_cpm = 0 - unlocked_keys: tuple[int, ...] = () - key_confidence: dict[int, float] = {} - if self.settings_repo is not None and self.layout_repo is not None: - settings = self.settings_repo.load() - target_speed_cpm = settings.target_speed_cpm - unlocked_keys, key_confidence = _snapshot_unlock_state( - session, - duration_ns, - repo=self.repo, - settings_repo=self.settings_repo, - layout_repo=self.layout_repo, - ) - _sync_alphabet_size(unlocked_keys, self.settings_repo) + settings = self.settings_repo.load() + target_speed_cpm = settings.target_speed_cpm + unlocked_keys, key_confidence = _snapshot_unlock_state( + session, + duration_ns, + repo=self.repo, + settings_repo=self.settings_repo, + layout_repo=self.layout_repo, + ) + _sync_alphabet_size(unlocked_keys, self.settings_repo) result = SessionResult( schema_version=3, @@ -332,11 +332,9 @@ class GetSessionBaseline: """ repo: SessionRepository - settings_repo: SettingsRepository | None = None + settings_repo: SettingsRepository = NULL_SETTINGS_REPOSITORY def __call__(self, result: SessionResult) -> SessionStatsBaseline | None: - if self.settings_repo is None: - return None window = self.settings_repo.load().confidence_session_window return confidence_window_session_baseline(self.repo, result, window=window) diff --git a/src/keystrike/application/settings_use_cases.py b/src/keystrike/application/settings_use_cases.py index 522d8d2..15c5eb8 100644 --- a/src/keystrike/application/settings_use_cases.py +++ b/src/keystrike/application/settings_use_cases.py @@ -20,34 +20,38 @@ class SettingsValidationError(ValueError): """Raised by UpdateSettings when a proposed change is invalid.""" +@dataclass(frozen=True, slots=True) +class SettingsUpdate: + """Proposed new values for the settings screen's editable fields, bundled + so `UpdateSettings.__call__` takes one parameter instead of five.""" + + layout: str + target_speed_cpm: int + target_speed_unit: TargetSpeedUnit + alphabet_size: int + learn_daily_minutes: int + + @dataclass(slots=True) class UpdateSettings: repo: SettingsRepository - def __call__( - self, - *, - layout: str, - target_speed_cpm: int, - target_speed_unit: TargetSpeedUnit, - alphabet_size: int, - learn_daily_minutes: int, - ) -> Settings: - if target_speed_cpm <= 0: + def __call__(self, update: SettingsUpdate) -> Settings: + if update.target_speed_cpm <= 0: raise SettingsValidationError("Target speed must be a positive integer.") - if target_speed_unit not in TargetSpeedUnit: + if update.target_speed_unit not in TargetSpeedUnit: raise SettingsValidationError("Target speed unit must be wpm or cpm.") - if alphabet_size < 0: + if update.alphabet_size < 0: raise SettingsValidationError("Number of letters must be zero or more.") - if learn_daily_minutes < 0: + if update.learn_daily_minutes < 0: raise SettingsValidationError("Daily learn minutes must be zero or more.") updated = replace( self.repo.load(), - layout=layout, - target_speed_cpm=target_speed_cpm, - target_speed_unit=target_speed_unit, - alphabet_size=alphabet_size, - learn_daily_minutes=learn_daily_minutes, + layout=update.layout, + target_speed_cpm=update.target_speed_cpm, + target_speed_unit=update.target_speed_unit, + alphabet_size=update.alphabet_size, + learn_daily_minutes=update.learn_daily_minutes, ) self.repo.save(updated) return updated diff --git a/src/keystrike/application/stats_use_cases.py b/src/keystrike/application/stats_use_cases.py index 29a67ce..67c94b9 100644 --- a/src/keystrike/application/stats_use_cases.py +++ b/src/keystrike/application/stats_use_cases.py @@ -2,7 +2,6 @@ from __future__ import annotations -import time from collections.abc import Mapping from dataclasses import dataclass @@ -17,7 +16,12 @@ target_ms_per_char, ) from keystrike.domain.models import KeyStats, SessionResult -from keystrike.domain.protocols import AggregatesCache, SessionRepository, SettingsRepository +from keystrike.domain.protocols import ( + AggregatesCache, + Clock, + SessionRepository, + SettingsRepository, +) from keystrike.domain.regression import estimate_sessions_to_goal _NS_PER_MS = 1e6 @@ -44,7 +48,7 @@ def __call__(self, layout: str) -> None: @dataclass(slots=True) -class EnsureAggregates: +class GetOrRebuildAggregates: """Query: current per-key aggregates for a layout. Rebuilds first (via ``rebuild``) only when the cache is missing or lacks @@ -79,6 +83,7 @@ class GetHeatmap: cache: AggregatesCache settings_repo: SettingsRepository + clock: Clock def __call__(self, layout: str) -> HeatmapView: aggregates = self.cache.get(layout) @@ -87,7 +92,7 @@ def __call__(self, layout: str) -> HeatmapView: stats = aggregates.keys settings = self.settings_repo.load() target = target_ms_per_char(settings.target_speed_cpm) - now = time.time() + now = self.clock.wall_epoch() return HeatmapView( confidence={ cp: confidence_of( diff --git a/src/keystrike/application/wordlist_use_cases.py b/src/keystrike/application/wordlist_use_cases.py index 5579e23..ad1a586 100644 --- a/src/keystrike/application/wordlist_use_cases.py +++ b/src/keystrike/application/wordlist_use_cases.py @@ -34,7 +34,7 @@ def __call__(self, url: str) -> int: validate_wordlist_url(url) try: words = self.store.download_and_cache(url) - except Exception as exc: + except (ValueError, OSError) as exc: raise WordListError(str(exc)) from exc updated = replace(self.settings_repo.load(), wordlist_url=url) self.settings_repo.save(updated) diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index 78b137a..4d863bd 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -1,16 +1,17 @@ -"""Pure confidence math for keybr-style progression. +"""Pure confidence/accuracy scoring and spaced-review urgency for keybr-style +progression. Used by the M2 Stats heatmap and the M3 adaptive engine's key-unlock / focus- -selection logic (§6 of PLAN.md). +selection logic (§6 of PLAN.md). Key-unlock policy lives in `domain.unlock`; +focus/practice-weight selection lives in `domain.focus` — both build on the +scoring primitives here. """ from __future__ import annotations -from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from collections.abc import Mapping from typing import Protocol -from .enums import FocusKind from .models import Bigram, KeyStats, TransitionStats _SECONDS_PER_DAY = 86_400.0 @@ -26,23 +27,6 @@ MIN_TRANSITION_CONFIDENCE_ATTEMPTS = 4 # Confidence scores are rounded before thresholds, focus, heatmap, and UI. CONFIDENCE_DECIMALS = 2 -# Defaults for Settings and generator call sites; tunable via settings.toml. -FOCUS_CHAR_BOOST = 3.0 -FOCUS_WORD_BOOST = 3.0 -FOCUS_BIGRAM_WORD_BOOST = 4.0 -FOCUS_TRANSITION_BOOST = 4.0 -FOCUS_WEAK_EXTRA_BOOST = 1.5 - - -@dataclass(frozen=True, slots=True) -class FocusReason: - """Why the adaptive engine is emphasizing today's lesson focus. Replaces - the old ad-hoc strings/formatted-string focus reasons; `pair` is set only - for the TRANSITION_* kinds. Presentation code pattern-matches on `kind` - to render its own display text instead of parsing suffixes/substrings.""" - - kind: FocusKind - pair: Bigram | None = None def round_confidence(value: float) -> float: @@ -155,7 +139,7 @@ def confidence_sample_factor( return min(1.0, attempts / minimum) -def _confidence_from_stats( +def confidence_from_stats( stats: HasConfidenceFields | None, target: float, *, @@ -182,70 +166,7 @@ def confidence_of( """Live confidence for one key from aggregated stats (typically the last `CONFIDENCE_SESSION_WINDOW` sessions, recency-weighted), recomputed from the current target. 0.0 for a never-practiced key.""" - return _confidence_from_stats(stats.get(codepoint), target, min_attempts=min_attempts) - - -def _measured_transitions_meet_threshold( - unlocked: Sequence[int], - transitions: Mapping[Bigram, TransitionStats], - target: float, - *, - threshold: float, - min_attempts: int, -) -> bool: - """True when every measured bigram among unlocked keys meets threshold.""" - for prev in unlocked: - for nxt in unlocked: - if is_same_key_transition(prev, nxt): - continue - if Bigram(prev, nxt) not in transitions: - continue - if ( - transition_confidence_of( - prev, - nxt, - transitions, - target, - min_attempts=min_attempts, - ) - < threshold - ): - return False - return True - - -def compute_unlocked( - learn_order: Sequence[int], - alphabet_size: int, - stats: Mapping[int, KeyStats], - target: float, - *, - threshold: float = 1.0, - min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, - transitions: Mapping[Bigram, TransitionStats] | None = None, - min_transition_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, -) -> tuple[int, ...]: - """The first `alphabet_size` keys are always unlocked; each further key in - `learn_order` unlocks only once every currently-unlocked key and every - measured bigram among them meets `threshold`.""" - forced_count = min(alphabet_size, len(learn_order)) - unlocked = list(learn_order[:forced_count]) - for codepoint in learn_order[forced_count:]: - if not all( - confidence_of(k, stats, target, min_attempts=min_attempts) >= threshold - for k in unlocked - ): - break - if transitions is not None and not _measured_transitions_meet_threshold( - unlocked, - transitions, - target, - threshold=threshold, - min_attempts=min_transition_attempts, - ): - break - unlocked.append(codepoint) - return tuple(unlocked) + return confidence_from_stats(stats.get(codepoint), target, min_attempts=min_attempts) def review_urgency(last_seen: float, now: float) -> float: @@ -283,176 +204,8 @@ def transition_confidence_of( min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, ) -> float: """Live confidence for one bigram transition. 0.0 when never practiced.""" - return _confidence_from_stats( - stats.get(Bigram(prev_cp, next_cp)), - target, - min_attempts=min_attempts, - ) - - -def transition_practice_weight( - confidence: float, - *, - max_bias: float = 3.0, - urgency: float = 0.0, - review_bias: float = 1.0, -) -> float: - """Sampling weight for a prev→next pair — mirrors `practice_weight`.""" - return practice_weight( - confidence, - max_bias=max_bias, - urgency=urgency, - review_bias=review_bias, - ) - - -def practice_weight( - confidence: float, - *, - max_bias: float = 3.0, - urgency: float = 0.0, - review_bias: float = 1.0, -) -> float: - """Sampling weight for practice-text generation: a weak key (confidence 0) - gets `1 + max_bias` the weight of a mastered key (confidence >= 1.0), so - generated text is deliberately concentrated on weak keys rather than - treating every unlocked key as equally likely to appear (see "Deliberate - practice targeting weak points" in docs/research/typing-pedagogy.md). - Capped at confidence 1.0 so an already-fast key doesn't get pushed below - baseline weight just for being unusually fast. - - `urgency` (from `review_urgency`) multiplies weight so stale-but-mastered - keys still appear in generated text.""" - base = 1.0 + max_bias * (1.0 - min(confidence, 1.0)) - return base * (1.0 + review_bias * urgency) - - -def _focus_score_from_entry( - stats: HasConfidenceFields | None, - target: float, - now: float, - *, - review_penalty: float, - min_attempts: int, -) -> float: - """Shared body for `_focus_score`/`_transition_focus_score`: confidence, - discounted further the longer it's been since last practiced, so a stale - mastered key/pair can still outrank a merely-weak recent one.""" - urgency = review_urgency(stats.last_seen if stats else 0.0, now) - confidence = _confidence_from_stats(stats, target, min_attempts=min_attempts) - return confidence * (1.0 - review_penalty * urgency) - - -def _focus_score( - codepoint: int, - stats: Mapping[int, KeyStats], - target: float, - now: float, - *, - review_penalty: float, - min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, -) -> float: - return _focus_score_from_entry( - stats.get(codepoint), - target, - now, - review_penalty=review_penalty, - min_attempts=min_attempts, - ) - - -def has_weak_unlocked_key( - unlocked: Sequence[int], - stats: Mapping[int, KeyStats], - target: float, - *, - threshold: float = 1.0, - min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, -) -> bool: - """True when any unlocked key is below mastery threshold.""" - return any( - confidence_of(cp, stats, target, min_attempts=min_attempts) < threshold for cp in unlocked - ) - - -def select_focus( - unlocked: Sequence[int], - stats: Mapping[int, KeyStats], - target: float, - now: float, - *, - review_penalty: float = 0.5, - min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, -) -> int: - """The unlocked key a lesson should emphasize — weakest by confidence, but - stale keys are treated as weaker so high-confidence keys due for review - still surface (SlimStampen-style spacing; see typing-pedagogy.md).""" - return min( - unlocked, - key=lambda cp: _focus_score( - cp, - stats, - target, - now, - review_penalty=review_penalty, - min_attempts=min_attempts, - ), - ) - - -def _transition_focus_score( - prev_cp: int, - next_cp: int, - stats: Mapping[Bigram, TransitionStats], - target: float, - now: float, - *, - review_penalty: float, - min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, -) -> float: - return _focus_score_from_entry( + return confidence_from_stats( stats.get(Bigram(prev_cp, next_cp)), target, - now, - review_penalty=review_penalty, min_attempts=min_attempts, ) - - -def select_focus_transition( - unlocked: Sequence[int], - transitions: Mapping[Bigram, TransitionStats], - target: float, - now: float, - *, - review_penalty: float = 0.5, - min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, -) -> Bigram | None: - """Weakest unlocked bigram by transition confidence; None when no transition data.""" - if not transitions: - return None - pairs = [ - Bigram(prev, nxt) - for prev in unlocked - for nxt in unlocked - if not is_same_key_transition(prev, nxt) and Bigram(prev, nxt) in transitions - ] - if not pairs: - return None - return min( - pairs, - key=lambda p: _transition_focus_score( - p[0], - p[1], - transitions, - target, - now, - review_penalty=review_penalty, - min_attempts=min_attempts, - ), - ) - - -def focus_key_from_transition(_prev_cp: int, next_cp: int) -> int: - """Endpoint key to mark as lesson focus for a transition-driven bigram.""" - return next_cp diff --git a/src/keystrike/domain/daily_learn.py b/src/keystrike/domain/daily_learn.py index 86246d7..a38094f 100644 --- a/src/keystrike/domain/daily_learn.py +++ b/src/keystrike/domain/daily_learn.py @@ -43,12 +43,33 @@ def daily_learn_duration_ns( return total -def format_daily_learn_display(budget: DailyLearnBudget, *, label: str) -> str: +@dataclass(frozen=True, slots=True) +class DailyLearnDisplay: + """Structured daily-learn-budget display data — pure numbers/flags only, + no markup. Presentation renders this into whatever text/styling it wants + (see `presentation.widgets.hud._format_daily_learn_segment`); keeps Rich + markup formatting out of the domain layer.""" + + shown: bool + used_minutes: float + limit_minutes: float + limit_reached: bool + + +def daily_learn_display(budget: DailyLearnBudget) -> DailyLearnDisplay: if not budget.limited: - return "" - limit_min = budget.limit_ns / 1e9 / 60 - used_min = budget.used_ns / 1e9 / 60 - return f"{label} [bold]{used_min:.1f}[/]/{limit_min:g} min" + return DailyLearnDisplay( + shown=False, + used_minutes=0.0, + limit_minutes=0.0, + limit_reached=False, + ) + return DailyLearnDisplay( + shown=True, + used_minutes=budget.used_ns / 1e9 / 60, + limit_minutes=budget.limit_ns / 1e9 / 60, + limit_reached=budget.limit_reached, + ) def compute_daily_learn_budget( diff --git a/src/keystrike/domain/focus.py b/src/keystrike/domain/focus.py new file mode 100644 index 0000000..ef4fd45 --- /dev/null +++ b/src/keystrike/domain/focus.py @@ -0,0 +1,215 @@ +"""Focus/practice-weight selection: which key or transition today's lesson +should emphasize, and how much sampling weight weak/stale keys get in +generated practice text (§6 of PLAN.md).""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +from .confidence import ( + MIN_CONFIDENCE_ATTEMPTS, + MIN_TRANSITION_CONFIDENCE_ATTEMPTS, + HasConfidenceFields, + confidence_from_stats, + confidence_of, + is_same_key_transition, + review_urgency, +) +from .enums import FocusKind +from .models import Bigram, KeyStats, TransitionStats + +# Defaults for Settings and generator call sites; tunable via settings.toml. +FOCUS_CHAR_BOOST = 3.0 +FOCUS_WORD_BOOST = 3.0 +FOCUS_BIGRAM_WORD_BOOST = 4.0 +FOCUS_TRANSITION_BOOST = 4.0 +FOCUS_WEAK_EXTRA_BOOST = 1.5 + +_TRANSITION_KINDS = (FocusKind.TRANSITION_WEAK, FocusKind.TRANSITION_REVIEW) + + +@dataclass(frozen=True, slots=True) +class FocusReason: + """Why the adaptive engine is emphasizing today's lesson focus. Replaces + the old ad-hoc strings/formatted-string focus reasons; `pair` is set only + for the TRANSITION_* kinds. Presentation code pattern-matches on `kind` + to render its own display text instead of parsing suffixes/substrings.""" + + kind: FocusKind + pair: Bigram | None = None + + def __post_init__(self) -> None: + is_transition_kind = self.kind in _TRANSITION_KINDS + if is_transition_kind and self.pair is None: + raise ValueError(f"{self.kind} requires a pair") + if not is_transition_kind and self.pair is not None: + raise ValueError(f"{self.kind} must not have a pair") + + +def _focus_score_from_entry( + stats: HasConfidenceFields | None, + target: float, + now: float, + *, + review_penalty: float, + min_attempts: int, +) -> float: + """Shared body for `_focus_score`/`_transition_focus_score`: confidence, + discounted further the longer it's been since last practiced, so a stale + mastered key/pair can still outrank a merely-weak recent one.""" + urgency = review_urgency(stats.last_seen if stats else 0.0, now) + confidence = confidence_from_stats(stats, target, min_attempts=min_attempts) + return confidence * (1.0 - review_penalty * urgency) + + +def _focus_score( + codepoint: int, + stats: Mapping[int, KeyStats], + target: float, + now: float, + *, + review_penalty: float, + min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, +) -> float: + return _focus_score_from_entry( + stats.get(codepoint), + target, + now, + review_penalty=review_penalty, + min_attempts=min_attempts, + ) + + +def has_weak_unlocked_key( + unlocked: Sequence[int], + stats: Mapping[int, KeyStats], + target: float, + *, + threshold: float = 1.0, + min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, +) -> bool: + """True when any unlocked key is below mastery threshold.""" + return any( + confidence_of(cp, stats, target, min_attempts=min_attempts) < threshold for cp in unlocked + ) + + +def select_focus( + unlocked: Sequence[int], + stats: Mapping[int, KeyStats], + target: float, + now: float, + *, + review_penalty: float = 0.5, + min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, +) -> int: + """The unlocked key a lesson should emphasize — weakest by confidence, but + stale keys are treated as weaker so high-confidence keys due for review + still surface (SlimStampen-style spacing; see typing-pedagogy.md).""" + return min( + unlocked, + key=lambda cp: _focus_score( + cp, + stats, + target, + now, + review_penalty=review_penalty, + min_attempts=min_attempts, + ), + ) + + +def _transition_focus_score( + prev_cp: int, + next_cp: int, + stats: Mapping[Bigram, TransitionStats], + target: float, + now: float, + *, + review_penalty: float, + min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, +) -> float: + return _focus_score_from_entry( + stats.get(Bigram(prev_cp, next_cp)), + target, + now, + review_penalty=review_penalty, + min_attempts=min_attempts, + ) + + +def select_focus_transition( + unlocked: Sequence[int], + transitions: Mapping[Bigram, TransitionStats], + target: float, + now: float, + *, + review_penalty: float = 0.5, + min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, +) -> Bigram | None: + """Weakest unlocked bigram by transition confidence; None when no transition data.""" + if not transitions: + return None + pairs = [ + Bigram(prev, nxt) + for prev in unlocked + for nxt in unlocked + if not is_same_key_transition(prev, nxt) and Bigram(prev, nxt) in transitions + ] + if not pairs: + return None + return min( + pairs, + key=lambda p: _transition_focus_score( + p.prev_cp, + p.next_cp, + transitions, + target, + now, + review_penalty=review_penalty, + min_attempts=min_attempts, + ), + ) + + +def focus_key_from_transition(_prev_cp: int, next_cp: int) -> int: + """Endpoint key to mark as lesson focus for a transition-driven bigram.""" + return next_cp + + +def practice_weight( + confidence: float, + *, + max_bias: float = 3.0, + urgency: float = 0.0, + review_bias: float = 1.0, +) -> float: + """Sampling weight for practice-text generation: a weak key (confidence 0) + gets `1 + max_bias` the weight of a mastered key (confidence >= 1.0), so + generated text is deliberately concentrated on weak keys rather than + treating every unlocked key as equally likely to appear (see "Deliberate + practice targeting weak points" in docs/research/typing-pedagogy.md). + Capped at confidence 1.0 so an already-fast key doesn't get pushed below + baseline weight just for being unusually fast. + + `urgency` (from `review_urgency`) multiplies weight so stale-but-mastered + keys still appear in generated text.""" + base = 1.0 + max_bias * (1.0 - min(confidence, 1.0)) + return base * (1.0 + review_bias * urgency) + + +def transition_practice_weight( + confidence: float, + *, + max_bias: float = 3.0, + urgency: float = 0.0, + review_bias: float = 1.0, +) -> float: + """Sampling weight for a prev→next pair — mirrors `practice_weight`.""" + return practice_weight( + confidence, + max_bias=max_bias, + urgency=urgency, + review_bias=review_bias, + ) diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index 76c00c1..e88d965 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from random import Random -from .confidence import FOCUS_BIGRAM_WORD_BOOST, FOCUS_WORD_BOOST +from .focus import FOCUS_BIGRAM_WORD_BOOST, FOCUS_WORD_BOOST from .markov import TransitionTable from .models import Bigram, Layout from .word_bounds import MAX_WORD_LEN, MIN_WORD_LEN @@ -28,10 +28,16 @@ class LessonWeighting: char_weights: Mapping[str, float] | None = None transition_weights: Mapping[Bigram, float] | None = None layout: Layout | None = None - words: list[str] | None = None + words: tuple[str, ...] | None = None focus_word_boost: float = FOCUS_WORD_BOOST focus_bigram_word_boost: float = FOCUS_BIGRAM_WORD_BOOST + def __post_init__(self) -> None: + # Freezing the dataclass only blocks attribute rebinding — wrap the + # list field too so in-place mutation of its contents also raises. + if self.words is not None: + object.__setattr__(self, "words", tuple(self.words)) + def wordlist_weight_for_word( word: str, @@ -109,12 +115,12 @@ def generate_lesson( *, word_count: int = DEFAULT_WORD_COUNT, weighting: LessonWeighting | None = None, - focus_bigram: tuple[int, int] | None = None, + focus_bigram: Bigram | None = None, ) -> str: weighting = weighting or LessonWeighting() focus_bigram_str: str | None = None if focus_bigram is not None: - focus_bigram_str = chr(focus_bigram[0]) + chr(focus_bigram[1]) + focus_bigram_str = focus_bigram.chars() wordlist_weights: list[float] | None = None if weighting.words and (weighting.char_weights or weighting.transition_weights): wordlist_weights = [ @@ -131,7 +137,7 @@ def generate_lesson( for _ in range(word_count) ] if focus_bigram is not None: - prev_char, next_char = chr(focus_bigram[0]), chr(focus_bigram[1]) + prev_char, next_char = chr(focus_bigram.prev_cp), chr(focus_bigram.next_cp) bigram = prev_char + next_char if not any(bigram in w for w in lesson_words): idx = self.rng.randrange(len(lesson_words)) diff --git a/src/keystrike/domain/markov.py b/src/keystrike/domain/markov.py index 332d90c..2d7f220 100644 --- a/src/keystrike/domain/markov.py +++ b/src/keystrike/domain/markov.py @@ -6,6 +6,7 @@ from collections.abc import Mapping from dataclasses import dataclass from random import Random +from types import MappingProxyType from .models import Bigram, Layout @@ -31,6 +32,11 @@ class TransitionTable: order: int transitions: Mapping[str, Mapping[str, int]] # context -> next_char -> weight + def __post_init__(self) -> None: + # Freezing the dataclass only blocks attribute rebinding — wrap the + # dict field too so in-place mutation of its contents also raises. + object.__setattr__(self, "transitions", MappingProxyType(dict(self.transitions))) + def sample( self, context: str, diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index 74bc557..c9752d3 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -33,13 +33,17 @@ class SessionResult: words_completed: int = 0 lang: str = "en" unlocked_keys: tuple[int, ...] = () - # Left as a plain (mutable-in-place) dict rather than MappingProxyType-wrapped - # like Layout.keys/LayoutAggregates: infrastructure.session_repo_jsonl's - # `asdict(header)` deep-copies every field, and copy.deepcopy cannot pickle - # a mappingproxy. Freezing this one would break header persistence. - key_confidence: dict[int, float] = field(default_factory=_empty_key_confidence) + key_confidence: Mapping[int, float] = field(default_factory=_empty_key_confidence) target_speed_cpm: int = 0 # goal active at finish; 0 = legacy sessions + def __post_init__(self) -> None: + # Freezing the dataclass only blocks attribute rebinding — wrap the + # dict field too so in-place mutation of its contents also raises. + # infrastructure.session_repo_jsonl's header serialization builds its + # own plain dict rather than routing this mappingproxy through + # dataclasses.asdict/copy.deepcopy (which can't pickle a mappingproxy). + object.__setattr__(self, "key_confidence", MappingProxyType(dict(self.key_confidence))) + @dataclass(frozen=True, slots=True) class KeyStats: diff --git a/src/keystrike/domain/null_adapters.py b/src/keystrike/domain/null_adapters.py index a721213..55e2b86 100644 --- a/src/keystrike/domain/null_adapters.py +++ b/src/keystrike/domain/null_adapters.py @@ -11,7 +11,7 @@ from collections.abc import Iterator from .daily_learn import DailyLearnBudget, compute_daily_learn_budget -from .models import KeyStats, Keystroke, SessionResult +from .models import KeyStats, Keystroke, Layout, SessionResult, Settings class NullSessionRepository: @@ -61,3 +61,32 @@ def __call__(self, *, extra_ns: int = 0) -> DailyLearnBudget: NULL_DAILY_LEARN_BUDGET = NullDailyLearnBudgetProvider() + + +class NullSettingsRepository: + """Inert stand-in when no persisted Settings should be read or written — + load() returns the plain defaults, save() discards.""" + + def load(self) -> Settings: + return Settings() + + def save(self, settings: Settings) -> None: + pass + + +NULL_SETTINGS_REPOSITORY = NullSettingsRepository() + + +class NullLayoutRepository: + """Inert stand-in when no real layout should be read — an empty layout + with no keys/learn_order, so downstream key-unlock math sees nothing to + unlock instead of raising.""" + + def list_available(self) -> list[str]: + return [] + + def get(self, name: str) -> Layout: + return Layout(name=name, keys={}, learn_order=()) + + +NULL_LAYOUT_REPOSITORY = NullLayoutRepository() diff --git a/src/keystrike/domain/protocols.py b/src/keystrike/domain/protocols.py index e23d9f7..6df1993 100644 --- a/src/keystrike/domain/protocols.py +++ b/src/keystrike/domain/protocols.py @@ -54,7 +54,7 @@ def __call__(self, layout: str) -> None: ... class AggregatesEnsurer(Protocol): - """Shape of `application.stats_use_cases.EnsureAggregates` — lets callers + """Shape of `application.stats_use_cases.GetOrRebuildAggregates` — lets callers depend on the behavior without importing application code.""" def __call__(self, layout: str) -> Mapping[int, KeyStats]: ... diff --git a/src/keystrike/domain/sync_merge.py b/src/keystrike/domain/sync_merge.py index d8bb3ba..8cbd3a7 100644 --- a/src/keystrike/domain/sync_merge.py +++ b/src/keystrike/domain/sync_merge.py @@ -14,15 +14,14 @@ import datetime as dt import tomllib from dataclasses import dataclass -from typing import Any -def index_session_ids(entries: list[dict[str, Any]]) -> set[str]: +def index_session_ids(entries: list[dict[str, object]]) -> set[str]: """Session ids present in a parsed sessions index.""" return {str(entry["session_id"]) for entry in entries} -def index_layouts(entries: list[dict[str, Any]]) -> set[str]: +def index_layouts(entries: list[dict[str, object]]) -> set[str]: """Distinct layout names referenced by a parsed sessions index.""" return {str(entry["layout"]) for entry in entries} @@ -40,7 +39,7 @@ class SessionImportPlan: def plan_missing_sessions( *, local_session_ids: set[str], - remote_entries: list[dict[str, Any]], + remote_entries: list[dict[str, object]], remote_lines: list[str], ) -> list[SessionImportPlan]: """Decide which remote sessions are missing locally and need importing. @@ -57,7 +56,12 @@ def plan_missing_sessions( session_id = str(entry["session_id"]) if session_id in seen: continue - started_at = float(entry["started_at"]) + started_at_raw = entry["started_at"] + if not isinstance(started_at_raw, (int, float)): + raise TypeError( + f"expected numeric started_at, got {type(started_at_raw).__name__}", + ) + started_at = float(started_at_raw) month = dt.datetime.fromtimestamp(started_at, tz=dt.UTC).strftime("%Y-%m") plans.append( SessionImportPlan( diff --git a/src/keystrike/domain/unlock.py b/src/keystrike/domain/unlock.py new file mode 100644 index 0000000..82a68a6 --- /dev/null +++ b/src/keystrike/domain/unlock.py @@ -0,0 +1,78 @@ +"""Key-unlock policy: which keys in `learn_order` are currently unlocked, +gated on measured per-key and per-transition confidence (§6 of PLAN.md).""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from .confidence import ( + MIN_CONFIDENCE_ATTEMPTS, + MIN_TRANSITION_CONFIDENCE_ATTEMPTS, + confidence_of, + is_same_key_transition, + transition_confidence_of, +) +from .models import Bigram, KeyStats, TransitionStats + + +def _measured_transitions_meet_threshold( + unlocked: Sequence[int], + transitions: Mapping[Bigram, TransitionStats], + target: float, + *, + threshold: float, + min_attempts: int, +) -> bool: + """True when every measured bigram among unlocked keys meets threshold.""" + for prev in unlocked: + for nxt in unlocked: + if is_same_key_transition(prev, nxt): + continue + if Bigram(prev, nxt) not in transitions: + continue + if ( + transition_confidence_of( + prev, + nxt, + transitions, + target, + min_attempts=min_attempts, + ) + < threshold + ): + return False + return True + + +def compute_unlocked( + learn_order: Sequence[int], + alphabet_size: int, + stats: Mapping[int, KeyStats], + target: float, + *, + threshold: float = 1.0, + min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, + transitions: Mapping[Bigram, TransitionStats] | None = None, + min_transition_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, +) -> tuple[int, ...]: + """The first `alphabet_size` keys are always unlocked; each further key in + `learn_order` unlocks only once every currently-unlocked key and every + measured bigram among them meets `threshold`.""" + forced_count = min(alphabet_size, len(learn_order)) + unlocked = list(learn_order[:forced_count]) + for codepoint in learn_order[forced_count:]: + if not all( + confidence_of(k, stats, target, min_attempts=min_attempts) >= threshold + for k in unlocked + ): + break + if transitions is not None and not _measured_transitions_meet_threshold( + unlocked, + transitions, + target, + threshold=threshold, + min_attempts=min_transition_attempts, + ): + break + unlocked.append(codepoint) + return tuple(unlocked) diff --git a/src/keystrike/infrastructure/aggregates_cache.py b/src/keystrike/infrastructure/aggregates_cache.py index e3692d0..5c20eba 100644 --- a/src/keystrike/infrastructure/aggregates_cache.py +++ b/src/keystrike/infrastructure/aggregates_cache.py @@ -11,29 +11,22 @@ from keystrike.domain.models import Bigram, KeyStats, LayoutAggregates, TransitionStats from .atomic_write import atomic_write_text +from .json_coerce import require_float, require_int from .paths import Paths -def _as_int(v: object) -> int: - return int(v) # type: ignore[arg-type] - - -def _as_float(v: object) -> float: - return float(v) # type: ignore[arg-type] - - def _coerce_samples(entry: dict[str, object]) -> int: - samples = _as_int(entry["samples"]) - if samples <= 0 and _as_float(entry["mean_time_ns"]) > 0: + samples = require_int(entry, "samples") + if samples <= 0 and require_float(entry, "mean_time_ns") > 0: return 1 return samples def _coerce_attempt_count(entry: dict[str, object]) -> int: samples = _coerce_samples(entry) - errors = _as_int(entry["error_count"]) + errors = require_int(entry, "error_count") inferred = samples + errors - stored = _as_int(entry.get("attempt_count", inferred)) + stored = require_int(entry, "attempt_count", inferred) if stored <= 0 and inferred > 0: return inferred return stored @@ -53,36 +46,40 @@ def get(self, layout: str) -> LayoutAggregates | None: return None try: data = json.loads(file.read_text(encoding="utf-8")) - except json.JSONDecodeError: - return None - keys = data.get("keys", {}) - transitions = data.get("transitions", {}) - parsed_transitions = { - Bigram(int(entry["prev_cp"]), int(entry["next_cp"])): TransitionStats( - prev_cp=int(entry["prev_cp"]), - next_cp=int(entry["next_cp"]), - samples=_coerce_samples(entry), - mean_time_ns=float(entry["mean_time_ns"]), - error_count=int(entry["error_count"]), - last_seen=float(entry["last_seen"]), - attempt_count=_coerce_attempt_count(entry), - ) - for entry in transitions.values() - } - return LayoutAggregates( - keys={ - int(cp): KeyStats( - codepoint=int(cp), + keys = data.get("keys", {}) + transitions = data.get("transitions", {}) + parsed_transitions = { + Bigram( + require_int(entry, "prev_cp"), + require_int(entry, "next_cp"), + ): TransitionStats( + prev_cp=require_int(entry, "prev_cp"), + next_cp=require_int(entry, "next_cp"), samples=_coerce_samples(entry), - mean_time_ns=float(entry["mean_time_ns"]), - error_count=int(entry["error_count"]), - last_seen=float(entry["last_seen"]), + mean_time_ns=require_float(entry, "mean_time_ns"), + error_count=require_int(entry, "error_count"), + last_seen=require_float(entry, "last_seen"), attempt_count=_coerce_attempt_count(entry), ) - for cp, entry in keys.items() - }, - transitions=without_same_key_transitions(parsed_transitions), - ) + for entry in transitions.values() + } + aggregates = LayoutAggregates( + keys={ + int(cp): KeyStats( + codepoint=int(cp), + samples=_coerce_samples(entry), + mean_time_ns=require_float(entry, "mean_time_ns"), + error_count=require_int(entry, "error_count"), + last_seen=require_float(entry, "last_seen"), + attempt_count=_coerce_attempt_count(entry), + ) + for cp, entry in keys.items() + }, + transitions=without_same_key_transitions(parsed_transitions), + ) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + return None + return aggregates def put(self, layout: str, aggregates: LayoutAggregates) -> None: transitions = without_same_key_transitions(aggregates.transitions) diff --git a/src/keystrike/infrastructure/json_coerce.py b/src/keystrike/infrastructure/json_coerce.py new file mode 100644 index 0000000..9452da2 --- /dev/null +++ b/src/keystrike/infrastructure/json_coerce.py @@ -0,0 +1,95 @@ +"""Validated extraction helpers for untrusted JSON-derived data. + +`json.loads` types everything as `Any`/`object`, so pyright strict correctly +refuses an unchecked `int(entry["key"])` on a `dict[str, object]` — `object` +isn't `ConvertibleToInt`/`ConvertibleToFloat`. These helpers isinstance-check +the raw value before casting and raise `KeyError`/`TypeError` on missing or +wrong-typed keys, so callers can catch a narrow set of exceptions to degrade +gracefully on corrupt/malformed data instead of suppressing the type error +with `# type: ignore`. +""" + +from __future__ import annotations + +from typing import Final + +_MISSING: Final[object] = object() + + +def _lookup(entry: dict[str, object], key: str, default: object) -> object: + if default is _MISSING: + if key not in entry: + raise KeyError(key) + return entry[key] + return entry.get(key, default) + + +def coerce_int(value: object, *, label: str = "value") -> int: + """Validate that `value` is `int`-convertible (`int` or numeric `str`, + never `bool`) and return it as `int`. + + `str` is accepted because JSON object keys are always strings (e.g. the + codepoint keys of a `key_confidence` mapping), so a numeric string here + reflects normal encoding, not corrupt data. + """ + if isinstance(value, bool): + raise TypeError(f"expected int for {label}, got bool: {value!r}") + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value) + except ValueError as exc: + raise ValueError(f"expected int for {label}, got non-numeric str: {value!r}") from exc + raise TypeError(f"expected int for {label}, got {type(value).__name__}: {value!r}") + + +def coerce_float(value: object, *, label: str = "value") -> float: + """Validate that `value` is `float`-convertible (`int`, `float`, or + numeric `str`, never `bool`) and return it as `float`.""" + if isinstance(value, bool): + raise TypeError(f"expected float for {label}, got bool: {value!r}") + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError as exc: + raise ValueError( + f"expected float for {label}, got non-numeric str: {value!r}", + ) from exc + raise TypeError(f"expected float for {label}, got {type(value).__name__}: {value!r}") + + +def coerce_str(value: object, *, label: str = "value") -> str: + """Validate that `value` is a `str` and return it.""" + if not isinstance(value, str): + raise TypeError(f"expected str for {label}, got {type(value).__name__}: {value!r}") + return value + + +def require_int(entry: dict[str, object], key: str, default: object = _MISSING) -> int: + """Look up `key` in `entry` (or use `default` if provided) as an `int`. + + Raises `KeyError` if `key` is missing and no `default` was given, or + `TypeError` if the value isn't an `int`. + """ + return coerce_int(_lookup(entry, key, default), label=repr(key)) + + +def require_float(entry: dict[str, object], key: str, default: object = _MISSING) -> float: + """Look up `key` in `entry` (or use `default` if provided) as a `float`. + + Raises `KeyError` if `key` is missing and no `default` was given, or + `TypeError` if the value isn't numeric. + """ + return coerce_float(_lookup(entry, key, default), label=repr(key)) + + +def require_str(entry: dict[str, object], key: str, default: object = _MISSING) -> str: + """Look up `key` in `entry` (or use `default` if provided) as a `str`. + + Raises `KeyError` if `key` is missing and no `default` was given, or + `TypeError` if the value isn't a `str`. + """ + return coerce_str(_lookup(entry, key, default), label=repr(key)) diff --git a/src/keystrike/infrastructure/layout_repo.py b/src/keystrike/infrastructure/layout_repo.py index 923eaca..731c15b 100644 --- a/src/keystrike/infrastructure/layout_repo.py +++ b/src/keystrike/infrastructure/layout_repo.py @@ -3,6 +3,9 @@ from __future__ import annotations +from collections.abc import Mapping +from types import MappingProxyType + from keystrike.domain.models import Layout from .bundled_layouts.colemak import LAYOUT as _COLEMAK @@ -12,13 +15,13 @@ from .layout_toml import load_layout_toml from .paths import Paths -BUNDLED_LAYOUTS: dict[str, Layout] = { - layout.name: layout for layout in (_QWERTY, _DVORAK, _COLEMAK, _COLEMAK_DH) -} +BUNDLED_LAYOUTS: Mapping[str, Layout] = MappingProxyType( + {layout.name: layout for layout in (_QWERTY, _DVORAK, _COLEMAK, _COLEMAK_DH)}, +) class CompositeLayoutRepository: - def __init__(self, paths: Paths, bundled: dict[str, Layout] | None = None) -> None: + def __init__(self, paths: Paths, bundled: Mapping[str, Layout] | None = None) -> None: self._paths = paths self._bundled = bundled if bundled is not None else BUNDLED_LAYOUTS diff --git a/src/keystrike/infrastructure/session_repo_jsonl.py b/src/keystrike/infrastructure/session_repo_jsonl.py index 57e1510..dee6974 100644 --- a/src/keystrike/infrastructure/session_repo_jsonl.py +++ b/src/keystrike/infrastructure/session_repo_jsonl.py @@ -6,13 +6,13 @@ import datetime as dt import json from collections.abc import Iterator -from dataclasses import asdict from pathlib import Path from typing import cast from keystrike.domain.enums import Mode, migrate_legacy_mode from keystrike.domain.models import Keystroke, SessionResult +from .json_coerce import coerce_float, coerce_int, require_float, require_int, require_str from .paths import Paths @@ -107,21 +107,27 @@ def _read_keystrokes(file: Path) -> Iterator[Keystroke]: def _header_to_dict(h: SessionResult) -> dict[str, object]: - d = asdict(h) - # Mode is a StrEnum → str; tuple → list for JSON. - d["mode"] = str(h.mode) - d["lesson_alphabet"] = list(h.lesson_alphabet) - d["unlocked_keys"] = list(h.unlocked_keys) - d["key_confidence"] = {str(k): v for k, v in h.key_confidence.items()} - return d - - -def _as_int(v: object) -> int: - return int(v) # type: ignore[arg-type] - - -def _as_float(v: object) -> float: - return float(v) # type: ignore[arg-type] + # Built field-by-field rather than via `dataclasses.asdict(h)`: asdict + # falls back to `copy.deepcopy` for non-dataclass/list/tuple/dict values, + # and deepcopy cannot pickle `key_confidence`'s `mappingproxy` wrapper. + # A shallow `dict(...)` here sidesteps that entirely. + return { + "schema_version": h.schema_version, + "session_id": h.session_id, + "started_at": h.started_at, + "duration_ns": h.duration_ns, + "layout": h.layout, + "mode": str(h.mode), # Mode is a StrEnum → str + "lesson_alphabet": list(h.lesson_alphabet), + "focus_key": h.focus_key, + "total_keystrokes": h.total_keystrokes, + "correct_keystrokes": h.correct_keystrokes, + "words_completed": h.words_completed, + "lang": h.lang, + "unlocked_keys": list(h.unlocked_keys), + "key_confidence": {str(k): v for k, v in h.key_confidence.items()}, + "target_speed_cpm": h.target_speed_cpm, + } def _parse_mode(raw: object) -> Mode: @@ -134,25 +140,27 @@ def _parse_key_confidence(raw: object) -> dict[int, float]: out: dict[int, float] = {} mapping = cast("dict[object, object]", raw) for k, v in mapping.items(): - out[_as_int(k)] = _as_float(v) + out[coerce_int(k, label="key_confidence key")] = coerce_float( + v, label="key_confidence value" + ) return out def _header_from_dict(d: dict[str, object]) -> SessionResult: return SessionResult( - schema_version=_as_int(d["schema_version"]), - session_id=str(d["session_id"]), - started_at=_as_float(d["started_at"]), - duration_ns=_as_int(d["duration_ns"]), - layout=str(d["layout"]), + schema_version=require_int(d, "schema_version"), + session_id=require_str(d, "session_id"), + started_at=require_float(d, "started_at"), + duration_ns=require_int(d, "duration_ns"), + layout=require_str(d, "layout"), mode=_parse_mode(d["mode"]), lesson_alphabet=tuple(d["lesson_alphabet"]), # type: ignore[arg-type] - focus_key=_as_int(d["focus_key"]) if d.get("focus_key") is not None else None, - total_keystrokes=_as_int(d["total_keystrokes"]), - correct_keystrokes=_as_int(d["correct_keystrokes"]), - words_completed=_as_int(d.get("words_completed", 0)), - lang=str(d.get("lang", "en")), + focus_key=require_int(d, "focus_key") if d.get("focus_key") is not None else None, + total_keystrokes=require_int(d, "total_keystrokes"), + correct_keystrokes=require_int(d, "correct_keystrokes"), + words_completed=require_int(d, "words_completed", 0), + lang=require_str(d, "lang", "en"), unlocked_keys=tuple(d.get("unlocked_keys", ())), # type: ignore[arg-type] key_confidence=_parse_key_confidence(d.get("key_confidence", {})), - target_speed_cpm=_as_int(d.get("target_speed_cpm", 0)), + target_speed_cpm=require_int(d, "target_speed_cpm", 0), ) diff --git a/src/keystrike/infrastructure/settings_repo_toml.py b/src/keystrike/infrastructure/settings_repo_toml.py index 0d3b72c..eddeef3 100644 --- a/src/keystrike/infrastructure/settings_repo_toml.py +++ b/src/keystrike/infrastructure/settings_repo_toml.py @@ -39,52 +39,38 @@ def load(self) -> Settings: except tomllib.TOMLDecodeError: return Settings() + # Field list (and per-field cast) is derived from the Settings dataclass + # itself, mirroring save(), so load/save can't silently drift apart when + # a field is added/removed from Settings. defaults = Settings() - unit_raw = str(raw.get("target_speed_unit", defaults.target_speed_unit)) - try: - target_speed_unit = TargetSpeedUnit(unit_raw) - except ValueError: - target_speed_unit = defaults.target_speed_unit - return Settings( - schema_version=int(raw.get("schema_version", defaults.schema_version)), - layout=str(raw.get("layout", defaults.layout)), - target_speed_cpm=int(raw.get("target_speed_cpm", defaults.target_speed_cpm)), - target_speed_unit=target_speed_unit, - alphabet_size=int(raw.get("alphabet_size", defaults.alphabet_size)), - confidence_session_window=int( - raw.get("confidence_session_window", defaults.confidence_session_window), - ), - min_confidence_attempts=int( - raw.get("min_confidence_attempts", defaults.min_confidence_attempts), - ), - min_transition_confidence_attempts=int( - raw.get( - "min_transition_confidence_attempts", - defaults.min_transition_confidence_attempts, - ), - ), - focus_char_boost=float( - raw.get("focus_char_boost", defaults.focus_char_boost), - ), - focus_word_boost=float( - raw.get("focus_word_boost", defaults.focus_word_boost), - ), - focus_bigram_word_boost=float( - raw.get("focus_bigram_word_boost", defaults.focus_bigram_word_boost), - ), - focus_transition_boost=float( - raw.get("focus_transition_boost", defaults.focus_transition_boost), - ), - focus_weak_extra_boost=float( - raw.get("focus_weak_extra_boost", defaults.focus_weak_extra_boost), - ), - lang=str(raw.get("lang", defaults.lang)), - learn_daily_minutes=int( - raw.get("learn_daily_minutes", defaults.learn_daily_minutes), - ), - wordlist_url=str(raw.get("wordlist_url", defaults.wordlist_url)), - updated_at=(str(raw["updated_at"]) if raw.get("updated_at") is not None else None), - ) + values: dict[str, object] = {} + for f in dataclasses.fields(Settings): + if f.name == "updated_at": + # Written fresh on save(), not defaulted — only round-tripped here. + raw_updated = raw.get("updated_at") + values[f.name] = str(raw_updated) if raw_updated is not None else None + continue + default = getattr(defaults, f.name) + if f.type is TargetSpeedUnit: + try: + values[f.name] = TargetSpeedUnit(str(raw.get(f.name, default))) + except ValueError: + values[f.name] = default + elif f.type is bool: + values[f.name] = bool(raw.get(f.name, default)) + elif f.type is int: + values[f.name] = int(raw.get(f.name, default)) + elif f.type is float: + values[f.name] = float(raw.get(f.name, default)) + elif f.type is str: + values[f.name] = str(raw.get(f.name, default)) + else: + raise TypeError(f"unsupported settings field type: {f.type!r}") + # `values` is built dynamically off `dataclasses.fields(Settings)`, so + # pyright can't statically match each entry to its declared parameter + # type the way it could with a hand-written call — the per-field + # isinstance/cast dispatch above is what actually keeps this sound. + return Settings(**values) # type: ignore[arg-type] def save(self, settings: Settings) -> None: # Field list is derived from the Settings dataclass itself (single diff --git a/src/keystrike/infrastructure/sync_git.py b/src/keystrike/infrastructure/sync_git.py index b218327..00d5d6d 100644 --- a/src/keystrike/infrastructure/sync_git.py +++ b/src/keystrike/infrastructure/sync_git.py @@ -15,7 +15,6 @@ import tomllib from dataclasses import dataclass from pathlib import Path -from typing import Any from keystrike.domain.models import SyncStatusReport from keystrike.domain.protocols import StatsRebuilder @@ -48,11 +47,11 @@ class SyncConfig: remote_url: str -def _read_index(path: Path) -> tuple[list[dict[str, Any]], list[str]]: +def _read_index(path: Path) -> tuple[list[dict[str, object]], list[str]]: """Load a sessions index file into parsed entries + matching raw lines.""" if not path.is_file(): return [], [] - entries: list[dict[str, Any]] = [] + entries: list[dict[str, object]] = [] lines: list[str] = [] with path.open(encoding="utf-8") as fh: for raw in fh: diff --git a/src/keystrike/presentation/formatting/trends.py b/src/keystrike/presentation/formatting/trends.py index 0ea4889..42708a1 100644 --- a/src/keystrike/presentation/formatting/trends.py +++ b/src/keystrike/presentation/formatting/trends.py @@ -12,10 +12,20 @@ from keystrike.application.session_use_cases import compute_wpm from keystrike.domain.confidence import target_ms_per_char from keystrike.domain.models import SessionResult +from keystrike.presentation.theme import ( + STYLE_TREND_ACCURACY, + STYLE_TREND_CONFIDENCE, + STYLE_TREND_SPEED, +) _SPARK = "▁▂▃▄▅▆▇█" +def _recent(headers: Sequence[SessionResult], limit: int) -> list[SessionResult]: + """The most recent ``limit`` headers, oldest→newest.""" + return sorted(headers, key=lambda h: h.started_at)[-limit:] + + def value_sparkline(values: Sequence[float]) -> str: """Unicode sparkline for numeric values, oldest→newest.""" if not values: @@ -32,7 +42,7 @@ def value_sparkline(values: Sequence[float]) -> str: def wpm_sparkline(headers: Sequence[SessionResult], *, limit: int = 20) -> str: """Unicode sparkline of WPM per session, oldest→newest.""" - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + ordered = _recent(headers, limit) if not ordered: return "" return value_sparkline([compute_wpm(h) for h in ordered]) @@ -56,7 +66,7 @@ def focus_confidence_values( limit: int = 20, current_target_speed_cpm: int = 0, ) -> list[float]: - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + ordered = _recent(headers, limit) return [ _display_confidence( h.key_confidence.get(h.focus_key, 0.0) if h.focus_key is not None else 0.0, @@ -91,7 +101,7 @@ def key_confidence_values( limit: int = 20, current_target_speed_cpm: int = 0, ) -> list[float]: - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + ordered = _recent(headers, limit) return [ _display_confidence( h.key_confidence.get(codepoint, 0.0), @@ -120,7 +130,7 @@ def key_confidence_sparkline( return value_sparkline(values) -def _char_label(codepoint: int) -> str: +def char_label(codepoint: int) -> str: ch = chr(codepoint) return ch if ch.isprintable() and not ch.isspace() else f"U+{codepoint:04X}" @@ -128,11 +138,11 @@ def _char_label(codepoint: int) -> str: def _focus_char_label(focus_key: int | None) -> str: if focus_key is None: return "?" - return _char_label(focus_key) + return char_label(focus_key) def format_wpm_trend_line(headers: Sequence[SessionResult], *, limit: int = 20) -> str: - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + ordered = _recent(headers, limit) if not ordered: return "" wpms = [compute_wpm(h) for h in ordered] @@ -149,7 +159,7 @@ def format_focus_confidence_trend_line( limit: int = 20, current_target_speed_cpm: int = 0, ) -> str: - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + ordered = _recent(headers, limit) if not ordered: return "" focus_key = ordered[-1].focus_key @@ -171,12 +181,6 @@ def format_focus_confidence_trend_line( ) -# Keep in sync with presentation/theme.py STYLE_TREND_*. -_TREND_CONFIDENCE_COLOR = "cyan" -_TREND_SPEED_COLOR = "green" -_TREND_ACCURACY_COLOR = "yellow" - - def _colored_sparkline(spark: str, color: str) -> str: return f"[{color}]{spark}[/]" if spark else "" @@ -197,16 +201,18 @@ def _format_metric_trend_line( *, format_value: Callable[[float], str] | None = None, suffix: str = "", - show_sessions: bool = True, grid: bool = False, spark_width: int = 20, ) -> str: if not values: return "" + # `grid` is the single mode switch: grid layout packs sparklines/values into + # fixed-width columns for side-by-side blocks and omits the session count + # (the block header already shows it); inline layout does the opposite. fmt = format_value or _default_metric_value label_text = label.ljust(_GRID_LABEL_WIDTH) if grid else label spark_text = spark.ljust(spark_width) if grid else spark - session_part = f" ({len(values)} sessions) " if show_sessions else " " + session_part = " " if grid else f" ({len(values)} sessions) " latest_str = fmt(values[-1]) peak_str = fmt(max(values)) if grid: @@ -233,10 +239,9 @@ def format_confidence_trend_line( ) -> str: return _format_metric_trend_line( "confidence", - _TREND_CONFIDENCE_COLOR, + STYLE_TREND_CONFIDENCE, values, value_sparkline(values), - show_sessions=not grid, grid=grid, spark_width=spark_width, ) @@ -249,11 +254,13 @@ def format_key_confidence_trend_line( limit: int = 20, current_target_speed_cpm: int = 0, cumulative: float | None = None, - include_key_name: bool = True, grid: bool = False, spark_width: int = 20, ) -> str: - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + # In grid layout the enclosing block's title already names the key, so the + # line itself drops the key name to avoid repeating it; inline layout has + # no such title, so the line names the key itself. + ordered = _recent(headers, limit) if not ordered: return "" values = key_confidence_values( @@ -268,18 +275,17 @@ def format_key_confidence_trend_line( limit=limit, current_target_speed_cpm=current_target_speed_cpm, ) - char = _char_label(codepoint) - label = f"'{char}' confidence" if include_key_name else "confidence" + char = char_label(codepoint) + label = "confidence" if grid else f"'{char}' confidence" suffix = "" if cumulative is not None: - suffix = f"[dim {_TREND_CONFIDENCE_COLOR}]cumulative {cumulative:.2f}[/]" + suffix = f"[dim {STYLE_TREND_CONFIDENCE}]cumulative {cumulative:.2f}[/]" return _format_metric_trend_line( label, - _TREND_CONFIDENCE_COLOR, + STYLE_TREND_CONFIDENCE, values, spark, suffix=suffix, - show_sessions=not grid, grid=grid, spark_width=spark_width, ) @@ -293,10 +299,9 @@ def format_key_speed_trend_line( ) -> str: return _format_metric_trend_line( "speed", - _TREND_SPEED_COLOR, + STYLE_TREND_SPEED, values, value_sparkline(values), - show_sessions=not grid, grid=grid, spark_width=spark_width, ) @@ -311,11 +316,10 @@ def format_key_accuracy_trend_line( pct_values = [v * 100 for v in values] return _format_metric_trend_line( "accuracy", - _TREND_ACCURACY_COLOR, + STYLE_TREND_ACCURACY, pct_values, value_sparkline(pct_values), format_value=lambda v: f"{v:.1f}%", - show_sessions=not grid, grid=grid, spark_width=spark_width, ) @@ -358,7 +362,7 @@ def format_key_metric_trend_block( """Trend block for a single key: confidence line driven by session headers.""" grid = True spark_width = limit - ordered = sorted(headers, key=lambda h: h.started_at)[-limit:] + ordered = _recent(headers, limit) session_count = len(ordered) if ordered else max(len(speed_values), len(accuracy_values), 0) conf_line = format_key_confidence_trend_line( headers, @@ -366,7 +370,6 @@ def format_key_metric_trend_block( limit=limit, current_target_speed_cpm=current_target_speed_cpm, cumulative=cumulative, - include_key_name=False, grid=grid, spark_width=spark_width, ) diff --git a/src/keystrike/presentation/screens/home.py b/src/keystrike/presentation/screens/home.py index 30d8afe..f2e4dd1 100644 --- a/src/keystrike/presentation/screens/home.py +++ b/src/keystrike/presentation/screens/home.py @@ -8,13 +8,16 @@ from textual.widgets import Footer, Static from keystrike.application.settings_use_cases import CycleLayout -from keystrike.domain.daily_learn import DailyLearnBudget, format_daily_learn_display +from keystrike.domain.daily_learn import DailyLearnBudget, daily_learn_display from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET from keystrike.domain.protocols import DailyLearnBudgetProvider, SettingsRepository def _format_daily_learn_line(budget: DailyLearnBudget) -> str: - return format_daily_learn_display(budget, label="Learn today:") + display = daily_learn_display(budget) + if not display.shown: + return "" + return f"Learn today: [bold]{display.used_minutes:.1f}[/]/{display.limit_minutes:g} min" def _hero_text(layout: str, learn_budget: DailyLearnBudget, *, app_version: str = "") -> str: diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index 5d282aa..5d00c2c 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -16,7 +16,7 @@ StartSession, format_session_stats_line, ) -from keystrike.domain.confidence import FocusReason +from keystrike.domain.focus import FocusReason from keystrike.domain.models import Layout, SessionResult from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET, NULL_STATS_REBUILDER from keystrike.domain.protocols import Clock, DailyLearnBudgetProvider, StatsRebuilder diff --git a/src/keystrike/presentation/screens/settings.py b/src/keystrike/presentation/screens/settings.py index 001ff4f..0615472 100644 --- a/src/keystrike/presentation/screens/settings.py +++ b/src/keystrike/presentation/screens/settings.py @@ -7,7 +7,11 @@ from textual.widgets import Footer, Input, Label, Select, Static from textual.widgets.select import NoSelection -from keystrike.application.settings_use_cases import SettingsValidationError, UpdateSettings +from keystrike.application.settings_use_cases import ( + SettingsUpdate, + SettingsValidationError, + UpdateSettings, +) from keystrike.application.wordlist_use_cases import ( DEFAULT_WORDLIST_URL, ClearWordList, @@ -126,11 +130,11 @@ def action_import_wordlist(self) -> None: def action_clear_wordlist(self) -> None: self._do_clear() - def action_save(self) -> None: + def _collect_form_values(self) -> SettingsUpdate | None: speed_raw = self.query_one("#settings-speed", Input).value target_speed_value = self._parse_int_field(speed_raw, "Target speed") if target_speed_value is None: - return + return None speed_unit_select = cast( "Select[TargetSpeedUnit]", @@ -138,7 +142,7 @@ def action_save(self) -> None: ) target_speed_unit = speed_unit_select.value if isinstance(target_speed_unit, NoSelection): - return + return None target_speed_cpm = ( cpm_from_wpm(target_speed_value) if target_speed_unit == TargetSpeedUnit.WPM @@ -148,7 +152,7 @@ def action_save(self) -> None: alphabet_size_raw = self.query_one("#settings-alphabet-size", Input).value alphabet_size = self._parse_int_field(alphabet_size_raw, "Number of letters") if alphabet_size is None: - return + return None learn_daily_minutes_raw = self.query_one("#settings-learn-daily-minutes", Input).value learn_daily_minutes = self._parse_int_field( @@ -156,22 +160,29 @@ def action_save(self) -> None: "Daily learn minutes", ) if learn_daily_minutes is None: - return + return None layout_select = cast("Select[str]", self.query_one("#settings-layout", Select)) layout = layout_select.value if isinstance(layout, NoSelection): # Unreachable with allow_blank=False + an initial value, but keeps typing sound. + return None + + return SettingsUpdate( + layout=layout, + target_speed_cpm=target_speed_cpm, + target_speed_unit=target_speed_unit, + alphabet_size=alphabet_size, + learn_daily_minutes=learn_daily_minutes, + ) + + def action_save(self) -> None: + values = self._collect_form_values() + if values is None: return try: - self._update_settings( - layout=layout, - target_speed_cpm=target_speed_cpm, - target_speed_unit=target_speed_unit, - alphabet_size=alphabet_size, - learn_daily_minutes=learn_daily_minutes, - ) + self._update_settings(values) except SettingsValidationError as exc: self._show_error(str(exc)) return diff --git a/src/keystrike/presentation/screens/stats.py b/src/keystrike/presentation/screens/stats.py index 26ff6db..30a3dd5 100644 --- a/src/keystrike/presentation/screens/stats.py +++ b/src/keystrike/presentation/screens/stats.py @@ -17,6 +17,7 @@ from keystrike.domain.protocols import LayoutRepository, StatsRebuilder from keystrike.presentation.bindings import BACK_BINDINGS from keystrike.presentation.formatting.trends import ( + char_label, format_aggregate_metric_trend_block, format_focus_confidence_trend_line, format_key_metric_trend_block, @@ -26,11 +27,6 @@ _View = Literal["overview", "key_detail"] -def _char_label(codepoint: int) -> str: - ch = chr(codepoint) - return ch if ch.isprintable() and not ch.isspace() else f"U+{codepoint:04X}" - - class StatsScreen(Screen[None]): DEFAULT_CSS = """ StatsScreen > Vertical { @@ -173,7 +169,7 @@ def _render_key_detail(self, codepoint: int) -> None: codepoint, current_target_speed_cpm=self._current_target_speed_cpm, ) - label = _char_label(codepoint) + label = char_label(codepoint) detail = format_key_metric_trend_block( title=f"'{label}'", headers=self._trend_history, diff --git a/src/keystrike/presentation/theme.py b/src/keystrike/presentation/theme.py index 434be2e..7ecbd27 100644 --- a/src/keystrike/presentation/theme.py +++ b/src/keystrike/presentation/theme.py @@ -2,7 +2,6 @@ STYLE_PENDING = "white" STYLE_CORRECT = "grey42" -STYLE_WRONG = "bold red" STYLE_CURRENT = "bold underline" STYLE_WRONG_CURRENT = "bold red underline" STYLE_CORRECTED = "dim yellow" @@ -10,5 +9,3 @@ STYLE_TREND_CONFIDENCE = "cyan" STYLE_TREND_SPEED = "green" STYLE_TREND_ACCURACY = "yellow" -STYLE_DELTA_IMPROVE = "green" -STYLE_DELTA_REGRESS = "red" diff --git a/src/keystrike/presentation/widgets/hud.py b/src/keystrike/presentation/widgets/hud.py index a888ff5..1c599df 100644 --- a/src/keystrike/presentation/widgets/hud.py +++ b/src/keystrike/presentation/widgets/hud.py @@ -2,11 +2,12 @@ from textual.widget import Widget from textual.widgets import Static -from keystrike.domain.confidence import FocusReason -from keystrike.domain.daily_learn import DailyLearnBudget, format_daily_learn_display +from keystrike.domain.daily_learn import DailyLearnBudget, daily_learn_display +from keystrike.domain.focus import FocusReason from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET from keystrike.domain.protocols import Clock, DailyLearnBudgetProvider from keystrike.domain.session import Session, active_typing_duration_ns, is_typing_idle +from keystrike.presentation.theme import STYLE_IDLE from keystrike.presentation.widgets.kb_heatmap import focus_reason_label, focus_transition_pair @@ -17,11 +18,12 @@ def learn_timer_dimmed(session: Session, now_ns: int) -> bool: def _format_daily_learn_segment(budget: DailyLearnBudget, *, dim: bool) -> str: - segment = format_daily_learn_display(budget, label=" Learn:") - if not segment: + display = daily_learn_display(budget) + if not display.shown: return "" - colored = f"[green]{segment}[/]" if budget.limit_reached else segment - return f"[dim]{colored}[/]" if dim else colored + segment = f" Learn: [bold]{display.used_minutes:.1f}[/]/{display.limit_minutes:g} min" + colored = f"[green]{segment}[/]" if display.limit_reached else segment + return f"[{STYLE_IDLE}]{colored}[/]" if dim else colored def _format_focus_segment(focus_key: int | None, focus_reason: FocusReason | None) -> str: diff --git a/src/keystrike/presentation/widgets/kb_heatmap.py b/src/keystrike/presentation/widgets/kb_heatmap.py index fa17c11..c7fea0e 100644 --- a/src/keystrike/presentation/widgets/kb_heatmap.py +++ b/src/keystrike/presentation/widgets/kb_heatmap.py @@ -3,8 +3,8 @@ from textual.widget import Widget from textual.widgets import Static -from keystrike.domain.confidence import FocusReason from keystrike.domain.enums import FocusKind +from keystrike.domain.focus import FocusReason from keystrike.domain.models import Bigram, Layout _ROWS = 3 diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 7a8e4d4..cbc52c6 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -1,15 +1,16 @@ from random import Random -from unittest.mock import patch from keystrike.application.build_lesson import BuildLesson, _transition_focus_metrics from keystrike.domain.aggregate import _combine_transition_maps_weighted, session_recency_weights -from keystrike.domain.confidence import FocusReason, target_ms_per_char, transition_confidence_of +from keystrike.domain.confidence import target_ms_per_char, transition_confidence_of from keystrike.domain.enums import FocusKind +from keystrike.domain.focus import FocusReason from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import Bigram, KeyStats, LayoutAggregates, Settings, TransitionStats from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from tests.fakes import ( FakeAggregatesCache, + FakeClock, FakeLanguageProvider, FakeLayoutRepository, FakeSettingsRepository, @@ -25,6 +26,7 @@ def _build_lesson(settings: Settings | None = None, rng_seed: int = 0) -> BuildL language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(rng_seed), + clock=FakeClock(), ) @@ -119,6 +121,7 @@ def test_lesson_prefers_weak_key_over_weak_transition(): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(0), + clock=FakeClock(), ) lesson = builder("qwerty") assert lesson.focus_reason == FocusReason(kind=FocusKind.KEY_WEAK) @@ -166,6 +169,7 @@ def test_lesson_ignores_weak_same_key_transition_for_focus(): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(0), + clock=FakeClock(), ) lesson = builder("qwerty") assert lesson.focus_reason is None or lesson.focus_reason.kind in ( @@ -210,6 +214,7 @@ def test_lesson_uses_transition_focus_when_transitions_weak(): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(0), + clock=FakeClock(), ) lesson = builder("qwerty") pair_bigram = Bigram(a, s) @@ -275,6 +280,7 @@ def test_build_lesson_eo_not_zero_confidence_when_counts_zeroed(): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(0), + clock=FakeClock(), ) lesson = builder(layout_name) assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=eo_key) @@ -437,9 +443,9 @@ def test_lesson_uses_transition_review_when_stale_and_mastered(): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(0), + clock=FakeClock(), ) - with patch("keystrike.application.build_lesson.time.time", return_value=now): - lesson = builder("qwerty") + lesson = builder("qwerty") assert lesson.focus_key == s assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_REVIEW, pair=Bigram(a, s)) @@ -482,6 +488,7 @@ def test_lesson_wordlist_biases_weak_transition(): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(by_url={url: cached}), rng=Random(0), + clock=FakeClock(), ) lesson = builder("qwerty") pair = chr(a) + chr(s) @@ -537,6 +544,7 @@ def test_weak_transition_focus_over_represented_in_lesson_words(): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(0), + clock=FakeClock(), ) pair = chr(a) + chr(s) bigram_words = 0 @@ -562,6 +570,7 @@ def test_lesson_uses_cached_wordlist_when_configured(): language_provider=FakeLanguageProvider(), wordlist_store=store, rng=Random(42), + clock=FakeClock(), ) lesson = builder("qwerty") lesson_words = set(lesson.text.split()) @@ -580,6 +589,7 @@ def test_lesson_falls_back_to_markov_when_cache_missing(): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(by_url={url: exclusive}), rng=Random(42), + clock=FakeClock(), ) assert "abcdefgh" in with_cache("qwerty").text @@ -590,6 +600,7 @@ def test_lesson_falls_back_to_markov_when_cache_missing(): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(42), + clock=FakeClock(), ) assert "abcdefgh" not in without_cache("qwerty").text @@ -606,6 +617,7 @@ def test_lesson_falls_back_when_alphabet_filters_all_words(): language_provider=FakeLanguageProvider(), wordlist_store=store, rng=Random(42), + clock=FakeClock(), ) lesson = builder("qwerty") assert all("z" not in word for word in lesson.text.split()) @@ -621,6 +633,7 @@ def test_lesson_uses_markov_when_url_saved_without_cache(): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(0), + clock=FakeClock(), ) lesson = builder("qwerty") assert lesson.text diff --git a/tests/application/test_prepare_practice.py b/tests/application/test_prepare_practice.py index 27e59b5..abf386c 100644 --- a/tests/application/test_prepare_practice.py +++ b/tests/application/test_prepare_practice.py @@ -5,7 +5,7 @@ from keystrike.application.build_lesson import BuildLesson from keystrike.application.learn_budget_use_cases import GetDailyLearnBudget from keystrike.application.prepare_practice import PreparePracticeSession -from keystrike.application.stats_use_cases import EnsureAggregates, RebuildAggregates +from keystrike.application.stats_use_cases import GetOrRebuildAggregates, RebuildAggregates from keystrike.domain.enums import Mode from keystrike.domain.models import Keystroke, SessionResult, Settings from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS, CompositeLayoutRepository @@ -51,6 +51,7 @@ def _prepare(*, settings: Settings | None = None) -> PreparePracticeSession: language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(0), + clock=clock, ), get_daily_learn_budget=get_daily_learn_budget, ) @@ -92,7 +93,7 @@ def test_prepare_ensures_transitions_before_lesson(): Keystroke(codepoint=ord("s"), typed=ord("x"), t_ns=600_000_000, correct=False), ] rebuild = RebuildAggregates(repo=session_repo, cache=cache, settings_repo=settings_repo) - ensure = EnsureAggregates(repo=session_repo, cache=cache, rebuild=rebuild) + ensure = GetOrRebuildAggregates(repo=session_repo, cache=cache, rebuild=rebuild) prepare = PreparePracticeSession( settings_repo=settings_repo, layout_repo=layout_repo, @@ -103,6 +104,7 @@ def test_prepare_ensures_transitions_before_lesson(): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(0), + clock=clock, ), get_daily_learn_budget=GetDailyLearnBudget( clock=clock, @@ -152,6 +154,7 @@ def test_prepare_adaptive_still_builds_lesson_when_daily_goal_reached(): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(0), + clock=clock, ), get_daily_learn_budget=GetDailyLearnBudget( clock=clock, @@ -186,6 +189,7 @@ def test_prepare_uses_custom_toml_layout(paths): language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(), rng=Random(0), + clock=FakeClock(), ), get_daily_learn_budget=GetDailyLearnBudget( clock=FakeClock(), diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index 9f287ec..2d7228d 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -19,13 +19,13 @@ from keystrike.domain.aggregate import combine_sessions from keystrike.domain.confidence import ( CONFIDENCE_SESSION_WINDOW, - compute_unlocked, confidence_of, target_ms_per_char, ) from keystrike.domain.enums import Mode, SessionState from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import SessionResult, Settings +from keystrike.domain.unlock import compute_unlocked from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from tests.fakes import ( FakeClock, diff --git a/tests/application/test_settings_use_cases.py b/tests/application/test_settings_use_cases.py index 305e86e..c1617cd 100644 --- a/tests/application/test_settings_use_cases.py +++ b/tests/application/test_settings_use_cases.py @@ -2,6 +2,7 @@ from keystrike.application.settings_use_cases import ( CycleLayout, + SettingsUpdate, SettingsValidationError, UpdateSettings, ) @@ -26,11 +27,13 @@ def test_update_settings_persists_all_fields(): update = UpdateSettings(repo=repo) result = update( - layout="dvorak", - target_speed_cpm=400, - target_speed_unit=TargetSpeedUnit.WPM, - alphabet_size=20, - learn_daily_minutes=15, + SettingsUpdate( + layout="dvorak", + target_speed_cpm=400, + target_speed_unit=TargetSpeedUnit.WPM, + alphabet_size=20, + learn_daily_minutes=15, + ), ) assert result.layout == "dvorak" @@ -58,11 +61,13 @@ def test_update_settings_preserves_confidence_fields_from_repo(): update = UpdateSettings(repo=repo) result = update( - layout="qwerty", - target_speed_cpm=300, - target_speed_unit=TargetSpeedUnit.CPM, - alphabet_size=16, - learn_daily_minutes=10, + SettingsUpdate( + layout="qwerty", + target_speed_cpm=300, + target_speed_unit=TargetSpeedUnit.CPM, + alphabet_size=16, + learn_daily_minutes=10, + ), ) assert result.confidence_session_window == 8 @@ -76,11 +81,13 @@ def test_update_settings_rejects_non_positive_speed(): with pytest.raises(SettingsValidationError): update( - layout="qwerty", - target_speed_cpm=0, - target_speed_unit=TargetSpeedUnit.CPM, - alphabet_size=16, - learn_daily_minutes=10, + SettingsUpdate( + layout="qwerty", + target_speed_cpm=0, + target_speed_unit=TargetSpeedUnit.CPM, + alphabet_size=16, + learn_daily_minutes=10, + ), ) assert repo.settings == Settings() # unchanged @@ -92,11 +99,13 @@ def test_update_settings_rejects_negative_alphabet_size(): with pytest.raises(SettingsValidationError): update( - layout="qwerty", - target_speed_cpm=300, - target_speed_unit=TargetSpeedUnit.CPM, - alphabet_size=-1, - learn_daily_minutes=10, + SettingsUpdate( + layout="qwerty", + target_speed_cpm=300, + target_speed_unit=TargetSpeedUnit.CPM, + alphabet_size=-1, + learn_daily_minutes=10, + ), ) assert repo.settings == Settings() # unchanged @@ -108,11 +117,13 @@ def test_update_settings_rejects_negative_learn_daily_minutes(): with pytest.raises(SettingsValidationError): update( - layout="qwerty", - target_speed_cpm=300, - target_speed_unit=TargetSpeedUnit.CPM, - alphabet_size=16, - learn_daily_minutes=-1, + SettingsUpdate( + layout="qwerty", + target_speed_cpm=300, + target_speed_unit=TargetSpeedUnit.CPM, + alphabet_size=16, + learn_daily_minutes=-1, + ), ) assert repo.settings == Settings() diff --git a/tests/application/test_stats_use_cases.py b/tests/application/test_stats_use_cases.py index 7c5668f..c65665b 100644 --- a/tests/application/test_stats_use_cases.py +++ b/tests/application/test_stats_use_cases.py @@ -1,10 +1,10 @@ from keystrike.application.stats_use_cases import ( - EnsureAggregates, GetAggregateMetricTrends, GetHeatmap, GetHistory, GetKeyMetricTrends, GetLearningRate, + GetOrRebuildAggregates, RebuildAggregates, ) from keystrike.domain.aggregate import session_recency_weights @@ -23,7 +23,12 @@ Settings, TransitionStats, ) -from tests.fakes import FakeAggregatesCache, FakeSessionRepository, FakeSettingsRepository +from tests.fakes import ( + FakeAggregatesCache, + FakeClock, + FakeSessionRepository, + FakeSettingsRepository, +) def _header(session_id: str, started_at: float, layout: str = "qwerty") -> SessionResult: @@ -148,7 +153,7 @@ def test_ensure_skips_when_cache_has_transitions(): cache=cache, settings_repo=FakeSettingsRepository(), ) - ensure = EnsureAggregates(repo=repo, cache=cache, rebuild=rebuild) + ensure = GetOrRebuildAggregates(repo=repo, cache=cache, rebuild=rebuild) keys = ensure("qwerty") @@ -178,7 +183,7 @@ def test_ensure_rebuilds_when_transitions_missing_but_sessions_exist(): cache=cache, settings_repo=FakeSettingsRepository(), ) - ensure = EnsureAggregates(repo=repo, cache=cache, rebuild=rebuild) + ensure = GetOrRebuildAggregates(repo=repo, cache=cache, rebuild=rebuild) ensure("qwerty") @@ -258,18 +263,17 @@ def test_rebuild_aggregates_respects_settings_window(): def test_get_heatmap_empty_cache_returns_empty_view(): cache = FakeAggregatesCache() settings_repo = FakeSettingsRepository() - get_heatmap = GetHeatmap(cache=cache, settings_repo=settings_repo) + get_heatmap = GetHeatmap(cache=cache, settings_repo=settings_repo, clock=FakeClock()) view = get_heatmap("qwerty") assert view.confidence == {} assert view.urgency == {} -def test_get_heatmap_confidence_ratio(monkeypatch): +def test_get_heatmap_confidence_ratio(): cache = FakeAggregatesCache() settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=300)) repo = FakeSessionRepository() now = 1_700_000_000.0 - monkeypatch.setattr("keystrike.application.stats_use_cases.time.time", lambda: now) header = _header("s1", now) repo.save_header(header) repo.keystrokes["s1"] = [ @@ -290,7 +294,7 @@ def test_get_heatmap_confidence_ratio(monkeypatch): settings_repo=FakeSettingsRepository(), )("qwerty") - get_heatmap = GetHeatmap(cache=cache, settings_repo=settings_repo) + get_heatmap = GetHeatmap(cache=cache, settings_repo=settings_repo, clock=FakeClock(wall=now)) view = get_heatmap("qwerty") # target_ms_per_char = 60000/300 = 200ms; mean_time = 200ms → confidence == 1.0 @@ -298,11 +302,10 @@ def test_get_heatmap_confidence_ratio(monkeypatch): assert view.urgency[ord("a")] == 0.0 -def test_get_heatmap_urgency_from_last_seen(monkeypatch): +def test_get_heatmap_urgency_from_last_seen(): cache = FakeAggregatesCache() settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=300)) now = 1_000_000.0 - monkeypatch.setattr("keystrike.application.stats_use_cases.time.time", lambda: now) cache.put( "qwerty", LayoutAggregates( @@ -318,7 +321,7 @@ def test_get_heatmap_urgency_from_last_seen(monkeypatch): ), ) - view = GetHeatmap(cache=cache, settings_repo=settings_repo)("qwerty") + view = GetHeatmap(cache=cache, settings_repo=settings_repo, clock=FakeClock(wall=now))("qwerty") assert view.urgency[ord("a")] == 1.0 diff --git a/tests/application/test_sync_use_cases.py b/tests/application/test_sync_use_cases.py new file mode 100644 index 0000000..184f79d --- /dev/null +++ b/tests/application/test_sync_use_cases.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass, field + +from keystrike.application.sync_use_cases import GetSyncStatus, InitSync, PullSync, PushSync +from keystrike.domain.models import SyncStatusReport +from tests.fakes import FakeSyncStore + + +@dataclass(slots=True) +class _RecordingRebuild: + """Records each layout it's called with — a StatsRebuilder test double.""" + + calls: list[str] = field(default_factory=list) + + def __call__(self, layout: str) -> None: + self.calls.append(layout) + + +def test_init_sync_configures_gateway_with_remote_url(): + gateway = FakeSyncStore() + init = InitSync(gateway=gateway) + + init("https://example.com/repo.git") + + assert gateway.configured is True + assert gateway.remote_url == "https://example.com/repo.git" + assert gateway.init_calls == ["https://example.com/repo.git"] + + +def test_pull_sync_returns_gateway_result(): + gateway = FakeSyncStore(pull_result=3) + rebuild = _RecordingRebuild() + pull = PullSync(gateway=gateway, rebuild=rebuild) + + result = pull() + + assert result == 3 + + +def test_pull_sync_rebuilds_aggregates_for_pulled_layouts(): + gateway = FakeSyncStore(pulled_layouts=["qwerty", "dvorak"], pull_result=2) + rebuild = _RecordingRebuild() + pull = PullSync(gateway=gateway, rebuild=rebuild) + + result = pull() + + assert result == 2 + assert rebuild.calls == ["qwerty", "dvorak"] + assert gateway.rebuilt_layouts == ["qwerty", "dvorak"] + + +def test_push_sync_returns_gateway_result(): + gateway = FakeSyncStore(push_result=True) + push = PushSync(gateway=gateway) + + assert push() is True + + +def test_push_sync_returns_false_when_gateway_reports_no_push(): + gateway = FakeSyncStore(push_result=False) + push = PushSync(gateway=gateway) + + assert push() is False + + +def test_get_sync_status_returns_gateway_report(): + report = SyncStatusReport( + configured=True, + remote_url="https://example.com/repo.git", + git_status="dirty", + local_sessions=5, + clone_sessions=3, + only_local=2, + only_clone=1, + ) + gateway = FakeSyncStore(configured=True, status_report=report) + get_status = GetSyncStatus(gateway=gateway) + + assert get_status() is report + + +def test_get_sync_status_default_when_unconfigured(): + gateway = FakeSyncStore() + get_status = GetSyncStatus(gateway=gateway) + + status = get_status() + + assert status.configured is False + assert status.remote_url is None diff --git a/tests/application/test_wordlist_use_cases.py b/tests/application/test_wordlist_use_cases.py index df6ff60..d2587bf 100644 --- a/tests/application/test_wordlist_use_cases.py +++ b/tests/application/test_wordlist_use_cases.py @@ -45,8 +45,9 @@ def test_import_wordlist_rejects_non_http_url(): def test_import_wordlist_wraps_download_errors(): - # FakeWordListStore raises RuntimeError (not ValueError) to verify wrapping. - store = FakeWordListStore(download_error=RuntimeError("network down")) + # OSError mirrors what FileWordListStore.download_and_cache actually raises + # for network/IO failures (ValueError covers bad-content failures). + store = FakeWordListStore(download_error=OSError("network down")) repo = FakeSettingsRepository(Settings()) import_wordlist = ImportWordList(store=store, settings_repo=repo) diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index c917d8f..cf29d48 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -1,21 +1,23 @@ from keystrike.domain.confidence import ( MIN_CONFIDENCE_ATTEMPTS, accuracy_of, - compute_unlocked, confidence_of, - focus_key_from_transition, - has_weak_unlocked_key, key_attempts, key_confidence, - practice_weight, review_urgency, - select_focus, - select_focus_transition, target_ms_per_char, transition_accuracy_of, transition_confidence_of, ) +from keystrike.domain.focus import ( + focus_key_from_transition, + has_weak_unlocked_key, + practice_weight, + select_focus, + select_focus_transition, +) from keystrike.domain.models import Bigram, KeyStats, TransitionStats +from keystrike.domain.unlock import compute_unlocked def test_target_ms_per_char(): diff --git a/tests/domain/test_daily_learn.py b/tests/domain/test_daily_learn.py index 983de58..aa75c85 100644 --- a/tests/domain/test_daily_learn.py +++ b/tests/domain/test_daily_learn.py @@ -2,8 +2,8 @@ from keystrike.domain.daily_learn import ( compute_daily_learn_budget, + daily_learn_display, daily_learn_duration_ns, - format_daily_learn_display, session_local_date, ) from keystrike.domain.enums import Mode @@ -51,19 +51,27 @@ def test_compute_daily_learn_budget_unlimited_when_limit_zero(): assert not budget.limit_reached -def test_format_daily_learn_display_shows_used_and_limit(): +def test_daily_learn_display_shows_used_and_limit(): budget = compute_daily_learn_budget(completed_ns=9 * 60 * 1_000_000_000, limit_minutes=10) - text = format_daily_learn_display(budget, label="Learn today:") - assert "9.0" in text - assert "/10 min" in text - assert "left" not in text + display = daily_learn_display(budget) + assert display.shown + assert display.used_minutes == 9.0 + assert display.limit_minutes == 10.0 + assert not display.limit_reached -def test_format_daily_learn_display_goal_reached(): +def test_daily_learn_display_goal_reached(): budget = compute_daily_learn_budget(completed_ns=10 * 60 * 1_000_000_000, limit_minutes=10) - text = format_daily_learn_display(budget, label="Learn today:") - assert "10.0" in text - assert "/10 min" in text + display = daily_learn_display(budget) + assert display.used_minutes == 10.0 + assert display.limit_minutes == 10.0 + assert display.limit_reached + + +def test_daily_learn_display_hidden_when_unlimited(): + budget = compute_daily_learn_budget(completed_ns=999_000_000_000, limit_minutes=0) + display = daily_learn_display(budget) + assert not display.shown def test_compute_daily_learn_budget_tracks_remaining_and_limit_reached(): diff --git a/tests/domain/test_generator.py b/tests/domain/test_generator.py index c9924f5..8a66a1a 100644 --- a/tests/domain/test_generator.py +++ b/tests/domain/test_generator.py @@ -59,7 +59,7 @@ def test_generate_lesson_char_weights_bias_toward_weak_key(): def test_generate_word_uses_wordlist_when_provided(): generator = AdaptiveGenerator(table=_uniform_table("xyz"), rng=Random(0)) - words = ["cab", "bad", "dab"] + words = ("cab", "bad", "dab") for _ in range(10): word = generator.generate_word(frozenset("abcd"), LessonWeighting(words=words)) assert word in words @@ -75,14 +75,14 @@ def test_generate_word_falls_back_when_wordlist_outside_alphabet(): generator = AdaptiveGenerator(table=_uniform_table("abc"), rng=Random(0)) word = generator.generate_word( frozenset("abc"), - LessonWeighting(words=["xyz", "qrs"]), + LessonWeighting(words=("xyz", "qrs")), ) assert set(word) <= {"a", "b", "c"} def test_generate_wordlist_char_weights_bias(): generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) - words = ["aaa", "bbb"] + words = ("aaa", "bbb") counts = {"a": 0, "b": 0} for _ in range(50): word = generator.generate_word( @@ -95,7 +95,7 @@ def test_generate_wordlist_char_weights_bias(): def test_generate_wordlist_transition_weights_bias(): generator = AdaptiveGenerator(table=_uniform_table("abcd"), rng=Random(0)) - words = ["cab", "cad"] + words = ("cab", "cad") counts = {"cab": 0, "cad": 0} for _ in range(50): word = generator.generate_word( @@ -125,7 +125,7 @@ def test_wordlist_weight_for_word_combines_char_and_transition_weights(): def test_generate_lesson_wordlist_transition_weights_bias(): generator = AdaptiveGenerator(table=_uniform_table("as"), rng=Random(0)) - words = ["asa", "ass", "sas", "ssa"] + words = ("asa", "ass", "sas", "ssa") ssa_count = 0 for seed in range(50): generator.rng = Random(seed) @@ -148,7 +148,7 @@ def test_generate_lesson_wordlist_transition_weights_bias(): def test_generate_lesson_focus_wordlist_overweights_focus_char(): generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) - words = ["aaa", "bbb"] + words = ("aaa", "bbb") focus_counts = 0 for seed in range(40): generator.rng = Random(seed) @@ -187,7 +187,7 @@ def test_generate_lesson_injects_focus_bigram(): frozenset("abc"), focus_char="c", word_count=4, - focus_bigram=(ord("a"), ord("z")), + focus_bigram=Bigram(ord("a"), ord("z")), ) assert "az" in lesson.replace(" ", "") diff --git a/tests/fakes.py b/tests/fakes.py index 42ddfcb..db5d841 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -11,7 +11,9 @@ LayoutAggregates, SessionResult, Settings, + SyncStatusReport, ) +from keystrike.domain.protocols import StatsRebuilder @dataclass(slots=True) @@ -143,3 +145,52 @@ def download_and_cache(self, url: str) -> list[str]: if url in self.by_url: return list(self.by_url[url]) raise ValueError("download failed") + + +@dataclass(slots=True) +class FakeSyncStore: + """Deterministic stand-in for `infrastructure.sync_git.GitSyncGateway`. + + `pulled_layouts` names the layouts `pull()` should invoke `rebuild` for + (simulating new sessions arriving from the remote for those layouts); + `pull_result`/`push_result` control the return values. + """ + + configured: bool = False + remote_url: str | None = None + pulled_layouts: list[str] = field(default_factory=list) + pull_result: int = 0 + push_result: bool = True + status_report: SyncStatusReport | None = None + init_calls: list[str] = field(default_factory=list) + rebuilt_layouts: list[str] = field(default_factory=list) + + def is_configured(self) -> bool: + return self.configured + + def init(self, remote_url: str) -> None: + self.configured = True + self.remote_url = remote_url + self.init_calls.append(remote_url) + + def pull(self, rebuild: StatsRebuilder) -> int: + for layout in self.pulled_layouts: + rebuild(layout) + self.rebuilt_layouts.append(layout) + return self.pull_result + + def push(self) -> bool: + return self.push_result + + def status(self) -> SyncStatusReport: + if self.status_report is not None: + return self.status_report + return SyncStatusReport( + configured=self.configured, + remote_url=self.remote_url, + git_status="clean", + local_sessions=0, + clone_sessions=0, + only_local=0, + only_clone=0, + ) diff --git a/tests/presentation/test_hud.py b/tests/presentation/test_hud.py index 8309648..5416647 100644 --- a/tests/presentation/test_hud.py +++ b/tests/presentation/test_hud.py @@ -2,9 +2,9 @@ from textual.app import App from textual.widgets import Static -from keystrike.domain.confidence import FocusReason from keystrike.domain.daily_learn import compute_daily_learn_budget from keystrike.domain.enums import FocusKind, Mode +from keystrike.domain.focus import FocusReason from keystrike.domain.models import Bigram from keystrike.domain.session import ( LEARN_IDLE_PAUSE_NS, diff --git a/tests/presentation/test_kb_heatmap.py b/tests/presentation/test_kb_heatmap.py index aa66a0d..537694c 100644 --- a/tests/presentation/test_kb_heatmap.py +++ b/tests/presentation/test_kb_heatmap.py @@ -1,5 +1,5 @@ -from keystrike.domain.confidence import FocusReason from keystrike.domain.enums import FocusKind +from keystrike.domain.focus import FocusReason from keystrike.domain.models import Bigram from keystrike.infrastructure.bundled_layouts.colemak_dh import LAYOUT as COLEMAK_DH from keystrike.infrastructure.bundled_layouts.qwerty import LAYOUT as QWERTY diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index 58b625b..b3f3647 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -15,11 +15,11 @@ ) from keystrike.application.settings_use_cases import CycleLayout, UpdateSettings from keystrike.application.stats_use_cases import ( - EnsureAggregates, GetAggregateMetricTrends, GetHeatmap, GetHistory, GetKeyMetricTrends, + GetOrRebuildAggregates, RebuildAggregates, ) from keystrike.application.wordlist_use_cases import ( @@ -74,6 +74,7 @@ def _build_app( language_provider=FakeLanguageProvider(), wordlist_store=wordlist_store, rng=Random(0), + clock=clock, ) get_daily_learn_budget = GetDailyLearnBudget( clock=clock, @@ -86,7 +87,7 @@ def _build_app( cache=cache, settings_repo=settings_repo, ) - ensure_aggregates = EnsureAggregates( + ensure_aggregates = GetOrRebuildAggregates( repo=session_repo, cache=cache, rebuild=rebuild_aggregates, @@ -114,7 +115,7 @@ def _build_app( prepare_practice=prepare_practice, get_session_baseline=GetSessionBaseline(repo=session_repo, settings_repo=settings_repo), rebuild_aggregates=rebuild_aggregates, - get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo), + get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo, clock=clock), get_history=GetHistory(repo=session_repo), get_key_metric_trends=GetKeyMetricTrends( repo=session_repo, diff --git a/tests/presentation/test_settings_screen.py b/tests/presentation/test_settings_screen.py index 752b034..bd785d0 100644 --- a/tests/presentation/test_settings_screen.py +++ b/tests/presentation/test_settings_screen.py @@ -323,7 +323,7 @@ async def test_import_prefilled_default_url(): @pytest.mark.asyncio async def test_import_shows_error_on_wordlist_error(): app = App() - store = FakeWordListStore(download_error=RuntimeError("network down")) + store = FakeWordListStore(download_error=OSError("network down")) screen, settings_repo, _store = _build_screen(wordlist_store=store) async with app.run_test() as pilot: await app.push_screen(screen) diff --git a/tests/presentation/test_stats_screen.py b/tests/presentation/test_stats_screen.py index 4d2b6fe..367dfd3 100644 --- a/tests/presentation/test_stats_screen.py +++ b/tests/presentation/test_stats_screen.py @@ -20,6 +20,7 @@ from keystrike.presentation.screens.stats import StatsScreen from tests.fakes import ( FakeAggregatesCache, + FakeClock, FakeLayoutRepository, FakeSessionRepository, FakeSettingsRepository, @@ -46,7 +47,7 @@ def _build_screen( cache=cache, settings_repo=settings_repo, ), - get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo), + get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo, clock=FakeClock()), get_history=GetHistory(repo=repo), get_key_metric_trends=GetKeyMetricTrends( repo=repo, diff --git a/tests/presentation/test_typing_area.py b/tests/presentation/test_typing_area.py index 8721a30..ffd1cfd 100644 --- a/tests/presentation/test_typing_area.py +++ b/tests/presentation/test_typing_area.py @@ -8,7 +8,6 @@ STYLE_CORRECTED, STYLE_CURRENT, STYLE_PENDING, - STYLE_WRONG, STYLE_WRONG_CURRENT, ) from keystrike.presentation.widgets.typing_area import ( @@ -49,7 +48,6 @@ def test_typing_color_constants(): assert STYLE_CORRECT == "grey42" assert STYLE_CORRECTED == "dim yellow" assert STYLE_CURRENT == "bold underline" - assert STYLE_WRONG == "bold red" def test_cursor_position_uses_underline_not_block(): From 0a3b8b7dbebfd5227dd1d03f44321b83b402aa9a Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 30 Jul 2026 15:58:35 +0300 Subject: [PATCH 20/47] refactor --- src/keystrike/app.py | 52 +++-- src/keystrike/application/build_lesson.py | 179 ++++++++++------- src/keystrike/domain/aggregate.py | 69 +++++-- src/keystrike/domain/confidence.py | 15 +- src/keystrike/domain/null_adapters.py | 8 - src/keystrike/domain/protocols.py | 6 - src/keystrike/domain/session.py | 7 + .../infrastructure/aggregates_cache.py | 18 +- src/keystrike/infrastructure/atomic_write.py | 7 +- .../infrastructure/session_repo_jsonl.py | 44 ++++- .../infrastructure/settings_repo_toml.py | 38 ++-- src/keystrike/infrastructure/sync_git.py | 130 ++++++++---- .../presentation/formatting/trends.py | 185 ++++++++++++------ .../presentation/screens/practice.py | 39 ++-- .../presentation/screens/settings.py | 16 +- src/keystrike/presentation/textual_app.py | 118 ++++++----- src/keystrike/presentation/widgets/hud.py | 9 +- .../infrastructure/test_session_repo_jsonl.py | 12 ++ .../infrastructure/test_settings_repo_toml.py | 19 ++ tests/infrastructure/test_sync_git.py | 132 +++++++++++++ tests/presentation/test_practice_screen.py | 72 ++++--- 21 files changed, 796 insertions(+), 379 deletions(-) diff --git a/src/keystrike/app.py b/src/keystrike/app.py index 77d7cd2..7e647d1 100644 --- a/src/keystrike/app.py +++ b/src/keystrike/app.py @@ -38,7 +38,13 @@ from keystrike.infrastructure.settings_repo_toml import TomlSettingsRepository from keystrike.infrastructure.sync_git import GitSyncGateway from keystrike.infrastructure.wordlist_store import FileWordListStore -from keystrike.presentation.textual_app import KeystrikeApp +from keystrike.presentation.textual_app import ( + HomeServices, + KeystrikeApp, + PracticeServices, + SettingsServices, + StatsServices, +) @dataclass(frozen=True, slots=True) @@ -139,24 +145,34 @@ def build() -> KeystrikeApp: ) return KeystrikeApp( - clock=clock, - start=start, - record=record, - finish=finish, settings_repo=settings_repo, layout_repo=layout_repo, - prepare_practice=prepare_practice, - get_session_baseline=get_session_baseline, - rebuild_aggregates=rebuild_aggregates, - get_heatmap=get_heatmap, - get_history=get_history, - get_key_metric_trends=get_key_metric_trends, - get_aggregate_metric_trends=get_aggregate_metric_trends, - cycle_layout=cycle_layout, - update_settings=update_settings, - import_wordlist=import_wordlist, - clear_wordlist=clear_wordlist, - get_wordlist_cache_status=get_wordlist_cache_status, - get_daily_learn_budget=get_daily_learn_budget, + home=HomeServices( + cycle_layout=cycle_layout, + get_daily_learn_budget=get_daily_learn_budget, + ), + practice=PracticeServices( + clock=clock, + start=start, + record=record, + finish=finish, + prepare_practice=prepare_practice, + get_session_baseline=get_session_baseline, + rebuild_aggregates=rebuild_aggregates, + get_daily_learn_budget=get_daily_learn_budget, + ), + stats=StatsServices( + rebuild_aggregates=rebuild_aggregates, + get_heatmap=get_heatmap, + get_history=get_history, + get_key_metric_trends=get_key_metric_trends, + get_aggregate_metric_trends=get_aggregate_metric_trends, + ), + settings=SettingsServices( + update_settings=update_settings, + import_wordlist=import_wordlist, + clear_wordlist=clear_wordlist, + get_wordlist_cache_status=get_wordlist_cache_status, + ), app_version=__version__, ) diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 11c957a..c44b20d 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -218,16 +218,8 @@ def _lesson_progress( threshold=_CONFIDENCE_GOOD, min_attempts=settings.min_confidence_attempts, ) - if keys_need_focus: - focus_bigram = None - focus = select_focus( - unlocked, - stats, - target, - now, - min_attempts=settings.min_confidence_attempts, - ) - elif transitions: + focus_bigram: Bigram | None = None + if not keys_need_focus and transitions: focus_bigram = select_focus_transition( unlocked, transitions, @@ -235,18 +227,10 @@ def _lesson_progress( now, min_attempts=settings.min_transition_confidence_attempts, ) - if focus_bigram is not None: - focus = focus_key_from_transition(*focus_bigram) - else: - focus = select_focus( - unlocked, - stats, - target, - now, - min_attempts=settings.min_confidence_attempts, - ) + + if focus_bigram is not None: + focus = focus_key_from_transition(*focus_bigram) else: - focus_bigram = None focus = select_focus( unlocked, stats, @@ -335,6 +319,16 @@ def _compute_weights( return char_weights, transition_weights +@dataclass(slots=True) +class _LessonContext: + settings: Settings + layout: Layout + stats: Mapping[int, KeyStats] + transitions: Mapping[Bigram, TransitionStats] + now: float + target: float + + @dataclass(slots=True) class BuildLesson: layout_repo: LayoutRepository @@ -346,80 +340,53 @@ class BuildLesson: clock: Clock def __call__(self, layout_name: str) -> Lesson: - settings = self.settings_repo.load() - layout = self.layout_repo.get(layout_name) - aggregates = self.aggregates_cache.get(layout_name) - stats: Mapping[int, KeyStats] = aggregates.keys if aggregates else {} - transitions: Mapping[Bigram, TransitionStats] = aggregates.transitions if aggregates else {} - now = self.clock.wall_epoch() + ctx = self._load_context(layout_name) unlocked, focus, state, focus_bigram = _lesson_progress( layout_name, - layout, - stats, - settings, - now, - transitions=transitions, + ctx.layout, + ctx.stats, + ctx.settings, + ctx.now, + transitions=ctx.transitions, ) - target = target_ms_per_char(settings.target_speed_cpm) focus_confidence = _resolve_focus_confidence( focus, focus_bigram, - stats=stats, - transitions=transitions, - target=target, - settings=settings, + stats=ctx.stats, + transitions=ctx.transitions, + target=ctx.target, + settings=ctx.settings, ) - - table = self.language_provider.transitions(settings.lang) - generator = AdaptiveGenerator(table=table, rng=self.rng) - alphabet_chars = frozenset(chr(cp) for cp in unlocked) char_weights, transition_weights = _compute_weights( state, focus, focus_bigram, focus_confidence, unlocked=unlocked, - stats=stats, - transitions=transitions, - target=target, - settings=settings, - now=now, + stats=ctx.stats, + transitions=ctx.transitions, + target=ctx.target, + settings=ctx.settings, + now=ctx.now, ) - - dict_words: tuple[str, ...] | None = None - if settings.wordlist_url: - cached = self.wordlist_store.load(settings.wordlist_url) - if cached: - filtered = words_for_alphabet(cached, alphabet_chars) - if filtered: - dict_words = tuple(filtered) - weighting = LessonWeighting( + alphabet_chars = frozenset(chr(cp) for cp in unlocked) + text = self._generate_text( + ctx, + alphabet_chars, + focus, + focus_bigram, char_weights=char_weights, transition_weights=transition_weights, - layout=layout, - words=dict_words, - focus_word_boost=settings.focus_word_boost, - focus_bigram_word_boost=settings.focus_bigram_word_boost, - ) - text = generator.generate_lesson( - alphabet_chars, - chr(focus), - word_count=WORD_COUNT, - weighting=weighting, - focus_bigram=focus_bigram, ) - - urgency = { - cp: review_urgency(stats[cp].last_seen if cp in stats else 0.0, now) for cp in unlocked - } + urgency = _compute_urgency(ctx.stats, unlocked, ctx.now) reason, focus_speed, focus_accuracy = _compute_focus_explanation( focus, focus_bigram, focus_confidence, - stats=stats, - transitions=transitions, - target=target, - now=now, + stats=ctx.stats, + transitions=ctx.transitions, + target=ctx.target, + now=ctx.now, ) return Lesson( text=text, @@ -430,3 +397,67 @@ def __call__(self, layout_name: str) -> Lesson: focus_speed=focus_speed, focus_accuracy=focus_accuracy, ) + + def _load_context(self, layout_name: str) -> _LessonContext: + settings = self.settings_repo.load() + layout = self.layout_repo.get(layout_name) + aggregates = self.aggregates_cache.get(layout_name) + stats: Mapping[int, KeyStats] = aggregates.keys if aggregates else {} + transitions: Mapping[Bigram, TransitionStats] = aggregates.transitions if aggregates else {} + return _LessonContext( + settings=settings, + layout=layout, + stats=stats, + transitions=transitions, + now=self.clock.wall_epoch(), + target=target_ms_per_char(settings.target_speed_cpm), + ) + + def _generate_text( + self, + ctx: _LessonContext, + alphabet_chars: frozenset[str], + focus: int, + focus_bigram: Bigram | None, + *, + char_weights: dict[str, float], + transition_weights: dict[Bigram, float], + ) -> str: + table = self.language_provider.transitions(ctx.settings.lang) + generator = AdaptiveGenerator(table=table, rng=self.rng) + weighting = LessonWeighting( + char_weights=char_weights, + transition_weights=transition_weights, + layout=ctx.layout, + words=self._resolve_dict_words(ctx.settings, alphabet_chars), + focus_word_boost=ctx.settings.focus_word_boost, + focus_bigram_word_boost=ctx.settings.focus_bigram_word_boost, + ) + return generator.generate_lesson( + alphabet_chars, + chr(focus), + word_count=WORD_COUNT, + weighting=weighting, + focus_bigram=focus_bigram, + ) + + def _resolve_dict_words( + self, + settings: Settings, + alphabet_chars: frozenset[str], + ) -> tuple[str, ...] | None: + if not settings.wordlist_url: + return None + cached = self.wordlist_store.load(settings.wordlist_url) + if not cached: + return None + filtered = words_for_alphabet(cached, alphabet_chars) + return tuple(filtered) if filtered else None + + +def _compute_urgency( + stats: Mapping[int, KeyStats], + unlocked: tuple[int, ...], + now: float, +) -> dict[int, float]: + return {cp: review_urgency(stats[cp].last_seen if cp in stats else 0.0, now) for cp in unlocked} diff --git a/src/keystrike/domain/aggregate.py b/src/keystrike/domain/aggregate.py index b9e2701..317eada 100644 --- a/src/keystrike/domain/aggregate.py +++ b/src/keystrike/domain/aggregate.py @@ -95,11 +95,21 @@ def _rounded_weighted_count(weighted: float) -> int: return max(1, rounded) if rounded == 0 else rounded +@dataclass(frozen=True, slots=True) +class MergedFields: + """Result of `_weighted_merge_fields`: the shared recency-weighted merge + math for key stats and transition stats.""" + + samples: int + mean_time_ns: float + error_count: int + attempt_count: int + last_seen: float + + def _weighted_merge_fields( entries: Sequence[tuple[HasConfidenceFields, float]], -) -> tuple[int, float, int, int, float]: - """Shared recency-weighted merge math for key stats and transition stats: - returns (samples, mean_time_ns, error_count, attempt_count, last_seen).""" +) -> MergedFields: weighted_samples = sum(weight * stats.samples for stats, weight in entries) weighted_errors = sum(weight * stats.error_count for stats, weight in entries) weighted_attempts = sum(weight * stats.attempt_count for stats, weight in entries) @@ -111,12 +121,12 @@ def _weighted_merge_fields( else: mean = 0.0 last_seen = max(stats.last_seen for stats, _ in entries) - return ( - _rounded_weighted_count(weighted_samples), - mean, - round(weighted_errors), - _rounded_weighted_count(weighted_attempts), - last_seen, + return MergedFields( + samples=_rounded_weighted_count(weighted_samples), + mean_time_ns=mean, + error_count=round(weighted_errors), + attempt_count=_rounded_weighted_count(weighted_attempts), + last_seen=last_seen, ) @@ -132,14 +142,14 @@ def _combine_key_maps_weighted( out: dict[int, KeyStats] = {} for cp, entries in by_cp.items(): - samples, mean, errors, attempts, last_seen = _weighted_merge_fields(entries) + merged = _weighted_merge_fields(entries) out[cp] = KeyStats( codepoint=cp, - samples=samples, - mean_time_ns=mean, - error_count=errors, - last_seen=last_seen, - attempt_count=attempts, + samples=merged.samples, + mean_time_ns=merged.mean_time_ns, + error_count=merged.error_count, + last_seen=merged.last_seen, + attempt_count=merged.attempt_count, ) return out @@ -155,15 +165,15 @@ def _combine_transition_maps_weighted( out: dict[Bigram, TransitionStats] = {} for key, entries in by_key.items(): - samples, mean, errors, attempts, last_seen = _weighted_merge_fields(entries) + merged = _weighted_merge_fields(entries) out[key] = TransitionStats( prev_cp=key.prev_cp, next_cp=key.next_cp, - samples=samples, - mean_time_ns=mean, - error_count=errors, - last_seen=last_seen, - attempt_count=attempts, + samples=merged.samples, + mean_time_ns=merged.mean_time_ns, + error_count=merged.error_count, + last_seen=merged.last_seen, + attempt_count=merged.attempt_count, ) return without_same_key_transitions(out) @@ -193,6 +203,23 @@ def combine_sessions( ) +def infer_key_stat_samples(samples: int, mean_time_ns: float) -> int: + """Legacy caches recorded `samples=0` alongside a real `mean_time_ns` + before the schema tracked keystroke counts; treat that as one sample.""" + if samples <= 0 and mean_time_ns > 0: + return 1 + return samples + + +def infer_key_stat_attempt_count(samples: int, error_count: int, attempt_count: int) -> int: + """Legacy caches predate a stored `attempt_count`; treat samples + errors + as the inferred total whenever the stored value is non-positive.""" + inferred = samples + error_count + if attempt_count <= 0 and inferred > 0: + return inferred + return attempt_count + + def merge_key_stats(a: KeyStats, b: KeyStats) -> KeyStats: if a.codepoint != b.codepoint: raise ValueError(f"codepoint mismatch: {a.codepoint} vs {b.codepoint}") diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index 4d863bd..b76f408 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -67,12 +67,19 @@ def mean_time_ns(self) -> float: ... def last_seen(self) -> float: ... +def _inferred_attempt(mean_time_ns: float) -> int: + """One inferred attempt when a stat has measured timing but its counts were + zeroed (stale cache / old merge) — the shared fallback rule that keeps + `_accuracy` and `_effective_attempt_count` aligned.""" + return 1 if mean_time_ns > 0 else 0 + + def _accuracy(stats: HasConfidenceFields) -> float: """Fraction of attempts that were correct. 0.0 for a stat with no correct attempts yet, including one that's been missed but never hit.""" samples = stats.samples - if samples <= 0 and stats.mean_time_ns > 0: - samples = 1 + if samples <= 0: + samples = _inferred_attempt(stats.mean_time_ns) total = samples + stats.error_count return samples / total if total > 0 else 0.0 @@ -98,9 +105,7 @@ def _effective_attempt_count( inferred = samples + error_count if inferred > 0: return inferred - if mean_time_ns > 0: - return 1 - return 0 + return _inferred_attempt(mean_time_ns) def _attempts(stats: HasConfidenceFields) -> int: diff --git a/src/keystrike/domain/null_adapters.py b/src/keystrike/domain/null_adapters.py index 55e2b86..a4f9931 100644 --- a/src/keystrike/domain/null_adapters.py +++ b/src/keystrike/domain/null_adapters.py @@ -47,14 +47,6 @@ def __call__(self, layout: str) -> dict[int, KeyStats]: NULL_AGGREGATES_ENSURER = NullAggregatesEnsurer() -class NullLearningRateEstimator: - def __call__(self, layout: str, codepoint: int) -> int | None: - return None - - -NULL_LEARNING_RATE_ESTIMATOR = NullLearningRateEstimator() - - class NullDailyLearnBudgetProvider: def __call__(self, *, extra_ns: int = 0) -> DailyLearnBudget: return compute_daily_learn_budget(completed_ns=0, limit_minutes=0, extra_ns=extra_ns) diff --git a/src/keystrike/domain/protocols.py b/src/keystrike/domain/protocols.py index 6df1993..73796b3 100644 --- a/src/keystrike/domain/protocols.py +++ b/src/keystrike/domain/protocols.py @@ -70,12 +70,6 @@ def cached_word_count(self, url: str) -> int | None: ... def download_and_cache(self, url: str) -> list[str]: ... -class LearningRateEstimator(Protocol): - """Shape of `application.stats_use_cases.GetLearningRate`.""" - - def __call__(self, layout: str, codepoint: int) -> int | None: ... - - class DailyLearnBudgetProvider(Protocol): """Shape of `application.learn_budget_use_cases.GetDailyLearnBudget`.""" diff --git a/src/keystrike/domain/session.py b/src/keystrike/domain/session.py index e6f4714..aa99d93 100644 --- a/src/keystrike/domain/session.py +++ b/src/keystrike/domain/session.py @@ -85,6 +85,13 @@ def is_typing_idle( return now_ns - session.last_keystroke_at_ns >= idle_pause_ns +def session_accuracy(session: Session) -> float: + """Running accuracy for the in-progress session (1.0 before any keystroke).""" + if session.total_count == 0: + return 1.0 + return session.correct_count / session.total_count + + @dataclass(slots=True) class Session: id: str diff --git a/src/keystrike/infrastructure/aggregates_cache.py b/src/keystrike/infrastructure/aggregates_cache.py index 5c20eba..e20ab61 100644 --- a/src/keystrike/infrastructure/aggregates_cache.py +++ b/src/keystrike/infrastructure/aggregates_cache.py @@ -7,7 +7,11 @@ from dataclasses import asdict from pathlib import Path -from keystrike.domain.aggregate import without_same_key_transitions +from keystrike.domain.aggregate import ( + infer_key_stat_attempt_count, + infer_key_stat_samples, + without_same_key_transitions, +) from keystrike.domain.models import Bigram, KeyStats, LayoutAggregates, TransitionStats from .atomic_write import atomic_write_text @@ -17,19 +21,15 @@ def _coerce_samples(entry: dict[str, object]) -> int: samples = require_int(entry, "samples") - if samples <= 0 and require_float(entry, "mean_time_ns") > 0: - return 1 - return samples + mean_time_ns = require_float(entry, "mean_time_ns") + return infer_key_stat_samples(samples, mean_time_ns) def _coerce_attempt_count(entry: dict[str, object]) -> int: samples = _coerce_samples(entry) errors = require_int(entry, "error_count") - inferred = samples + errors - stored = require_int(entry, "attempt_count", inferred) - if stored <= 0 and inferred > 0: - return inferred - return stored + stored = require_int(entry, "attempt_count", samples + errors) + return infer_key_stat_attempt_count(samples, errors, stored) class FileAggregatesCache: diff --git a/src/keystrike/infrastructure/atomic_write.py b/src/keystrike/infrastructure/atomic_write.py index e570a66..5112e92 100644 --- a/src/keystrike/infrastructure/atomic_write.py +++ b/src/keystrike/infrastructure/atomic_write.py @@ -3,14 +3,17 @@ from __future__ import annotations import os +import tempfile from pathlib import Path def atomic_write_text(path: Path, contents: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") + fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + tmp = Path(tmp_name) try: - tmp.write_text(contents, encoding="utf-8") + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(contents) except BaseException: tmp.unlink(missing_ok=True) raise diff --git a/src/keystrike/infrastructure/session_repo_jsonl.py b/src/keystrike/infrastructure/session_repo_jsonl.py index dee6974..f50115c 100644 --- a/src/keystrike/infrastructure/session_repo_jsonl.py +++ b/src/keystrike/infrastructure/session_repo_jsonl.py @@ -29,8 +29,9 @@ def __init__(self, paths: Paths) -> None: self._paths = paths # In-memory index maps session_id → header so load_keystrokes can locate # the file without re-reading index.jsonl. Populated by save_header and - # (lazily) by iter_headers. + # by _ensure_index (called explicitly, not as an incidental read side effect). self._session_index: dict[str, SessionResult] = {} + self._indexed = False def append_keystroke(self, session_id: str, started_at: float, k: Keystroke) -> None: file = self._paths.sessions_dir / _month_dir(started_at) / f"{session_id}.jsonl" @@ -61,6 +62,10 @@ def iter_headers(self, layout: str) -> Iterator[SessionResult]: yield header def iter_all_headers(self) -> Iterator[SessionResult]: + yield from self._parsed_headers() + self._indexed = True + + def _parsed_headers(self) -> Iterator[SessionResult]: if not self._paths.sessions_index.exists(): return with self._paths.sessions_index.open("r", encoding="utf-8") as fh: @@ -68,12 +73,25 @@ def iter_all_headers(self) -> Iterator[SessionResult]: line = raw.strip() if not line: continue - data = json.loads(line) - header = _header_from_dict(data) + try: + header = _header_from_dict(json.loads(line)) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + # Skip a corrupt/truncated index row rather than aborting + # every session recorded after it. + continue self._session_index[header.session_id] = header yield header + def _ensure_index(self) -> None: + """Populate the session-id → header index from disk, once.""" + if self._indexed: + return + for _ in self._parsed_headers(): + pass + self._indexed = True + def load_keystrokes(self, session_id: str) -> Iterator[Keystroke]: + self._ensure_index() header = self._session_index.get(session_id) if header is None: # Fall back: scan all month directories for the ulid. @@ -130,8 +148,18 @@ def _header_to_dict(h: SessionResult) -> dict[str, object]: } -def _parse_mode(raw: object) -> Mode: - return migrate_legacy_mode(str(raw)) +def _parse_mode(raw: str) -> Mode: + return migrate_legacy_mode(raw) + + +def _require_int_tuple( + d: dict[str, object], key: str, default: tuple[int, ...] = () +) -> tuple[int, ...]: + raw = d.get(key, default) + if not isinstance(raw, (list, tuple)): + raise TypeError(f"expected list/tuple for {key!r}, got {type(raw).__name__}: {raw!r}") + items = cast("list[object] | tuple[object, ...]", raw) + return tuple(coerce_int(v, label=f"{key} element") for v in items) def _parse_key_confidence(raw: object) -> dict[int, float]: @@ -153,14 +181,14 @@ def _header_from_dict(d: dict[str, object]) -> SessionResult: started_at=require_float(d, "started_at"), duration_ns=require_int(d, "duration_ns"), layout=require_str(d, "layout"), - mode=_parse_mode(d["mode"]), - lesson_alphabet=tuple(d["lesson_alphabet"]), # type: ignore[arg-type] + mode=_parse_mode(require_str(d, "mode")), + lesson_alphabet=_require_int_tuple(d, "lesson_alphabet"), focus_key=require_int(d, "focus_key") if d.get("focus_key") is not None else None, total_keystrokes=require_int(d, "total_keystrokes"), correct_keystrokes=require_int(d, "correct_keystrokes"), words_completed=require_int(d, "words_completed", 0), lang=require_str(d, "lang", "en"), - unlocked_keys=tuple(d.get("unlocked_keys", ())), # type: ignore[arg-type] + unlocked_keys=_require_int_tuple(d, "unlocked_keys"), key_confidence=_parse_key_confidence(d.get("key_confidence", {})), target_speed_cpm=require_int(d, "target_speed_cpm", 0), ) diff --git a/src/keystrike/infrastructure/settings_repo_toml.py b/src/keystrike/infrastructure/settings_repo_toml.py index eddeef3..9b1ad5e 100644 --- a/src/keystrike/infrastructure/settings_repo_toml.py +++ b/src/keystrike/infrastructure/settings_repo_toml.py @@ -27,6 +27,23 @@ def _fmt_scalar(v: object) -> str: raise TypeError(f"unsupported settings value type: {type(v).__name__}") +def _coerce_field(default: object, raw_value: object) -> object: + # Dispatches on the *default value's* runtime type rather than the + # dataclass field's declared type, so this stays correct regardless of + # whether Settings uses postponed annotation evaluation. + if isinstance(default, TargetSpeedUnit): + return TargetSpeedUnit(str(raw_value)) + if isinstance(default, bool): + return bool(raw_value) + if isinstance(default, int): + return int(raw_value) # type: ignore[call-overload] + if isinstance(default, float): + return float(raw_value) # type: ignore[arg-type] + if isinstance(default, str): + return str(raw_value) + raise AssertionError(f"unsupported settings field type: {type(default)!r}") + + class TomlSettingsRepository: def __init__(self, paths: Paths) -> None: self._paths = paths @@ -51,21 +68,12 @@ def load(self) -> Settings: values[f.name] = str(raw_updated) if raw_updated is not None else None continue default = getattr(defaults, f.name) - if f.type is TargetSpeedUnit: - try: - values[f.name] = TargetSpeedUnit(str(raw.get(f.name, default))) - except ValueError: - values[f.name] = default - elif f.type is bool: - values[f.name] = bool(raw.get(f.name, default)) - elif f.type is int: - values[f.name] = int(raw.get(f.name, default)) - elif f.type is float: - values[f.name] = float(raw.get(f.name, default)) - elif f.type is str: - values[f.name] = str(raw.get(f.name, default)) - else: - raise TypeError(f"unsupported settings field type: {f.type!r}") + try: + values[f.name] = _coerce_field(default, raw.get(f.name, default)) + except (ValueError, TypeError): + # A hand-edited or stale settings.toml shouldn't be able to crash + # startup — fall back to this field's default and keep the rest. + values[f.name] = default # `values` is built dynamically off `dataclasses.fields(Settings)`, so # pyright can't statically match each entry to its declared parameter # type the way it could with a hand-written call — the per-field diff --git a/src/keystrike/infrastructure/sync_git.py b/src/keystrike/infrastructure/sync_git.py index 00d5d6d..8461938 100644 --- a/src/keystrike/infrastructure/sync_git.py +++ b/src/keystrike/infrastructure/sync_git.py @@ -39,7 +39,27 @@ class GitSyncError(RuntimeError): """Raised when a git subprocess used for backup sync fails to run — - either it hung past the timeout or the `git` binary isn't installed.""" + it hung past the timeout, the `git` binary isn't installed, or the + command itself exited non-zero (auth failure, network error, ...).""" + + def __init__(self, message: str, *, stderr: str = "") -> None: + super().__init__(message) + self.stderr = stderr + + +# Substrings of `git clone` stderr that indicate the remote simply doesn't +# have any content yet (fresh bare repo, not-yet-created path) rather than a +# real failure — safe to fall back to `git init` + `git remote add` for. +_CLONE_MISSING_REMOTE_MARKERS = ( + "does not exist", + "repository not found", + "not appear to be a git repository", +) + + +def _is_missing_remote_error(stderr: str) -> bool: + lowered = stderr.lower() + return any(marker in lowered for marker in _CLONE_MISSING_REMOTE_MARKERS) @dataclass(frozen=True, slots=True) @@ -161,11 +181,73 @@ def copy_file_if_exists(src: Path, dest: Path) -> bool: return True +class GitClient: + """Thin subprocess wrapper around the `git` CLI. + + Isolates raw process plumbing so `GitSyncGateway`'s merge/sync + orchestration can be unit-tested against a fake client instead of + shelling out to real git. + """ + + def __init__(self, timeout_s: float = _GIT_TIMEOUT_S) -> None: + self._timeout_s = timeout_s + + def run(self, *args: str, cwd: Path | None = None) -> str: + try: + result = subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + timeout=self._timeout_s, + ) + except subprocess.TimeoutExpired as exc: + raise GitSyncError( + f"git {args[0]} timed out after {self._timeout_s}s " + "(hung waiting on network/credentials?)", + ) from exc + except FileNotFoundError as exc: + raise GitSyncError("git executable not found — is git installed?") from exc + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or "").strip() + raise GitSyncError(f"git {args[0]} failed: {stderr or exc}", stderr=stderr) from exc + return result.stdout + + def clone(self, url: str, dest: Path) -> None: + self.run("clone", url, str(dest)) + + def init(self, cwd: Path) -> None: + self.run("init", cwd=cwd) + + def add_remote(self, cwd: Path, url: str) -> None: + self.run("remote", "add", "origin", url, cwd=cwd) + + def pull_ff_only(self, cwd: Path) -> None: + self.run("pull", "--ff-only", cwd=cwd) + + def add(self, cwd: Path, *paths: str) -> None: + self.run("add", *paths, cwd=cwd) + + def commit(self, cwd: Path, message: str) -> None: + self.run("commit", "-m", message, cwd=cwd) + + def push(self, cwd: Path) -> None: + self.run("push", cwd=cwd) + + def status_porcelain(self, cwd: Path) -> str: + return self.run("status", "--porcelain", cwd=cwd) + + def status_short(self, cwd: Path) -> str: + return self.run("status", "--short", cwd=cwd) + + class GitSyncGateway: - def __init__(self, paths: Paths) -> None: + def __init__(self, paths: Paths, *, client: GitClient | None = None) -> None: self._paths = paths self._config_path = paths.sync_config_file self._clone_dir = paths.sync_clone_dir + self._client = client or GitClient() @property def clone_settings(self) -> Path: @@ -244,27 +326,29 @@ def _clone(self, url: str) -> None: if self._clone_dir.exists(): shutil.rmtree(self._clone_dir) try: - _git("clone", url, str(self._clone_dir)) - except subprocess.CalledProcessError: - self._clone_dir.mkdir(parents=True) - _git("init", cwd=self._clone_dir) - _git("remote", "add", "origin", url, cwd=self._clone_dir) + self._client.clone(url, self._clone_dir) + except GitSyncError as exc: + if not _is_missing_remote_error(exc.stderr): + raise + self._clone_dir.mkdir(parents=True, exist_ok=True) + self._client.init(self._clone_dir) + self._client.add_remote(self._clone_dir, url) def _pull_remote(self) -> None: - _git("pull", "--ff-only", cwd=self._clone_dir) + self._client.pull_ff_only(self._clone_dir) def _push_remote(self, message: str = "keystrike sync") -> bool: - _git("add", *_SYNC_REL_PATHS, cwd=self._clone_dir) - if not _git("status", "--porcelain", cwd=self._clone_dir).strip(): + self._client.add(self._clone_dir, *_SYNC_REL_PATHS) + if not self._client.status_porcelain(self._clone_dir).strip(): return False - _git("commit", "-m", message, cwd=self._clone_dir) - _git("push", cwd=self._clone_dir) + self._client.commit(self._clone_dir, message) + self._client.push(self._clone_dir) return True def _status_text(self) -> str: if not self._clone_dir.exists(): return "clone missing" - return _git("status", "--short", cwd=self._clone_dir) + return self._client.status_short(self._clone_dir) def _require_configured(self) -> None: if not self.is_configured(): @@ -314,23 +398,3 @@ def _rebuild_all_layouts(self, rebuild: StatsRebuilder) -> None: layouts = iter_layouts_from_index(self._paths.sessions_index) for layout in sorted(layouts): rebuild(layout) - - -def _git(*args: str, cwd: Path | None = None) -> str: - try: - result = subprocess.run( - ["git", *args], - cwd=cwd, - check=True, - capture_output=True, - text=True, - timeout=_GIT_TIMEOUT_S, - ) - except subprocess.TimeoutExpired as exc: - raise GitSyncError( - f"git {args[0]} timed out after {_GIT_TIMEOUT_S}s " - "(hung waiting on network/credentials?)", - ) from exc - except FileNotFoundError as exc: - raise GitSyncError("git executable not found — is git installed?") from exc - return result.stdout diff --git a/src/keystrike/presentation/formatting/trends.py b/src/keystrike/presentation/formatting/trends.py index 42708a1..09b8caa 100644 --- a/src/keystrike/presentation/formatting/trends.py +++ b/src/keystrike/presentation/formatting/trends.py @@ -193,7 +193,26 @@ def _default_metric_value(value: float) -> str: _GRID_VALUE_WIDTH = 8 -def _format_metric_trend_line( +def _assemble_metric_line( + label_text: str, + session_part: str, + spark_text: str, + values_part: str, + *, + color: str, + suffix: str, +) -> str: + line = ( + f"[bold {color}]{label_text}[/]{session_part}" + f"{_colored_sparkline(spark_text, color)} " + f"[dim {color}]{values_part}[/]" + ) + if suffix: + line += f" {suffix}" + return line + + +def _format_metric_trend_line_inline( label: str, color: str, values: Sequence[float], @@ -201,48 +220,58 @@ def _format_metric_trend_line( *, format_value: Callable[[float], str] | None = None, suffix: str = "", - grid: bool = False, - spark_width: int = 20, ) -> str: + """One metric per line, session count inline, sparkline unpadded.""" if not values: return "" - # `grid` is the single mode switch: grid layout packs sparklines/values into - # fixed-width columns for side-by-side blocks and omits the session count - # (the block header already shows it); inline layout does the opposite. fmt = format_value or _default_metric_value - label_text = label.ljust(_GRID_LABEL_WIDTH) if grid else label - spark_text = spark.ljust(spark_width) if grid else spark - session_part = " " if grid else f" ({len(values)} sessions) " - latest_str = fmt(values[-1]) - peak_str = fmt(max(values)) - if grid: - values_part = ( - f"latest {latest_str:>{_GRID_VALUE_WIDTH}} peak {peak_str:>{_GRID_VALUE_WIDTH}}" - ) - else: - values_part = f"latest {latest_str} peak {peak_str}" - line = ( - f"[bold {color}]{label_text}[/]{session_part}" - f"{_colored_sparkline(spark_text, color)} " - f"[dim {color}]{values_part}[/]" + session_part = f" ({len(values)} sessions) " + values_part = f"latest {fmt(values[-1])} peak {fmt(max(values))}" + return _assemble_metric_line( + label, session_part, spark, values_part, color=color, suffix=suffix ) - if suffix: - line += f" {suffix}" - return line -def format_confidence_trend_line( +def _format_metric_trend_line_grid( + label: str, + color: str, values: Sequence[float], + spark: str, *, - grid: bool = False, + format_value: Callable[[float], str] | None = None, + suffix: str = "", spark_width: int = 20, ) -> str: - return _format_metric_trend_line( + """Fixed-width columns for side-by-side blocks; session count omitted + (the enclosing block header already shows it).""" + if not values: + return "" + fmt = format_value or _default_metric_value + label_text = label.ljust(_GRID_LABEL_WIDTH) + spark_text = spark.ljust(spark_width) + latest_str = fmt(values[-1]) + peak_str = fmt(max(values)) + values_part = f"latest {latest_str:>{_GRID_VALUE_WIDTH}} peak {peak_str:>{_GRID_VALUE_WIDTH}}" + return _assemble_metric_line( + label_text, " ", spark_text, values_part, color=color, suffix=suffix + ) + + +def format_confidence_trend_line(values: Sequence[float]) -> str: + return _format_metric_trend_line_inline( + "confidence", + STYLE_TREND_CONFIDENCE, + values, + value_sparkline(values), + ) + + +def _format_confidence_trend_line_grid(values: Sequence[float], *, spark_width: int = 20) -> str: + return _format_metric_trend_line_grid( "confidence", STYLE_TREND_CONFIDENCE, values, value_sparkline(values), - grid=grid, spark_width=spark_width, ) @@ -254,12 +283,7 @@ def format_key_confidence_trend_line( limit: int = 20, current_target_speed_cpm: int = 0, cumulative: float | None = None, - grid: bool = False, - spark_width: int = 20, ) -> str: - # In grid layout the enclosing block's title already names the key, so the - # line itself drops the key name to avoid repeating it; inline layout has - # no such title, so the line names the key itself. ordered = _recent(headers, limit) if not ordered: return "" @@ -276,51 +300,95 @@ def format_key_confidence_trend_line( current_target_speed_cpm=current_target_speed_cpm, ) char = char_label(codepoint) - label = "confidence" if grid else f"'{char}' confidence" suffix = "" if cumulative is not None: suffix = f"[dim {STYLE_TREND_CONFIDENCE}]cumulative {cumulative:.2f}[/]" - return _format_metric_trend_line( - label, + return _format_metric_trend_line_inline( + f"'{char}' confidence", STYLE_TREND_CONFIDENCE, values, spark, suffix=suffix, - grid=grid, - spark_width=spark_width, ) -def format_key_speed_trend_line( - values: Sequence[float], +def _format_key_confidence_trend_line_grid( + headers: Sequence[SessionResult], + codepoint: int, *, - grid: bool = False, + limit: int = 20, + current_target_speed_cpm: int = 0, + cumulative: float | None = None, spark_width: int = 20, ) -> str: - return _format_metric_trend_line( + # The enclosing block's title already names the key, so the line itself + # drops the key name to avoid repeating it. + ordered = _recent(headers, limit) + if not ordered: + return "" + values = key_confidence_values( + headers, + codepoint, + limit=limit, + current_target_speed_cpm=current_target_speed_cpm, + ) + spark = key_confidence_sparkline( + headers, + codepoint, + limit=limit, + current_target_speed_cpm=current_target_speed_cpm, + ) + suffix = "" + if cumulative is not None: + suffix = f"[dim {STYLE_TREND_CONFIDENCE}]cumulative {cumulative:.2f}[/]" + return _format_metric_trend_line_grid( + "confidence", + STYLE_TREND_CONFIDENCE, + values, + spark, + suffix=suffix, + spark_width=spark_width, + ) + + +def format_key_speed_trend_line(values: Sequence[float]) -> str: + return _format_metric_trend_line_inline( + "speed", + STYLE_TREND_SPEED, + values, + value_sparkline(values), + ) + + +def _format_key_speed_trend_line_grid(values: Sequence[float], *, spark_width: int = 20) -> str: + return _format_metric_trend_line_grid( "speed", STYLE_TREND_SPEED, values, value_sparkline(values), - grid=grid, spark_width=spark_width, ) -def format_key_accuracy_trend_line( - values: Sequence[float], - *, - grid: bool = False, - spark_width: int = 20, -) -> str: +def format_key_accuracy_trend_line(values: Sequence[float]) -> str: + pct_values = [v * 100 for v in values] + return _format_metric_trend_line_inline( + "accuracy", + STYLE_TREND_ACCURACY, + pct_values, + value_sparkline(pct_values), + format_value=lambda v: f"{v:.1f}%", + ) + + +def _format_key_accuracy_trend_line_grid(values: Sequence[float], *, spark_width: int = 20) -> str: pct_values = [v * 100 for v in values] - return _format_metric_trend_line( + return _format_metric_trend_line_grid( "accuracy", STYLE_TREND_ACCURACY, pct_values, value_sparkline(pct_values), format_value=lambda v: f"{v:.1f}%", - grid=grid, spark_width=spark_width, ) @@ -332,7 +400,6 @@ def _assemble_trend_block( speed_values: Sequence[float], accuracy_values: Sequence[float], *, - grid: bool, spark_width: int, ) -> str: header = f"[bold]{title}[/]" @@ -342,8 +409,8 @@ def _assemble_trend_block( lines = [ header, conf_line, - format_key_speed_trend_line(speed_values, grid=grid, spark_width=spark_width), - format_key_accuracy_trend_line(accuracy_values, grid=grid, spark_width=spark_width), + _format_key_speed_trend_line_grid(speed_values, spark_width=spark_width), + _format_key_accuracy_trend_line_grid(accuracy_values, spark_width=spark_width), ] return "\n".join(line for line in lines if line) @@ -360,17 +427,15 @@ def format_key_metric_trend_block( cumulative: float | None = None, ) -> str: """Trend block for a single key: confidence line driven by session headers.""" - grid = True spark_width = limit ordered = _recent(headers, limit) session_count = len(ordered) if ordered else max(len(speed_values), len(accuracy_values), 0) - conf_line = format_key_confidence_trend_line( + conf_line = _format_key_confidence_trend_line_grid( headers, codepoint, limit=limit, current_target_speed_cpm=current_target_speed_cpm, cumulative=cumulative, - grid=grid, spark_width=spark_width, ) return _assemble_trend_block( @@ -379,7 +444,6 @@ def format_key_metric_trend_block( conf_line, speed_values, accuracy_values, - grid=grid, spark_width=spark_width, ) @@ -393,12 +457,10 @@ def format_aggregate_metric_trend_block( limit: int = 20, ) -> str: """Trend block for layout-wide (or other pre-computed) confidence values.""" - grid = True spark_width = limit session_count = max(len(confidence_values), len(speed_values), len(accuracy_values), 0) - conf_line = format_confidence_trend_line( + conf_line = _format_confidence_trend_line_grid( confidence_values, - grid=grid, spark_width=spark_width, ) return _assemble_trend_block( @@ -407,6 +469,5 @@ def format_aggregate_metric_trend_block( conf_line, speed_values, accuracy_values, - grid=grid, spark_width=spark_width, ) diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index 5d00c2c..a0e90b3 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -16,8 +16,7 @@ StartSession, format_session_stats_line, ) -from keystrike.domain.focus import FocusReason -from keystrike.domain.models import Layout, SessionResult +from keystrike.domain.models import SessionResult from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET, NULL_STATS_REBUILDER from keystrike.domain.protocols import Clock, DailyLearnBudgetProvider, StatsRebuilder from keystrike.domain.session import leading_key_char, skip_leading_whitespace @@ -74,13 +73,7 @@ def __init__( self._get_session_baseline = get_session_baseline self._rebuild_aggregates = rebuild_aggregates self._get_daily_learn_budget = get_daily_learn_budget - self._layout_obj: Layout | None = initial.layout_obj - self._lesson_heatmap = initial.lesson_heatmap - self._focus_key = initial.focus_key - self._focus_reason: FocusReason | None = initial.focus_reason - self._focus_confidence = initial.focus_confidence - self._focus_speed = initial.focus_speed - self._focus_accuracy = initial.focus_accuracy + self._prep = initial self._kb_heatmap: KbHeatmap | None = None self._session = self._start( initial.target_text, @@ -100,13 +93,13 @@ def compose(self) -> ComposeResult: with Vertical(): yield self._hud yield self._typing_area - if self._layout_obj is not None and self._lesson_heatmap is not None: + if self._prep.layout_obj is not None and self._prep.lesson_heatmap is not None: self._kb_heatmap = KbHeatmap( - self._layout_obj, - self._lesson_heatmap, - focus=self._focus_key, + self._prep.layout_obj, + self._prep.lesson_heatmap, + focus=self._prep.focus_key, urgency=None, - focus_transition=focus_transition_pair(self._focus_reason), + focus_transition=focus_transition_pair(self._prep.focus_reason), ) yield self._kb_heatmap yield Static( @@ -119,11 +112,11 @@ def compose(self) -> ComposeResult: def _focus_note_text(self) -> str: return ( format_focus_note( - self._focus_key, - self._focus_reason, - confidence=self._focus_confidence, - speed=self._focus_speed, - accuracy=self._focus_accuracy, + self._prep.focus_key, + self._prep.focus_reason, + confidence=self._prep.focus_confidence, + speed=self._prep.focus_speed, + accuracy=self._prep.focus_accuracy, ) or "" ) @@ -179,13 +172,7 @@ def _refresh_focus_note(self) -> None: note.display = bool(text) def _begin_session(self, prep: SessionPrep) -> None: - self._layout_obj = prep.layout_obj - self._lesson_heatmap = prep.lesson_heatmap - self._focus_key = prep.focus_key - self._focus_reason = prep.focus_reason - self._focus_confidence = prep.focus_confidence - self._focus_speed = prep.focus_speed - self._focus_accuracy = prep.focus_accuracy + self._prep = prep self._session = self._start( prep.target_text, layout=prep.layout, diff --git a/src/keystrike/presentation/screens/settings.py b/src/keystrike/presentation/screens/settings.py index 0615472..4869d04 100644 --- a/src/keystrike/presentation/screens/settings.py +++ b/src/keystrike/presentation/screens/settings.py @@ -132,8 +132,9 @@ def action_clear_wordlist(self) -> None: def _collect_form_values(self) -> SettingsUpdate | None: speed_raw = self.query_one("#settings-speed", Input).value - target_speed_value = self._parse_int_field(speed_raw, "Target speed") + target_speed_value = self._parse_int_field(speed_raw) if target_speed_value is None: + self._show_error("Target speed must be an integer.") return None speed_unit_select = cast( @@ -150,16 +151,15 @@ def _collect_form_values(self) -> SettingsUpdate | None: ) alphabet_size_raw = self.query_one("#settings-alphabet-size", Input).value - alphabet_size = self._parse_int_field(alphabet_size_raw, "Number of letters") + alphabet_size = self._parse_int_field(alphabet_size_raw) if alphabet_size is None: + self._show_error("Number of letters must be an integer.") return None learn_daily_minutes_raw = self.query_one("#settings-learn-daily-minutes", Input).value - learn_daily_minutes = self._parse_int_field( - learn_daily_minutes_raw, - "Daily learn minutes", - ) + learn_daily_minutes = self._parse_int_field(learn_daily_minutes_raw) if learn_daily_minutes is None: + self._show_error("Daily learn minutes must be an integer.") return None layout_select = cast("Select[str]", self.query_one("#settings-layout", Select)) @@ -243,9 +243,9 @@ def _refresh_layout_select(self) -> None: def _show_error(self, message: str) -> None: self.query_one("#settings-error", Static).update(f"[bold red]{message}[/]") - def _parse_int_field(self, raw: str, field_label: str) -> int | None: + @staticmethod + def _parse_int_field(raw: str) -> int | None: try: return int(raw) except ValueError: - self._show_error(f"{field_label} must be an integer.") return None diff --git a/src/keystrike/presentation/textual_app.py b/src/keystrike/presentation/textual_app.py index b864945..abe89dc 100644 --- a/src/keystrike/presentation/textual_app.py +++ b/src/keystrike/presentation/textual_app.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import ClassVar from textual.app import App @@ -37,6 +38,41 @@ from keystrike.presentation.screens.stats import StatsScreen +@dataclass(frozen=True, slots=True) +class HomeServices: + cycle_layout: CycleLayout + get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET + + +@dataclass(frozen=True, slots=True) +class PracticeServices: + clock: Clock + start: StartSession + record: RecordKeystroke + finish: FinishSession + prepare_practice: PreparePracticeSession + get_session_baseline: GetSessionBaseline + rebuild_aggregates: StatsRebuilder + get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET + + +@dataclass(frozen=True, slots=True) +class StatsServices: + rebuild_aggregates: StatsRebuilder + get_heatmap: GetHeatmap + get_history: GetHistory + get_key_metric_trends: GetKeyMetricTrends + get_aggregate_metric_trends: GetAggregateMetricTrends + + +@dataclass(frozen=True, slots=True) +class SettingsServices: + update_settings: UpdateSettings + import_wordlist: ImportWordList + clear_wordlist: ClearWordList + get_wordlist_cache_status: GetWordListCacheStatus + + class KeystrikeApp(App[None]): ENABLE_COMMAND_PALETTE = False BINDINGS: ClassVar[list[BindingType]] = [QUIT] @@ -44,47 +80,21 @@ class KeystrikeApp(App[None]): def __init__( self, *, - clock: Clock, - start: StartSession, - record: RecordKeystroke, - finish: FinishSession, settings_repo: SettingsRepository, layout_repo: LayoutRepository, - prepare_practice: PreparePracticeSession, - get_session_baseline: GetSessionBaseline, - rebuild_aggregates: StatsRebuilder, - get_heatmap: GetHeatmap, - get_history: GetHistory, - get_key_metric_trends: GetKeyMetricTrends, - get_aggregate_metric_trends: GetAggregateMetricTrends, - cycle_layout: CycleLayout, - update_settings: UpdateSettings, - import_wordlist: ImportWordList, - clear_wordlist: ClearWordList, - get_wordlist_cache_status: GetWordListCacheStatus, - get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET, + home: HomeServices, + practice: PracticeServices, + stats: StatsServices, + settings: SettingsServices, app_version: str = "", ) -> None: super().__init__() - self._clock = clock - self._start = start - self._record = record - self._finish = finish self._settings_repo = settings_repo self._layout_repo = layout_repo - self._prepare_practice = prepare_practice - self._get_session_baseline = get_session_baseline - self._rebuild_aggregates = rebuild_aggregates - self._get_heatmap = get_heatmap - self._get_history = get_history - self._get_key_metric_trends = get_key_metric_trends - self._get_aggregate_metric_trends = get_aggregate_metric_trends - self._cycle_layout = cycle_layout - self._update_settings = update_settings - self._import_wordlist = import_wordlist - self._clear_wordlist = clear_wordlist - self._get_wordlist_cache_status = get_wordlist_cache_status - self._get_daily_learn_budget = get_daily_learn_budget + self._home = home + self._practice = practice + self._stats = stats + self._settings = settings self._app_version = app_version def on_mount(self) -> None: @@ -94,26 +104,26 @@ def on_mount(self) -> None: def _build_home(self) -> HomeScreen: return HomeScreen( settings_repo=self._settings_repo, - cycle_layout=self._cycle_layout, - get_daily_learn_budget=self._get_daily_learn_budget, + cycle_layout=self._home.cycle_layout, + get_daily_learn_budget=self._home.get_daily_learn_budget, app_version=self._app_version, ) def on_home_screen_start_practice(self, _: HomeScreen.StartPractice) -> None: - initial = self._prepare_practice() + initial = self._practice.prepare_practice() if initial is None: return practice = PracticeScreen( - start=self._start, - record=self._record, - finish=self._finish, - clock=self._clock, + start=self._practice.start, + record=self._practice.record, + finish=self._practice.finish, + clock=self._practice.clock, initial=initial, - prepare_next=self._prepare_practice, - get_session_baseline=self._get_session_baseline, - rebuild_aggregates=self._rebuild_aggregates, - get_daily_learn_budget=self._get_daily_learn_budget, + prepare_next=self._practice.prepare_practice, + get_session_baseline=self._practice.get_session_baseline, + rebuild_aggregates=self._practice.rebuild_aggregates, + get_daily_learn_budget=self._practice.get_daily_learn_budget, ) self.push_screen(practice) @@ -123,11 +133,11 @@ def on_home_screen_open_stats(self, _: HomeScreen.OpenStats) -> None: StatsScreen( layout=settings.layout, layout_repo=self._layout_repo, - rebuild_aggregates=self._rebuild_aggregates, - get_heatmap=self._get_heatmap, - get_history=self._get_history, - get_key_metric_trends=self._get_key_metric_trends, - get_aggregate_metric_trends=self._get_aggregate_metric_trends, + rebuild_aggregates=self._stats.rebuild_aggregates, + get_heatmap=self._stats.get_heatmap, + get_history=self._stats.get_history, + get_key_metric_trends=self._stats.get_key_metric_trends, + get_aggregate_metric_trends=self._stats.get_aggregate_metric_trends, current_target_speed_cpm=settings.target_speed_cpm, confidence_session_window=settings.confidence_session_window, ) @@ -138,10 +148,10 @@ def on_home_screen_open_settings(self, _: HomeScreen.OpenSettings) -> None: SettingsScreen( settings_repo=self._settings_repo, layout_repo=self._layout_repo, - update_settings=self._update_settings, - import_wordlist=self._import_wordlist, - clear_wordlist=self._clear_wordlist, - get_wordlist_cache_status=self._get_wordlist_cache_status, + update_settings=self._settings.update_settings, + import_wordlist=self._settings.import_wordlist, + clear_wordlist=self._settings.clear_wordlist, + get_wordlist_cache_status=self._settings.get_wordlist_cache_status, ) ) diff --git a/src/keystrike/presentation/widgets/hud.py b/src/keystrike/presentation/widgets/hud.py index 1c599df..ea51355 100644 --- a/src/keystrike/presentation/widgets/hud.py +++ b/src/keystrike/presentation/widgets/hud.py @@ -6,7 +6,12 @@ from keystrike.domain.focus import FocusReason from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET from keystrike.domain.protocols import Clock, DailyLearnBudgetProvider -from keystrike.domain.session import Session, active_typing_duration_ns, is_typing_idle +from keystrike.domain.session import ( + Session, + active_typing_duration_ns, + is_typing_idle, + session_accuracy, +) from keystrike.presentation.theme import STYLE_IDLE from keystrike.presentation.widgets.kb_heatmap import focus_reason_label, focus_transition_pair @@ -41,7 +46,7 @@ def _format_hud( focus_reason: FocusReason | None = None, dim_learn: bool = True, ) -> str: - accuracy = (session.correct_count / session.total_count) if session.total_count else 1.0 + accuracy = session_accuracy(session) return ( f"Acc: [bold]{accuracy * 100:5.1f}%[/]" f"{_format_daily_learn_segment(daily_budget, dim=dim_learn)}" diff --git a/tests/infrastructure/test_session_repo_jsonl.py b/tests/infrastructure/test_session_repo_jsonl.py index 05ca622..a719658 100644 --- a/tests/infrastructure/test_session_repo_jsonl.py +++ b/tests/infrastructure/test_session_repo_jsonl.py @@ -158,6 +158,18 @@ def test_round_trip_target_speed_cpm(paths): assert headers[0].target_speed_cpm == 400 +def test_corrupt_index_line_is_skipped_not_fatal(paths): + repo = JsonlSessionRepository(paths) + repo.save_header(_header(sid="A")) + with paths.sessions_index.open("a", encoding="utf-8") as fh: + fh.write("{not valid json\n") + fh.write('{"schema_version": 1}\n') # valid JSON, missing required fields + repo.save_header(_header(sid="B")) + + headers = [h.session_id for h in JsonlSessionRepository(paths).iter_headers("qwerty")] + assert headers == ["A", "B"] + + def test_keystrokes_persisted_with_header_at_finish(paths): repo = JsonlSessionRepository(paths) header = _header(sid="S2") diff --git a/tests/infrastructure/test_settings_repo_toml.py b/tests/infrastructure/test_settings_repo_toml.py index a83cc50..cf08daf 100644 --- a/tests/infrastructure/test_settings_repo_toml.py +++ b/tests/infrastructure/test_settings_repo_toml.py @@ -80,3 +80,22 @@ def test_ignores_removed_settings_keys(paths): ) s = TomlSettingsRepository(paths).load() assert s == Settings() + + +def test_malformed_field_value_falls_back_to_default_instead_of_crashing(paths): + paths.settings_file.write_text( + 'schema_version = 1\nlayout = "qwerty"\ntarget_speed_cpm = "fast"\n', + encoding="utf-8", + ) + s = TomlSettingsRepository(paths).load() + assert s.layout == "qwerty" + assert s.target_speed_cpm == Settings().target_speed_cpm + + +def test_malformed_enum_value_falls_back_to_default(paths): + paths.settings_file.write_text( + 'schema_version = 1\ntarget_speed_unit = "not-a-unit"\n', + encoding="utf-8", + ) + s = TomlSettingsRepository(paths).load() + assert s.target_speed_unit == Settings().target_speed_unit diff --git a/tests/infrastructure/test_sync_git.py b/tests/infrastructure/test_sync_git.py index cb429f9..8b59d6a 100644 --- a/tests/infrastructure/test_sync_git.py +++ b/tests/infrastructure/test_sync_git.py @@ -5,7 +5,11 @@ import pytest +from keystrike.infrastructure.paths import Paths from keystrike.infrastructure.sync_git import ( + GitClient, + GitSyncError, + GitSyncGateway, copy_file_if_exists, copy_layouts_missing, copy_layouts_to_remote, @@ -268,3 +272,131 @@ def test_copy_file_if_exists_false_when_missing(tree: dict[str, Path]) -> None: assert copy_file_if_exists(src, dest) is False assert not dest.exists() + + +class _FakeGitClient(GitClient): + """Records calls and raises canned errors instead of shelling out to git.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, ...]] = [] + self.clone_error: GitSyncError | None = None + self.porcelain_status = " M settings.toml" + + def run(self, *args: str, cwd: Path | None = None) -> str: # pragma: no cover + raise AssertionError(f"unexpected raw run() call: {args}") + + def clone(self, url: str, dest: Path) -> None: + self.calls.append(("clone", url, str(dest))) + if self.clone_error is not None: + raise self.clone_error + + def init(self, cwd: Path) -> None: + self.calls.append(("init", str(cwd))) + + def add_remote(self, cwd: Path, url: str) -> None: + self.calls.append(("add_remote", str(cwd), url)) + + def pull_ff_only(self, cwd: Path) -> None: + self.calls.append(("pull", str(cwd))) + + def add(self, cwd: Path, *paths: str) -> None: + self.calls.append(("add", str(cwd), *paths)) + + def commit(self, cwd: Path, message: str) -> None: + self.calls.append(("commit", str(cwd), message)) + + def push(self, cwd: Path) -> None: + self.calls.append(("push", str(cwd))) + + def status_porcelain(self, cwd: Path) -> str: + self.calls.append(("status_porcelain", str(cwd))) + return self.porcelain_status + + def status_short(self, cwd: Path) -> str: + self.calls.append(("status_short", str(cwd))) + return "clean" + + +@pytest.fixture +def paths(tmp_path: Path) -> Paths: + return Paths( + config_dir=tmp_path / "config", + data_dir=tmp_path / "data", + log_dir=tmp_path / "log", + ) + + +def test_git_client_run_wraps_called_process_error(tmp_path: Path) -> None: + client = GitClient() + + with pytest.raises(GitSyncError) as exc_info: + client.run("this-is-not-a-real-git-command", cwd=tmp_path) + + assert exc_info.value.stderr + + +def test_gateway_init_falls_back_to_git_init_on_missing_remote(paths: Paths) -> None: + client = _FakeGitClient() + client.clone_error = GitSyncError( + "git clone failed: repo not found", + stderr="fatal: repository 'origin' does not exist", + ) + gateway = GitSyncGateway(paths, client=client) + + gateway._clone("origin") + + assert ("init", str(paths.sync_clone_dir)) in client.calls + assert ("add_remote", str(paths.sync_clone_dir), "origin") in client.calls + assert paths.sync_clone_dir.is_dir() + + +def test_gateway_init_reraises_real_clone_failures(paths: Paths) -> None: + client = _FakeGitClient() + client.clone_error = GitSyncError( + "git clone failed: auth denied", + stderr="fatal: Authentication failed for 'origin'", + ) + gateway = GitSyncGateway(paths, client=client) + + with pytest.raises(GitSyncError) as exc_info: + gateway._clone("origin") + + assert "Authentication failed" in exc_info.value.stderr + assert not any(call[0] == "init" for call in client.calls) + + +def test_gateway_clone_dir_survives_partial_clone(paths: Paths) -> None: + # Simulates a clone that was interrupted mid-transfer, leaving the + # destination directory partially populated before the retryable init. + paths.sync_clone_dir.mkdir(parents=True) + (paths.sync_clone_dir / "leftover").write_text("partial", encoding="utf-8") + client = _FakeGitClient() + client.clone_error = GitSyncError( + "git clone failed: repo not found", + stderr="fatal: repository 'origin' does not exist", + ) + gateway = GitSyncGateway(paths, client=client) + + gateway._clone("origin") + + assert paths.sync_clone_dir.is_dir() + + +def test_gateway_push_propagates_git_sync_error_not_raw_subprocess_error( + paths: Paths, +) -> None: + class _FailingPushClient(_FakeGitClient): + def push(self, cwd: Path) -> None: + raise GitSyncError("git push failed: non-fast-forward", stderr="rejected") + + paths.sync_clone_dir.mkdir(parents=True) + gateway = GitSyncGateway(paths, client=_FailingPushClient()) + + with pytest.raises(GitSyncError, match="non-fast-forward"): + gateway._push_remote() + + +def test_git_client_is_the_default_when_none_injected(paths: Paths) -> None: + gateway = GitSyncGateway(paths) + + assert isinstance(gateway._client, GitClient) diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index b3f3647..d12e6c5 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -35,7 +35,13 @@ from keystrike.presentation.screens.practice import PracticeScreen from keystrike.presentation.screens.settings import SettingsScreen from keystrike.presentation.screens.stats import StatsScreen -from keystrike.presentation.textual_app import KeystrikeApp +from keystrike.presentation.textual_app import ( + HomeServices, + KeystrikeApp, + PracticeServices, + SettingsServices, + StatsServices, +) from keystrike.presentation.widgets.hud import HUD from keystrike.presentation.widgets.kb_heatmap import KbHeatmap from tests.fakes import ( @@ -101,36 +107,46 @@ def _build_app( ) app = KeystrikeApp( - clock=clock, - start=StartSession(clock=clock, id_gen=id_gen), - record=RecordKeystroke(clock=clock), - finish=FinishSession( - clock=clock, - repo=session_repo, - settings_repo=settings_repo, - layout_repo=layout_repo, - ), settings_repo=settings_repo, layout_repo=layout_repo, - prepare_practice=prepare_practice, - get_session_baseline=GetSessionBaseline(repo=session_repo, settings_repo=settings_repo), - rebuild_aggregates=rebuild_aggregates, - get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo, clock=clock), - get_history=GetHistory(repo=session_repo), - get_key_metric_trends=GetKeyMetricTrends( - repo=session_repo, - settings_repo=settings_repo, + home=HomeServices( + cycle_layout=CycleLayout(settings_repo=settings_repo, layout_repo=layout_repo), + get_daily_learn_budget=get_daily_learn_budget, ), - get_aggregate_metric_trends=GetAggregateMetricTrends( - repo=session_repo, - settings_repo=settings_repo, + practice=PracticeServices( + clock=clock, + start=StartSession(clock=clock, id_gen=id_gen), + record=RecordKeystroke(clock=clock), + finish=FinishSession( + clock=clock, + repo=session_repo, + settings_repo=settings_repo, + layout_repo=layout_repo, + ), + prepare_practice=prepare_practice, + get_session_baseline=GetSessionBaseline(repo=session_repo, settings_repo=settings_repo), + rebuild_aggregates=rebuild_aggregates, + get_daily_learn_budget=get_daily_learn_budget, + ), + stats=StatsServices( + rebuild_aggregates=rebuild_aggregates, + get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo, clock=clock), + get_history=GetHistory(repo=session_repo), + get_key_metric_trends=GetKeyMetricTrends( + repo=session_repo, + settings_repo=settings_repo, + ), + get_aggregate_metric_trends=GetAggregateMetricTrends( + repo=session_repo, + settings_repo=settings_repo, + ), + ), + settings=SettingsServices( + update_settings=UpdateSettings(repo=settings_repo), + import_wordlist=ImportWordList(store=wordlist_store, settings_repo=settings_repo), + clear_wordlist=ClearWordList(settings_repo=settings_repo), + get_wordlist_cache_status=GetWordListCacheStatus(store=wordlist_store), ), - get_daily_learn_budget=get_daily_learn_budget, - cycle_layout=CycleLayout(settings_repo=settings_repo, layout_repo=layout_repo), - update_settings=UpdateSettings(repo=settings_repo), - import_wordlist=ImportWordList(store=wordlist_store, settings_repo=settings_repo), - clear_wordlist=ClearWordList(settings_repo=settings_repo), - get_wordlist_cache_status=GetWordListCacheStatus(store=wordlist_store), ) return app, clock, session_repo, settings_repo @@ -293,7 +309,7 @@ async def test_adaptive_practice_focus_note_shows_speed_and_accuracy(): practice = app.screen assert isinstance(practice, PracticeScreen) note = str(practice.query_one("#focus-note", Static).content) - if practice._focus_reason: + if practice._prep.focus_reason: assert "speed " in note assert "accuracy " in note assert "confidence " in note From 366dde1a1ef1a02ee417c856760edb9e537c9f07 Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 30 Jul 2026 16:30:48 +0300 Subject: [PATCH 21/47] refactor --- src/keystrike/app.py | 14 +- src/keystrike/application/build_lesson.py | 214 +++++++--------- .../application/session_use_cases.py | 48 +--- src/keystrike/application/stats_use_cases.py | 159 +++++++----- src/keystrike/domain/aggregate.py | 32 +-- src/keystrike/domain/confidence.py | 19 +- src/keystrike/domain/focus.py | 19 +- src/keystrike/domain/generator.py | 23 +- src/keystrike/domain/models.py | 29 ++- src/keystrike/domain/null_adapters.py | 7 +- src/keystrike/domain/protocols.py | 5 +- src/keystrike/domain/sync_merge.py | 36 ++- .../infrastructure/aggregates_cache.py | 42 ++-- src/keystrike/infrastructure/atomic_write.py | 4 + src/keystrike/infrastructure/git_client.py | 86 +++++++ .../infrastructure/session_repo_jsonl.py | 30 ++- .../infrastructure/settings_repo_toml.py | 4 +- src/keystrike/infrastructure/sync_git.py | 233 ++---------------- src/keystrike/infrastructure/sync_merge_io.py | 135 ++++++++++ .../infrastructure/wordlist_store.py | 4 + .../presentation/formatting/trends.py | 48 +++- src/keystrike/presentation/screens/home.py | 18 +- .../presentation/screens/practice.py | 56 ++--- .../presentation/screens/settings.py | 52 ++-- src/keystrike/presentation/screens/stats.py | 58 ++--- src/keystrike/presentation/services.py | 76 ++++++ src/keystrike/presentation/textual_app.py | 119 ++------- src/keystrike/presentation/theme.py | 2 + tests/application/test_build_lesson.py | 12 +- tests/application/test_session_use_cases.py | 2 +- tests/fakes.py | 8 +- tests/infrastructure/test_atomic_write.py | 54 ++++ .../infrastructure/test_session_repo_jsonl.py | 28 +++ .../infrastructure/test_settings_repo_toml.py | 15 +- tests/infrastructure/test_wordlist_store.py | 10 + tests/presentation/test_home_screen.py | 9 +- tests/presentation/test_practice_screen.py | 13 +- tests/presentation/test_settings_screen.py | 85 ++++--- tests/presentation/test_stats_screen.py | 35 +-- 39 files changed, 1026 insertions(+), 817 deletions(-) create mode 100644 src/keystrike/infrastructure/git_client.py create mode 100644 src/keystrike/infrastructure/sync_merge_io.py create mode 100644 src/keystrike/presentation/services.py create mode 100644 tests/infrastructure/test_atomic_write.py diff --git a/src/keystrike/app.py b/src/keystrike/app.py index 7e647d1..5977979 100644 --- a/src/keystrike/app.py +++ b/src/keystrike/app.py @@ -7,6 +7,7 @@ from keystrike.application.learn_budget_use_cases import GetDailyLearnBudget from keystrike.application.prepare_practice import PreparePracticeSession from keystrike.application.session_use_cases import ( + AbortSession, FinishSession, GetSessionBaseline, RecordKeystroke, @@ -38,13 +39,13 @@ from keystrike.infrastructure.settings_repo_toml import TomlSettingsRepository from keystrike.infrastructure.sync_git import GitSyncGateway from keystrike.infrastructure.wordlist_store import FileWordListStore -from keystrike.presentation.textual_app import ( +from keystrike.presentation.services import ( HomeServices, - KeystrikeApp, PracticeServices, SettingsServices, StatsServices, ) +from keystrike.presentation.textual_app import KeystrikeApp @dataclass(frozen=True, slots=True) @@ -94,6 +95,7 @@ def build() -> KeystrikeApp: settings_repo=settings_repo, layout_repo=layout_repo, ) + abort = AbortSession() get_session_baseline = GetSessionBaseline(repo=session_repo, settings_repo=settings_repo) rebuild_aggregates = RebuildAggregates( repo=session_repo, @@ -145,9 +147,8 @@ def build() -> KeystrikeApp: ) return KeystrikeApp( - settings_repo=settings_repo, - layout_repo=layout_repo, home=HomeServices( + settings_repo=settings_repo, cycle_layout=cycle_layout, get_daily_learn_budget=get_daily_learn_budget, ), @@ -156,12 +157,15 @@ def build() -> KeystrikeApp: start=start, record=record, finish=finish, + abort=abort, prepare_practice=prepare_practice, get_session_baseline=get_session_baseline, rebuild_aggregates=rebuild_aggregates, get_daily_learn_budget=get_daily_learn_budget, ), stats=StatsServices( + settings_repo=settings_repo, + layout_repo=layout_repo, rebuild_aggregates=rebuild_aggregates, get_heatmap=get_heatmap, get_history=get_history, @@ -169,6 +173,8 @@ def build() -> KeystrikeApp: get_aggregate_metric_trends=get_aggregate_metric_trends, ), settings=SettingsServices( + settings_repo=settings_repo, + layout_repo=layout_repo, update_settings=update_settings, import_wordlist=import_wordlist, clear_wordlist=clear_wordlist, diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index c44b20d..8eb07c6 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -56,17 +56,30 @@ _CONFIDENCE_GOOD = 1.0 +@dataclass(slots=True, frozen=True) +class FocusMetrics: + speed: float + accuracy: float + + +@dataclass(slots=True, frozen=True) +class FocusExplanation: + reason: FocusReason | None + speed: float | None = None + accuracy: float | None = None + + def _key_focus_metrics( focus: int, stats: Mapping[int, KeyStats], target: float, -) -> tuple[float, float]: +) -> FocusMetrics: key_stats = stats.get(focus) if key_stats is None: - return 0.0, 0.0 - return ( - round_confidence(key_confidence(target, key_stats.mean_time_ns)), - round_confidence(accuracy_of(key_stats)), + return FocusMetrics(speed=0.0, accuracy=0.0) + return FocusMetrics( + speed=round_confidence(key_confidence(target, key_stats.mean_time_ns)), + accuracy=round_confidence(accuracy_of(key_stats)), ) @@ -75,13 +88,13 @@ def _transition_focus_metrics( next_cp: int, transitions: Mapping[Bigram, TransitionStats], target: float, -) -> tuple[float, float]: +) -> FocusMetrics: t_stats = transitions.get(Bigram(prev_cp, next_cp)) if t_stats is None: - return 0.0, 0.0 - return ( - round_confidence(transition_confidence(target, t_stats.mean_time_ns)), - round_confidence(transition_accuracy_of(t_stats)), + return FocusMetrics(speed=0.0, accuracy=0.0) + return FocusMetrics( + speed=round_confidence(transition_confidence(target, t_stats.mean_time_ns)), + accuracy=round_confidence(transition_accuracy_of(t_stats)), ) @@ -105,11 +118,7 @@ def _focus_reason_for_confidence( def _resolve_focus_confidence( focus: int, focus_bigram: Bigram | None, - *, - stats: Mapping[int, KeyStats], - transitions: Mapping[Bigram, TransitionStats], - target: float, - settings: Settings, + ctx: _LessonContext, ) -> float: """The one confidence value the rest of lesson-building keys off of — computed once so weighting and the focus-reason explanation agree.""" @@ -117,29 +126,30 @@ def _resolve_focus_confidence( return transition_confidence_of( focus_bigram.prev_cp, focus_bigram.next_cp, - transitions, - target, - min_attempts=settings.min_transition_confidence_attempts, + ctx.transitions, + ctx.target, + min_attempts=ctx.settings.min_transition_confidence_attempts, ) - return confidence_of(focus, stats, target, min_attempts=settings.min_confidence_attempts) + return confidence_of( + focus, + ctx.stats, + ctx.target, + min_attempts=ctx.settings.min_confidence_attempts, + ) def _compute_focus_explanation( focus: int, focus_bigram: Bigram | None, focus_confidence: float, - *, - stats: Mapping[int, KeyStats], - transitions: Mapping[Bigram, TransitionStats], - target: float, - now: float, -) -> tuple[FocusReason | None, float | None, float | None]: + ctx: _LessonContext, +) -> FocusExplanation: """Why (if at all) today's focus is being called out, plus the raw speed/accuracy to show alongside it. Reuses `focus_confidence` rather than recomputing it (it was already needed for weighting).""" if focus_bigram is not None: - t_stats = transitions.get(focus_bigram) - urgency = review_urgency(t_stats.last_seen if t_stats else 0.0, now) + t_stats = ctx.transitions.get(focus_bigram) + urgency = review_urgency(t_stats.last_seen if t_stats else 0.0, ctx.now) reason = _focus_reason_for_confidence( focus_confidence, urgency, @@ -148,17 +158,17 @@ def _compute_focus_explanation( pair=focus_bigram, ) if reason is None: - return None, None, None - speed, accuracy = _transition_focus_metrics( + return FocusExplanation(reason=None) + metrics = _transition_focus_metrics( focus_bigram.prev_cp, focus_bigram.next_cp, - transitions, - target, + ctx.transitions, + ctx.target, ) - return reason, speed, accuracy + return FocusExplanation(reason=reason, speed=metrics.speed, accuracy=metrics.accuracy) - key_stats = stats.get(focus) - urgency = review_urgency(key_stats.last_seen if key_stats else 0.0, now) + key_stats = ctx.stats.get(focus) + urgency = review_urgency(key_stats.last_seen if key_stats else 0.0, ctx.now) reason = _focus_reason_for_confidence( focus_confidence, urgency, @@ -167,9 +177,9 @@ def _compute_focus_explanation( pair=None, ) if reason is None: - return None, None, None - speed, accuracy = _key_focus_metrics(focus, stats, target) - return reason, speed, accuracy + return FocusExplanation(reason=None) + metrics = _key_focus_metrics(focus, ctx.stats, ctx.target) + return FocusExplanation(reason=reason, speed=metrics.speed, accuracy=metrics.accuracy) @dataclass(slots=True) @@ -193,39 +203,33 @@ def heatmap(self) -> dict[int, float]: def _lesson_progress( layout_name: str, - layout: Layout, - stats: Mapping[int, KeyStats], - settings: Settings, - now: float, - *, - transitions: Mapping[Bigram, TransitionStats] | None = None, + ctx: _LessonContext, ) -> tuple[tuple[int, ...], int, LessonState, Bigram | None]: - target = target_ms_per_char(settings.target_speed_cpm) - order = keyboard_order(layout) + order = keyboard_order(ctx.layout) unlocked = compute_unlocked( order, - settings.alphabet_size, - stats, - target, - min_attempts=settings.min_confidence_attempts, - transitions=transitions, - min_transition_attempts=settings.min_transition_confidence_attempts, + ctx.settings.alphabet_size, + ctx.stats, + ctx.target, + min_attempts=ctx.settings.min_confidence_attempts, + transitions=ctx.transitions, + min_transition_attempts=ctx.settings.min_transition_confidence_attempts, ) keys_need_focus = has_weak_unlocked_key( unlocked, - stats, - target, + ctx.stats, + ctx.target, threshold=_CONFIDENCE_GOOD, - min_attempts=settings.min_confidence_attempts, + min_attempts=ctx.settings.min_confidence_attempts, ) focus_bigram: Bigram | None = None - if not keys_need_focus and transitions: + if not keys_need_focus and ctx.transitions: focus_bigram = select_focus_transition( unlocked, - transitions, - target, - now, - min_attempts=settings.min_transition_confidence_attempts, + ctx.transitions, + ctx.target, + ctx.now, + min_attempts=ctx.settings.min_transition_confidence_attempts, ) if focus_bigram is not None: @@ -233,10 +237,10 @@ def _lesson_progress( else: focus = select_focus( unlocked, - stats, - target, - now, - min_attempts=settings.min_confidence_attempts, + ctx.stats, + ctx.target, + ctx.now, + min_attempts=ctx.settings.min_confidence_attempts, ) keys = tuple( @@ -245,9 +249,9 @@ def _lesson_progress( unlocked=True, confidence=confidence_of( cp, - stats, - target, - min_attempts=settings.min_confidence_attempts, + ctx.stats, + ctx.target, + min_attempts=ctx.settings.min_confidence_attempts, ), is_focus=(cp == focus), ) @@ -256,8 +260,8 @@ def _lesson_progress( state = LessonState( layout=layout_name, keys=keys, - alphabet_size=settings.alphabet_size, - target_speed_cpm=settings.target_speed_cpm, + alphabet_size=ctx.settings.alphabet_size, + target_speed_cpm=ctx.settings.target_speed_cpm, ) return unlocked, focus, state, focus_bigram @@ -269,11 +273,7 @@ def _compute_weights( focus_confidence: float, *, unlocked: tuple[int, ...], - stats: Mapping[int, KeyStats], - transitions: Mapping[Bigram, TransitionStats], - target: float, - settings: Settings, - now: float, + ctx: _LessonContext, ) -> tuple[dict[str, float], dict[Bigram, float]]: """Per-char and per-transition sampling weights for practice-text generation, biased toward weak/stale keys and boosted further for @@ -282,27 +282,27 @@ def _compute_weights( chr(k.codepoint): practice_weight( k.confidence, urgency=review_urgency( - stats[k.codepoint].last_seen if k.codepoint in stats else 0.0, - now, + ctx.stats[k.codepoint].last_seen if k.codepoint in ctx.stats else 0.0, + ctx.now, ), ) for k in state.keys } - char_weights[chr(focus)] *= settings.focus_char_boost + char_weights[chr(focus)] *= ctx.settings.focus_char_boost transition_weights = { Bigram(prev, nxt): transition_practice_weight( transition_confidence_of( prev, nxt, - transitions, - target, - min_attempts=settings.min_transition_confidence_attempts, + ctx.transitions, + ctx.target, + min_attempts=ctx.settings.min_transition_confidence_attempts, ), urgency=review_urgency( - transitions[Bigram(prev, nxt)].last_seen - if Bigram(prev, nxt) in transitions + ctx.transitions[Bigram(prev, nxt)].last_seen + if Bigram(prev, nxt) in ctx.transitions else 0.0, - now, + ctx.now, ), ) for prev in unlocked @@ -310,12 +310,12 @@ def _compute_weights( if not is_same_key_transition(prev, nxt) } if focus_bigram is not None: - transition_weights[focus_bigram] *= settings.focus_transition_boost + transition_weights[focus_bigram] *= ctx.settings.focus_transition_boost if focus_confidence < _CONFIDENCE_GOOD: - transition_weights[focus_bigram] *= settings.focus_weak_extra_boost - char_weights[chr(focus)] *= settings.focus_weak_extra_boost + transition_weights[focus_bigram] *= ctx.settings.focus_weak_extra_boost + char_weights[chr(focus)] *= ctx.settings.focus_weak_extra_boost elif focus_confidence < _CONFIDENCE_GOOD: - char_weights[chr(focus)] *= settings.focus_weak_extra_boost + char_weights[chr(focus)] *= ctx.settings.focus_weak_extra_boost return char_weights, transition_weights @@ -341,33 +341,15 @@ class BuildLesson: def __call__(self, layout_name: str) -> Lesson: ctx = self._load_context(layout_name) - unlocked, focus, state, focus_bigram = _lesson_progress( - layout_name, - ctx.layout, - ctx.stats, - ctx.settings, - ctx.now, - transitions=ctx.transitions, - ) - focus_confidence = _resolve_focus_confidence( - focus, - focus_bigram, - stats=ctx.stats, - transitions=ctx.transitions, - target=ctx.target, - settings=ctx.settings, - ) + unlocked, focus, state, focus_bigram = _lesson_progress(layout_name, ctx) + focus_confidence = _resolve_focus_confidence(focus, focus_bigram, ctx) char_weights, transition_weights = _compute_weights( state, focus, focus_bigram, focus_confidence, unlocked=unlocked, - stats=ctx.stats, - transitions=ctx.transitions, - target=ctx.target, - settings=ctx.settings, - now=ctx.now, + ctx=ctx, ) alphabet_chars = frozenset(chr(cp) for cp in unlocked) text = self._generate_text( @@ -379,23 +361,15 @@ def __call__(self, layout_name: str) -> Lesson: transition_weights=transition_weights, ) urgency = _compute_urgency(ctx.stats, unlocked, ctx.now) - reason, focus_speed, focus_accuracy = _compute_focus_explanation( - focus, - focus_bigram, - focus_confidence, - stats=ctx.stats, - transitions=ctx.transitions, - target=ctx.target, - now=ctx.now, - ) + explanation = _compute_focus_explanation(focus, focus_bigram, focus_confidence, ctx) return Lesson( text=text, state=state, urgency=urgency, - focus_reason=reason, - focus_confidence=focus_confidence if reason else None, - focus_speed=focus_speed, - focus_accuracy=focus_accuracy, + focus_reason=explanation.reason, + focus_confidence=focus_confidence if explanation.reason else None, + focus_speed=explanation.speed, + focus_accuracy=explanation.accuracy, ) def _load_context(self, layout_name: str) -> _LessonContext: diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 5d93830..02340dc 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -215,8 +215,7 @@ def __call__(self, session: Session) -> SessionResult: key_confidence=key_confidence, target_speed_cpm=target_speed_cpm, ) - for k in session.keystrokes: - self.repo.append_keystroke(session.id, session.started_at_wall, k) + self.repo.append_keystrokes(session.id, session.started_at_wall, session.keystrokes) self.repo.save_header(result) return result @@ -271,11 +270,6 @@ def compute_accuracy(result: SessionResult) -> float: return result.correct_keystrokes / result.total_keystrokes -# Keep in sync with presentation/theme.py STYLE_DELTA_*. -_DELTA_IMPROVE_COLOR = "green" -_DELTA_REGRESS_COLOR = "red" - - def previous_session_header( repo: SessionRepository, result: SessionResult, @@ -337,43 +331,3 @@ class GetSessionBaseline: def __call__(self, result: SessionResult) -> SessionStatsBaseline | None: window = self.settings_repo.load().confidence_session_window return confidence_window_session_baseline(self.repo, result, window=window) - - -def _format_metric_delta( - current: float, - previous: float, - *, - higher_is_better: bool = True, - suffix: str = "", -) -> str: - delta = current - previous - if round(abs(delta), 1) == 0: - return "" - improved = delta > 0 if higher_is_better else delta < 0 - color = _DELTA_IMPROVE_COLOR if improved else _DELTA_REGRESS_COLOR - arrow = "↑" if delta > 0 else "↓" - return f" [{color}]{arrow}{abs(delta):.1f}{suffix}[/]" - - -def format_session_stats_line( - result: SessionResult, - *, - baseline: SessionStatsBaseline | None = None, -) -> str: - wpm = compute_wpm(result) - acc = compute_accuracy(result) * 100 - duration = result.duration_ns / 1e9 - wpm_delta = "" - acc_delta = "" - if baseline is not None: - wpm_delta = _format_metric_delta(wpm, baseline.wpm) - acc_delta = _format_metric_delta( - acc, - baseline.accuracy_pct, - suffix="%", - ) - return ( - f"Last: WPM [bold]{wpm:5.1f}[/]{wpm_delta} " - f"Acc [bold]{acc:5.1f}%[/]{acc_delta} " - f"Time [bold]{duration:5.1f}s[/]" - ) diff --git a/src/keystrike/application/stats_use_cases.py b/src/keystrike/application/stats_use_cases.py index 67c94b9..4be82ab 100644 --- a/src/keystrike/application/stats_use_cases.py +++ b/src/keystrike/application/stats_use_cases.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Iterator, Mapping from dataclasses import dataclass from keystrike.domain.aggregate import combine_sessions, per_key_deltas @@ -193,11 +193,33 @@ def _aggregate_confidence( return round_confidence(weighted_sum / total_weight) -def _metric_trends( +def _windowed_session_replays( + repo: SessionRepository, + all_headers: list[SessionResult], + window: int, + fallback_target: float, +) -> Iterator[tuple[SessionResult, Mapping[int, KeyStats], float]]: + """Replay each session in the trailing window against its own trailing + confidence window, yielding the combined per-key stats as of that session.""" + ordered = all_headers[-window:] + start_offset = len(all_headers) - len(ordered) + for rel_i, header in enumerate(ordered): + abs_i = start_offset + rel_i + window_headers = all_headers[max(0, abs_i - window + 1) : abs_i + 1] + sessions = [(h, repo.load_keystrokes(h.session_id)) for h in window_headers] + combined = combine_sessions(sessions).keys + if header.target_speed_cpm > 0: + session_target = target_ms_per_char(header.target_speed_cpm) + else: + session_target = fallback_target + yield header, combined, session_target + + +def _key_metric_trends( repo: SessionRepository, settings_repo: SettingsRepository, layout: str, - codepoint: int | None, + codepoint: int, *, current_target_speed_cpm: int = 0, ) -> tuple[list[float], list[float], list[float]]: @@ -213,61 +235,87 @@ def _metric_trends( if not all_headers: return [], [], [] - ordered = all_headers[-window:] - start_offset = len(all_headers) - len(ordered) - speeds: list[float] = [] accuracies: list[float] = [] confidences: list[float] = [] - for rel_i, header in enumerate(ordered): - abs_i = start_offset + rel_i - window_headers = all_headers[max(0, abs_i - window + 1) : abs_i + 1] - sessions = [(h, repo.load_keystrokes(h.session_id)) for h in window_headers] - combined = combine_sessions(sessions).keys + for header, combined, session_target in _windowed_session_replays( + repo, + all_headers, + window, + fallback_target, + ): + key_stats = combined.get(codepoint) + if key_stats is None or (key_stats.samples == 0 and key_stats.error_count == 0): + speeds.append(0.0) + accuracies.append(0.0) + confidences.append(0.0) + continue + raw_speed = round_confidence( + key_confidence(session_target, key_stats.mean_time_ns), + ) + speed = round_confidence( + _normalize_speed_to_current_goal( + raw_speed, + header.target_speed_cpm, + current_target_speed_cpm, + ), + ) + accuracy = round_confidence(accuracy_of(key_stats)) + confidence = confidence_of( + codepoint, + combined, + session_target, + min_attempts=min_attempts, + ) - if header.target_speed_cpm > 0: - session_target = target_ms_per_char(header.target_speed_cpm) - else: - session_target = fallback_target + speeds.append(speed) + accuracies.append(accuracy) + confidences.append(confidence) - if codepoint is not None: - key_stats = combined.get(codepoint) - if key_stats is None or (key_stats.samples == 0 and key_stats.error_count == 0): - speeds.append(0.0) - accuracies.append(0.0) - confidences.append(0.0) - continue - raw_speed = round_confidence( - key_confidence(session_target, key_stats.mean_time_ns), - ) - speed = round_confidence( - _normalize_speed_to_current_goal( - raw_speed, - header.target_speed_cpm, - current_target_speed_cpm, - ), - ) - accuracy = round_confidence(accuracy_of(key_stats)) - confidence = confidence_of( - codepoint, - combined, - session_target, - min_attempts=min_attempts, - ) - else: - speed, accuracy = _aggregate_speed_accuracy(combined, session_target) - speed = round_confidence( - _normalize_speed_to_current_goal( - speed, - header.target_speed_cpm, - current_target_speed_cpm, - ), - ) - confidence = _aggregate_confidence( - combined, - session_target, - min_attempts, - ) + return speeds, accuracies, confidences + + +def _aggregate_metric_trends( + repo: SessionRepository, + settings_repo: SettingsRepository, + layout: str, + *, + current_target_speed_cpm: int = 0, +) -> tuple[list[float], list[float], list[float]]: + settings = settings_repo.load() + window = settings.confidence_session_window + fallback_target = target_ms_per_char(settings.target_speed_cpm) + min_attempts = settings.min_confidence_attempts + + all_headers = sorted( + repo.iter_headers(layout), + key=lambda h: h.started_at, + ) + if not all_headers: + return [], [], [] + + speeds: list[float] = [] + accuracies: list[float] = [] + confidences: list[float] = [] + for header, combined, session_target in _windowed_session_replays( + repo, + all_headers, + window, + fallback_target, + ): + speed, accuracy = _aggregate_speed_accuracy(combined, session_target) + speed = round_confidence( + _normalize_speed_to_current_goal( + speed, + header.target_speed_cpm, + current_target_speed_cpm, + ), + ) + confidence = _aggregate_confidence( + combined, + session_target, + min_attempts, + ) speeds.append(speed) accuracies.append(accuracy) @@ -295,7 +343,7 @@ def __call__( *, current_target_speed_cpm: int = 0, ) -> tuple[list[float], list[float]]: - speeds, accuracies, _ = _metric_trends( + speeds, accuracies, _ = _key_metric_trends( self.repo, self.settings_repo, layout, @@ -322,11 +370,10 @@ def __call__( *, current_target_speed_cpm: int = 0, ) -> tuple[list[float], list[float], list[float]]: - speeds, accuracies, confidences = _metric_trends( + speeds, accuracies, confidences = _aggregate_metric_trends( self.repo, self.settings_repo, layout, - None, current_target_speed_cpm=current_target_speed_cpm, ) return confidences, speeds, accuracies diff --git a/src/keystrike/domain/aggregate.py b/src/keystrike/domain/aggregate.py index 317eada..2a9d248 100644 --- a/src/keystrike/domain/aggregate.py +++ b/src/keystrike/domain/aggregate.py @@ -220,18 +220,23 @@ def infer_key_stat_attempt_count(samples: int, error_count: int, attempt_count: return attempt_count +def _merge_pair(a: HasConfidenceFields, b: HasConfidenceFields) -> MergedFields: + """Unweighted merge of two same-key stats: the weight=1.0 case of + `_weighted_merge_fields`.""" + return _weighted_merge_fields([(a, 1.0), (b, 1.0)]) + + def merge_key_stats(a: KeyStats, b: KeyStats) -> KeyStats: if a.codepoint != b.codepoint: raise ValueError(f"codepoint mismatch: {a.codepoint} vs {b.codepoint}") - total = a.samples + b.samples - mean = (a.mean_time_ns * a.samples + b.mean_time_ns * b.samples) / total if total > 0 else 0.0 + merged = _merge_pair(a, b) return KeyStats( codepoint=a.codepoint, - samples=total, - mean_time_ns=mean, - error_count=a.error_count + b.error_count, - last_seen=max(a.last_seen, b.last_seen), - attempt_count=a.attempt_count + b.attempt_count, + samples=merged.samples, + mean_time_ns=merged.mean_time_ns, + error_count=merged.error_count, + last_seen=merged.last_seen, + attempt_count=merged.attempt_count, ) @@ -336,16 +341,15 @@ def merge_transition_stats(a: TransitionStats, b: TransitionStats) -> Transition raise ValueError( f"transition mismatch: {a.prev_cp}→{a.next_cp} vs {b.prev_cp}→{b.next_cp}", ) - total = a.samples + b.samples - mean = (a.mean_time_ns * a.samples + b.mean_time_ns * b.samples) / total if total > 0 else 0.0 + merged = _merge_pair(a, b) return TransitionStats( prev_cp=a.prev_cp, next_cp=a.next_cp, - samples=total, - mean_time_ns=mean, - error_count=a.error_count + b.error_count, - last_seen=max(a.last_seen, b.last_seen), - attempt_count=a.attempt_count + b.attempt_count, + samples=merged.samples, + mean_time_ns=merged.mean_time_ns, + error_count=merged.error_count, + last_seen=merged.last_seen, + attempt_count=merged.attempt_count, ) diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index b76f408..857469b 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -12,19 +12,24 @@ from collections.abc import Mapping from typing import Protocol -from .models import Bigram, KeyStats, TransitionStats +from .models import ( + CONFIDENCE_SESSION_WINDOW, + MIN_CONFIDENCE_ATTEMPTS, + MIN_TRANSITION_CONFIDENCE_ATTEMPTS, + Bigram, + KeyStats, + TransitionStats, +) + +# Re-exported for call sites/tests that reference the session-window size via +# this module rather than `domain.models` directly. +__all__ = ["CONFIDENCE_SESSION_WINDOW"] _SECONDS_PER_DAY = 86_400.0 _REVIEW_URGENCY_FULL_DAYS = 3.0 -# Confidence, unlocks, focus, and heatmap use aggregates from this many sessions. -CONFIDENCE_SESSION_WINDOW = 10 # Exponential decay per session step when merging windowed stats (most recent = 1.0). # ponytail: fixed constant; upgrade to settings if users want tunable recency bias. SESSION_RECENCY_DECAY = 0.7 -# Raw min(speed, accuracy) confidence ramps linearly until this many attempts per key. -MIN_CONFIDENCE_ATTEMPTS = 10 -# Bigrams are sparser — lower floor so transition focus reflects measured weakness. -MIN_TRANSITION_CONFIDENCE_ATTEMPTS = 4 # Confidence scores are rounded before thresholds, focus, heatmap, and UI. CONFIDENCE_DECIMALS = 2 diff --git a/src/keystrike/domain/focus.py b/src/keystrike/domain/focus.py index ef4fd45..7e5059b 100644 --- a/src/keystrike/domain/focus.py +++ b/src/keystrike/domain/focus.py @@ -17,14 +17,17 @@ review_urgency, ) from .enums import FocusKind -from .models import Bigram, KeyStats, TransitionStats - -# Defaults for Settings and generator call sites; tunable via settings.toml. -FOCUS_CHAR_BOOST = 3.0 -FOCUS_WORD_BOOST = 3.0 -FOCUS_BIGRAM_WORD_BOOST = 4.0 -FOCUS_TRANSITION_BOOST = 4.0 -FOCUS_WEAK_EXTRA_BOOST = 1.5 +from .models import ( + FOCUS_BIGRAM_WORD_BOOST, + FOCUS_WORD_BOOST, + Bigram, + KeyStats, + TransitionStats, +) + +# Re-exported for domain.generator, which imports these boost defaults from +# this module rather than `domain.models` directly. +__all__ = ["FOCUS_BIGRAM_WORD_BOOST", "FOCUS_WORD_BOOST"] _TRANSITION_KINDS = (FocusKind.TRANSITION_WEAK, FocusKind.TRANSITION_REVIEW) diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index e88d965..845dd52 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -98,9 +98,28 @@ def generate_word( ) -> str: weighting = weighting or LessonWeighting() if weighting.words: - word = self._sample_from_wordlist(weighting, wordlist_weights) - if MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN and set(word) <= alphabet: + word = self._generate_word_from_wordlist(alphabet, weighting, wordlist_weights) + if word is not None: return word + return self._generate_word_via_markov(alphabet, weighting) + + def _generate_word_from_wordlist( + self, + alphabet: frozenset[str], + weighting: LessonWeighting, + wordlist_weights: list[float] | None, + ) -> str | None: + """Sample a dictionary word, or None if it doesn't fit length/alphabet bounds.""" + word = self._sample_from_wordlist(weighting, wordlist_weights) + if MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN and set(word) <= alphabet: + return word + return None + + def _generate_word_via_markov( + self, + alphabet: frozenset[str], + weighting: LessonWeighting, + ) -> str: word = "" for _ in range(MAX_RETRIES): word = self._sample_word(alphabet, weighting) diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index c9752d3..ba51936 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -5,6 +5,19 @@ from .enums import Finger, Hand, Mode, TargetSpeedUnit +# Tuning defaults shared with domain.confidence / domain.focus and mirrored as +# Settings fields below. Declared here — the lowest-level domain module, with +# no dependents to create a cycle — as the single source of truth so the two +# sets of numbers can't silently drift apart. +CONFIDENCE_SESSION_WINDOW = 10 +MIN_CONFIDENCE_ATTEMPTS = 10 +MIN_TRANSITION_CONFIDENCE_ATTEMPTS = 4 +FOCUS_CHAR_BOOST = 3.0 +FOCUS_WORD_BOOST = 3.0 +FOCUS_BIGRAM_WORD_BOOST = 4.0 +FOCUS_TRANSITION_BOOST = 4.0 +FOCUS_WEAK_EXTRA_BOOST = 1.5 + @dataclass(frozen=True, slots=True) class Keystroke: @@ -139,14 +152,14 @@ class Settings: target_speed_cpm: int = 300 # ~46 wpm at typical generated word length target_speed_unit: TargetSpeedUnit = TargetSpeedUnit.WPM alphabet_size: int = 16 # letters force-unlocked from cold start - confidence_session_window: int = 10 # sessions in rolling stats for confidence - min_confidence_attempts: int = 10 # presses before key confidence reaches full weight - min_transition_confidence_attempts: int = 4 # lower floor — bigrams are sparser - focus_char_boost: float = 3.0 # char weight multiplier for focus key - focus_word_boost: float = 3.0 # wordlist/Markov boost when focus char present - focus_bigram_word_boost: float = 4.0 # word boost when focus bigram present - focus_transition_boost: float = 4.0 # transition weight multiplier for focus pair - focus_weak_extra_boost: float = 1.5 # extra multiplier when focus confidence < 1.0 + confidence_session_window: int = CONFIDENCE_SESSION_WINDOW # sessions in rolling stats + min_confidence_attempts: int = MIN_CONFIDENCE_ATTEMPTS # presses before full weight + min_transition_confidence_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS # bigrams sparser + focus_char_boost: float = FOCUS_CHAR_BOOST # char weight multiplier for focus key + focus_word_boost: float = FOCUS_WORD_BOOST # wordlist/Markov boost when focus char present + focus_bigram_word_boost: float = FOCUS_BIGRAM_WORD_BOOST # word boost when focus bigram present + focus_transition_boost: float = FOCUS_TRANSITION_BOOST # transition weight multiplier + focus_weak_extra_boost: float = FOCUS_WEAK_EXTRA_BOOST # extra multiplier when confidence < 1.0 lang: str = "en" learn_daily_minutes: int = 10 # adaptive mode daily goal (minutes); 0 = no goal wordlist_url: str = "" # non-empty + cached file → real words; else Markov diff --git a/src/keystrike/domain/null_adapters.py b/src/keystrike/domain/null_adapters.py index a4f9931..a6de197 100644 --- a/src/keystrike/domain/null_adapters.py +++ b/src/keystrike/domain/null_adapters.py @@ -8,7 +8,7 @@ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from .daily_learn import DailyLearnBudget, compute_daily_learn_budget from .models import KeyStats, Keystroke, Layout, SessionResult, Settings @@ -18,6 +18,11 @@ class NullSessionRepository: def append_keystroke(self, session_id: str, started_at: float, k: Keystroke) -> None: pass + def append_keystrokes( + self, session_id: str, started_at: float, keystrokes: Iterable[Keystroke] + ) -> None: + pass + def save_header(self, header: SessionResult) -> None: pass diff --git a/src/keystrike/domain/protocols.py b/src/keystrike/domain/protocols.py index 73796b3..9412656 100644 --- a/src/keystrike/domain/protocols.py +++ b/src/keystrike/domain/protocols.py @@ -1,4 +1,4 @@ -from collections.abc import Iterator, Mapping +from collections.abc import Iterable, Iterator, Mapping from typing import Protocol from .daily_learn import DailyLearnBudget @@ -25,6 +25,9 @@ def new_id(self) -> str: ... class SessionRepository(Protocol): def append_keystroke(self, session_id: str, started_at: float, k: Keystroke) -> None: ... + def append_keystrokes( + self, session_id: str, started_at: float, keystrokes: Iterable[Keystroke] + ) -> None: ... def save_header(self, header: SessionResult) -> None: ... def iter_headers(self, layout: str) -> Iterator[SessionResult]: ... def iter_all_headers(self) -> Iterator[SessionResult]: ... diff --git a/src/keystrike/domain/sync_merge.py b/src/keystrike/domain/sync_merge.py index 8cbd3a7..0655c84 100644 --- a/src/keystrike/domain/sync_merge.py +++ b/src/keystrike/domain/sync_merge.py @@ -13,17 +13,32 @@ import datetime as dt import tomllib +from collections.abc import Mapping, Sequence from dataclasses import dataclass +from typing import Literal -def index_session_ids(entries: list[dict[str, object]]) -> set[str]: +def _entry_str(entry: Mapping[str, object], key: str) -> str: + """Read a required string field off a parsed session-index entry.""" + return str(entry[key]) + + +def _entry_epoch(entry: Mapping[str, object], key: str) -> float: + """Read a required numeric (epoch seconds) field off a parsed session-index entry.""" + value = entry[key] + if not isinstance(value, (int, float)): + raise TypeError(f"expected numeric {key!r}, got {type(value).__name__}") + return float(value) + + +def index_session_ids(entries: Sequence[Mapping[str, object]]) -> set[str]: """Session ids present in a parsed sessions index.""" - return {str(entry["session_id"]) for entry in entries} + return {_entry_str(entry, "session_id") for entry in entries} -def index_layouts(entries: list[dict[str, object]]) -> set[str]: +def index_layouts(entries: Sequence[Mapping[str, object]]) -> set[str]: """Distinct layout names referenced by a parsed sessions index.""" - return {str(entry["layout"]) for entry in entries} + return {_entry_str(entry, "layout") for entry in entries} @dataclass(frozen=True, slots=True) @@ -39,7 +54,7 @@ class SessionImportPlan: def plan_missing_sessions( *, local_session_ids: set[str], - remote_entries: list[dict[str, object]], + remote_entries: Sequence[Mapping[str, object]], remote_lines: list[str], ) -> list[SessionImportPlan]: """Decide which remote sessions are missing locally and need importing. @@ -53,15 +68,10 @@ def plan_missing_sessions( seen = set(local_session_ids) plans: list[SessionImportPlan] = [] for entry, line in zip(remote_entries, remote_lines, strict=True): - session_id = str(entry["session_id"]) + session_id = _entry_str(entry, "session_id") if session_id in seen: continue - started_at_raw = entry["started_at"] - if not isinstance(started_at_raw, (int, float)): - raise TypeError( - f"expected numeric started_at, got {type(started_at_raw).__name__}", - ) - started_at = float(started_at_raw) + started_at = _entry_epoch(entry, "started_at") month = dt.datetime.fromtimestamp(started_at, tz=dt.UTC).strftime("%Y-%m") plans.append( SessionImportPlan( @@ -99,7 +109,7 @@ def decide_settings_winner( remote_exists: bool, local_epoch: float, remote_epoch: float, -) -> str: +) -> Literal["local", "remote", "none"]: """Which settings file should win: `'local'`, `'remote'`, or `'none'`. Callers copy the winning file over the loser: when the result is diff --git a/src/keystrike/infrastructure/aggregates_cache.py b/src/keystrike/infrastructure/aggregates_cache.py index e20ab61..7725863 100644 --- a/src/keystrike/infrastructure/aggregates_cache.py +++ b/src/keystrike/infrastructure/aggregates_cache.py @@ -25,8 +25,7 @@ def _coerce_samples(entry: dict[str, object]) -> int: return infer_key_stat_samples(samples, mean_time_ns) -def _coerce_attempt_count(entry: dict[str, object]) -> int: - samples = _coerce_samples(entry) +def _coerce_attempt_count(entry: dict[str, object], samples: int) -> int: errors = require_int(entry, "error_count") stored = require_int(entry, "attempt_count", samples + errors) return infer_key_stat_attempt_count(samples, errors, stored) @@ -48,33 +47,36 @@ def get(self, layout: str) -> LayoutAggregates | None: data = json.loads(file.read_text(encoding="utf-8")) keys = data.get("keys", {}) transitions = data.get("transitions", {}) - parsed_transitions = { - Bigram( + + def _transition(entry: dict[str, object]) -> tuple[Bigram, TransitionStats]: + samples = _coerce_samples(entry) + return Bigram( require_int(entry, "prev_cp"), require_int(entry, "next_cp"), - ): TransitionStats( + ), TransitionStats( prev_cp=require_int(entry, "prev_cp"), next_cp=require_int(entry, "next_cp"), - samples=_coerce_samples(entry), + samples=samples, mean_time_ns=require_float(entry, "mean_time_ns"), error_count=require_int(entry, "error_count"), last_seen=require_float(entry, "last_seen"), - attempt_count=_coerce_attempt_count(entry), + attempt_count=_coerce_attempt_count(entry, samples), ) - for entry in transitions.values() - } + + def _key(cp: str, entry: dict[str, object]) -> KeyStats: + samples = _coerce_samples(entry) + return KeyStats( + codepoint=int(cp), + samples=samples, + mean_time_ns=require_float(entry, "mean_time_ns"), + error_count=require_int(entry, "error_count"), + last_seen=require_float(entry, "last_seen"), + attempt_count=_coerce_attempt_count(entry, samples), + ) + + parsed_transitions = dict(_transition(entry) for entry in transitions.values()) aggregates = LayoutAggregates( - keys={ - int(cp): KeyStats( - codepoint=int(cp), - samples=_coerce_samples(entry), - mean_time_ns=require_float(entry, "mean_time_ns"), - error_count=require_int(entry, "error_count"), - last_seen=require_float(entry, "last_seen"), - attempt_count=_coerce_attempt_count(entry), - ) - for cp, entry in keys.items() - }, + keys={int(cp): _key(cp, entry) for cp, entry in keys.items()}, transitions=without_same_key_transitions(parsed_transitions), ) except (json.JSONDecodeError, KeyError, TypeError, ValueError): diff --git a/src/keystrike/infrastructure/atomic_write.py b/src/keystrike/infrastructure/atomic_write.py index 5112e92..e61ce03 100644 --- a/src/keystrike/infrastructure/atomic_write.py +++ b/src/keystrike/infrastructure/atomic_write.py @@ -9,11 +9,15 @@ def atomic_write_text(path: Path, contents: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) + mode = path.stat().st_mode & 0o777 if path.exists() else 0o644 fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") tmp = Path(tmp_name) try: + os.chmod(tmp, mode) with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write(contents) + fh.flush() + os.fsync(fh.fileno()) except BaseException: tmp.unlink(missing_ok=True) raise diff --git a/src/keystrike/infrastructure/git_client.py b/src/keystrike/infrastructure/git_client.py new file mode 100644 index 0000000..6ac90ab --- /dev/null +++ b/src/keystrike/infrastructure/git_client.py @@ -0,0 +1,86 @@ +"""Thin subprocess wrapper around the `git` CLI. + +Isolates raw process plumbing so `GitSyncGateway`'s merge/sync orchestration +(in `sync_git.py`) can be unit-tested against a fake client instead of +shelling out to real git. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +# Network-bound git ops (clone/pull/push) can otherwise hang indefinitely +# waiting on credentials, freezing the whole TUI. +_GIT_TIMEOUT_S = 30 + + +class GitSyncError(RuntimeError): + """Raised when a git subprocess used for backup sync fails to run — + it hung past the timeout, the `git` binary isn't installed, or the + command itself exited non-zero (auth failure, network error, ...).""" + + def __init__(self, message: str, *, stderr: str = "") -> None: + super().__init__(message) + self.stderr = stderr + + +class GitClient: + """Thin subprocess wrapper around the `git` CLI. + + Isolates raw process plumbing so `GitSyncGateway`'s merge/sync + orchestration can be unit-tested against a fake client instead of + shelling out to real git. + """ + + def __init__(self, timeout_s: float = _GIT_TIMEOUT_S) -> None: + self._timeout_s = timeout_s + + def run(self, *args: str, cwd: Path | None = None) -> str: + try: + result = subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + timeout=self._timeout_s, + ) + except subprocess.TimeoutExpired as exc: + raise GitSyncError( + f"git {args[0]} timed out after {self._timeout_s}s " + "(hung waiting on network/credentials?)", + ) from exc + except FileNotFoundError as exc: + raise GitSyncError("git executable not found — is git installed?") from exc + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or "").strip() + raise GitSyncError(f"git {args[0]} failed: {stderr or exc}", stderr=stderr) from exc + return result.stdout + + def clone(self, url: str, dest: Path) -> None: + self.run("clone", "--", url, str(dest)) + + def init(self, cwd: Path) -> None: + self.run("init", cwd=cwd) + + def add_remote(self, cwd: Path, url: str) -> None: + self.run("remote", "add", "origin", "--", url, cwd=cwd) + + def pull_ff_only(self, cwd: Path) -> None: + self.run("pull", "--ff-only", cwd=cwd) + + def add(self, cwd: Path, *paths: str) -> None: + self.run("add", "--", *paths, cwd=cwd) + + def commit(self, cwd: Path, message: str) -> None: + self.run("commit", "-m", message, cwd=cwd) + + def push(self, cwd: Path) -> None: + self.run("push", cwd=cwd) + + def status_porcelain(self, cwd: Path) -> str: + return self.run("status", "--porcelain", cwd=cwd) + + def status_short(self, cwd: Path) -> str: + return self.run("status", "--short", cwd=cwd) diff --git a/src/keystrike/infrastructure/session_repo_jsonl.py b/src/keystrike/infrastructure/session_repo_jsonl.py index f50115c..db10cff 100644 --- a/src/keystrike/infrastructure/session_repo_jsonl.py +++ b/src/keystrike/infrastructure/session_repo_jsonl.py @@ -5,7 +5,7 @@ import datetime as dt import json -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from pathlib import Path from typing import cast @@ -34,20 +34,26 @@ def __init__(self, paths: Paths) -> None: self._indexed = False def append_keystroke(self, session_id: str, started_at: float, k: Keystroke) -> None: + self.append_keystrokes(session_id, started_at, (k,)) + + def append_keystrokes( + self, session_id: str, started_at: float, keystrokes: Iterable[Keystroke] + ) -> None: file = self._paths.sessions_dir / _month_dir(started_at) / f"{session_id}.jsonl" file.parent.mkdir(parents=True, exist_ok=True) with file.open("a", encoding="utf-8") as fh: - fh.write( - json.dumps( - { - "codepoint": k.codepoint, - "typed": k.typed, - "t_ns": k.t_ns, - "correct": k.correct, - } + for k in keystrokes: + fh.write( + json.dumps( + { + "codepoint": k.codepoint, + "typed": k.typed, + "t_ns": k.t_ns, + "correct": k.correct, + } + ) ) - ) - fh.write("\n") + fh.write("\n") def save_header(self, header: SessionResult) -> None: self._session_index[header.session_id] = header @@ -94,6 +100,8 @@ def load_keystrokes(self, session_id: str) -> Iterator[Keystroke]: self._ensure_index() header = self._session_index.get(session_id) if header is None: + if not self._paths.sessions_dir.exists(): + return # Fall back: scan all month directories for the ulid. for month_dir in self._paths.sessions_dir.iterdir(): if not month_dir.is_dir(): diff --git a/src/keystrike/infrastructure/settings_repo_toml.py b/src/keystrike/infrastructure/settings_repo_toml.py index 9b1ad5e..19a1a46 100644 --- a/src/keystrike/infrastructure/settings_repo_toml.py +++ b/src/keystrike/infrastructure/settings_repo_toml.py @@ -34,7 +34,9 @@ def _coerce_field(default: object, raw_value: object) -> object: if isinstance(default, TargetSpeedUnit): return TargetSpeedUnit(str(raw_value)) if isinstance(default, bool): - return bool(raw_value) + if not isinstance(raw_value, bool): + raise TypeError(f"expected bool, got {type(raw_value).__name__}") + return raw_value if isinstance(default, int): return int(raw_value) # type: ignore[call-overload] if isinstance(default, float): diff --git a/src/keystrike/infrastructure/sync_git.py b/src/keystrike/infrastructure/sync_git.py index 8461938..8591ad0 100644 --- a/src/keystrike/infrastructure/sync_git.py +++ b/src/keystrike/infrastructure/sync_git.py @@ -1,51 +1,47 @@ -"""Git CLI adapter and merge orchestration for opt-in backup sync. +"""Git-backed sync orchestration for opt-in backup sync. -The actual filesystem I/O for merging (copying session files, settings, -layouts) lives here; `domain/sync_merge.py` only decides *what* to do given -already-loaded data. Each `*_execute`-style helper below reads inputs off -disk, calls the matching pure domain function, and performs the resulting -copy/write plan. +Composes `GitClient` (raw git subprocess plumbing, in `git_client.py`) with +the merge-execution I/O helpers (in `sync_merge_io.py`) to drive init/pull/push. """ from __future__ import annotations -import json import shutil -import subprocess import tomllib from dataclasses import dataclass from pathlib import Path from keystrike.domain.models import SyncStatusReport from keystrike.domain.protocols import StatsRebuilder -from keystrike.domain.sync_merge import ( - decide_settings_winner, - index_layouts, - index_session_ids, - plan_layouts_to_copy, - plan_missing_sessions, - settings_epoch_from_toml, -) +from keystrike.infrastructure.git_client import GitClient, GitSyncError from keystrike.infrastructure.paths import Paths +from keystrike.infrastructure.sync_merge_io import ( + copy_file_if_exists, + copy_layouts_missing, + copy_layouts_to_remote, + import_missing_sessions, + iter_layouts_from_index, + read_index_session_ids, + resolve_settings_lww, +) from .atomic_write import atomic_write_text -_SYNC_REL_PATHS = ("settings.toml", "layouts", "sessions") - -# Network-bound git ops (clone/pull/push) can otherwise hang indefinitely -# waiting on credentials, freezing the whole TUI. -_GIT_TIMEOUT_S = 30 - - -class GitSyncError(RuntimeError): - """Raised when a git subprocess used for backup sync fails to run — - it hung past the timeout, the `git` binary isn't installed, or the - command itself exited non-zero (auth failure, network error, ...).""" - - def __init__(self, message: str, *, stderr: str = "") -> None: - super().__init__(message) - self.stderr = stderr +__all__ = [ + "GitClient", + "GitSyncError", + "GitSyncGateway", + "SyncConfig", + "copy_file_if_exists", + "copy_layouts_missing", + "copy_layouts_to_remote", + "import_missing_sessions", + "iter_layouts_from_index", + "read_index_session_ids", + "resolve_settings_lww", +] +_SYNC_REL_PATHS = ("settings.toml", "layouts", "sessions") # Substrings of `git clone` stderr that indicate the remote simply doesn't # have any content yet (fresh bare repo, not-yet-created path) rather than a @@ -67,181 +63,6 @@ class SyncConfig: remote_url: str -def _read_index(path: Path) -> tuple[list[dict[str, object]], list[str]]: - """Load a sessions index file into parsed entries + matching raw lines.""" - if not path.is_file(): - return [], [] - entries: list[dict[str, object]] = [] - lines: list[str] = [] - with path.open(encoding="utf-8") as fh: - for raw in fh: - line = raw.strip() - if not line: - continue - entries.append(json.loads(line)) - lines.append(line) - return entries, lines - - -def read_index_session_ids(index_path: Path) -> set[str]: - entries, _ = _read_index(index_path) - return index_session_ids(entries) - - -def iter_layouts_from_index(index_path: Path) -> set[str]: - entries, _ = _read_index(index_path) - return index_layouts(entries) - - -def import_missing_sessions( - *, - local_sessions_dir: Path, - remote_sessions_dir: Path, - local_index: Path, - remote_index: Path, -) -> list[str]: - """Copy session files and append index entries present remotely but not locally.""" - local_entries, _ = _read_index(local_index) - remote_entries, remote_lines = _read_index(remote_index) - plans = plan_missing_sessions( - local_session_ids=index_session_ids(local_entries), - remote_entries=remote_entries, - remote_lines=remote_lines, - ) - if not plans: - return [] - local_sessions_dir.mkdir(parents=True, exist_ok=True) - imported: list[str] = [] - for plan in plans: - remote_file = remote_sessions_dir / plan.month / plan.filename - if not remote_file.is_file(): - continue - dest_dir = local_sessions_dir / plan.month - dest_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(remote_file, dest_dir / plan.filename) - with local_index.open("a", encoding="utf-8") as out: - out.write(plan.index_line) - out.write("\n") - imported.append(plan.session_id) - return imported - - -def _settings_epoch(path: Path) -> float: - return settings_epoch_from_toml(path.read_text(encoding="utf-8"), path.stat().st_mtime) - - -def resolve_settings_lww(*, local_path: Path, remote_path: Path) -> str: - """Copy the newer settings file to both sides. Returns 'local', 'remote', or 'none'.""" - local_exists = local_path.is_file() - remote_exists = remote_path.is_file() - winner = decide_settings_winner( - local_exists=local_exists, - remote_exists=remote_exists, - local_epoch=_settings_epoch(local_path) if local_exists else 0.0, - remote_epoch=_settings_epoch(remote_path) if remote_exists else 0.0, - ) - if winner == "remote": - local_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(remote_path, local_path) - elif winner == "local" and remote_exists: - remote_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(local_path, remote_path) - return winner - - -def copy_layouts_missing(*, local_layouts: Path, remote_layouts: Path) -> int: - """Copy remote layout TOML files missing locally.""" - if not remote_layouts.is_dir(): - return 0 - remote_names = {p.name for p in remote_layouts.glob("*.toml")} - local_names: set[str] = ( - {p.name for p in local_layouts.glob("*.toml")} if local_layouts.is_dir() else set() - ) - missing = plan_layouts_to_copy(source_names=remote_names, dest_names=local_names) - if missing: - local_layouts.mkdir(parents=True, exist_ok=True) - for name in missing: - shutil.copy2(remote_layouts / name, local_layouts / name) - return len(missing) - - -def copy_layouts_to_remote(*, local_layouts: Path, remote_layouts: Path) -> None: - if not local_layouts.is_dir(): - return - remote_layouts.mkdir(parents=True, exist_ok=True) - for src in local_layouts.glob("*.toml"): - shutil.copy2(src, remote_layouts / src.name) - - -def copy_file_if_exists(src: Path, dest: Path) -> bool: - if not src.is_file(): - return False - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, dest) - return True - - -class GitClient: - """Thin subprocess wrapper around the `git` CLI. - - Isolates raw process plumbing so `GitSyncGateway`'s merge/sync - orchestration can be unit-tested against a fake client instead of - shelling out to real git. - """ - - def __init__(self, timeout_s: float = _GIT_TIMEOUT_S) -> None: - self._timeout_s = timeout_s - - def run(self, *args: str, cwd: Path | None = None) -> str: - try: - result = subprocess.run( - ["git", *args], - cwd=cwd, - check=True, - capture_output=True, - text=True, - timeout=self._timeout_s, - ) - except subprocess.TimeoutExpired as exc: - raise GitSyncError( - f"git {args[0]} timed out after {self._timeout_s}s " - "(hung waiting on network/credentials?)", - ) from exc - except FileNotFoundError as exc: - raise GitSyncError("git executable not found — is git installed?") from exc - except subprocess.CalledProcessError as exc: - stderr = (exc.stderr or "").strip() - raise GitSyncError(f"git {args[0]} failed: {stderr or exc}", stderr=stderr) from exc - return result.stdout - - def clone(self, url: str, dest: Path) -> None: - self.run("clone", url, str(dest)) - - def init(self, cwd: Path) -> None: - self.run("init", cwd=cwd) - - def add_remote(self, cwd: Path, url: str) -> None: - self.run("remote", "add", "origin", url, cwd=cwd) - - def pull_ff_only(self, cwd: Path) -> None: - self.run("pull", "--ff-only", cwd=cwd) - - def add(self, cwd: Path, *paths: str) -> None: - self.run("add", *paths, cwd=cwd) - - def commit(self, cwd: Path, message: str) -> None: - self.run("commit", "-m", message, cwd=cwd) - - def push(self, cwd: Path) -> None: - self.run("push", cwd=cwd) - - def status_porcelain(self, cwd: Path) -> str: - return self.run("status", "--porcelain", cwd=cwd) - - def status_short(self, cwd: Path) -> str: - return self.run("status", "--short", cwd=cwd) - - class GitSyncGateway: def __init__(self, paths: Paths, *, client: GitClient | None = None) -> None: self._paths = paths diff --git a/src/keystrike/infrastructure/sync_merge_io.py b/src/keystrike/infrastructure/sync_merge_io.py new file mode 100644 index 0000000..0c2b19d --- /dev/null +++ b/src/keystrike/infrastructure/sync_merge_io.py @@ -0,0 +1,135 @@ +"""Filesystem I/O for merging synced state (session files, settings, layouts). + +`domain/sync_merge.py` only decides *what* to do given already-loaded data. +Each function below reads inputs off disk, calls the matching pure domain +function, and performs the resulting copy/write plan. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +from keystrike.domain.sync_merge import ( + decide_settings_winner, + index_layouts, + index_session_ids, + plan_layouts_to_copy, + plan_missing_sessions, + settings_epoch_from_toml, +) + + +def _read_index(path: Path) -> tuple[list[dict[str, object]], list[str]]: + """Load a sessions index file into parsed entries + matching raw lines.""" + if not path.is_file(): + return [], [] + entries: list[dict[str, object]] = [] + lines: list[str] = [] + with path.open(encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line: + continue + entries.append(json.loads(line)) + lines.append(line) + return entries, lines + + +def read_index_session_ids(index_path: Path) -> set[str]: + entries, _ = _read_index(index_path) + return index_session_ids(entries) + + +def iter_layouts_from_index(index_path: Path) -> set[str]: + entries, _ = _read_index(index_path) + return index_layouts(entries) + + +def import_missing_sessions( + *, + local_sessions_dir: Path, + remote_sessions_dir: Path, + local_index: Path, + remote_index: Path, +) -> list[str]: + """Copy session files and append index entries present remotely but not locally.""" + local_entries, _ = _read_index(local_index) + remote_entries, remote_lines = _read_index(remote_index) + plans = plan_missing_sessions( + local_session_ids=index_session_ids(local_entries), + remote_entries=remote_entries, + remote_lines=remote_lines, + ) + if not plans: + return [] + local_sessions_dir.mkdir(parents=True, exist_ok=True) + imported: list[str] = [] + for plan in plans: + remote_file = remote_sessions_dir / plan.month / plan.filename + if not remote_file.is_file(): + continue + dest_dir = local_sessions_dir / plan.month + dest_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(remote_file, dest_dir / plan.filename) + with local_index.open("a", encoding="utf-8") as out: + out.write(plan.index_line) + out.write("\n") + imported.append(plan.session_id) + return imported + + +def _settings_epoch(path: Path) -> float: + return settings_epoch_from_toml(path.read_text(encoding="utf-8"), path.stat().st_mtime) + + +def resolve_settings_lww(*, local_path: Path, remote_path: Path) -> str: + """Copy the newer settings file to both sides. Returns 'local', 'remote', or 'none'.""" + local_exists = local_path.is_file() + remote_exists = remote_path.is_file() + winner = decide_settings_winner( + local_exists=local_exists, + remote_exists=remote_exists, + local_epoch=_settings_epoch(local_path) if local_exists else 0.0, + remote_epoch=_settings_epoch(remote_path) if remote_exists else 0.0, + ) + if winner == "remote": + local_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(remote_path, local_path) + elif winner == "local" and remote_exists: + remote_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(local_path, remote_path) + return winner + + +def copy_layouts_missing(*, local_layouts: Path, remote_layouts: Path) -> int: + """Copy remote layout TOML files missing locally.""" + if not remote_layouts.is_dir(): + return 0 + remote_names = {p.name for p in remote_layouts.glob("*.toml")} + local_names: set[str] = ( + {p.name for p in local_layouts.glob("*.toml")} if local_layouts.is_dir() else set() + ) + missing = plan_layouts_to_copy(source_names=remote_names, dest_names=local_names) + if missing: + local_layouts.mkdir(parents=True, exist_ok=True) + for name in missing: + shutil.copy2(remote_layouts / name, local_layouts / name) + return len(missing) + + +def copy_layouts_to_remote(*, local_layouts: Path, remote_layouts: Path) -> None: + if not local_layouts.is_dir(): + return + remote_layouts.mkdir(parents=True, exist_ok=True) + for src in local_layouts.glob("*.toml"): + shutil.copy2(src, remote_layouts / src.name) + + +def copy_file_if_exists(src: Path, dest: Path) -> bool: + if not src.is_file(): + return False + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + return True diff --git a/src/keystrike/infrastructure/wordlist_store.py b/src/keystrike/infrastructure/wordlist_store.py index fb5d7d9..e81afd1 100644 --- a/src/keystrike/infrastructure/wordlist_store.py +++ b/src/keystrike/infrastructure/wordlist_store.py @@ -4,6 +4,7 @@ import hashlib import urllib.error +import urllib.parse import urllib.request from keystrike.domain.version import __version__ @@ -46,6 +47,9 @@ def _cache_name(self, url: str) -> str: def _download_text(url: str, max_bytes: int) -> str: + scheme = urllib.parse.urlparse(url).scheme + if scheme not in ("http", "https"): + raise ValueError(f"unsupported URL scheme {scheme!r}, expected http or https") req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) try: with urllib.request.urlopen(req, timeout=30) as resp: diff --git a/src/keystrike/presentation/formatting/trends.py b/src/keystrike/presentation/formatting/trends.py index 09b8caa..b34484e 100644 --- a/src/keystrike/presentation/formatting/trends.py +++ b/src/keystrike/presentation/formatting/trends.py @@ -9,10 +9,16 @@ from collections.abc import Callable, Sequence -from keystrike.application.session_use_cases import compute_wpm +from keystrike.application.session_use_cases import ( + SessionStatsBaseline, + compute_accuracy, + compute_wpm, +) from keystrike.domain.confidence import target_ms_per_char from keystrike.domain.models import SessionResult from keystrike.presentation.theme import ( + STYLE_DELTA_IMPROVE, + STYLE_DELTA_REGRESS, STYLE_TREND_ACCURACY, STYLE_TREND_CONFIDENCE, STYLE_TREND_SPEED, @@ -471,3 +477,43 @@ def format_aggregate_metric_trend_block( accuracy_values, spark_width=spark_width, ) + + +def _format_metric_delta( + current: float, + previous: float, + *, + higher_is_better: bool = True, + suffix: str = "", +) -> str: + delta = current - previous + if round(abs(delta), 1) == 0: + return "" + improved = delta > 0 if higher_is_better else delta < 0 + color = STYLE_DELTA_IMPROVE if improved else STYLE_DELTA_REGRESS + arrow = "↑" if delta > 0 else "↓" + return f" [{color}]{arrow}{abs(delta):.1f}{suffix}[/]" + + +def format_session_stats_line( + result: SessionResult, + *, + baseline: SessionStatsBaseline | None = None, +) -> str: + wpm = compute_wpm(result) + acc = compute_accuracy(result) * 100 + duration = result.duration_ns / 1e9 + wpm_delta = "" + acc_delta = "" + if baseline is not None: + wpm_delta = _format_metric_delta(wpm, baseline.wpm) + acc_delta = _format_metric_delta( + acc, + baseline.accuracy_pct, + suffix="%", + ) + return ( + f"Last: WPM [bold]{wpm:5.1f}[/]{wpm_delta} " + f"Acc [bold]{acc:5.1f}%[/]{acc_delta} " + f"Time [bold]{duration:5.1f}s[/]" + ) diff --git a/src/keystrike/presentation/screens/home.py b/src/keystrike/presentation/screens/home.py index f2e4dd1..89817b5 100644 --- a/src/keystrike/presentation/screens/home.py +++ b/src/keystrike/presentation/screens/home.py @@ -7,10 +7,8 @@ from textual.screen import Screen from textual.widgets import Footer, Static -from keystrike.application.settings_use_cases import CycleLayout from keystrike.domain.daily_learn import DailyLearnBudget, daily_learn_display -from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET -from keystrike.domain.protocols import DailyLearnBudgetProvider, SettingsRepository +from keystrike.presentation.services import HomeServices def _format_daily_learn_line(budget: DailyLearnBudget) -> str: @@ -70,15 +68,11 @@ class OpenSettings(Message): def __init__( self, *, - settings_repo: SettingsRepository, - cycle_layout: CycleLayout, - get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET, + services: HomeServices, app_version: str = "", ) -> None: super().__init__() - self._settings_repo = settings_repo - self._cycle_layout = cycle_layout - self._get_daily_learn_budget = get_daily_learn_budget + self._services = services self._app_version = app_version def compose(self) -> ComposeResult: @@ -89,10 +83,10 @@ def on_screen_resume(self) -> None: self._refresh_hero() def _render_hero(self) -> str: - settings = self._settings_repo.load() + settings = self._services.settings_repo.load() return _hero_text( settings.layout, - self._get_daily_learn_budget(), + self._services.get_daily_learn_budget(), app_version=self._app_version, ) @@ -109,5 +103,5 @@ def action_open_settings(self) -> None: self.post_message(self.OpenSettings()) def action_cycle_layout(self) -> None: - self._cycle_layout() + self._services.cycle_layout() self._refresh_hero() diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index a0e90b3..7c78f1d 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -7,20 +7,12 @@ from textual.screen import Screen from textual.widgets import Footer, Static -from keystrike.application.prepare_practice import PrepareNextSession, SessionPrep -from keystrike.application.session_use_cases import ( - AbortSession, - FinishSession, - GetSessionBaseline, - RecordKeystroke, - StartSession, - format_session_stats_line, -) +from keystrike.application.prepare_practice import SessionPrep from keystrike.domain.models import SessionResult -from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET, NULL_STATS_REBUILDER -from keystrike.domain.protocols import Clock, DailyLearnBudgetProvider, StatsRebuilder from keystrike.domain.session import leading_key_char, skip_leading_whitespace from keystrike.presentation.bindings import BACK_BINDINGS +from keystrike.presentation.formatting.trends import format_session_stats_line +from keystrike.presentation.services import PracticeServices from keystrike.presentation.widgets.hud import HUD from keystrike.presentation.widgets.kb_heatmap import ( KbHeatmap, @@ -54,28 +46,14 @@ class PracticeScreen(Screen[None]): def __init__( self, *, - start: StartSession, - record: RecordKeystroke, - finish: FinishSession, - clock: Clock, + services: PracticeServices, initial: SessionPrep, - prepare_next: PrepareNextSession, - get_session_baseline: GetSessionBaseline, - rebuild_aggregates: StatsRebuilder = NULL_STATS_REBUILDER, - get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET, ) -> None: super().__init__() - self._start = start - self._record = record - self._finish = finish - self._clock = clock - self._prepare_next = prepare_next - self._get_session_baseline = get_session_baseline - self._rebuild_aggregates = rebuild_aggregates - self._get_daily_learn_budget = get_daily_learn_budget + self._services = services self._prep = initial self._kb_heatmap: KbHeatmap | None = None - self._session = self._start( + self._session = self._services.start( initial.target_text, layout=initial.layout, mode=initial.mode, @@ -84,8 +62,8 @@ def __init__( self._typing_area = TypingArea(self._session) self._hud = HUD( self._session, - clock, - get_daily_learn_budget=self._get_daily_learn_budget, + self._services.clock, + get_daily_learn_budget=self._services.get_daily_learn_budget, focus_reason=initial.focus_reason, ) @@ -135,11 +113,11 @@ def on_key(self, event: events.Key) -> None: return if key == "backspace": - self._record.backspace(self._session) + self._services.record.backspace(self._session) elif key == "space": - self._record(self._session, " ") + self._services.record(self._session, " ") elif char is not None and len(char) == 1 and char.isprintable(): - self._record(self._session, char) + self._services.record(self._session, char) else: return @@ -151,16 +129,16 @@ def on_key(self, event: events.Key) -> None: self._finish_session() def _finish_session(self) -> None: - result = self._finish(self._session) - self._rebuild_aggregates(result.layout) + result = self._services.finish(self._session) + self._services.rebuild_aggregates(result.layout) self._show_last_session_stats(result) - prep = self._prepare_next() + prep = self._services.prepare_practice() if prep is None: return self._begin_session(prep) def _show_last_session_stats(self, result: SessionResult) -> None: - baseline = self._get_session_baseline(result) + baseline = self._services.get_session_baseline(result) self.query_one("#last-session-stats", Static).update( format_session_stats_line(result, baseline=baseline), ) @@ -173,7 +151,7 @@ def _refresh_focus_note(self) -> None: def _begin_session(self, prep: SessionPrep) -> None: self._prep = prep - self._session = self._start( + self._session = self._services.start( prep.target_text, layout=prep.layout, mode=prep.mode, @@ -195,5 +173,5 @@ def _begin_session(self, prep: SessionPrep) -> None: self._refresh_focus_note() def action_back(self) -> None: - AbortSession()(self._session) + self._services.abort(self._session) self.app.pop_screen() diff --git a/src/keystrike/presentation/screens/settings.py b/src/keystrike/presentation/screens/settings.py index 4869d04..5894c96 100644 --- a/src/keystrike/presentation/screens/settings.py +++ b/src/keystrike/presentation/screens/settings.py @@ -7,22 +7,12 @@ from textual.widgets import Footer, Input, Label, Select, Static from textual.widgets.select import NoSelection -from keystrike.application.settings_use_cases import ( - SettingsUpdate, - SettingsValidationError, - UpdateSettings, -) -from keystrike.application.wordlist_use_cases import ( - DEFAULT_WORDLIST_URL, - ClearWordList, - GetWordListCacheStatus, - ImportWordList, - WordListError, -) +from keystrike.application.settings_use_cases import SettingsUpdate, SettingsValidationError +from keystrike.application.wordlist_use_cases import DEFAULT_WORDLIST_URL, WordListError from keystrike.domain.enums import TargetSpeedUnit from keystrike.domain.generator import cpm_from_wpm, wpm_from_cpm -from keystrike.domain.protocols import LayoutRepository, SettingsRepository from keystrike.presentation.bindings import BACK_BINDINGS, SAVE +from keystrike.presentation.services import SettingsServices class SettingsScreen(Screen[None]): @@ -44,26 +34,12 @@ class SettingsScreen(Screen[None]): *BACK_BINDINGS, ] - def __init__( - self, - *, - settings_repo: SettingsRepository, - layout_repo: LayoutRepository, - update_settings: UpdateSettings, - import_wordlist: ImportWordList, - clear_wordlist: ClearWordList, - get_wordlist_cache_status: GetWordListCacheStatus, - ) -> None: + def __init__(self, *, services: SettingsServices) -> None: super().__init__() - self._settings_repo = settings_repo - self._layout_repo = layout_repo - self._update_settings = update_settings - self._import_wordlist = import_wordlist - self._clear_wordlist = clear_wordlist - self._get_wordlist_cache_status = get_wordlist_cache_status + self._services = services def compose(self) -> ComposeResult: - settings = self._settings_repo.load() + settings = self._services.settings_repo.load() layouts = self._layout_select_options() with Vertical(): yield Static("[bold]Settings[/] [dim](Ctrl+S save, Esc/q back)[/]") @@ -120,7 +96,7 @@ def on_mount(self) -> None: self._refresh_wordlist_status() def on_screen_resume(self) -> None: - settings = self._settings_repo.load() + settings = self._services.settings_repo.load() self.query_one("#settings-alphabet-size", Input).value = str(settings.alphabet_size) self._refresh_layout_select() @@ -182,7 +158,7 @@ def action_save(self) -> None: return try: - self._update_settings(values) + self._services.update_settings(values) except SettingsValidationError as exc: self._show_error(str(exc)) return @@ -197,7 +173,7 @@ def _display_wordlist_url(persisted_url: str) -> str: return persisted_url or DEFAULT_WORDLIST_URL def _do_clear(self) -> None: - self._clear_wordlist() + self._services.clear_wordlist() self.query_one("#settings-error", Static).update("") self.query_one("#settings-wordlist-url", Input).value = DEFAULT_WORDLIST_URL self._refresh_wordlist_status() @@ -205,13 +181,13 @@ def _do_clear(self) -> None: def _do_import(self) -> None: url = self.query_one("#settings-wordlist-url", Input).value try: - count = self._import_wordlist(url) + count = self._services.import_wordlist(url) except WordListError as exc: self._show_error(str(exc)) return self.query_one("#settings-error", Static).update("") self._refresh_wordlist_status(f"Imported {count} words.") - persisted = self._settings_repo.load().wordlist_url + persisted = self._services.settings_repo.load().wordlist_url self.query_one("#settings-wordlist-url", Input).value = self._display_wordlist_url( persisted, ) @@ -220,18 +196,18 @@ def _wordlist_status(self, persisted_url: str) -> str: persisted = persisted_url.strip() if not persisted: return "[dim]Markov words.[/]" - count = self._get_wordlist_cache_status(persisted) + count = self._services.get_wordlist_cache_status(persisted) if count is not None: return f"[dim]{count} words cached.[/]" return "[dim]Not cached.[/]" def _refresh_wordlist_status(self, message: str | None = None) -> None: - persisted = self._settings_repo.load().wordlist_url + persisted = self._services.settings_repo.load().wordlist_url text = message or self._wordlist_status(persisted) self.query_one("#settings-wordlist-status", Static).update(text) def _layout_select_options(self) -> list[tuple[str, str]]: - return [(name, name) for name in self._layout_repo.list_available()] + return [(name, name) for name in self._services.layout_repo.list_available()] def _refresh_layout_select(self) -> None: layout_select = cast("Select[str]", self.query_one("#settings-layout", Select)) diff --git a/src/keystrike/presentation/screens/stats.py b/src/keystrike/presentation/screens/stats.py index 30a3dd5..69cafc2 100644 --- a/src/keystrike/presentation/screens/stats.py +++ b/src/keystrike/presentation/screens/stats.py @@ -7,14 +7,8 @@ from textual.screen import Screen from textual.widgets import Footer, Static -from keystrike.application.stats_use_cases import ( - GetAggregateMetricTrends, - GetHeatmap, - GetHistory, - GetKeyMetricTrends, -) +from keystrike.application.stats_use_cases import HeatmapView from keystrike.domain.models import Layout, SessionResult -from keystrike.domain.protocols import LayoutRepository, StatsRebuilder from keystrike.presentation.bindings import BACK_BINDINGS from keystrike.presentation.formatting.trends import ( char_label, @@ -22,6 +16,7 @@ format_focus_confidence_trend_line, format_key_metric_trend_block, ) +from keystrike.presentation.services import StatsServices from keystrike.presentation.widgets.kb_heatmap import KbHeatmap _View = Literal["overview", "key_detail"] @@ -42,30 +37,18 @@ def __init__( self, *, layout: str, - layout_repo: LayoutRepository, - rebuild_aggregates: StatsRebuilder, - get_heatmap: GetHeatmap, - get_history: GetHistory, - get_key_metric_trends: GetKeyMetricTrends, - get_aggregate_metric_trends: GetAggregateMetricTrends, + services: StatsServices, current_target_speed_cpm: int = 0, confidence_session_window: int = 10, ) -> None: super().__init__() self._layout_name = layout - self._layout_repo = layout_repo - self._rebuild_aggregates = rebuild_aggregates - self._get_heatmap = get_heatmap - self._get_history = get_history - self._get_key_metric_trends = get_key_metric_trends - self._get_aggregate_metric_trends = get_aggregate_metric_trends + self._services = services self._current_target_speed_cpm = current_target_speed_cpm self._confidence_session_window = confidence_session_window self._view: _View = "overview" - self._selected_cp: int | None = None self._layout: Layout | None = None - self._heatmap_confidence: dict[int, float] = {} - self._heatmap_urgency: dict[int, float] = {} + self._heatmap: HeatmapView | None = None self._trend_history: list[SessionResult] = [] self._kb_heatmap: KbHeatmap | None = None @@ -77,12 +60,11 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.focus() - self._rebuild_aggregates(self._layout_name) - heatmap_view = self._get_heatmap(self._layout_name) - layout = self._layout_repo.get(self._layout_name) + self._services.rebuild_aggregates(self._layout_name) + heatmap_view = self._services.get_heatmap(self._layout_name) + layout = self._services.layout_repo.get(self._layout_name) self._layout = layout - self._heatmap_confidence = heatmap_view.confidence - self._heatmap_urgency = heatmap_view.urgency + self._heatmap = heatmap_view title = f"[bold]Stats — {self._layout_name}[/]" if layout.ortholinear: @@ -103,7 +85,7 @@ def on_mount(self) -> None: self._kb_heatmap, ) - self._trend_history = self._get_history( + self._trend_history = self._services.get_history( self._layout_name, limit=self._confidence_session_window, ) @@ -142,9 +124,11 @@ def _codepoint_from_key(self, event: events.Key) -> int | None: def _render_overview(self) -> None: widget = self.query_one("#stats-trends", Static) limit = self._confidence_session_window - confidence_values, speed_values, accuracy_values = self._get_aggregate_metric_trends( - self._layout_name, - current_target_speed_cpm=self._current_target_speed_cpm, + confidence_values, speed_values, accuracy_values = ( + self._services.get_aggregate_metric_trends( + self._layout_name, + current_target_speed_cpm=self._current_target_speed_cpm, + ) ) layout_block = format_aggregate_metric_trend_block( title="Layout", @@ -164,7 +148,7 @@ def _render_overview(self) -> None: def _render_key_detail(self, codepoint: int) -> None: widget = self.query_one("#stats-trends", Static) limit = self._confidence_session_window - speed_values, accuracy_values = self._get_key_metric_trends( + speed_values, accuracy_values = self._services.get_key_metric_trends( self._layout_name, codepoint, current_target_speed_cpm=self._current_target_speed_cpm, @@ -183,23 +167,21 @@ def _render_key_detail(self, codepoint: int) -> None: def _show_overview(self) -> None: self._view = "overview" - self._selected_cp = None self._render_overview() KbHeatmap.update_or_none( self._kb_heatmap, self._layout, - self._heatmap_confidence, - urgency=self._heatmap_urgency, + self._heatmap.confidence if self._heatmap is not None else None, + urgency=self._heatmap.urgency if self._heatmap is not None else None, ) def _show_key_detail(self, codepoint: int) -> None: self._view = "key_detail" - self._selected_cp = codepoint self._render_key_detail(codepoint) KbHeatmap.update_or_none( self._kb_heatmap, self._layout, - self._heatmap_confidence, + self._heatmap.confidence if self._heatmap is not None else None, focus=codepoint, - urgency=self._heatmap_urgency, + urgency=self._heatmap.urgency if self._heatmap is not None else None, ) diff --git a/src/keystrike/presentation/services.py b/src/keystrike/presentation/services.py new file mode 100644 index 0000000..a9ab94e --- /dev/null +++ b/src/keystrike/presentation/services.py @@ -0,0 +1,76 @@ +"""Per-screen dependency-injection bundles, shared by the composition root (app.py), +KeystrikeApp, and the screens themselves. Split out from textual_app.py so screens can +depend on these types without importing textual_app.py (which imports the screens). +""" + +from dataclasses import dataclass + +from keystrike.application.prepare_practice import PreparePracticeSession +from keystrike.application.session_use_cases import ( + AbortSession, + FinishSession, + GetSessionBaseline, + RecordKeystroke, + StartSession, +) +from keystrike.application.settings_use_cases import CycleLayout, UpdateSettings +from keystrike.application.stats_use_cases import ( + GetAggregateMetricTrends, + GetHeatmap, + GetHistory, + GetKeyMetricTrends, +) +from keystrike.application.wordlist_use_cases import ( + ClearWordList, + GetWordListCacheStatus, + ImportWordList, +) +from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET +from keystrike.domain.protocols import ( + Clock, + DailyLearnBudgetProvider, + LayoutRepository, + SettingsRepository, + StatsRebuilder, +) + + +@dataclass(frozen=True, slots=True) +class HomeServices: + settings_repo: SettingsRepository + cycle_layout: CycleLayout + get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET + + +@dataclass(frozen=True, slots=True) +class PracticeServices: + clock: Clock + start: StartSession + record: RecordKeystroke + finish: FinishSession + abort: AbortSession + prepare_practice: PreparePracticeSession + get_session_baseline: GetSessionBaseline + rebuild_aggregates: StatsRebuilder + get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET + + +@dataclass(frozen=True, slots=True) +class StatsServices: + settings_repo: SettingsRepository + layout_repo: LayoutRepository + rebuild_aggregates: StatsRebuilder + get_heatmap: GetHeatmap + get_history: GetHistory + get_key_metric_trends: GetKeyMetricTrends + get_aggregate_metric_trends: GetAggregateMetricTrends + + +@dataclass(frozen=True, slots=True) +class SettingsServices: + settings_repo: SettingsRepository + layout_repo: LayoutRepository + update_settings: UpdateSettings + import_wordlist: ImportWordList + clear_wordlist: ClearWordList + get_wordlist_cache_status: GetWordListCacheStatus diff --git a/src/keystrike/presentation/textual_app.py b/src/keystrike/presentation/textual_app.py index abe89dc..c7a775e 100644 --- a/src/keystrike/presentation/textual_app.py +++ b/src/keystrike/presentation/textual_app.py @@ -1,76 +1,27 @@ -from dataclasses import dataclass from typing import ClassVar from textual.app import App from textual.binding import BindingType -from keystrike.application.prepare_practice import PreparePracticeSession -from keystrike.application.session_use_cases import ( - FinishSession, - GetSessionBaseline, - RecordKeystroke, - StartSession, -) -from keystrike.application.settings_use_cases import CycleLayout, UpdateSettings -from keystrike.application.stats_use_cases import ( - GetAggregateMetricTrends, - GetHeatmap, - GetHistory, - GetKeyMetricTrends, -) -from keystrike.application.wordlist_use_cases import ( - ClearWordList, - GetWordListCacheStatus, - ImportWordList, -) -from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET -from keystrike.domain.protocols import ( - Clock, - DailyLearnBudgetProvider, - LayoutRepository, - SettingsRepository, - StatsRebuilder, -) from keystrike.presentation.bindings import QUIT from keystrike.presentation.screens.home import HomeScreen from keystrike.presentation.screens.practice import PracticeScreen from keystrike.presentation.screens.settings import SettingsScreen from keystrike.presentation.screens.stats import StatsScreen +from keystrike.presentation.services import ( + HomeServices, + PracticeServices, + SettingsServices, + StatsServices, +) - -@dataclass(frozen=True, slots=True) -class HomeServices: - cycle_layout: CycleLayout - get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET - - -@dataclass(frozen=True, slots=True) -class PracticeServices: - clock: Clock - start: StartSession - record: RecordKeystroke - finish: FinishSession - prepare_practice: PreparePracticeSession - get_session_baseline: GetSessionBaseline - rebuild_aggregates: StatsRebuilder - get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET - - -@dataclass(frozen=True, slots=True) -class StatsServices: - rebuild_aggregates: StatsRebuilder - get_heatmap: GetHeatmap - get_history: GetHistory - get_key_metric_trends: GetKeyMetricTrends - get_aggregate_metric_trends: GetAggregateMetricTrends - - -@dataclass(frozen=True, slots=True) -class SettingsServices: - update_settings: UpdateSettings - import_wordlist: ImportWordList - clear_wordlist: ClearWordList - get_wordlist_cache_status: GetWordListCacheStatus +__all__ = [ + "HomeServices", + "KeystrikeApp", + "PracticeServices", + "SettingsServices", + "StatsServices", +] class KeystrikeApp(App[None]): @@ -80,8 +31,6 @@ class KeystrikeApp(App[None]): def __init__( self, *, - settings_repo: SettingsRepository, - layout_repo: LayoutRepository, home: HomeServices, practice: PracticeServices, stats: StatsServices, @@ -89,8 +38,6 @@ def __init__( app_version: str = "", ) -> None: super().__init__() - self._settings_repo = settings_repo - self._layout_repo = layout_repo self._home = home self._practice = practice self._stats = stats @@ -102,58 +49,28 @@ def on_mount(self) -> None: self.push_screen("home") def _build_home(self) -> HomeScreen: - return HomeScreen( - settings_repo=self._settings_repo, - cycle_layout=self._home.cycle_layout, - get_daily_learn_budget=self._home.get_daily_learn_budget, - app_version=self._app_version, - ) + return HomeScreen(services=self._home, app_version=self._app_version) def on_home_screen_start_practice(self, _: HomeScreen.StartPractice) -> None: initial = self._practice.prepare_practice() if initial is None: return - practice = PracticeScreen( - start=self._practice.start, - record=self._practice.record, - finish=self._practice.finish, - clock=self._practice.clock, - initial=initial, - prepare_next=self._practice.prepare_practice, - get_session_baseline=self._practice.get_session_baseline, - rebuild_aggregates=self._practice.rebuild_aggregates, - get_daily_learn_budget=self._practice.get_daily_learn_budget, - ) - self.push_screen(practice) + self.push_screen(PracticeScreen(services=self._practice, initial=initial)) def on_home_screen_open_stats(self, _: HomeScreen.OpenStats) -> None: - settings = self._settings_repo.load() + settings = self._stats.settings_repo.load() self.push_screen( StatsScreen( layout=settings.layout, - layout_repo=self._layout_repo, - rebuild_aggregates=self._stats.rebuild_aggregates, - get_heatmap=self._stats.get_heatmap, - get_history=self._stats.get_history, - get_key_metric_trends=self._stats.get_key_metric_trends, - get_aggregate_metric_trends=self._stats.get_aggregate_metric_trends, + services=self._stats, current_target_speed_cpm=settings.target_speed_cpm, confidence_session_window=settings.confidence_session_window, ) ) def on_home_screen_open_settings(self, _: HomeScreen.OpenSettings) -> None: - self.push_screen( - SettingsScreen( - settings_repo=self._settings_repo, - layout_repo=self._layout_repo, - update_settings=self._settings.update_settings, - import_wordlist=self._settings.import_wordlist, - clear_wordlist=self._settings.clear_wordlist, - get_wordlist_cache_status=self._settings.get_wordlist_cache_status, - ) - ) + self.push_screen(SettingsScreen(services=self._settings)) def action_quit_app(self) -> None: self.exit() diff --git a/src/keystrike/presentation/theme.py b/src/keystrike/presentation/theme.py index 7ecbd27..f0ce47f 100644 --- a/src/keystrike/presentation/theme.py +++ b/src/keystrike/presentation/theme.py @@ -9,3 +9,5 @@ STYLE_TREND_CONFIDENCE = "cyan" STYLE_TREND_SPEED = "green" STYLE_TREND_ACCURACY = "yellow" +STYLE_DELTA_IMPROVE = "green" +STYLE_DELTA_REGRESS = "red" diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index cbc52c6..6d08507 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -238,14 +238,14 @@ def test_transition_focus_metrics_reports_accuracy_when_speed_measured(): attempt_count=1, ), } - speed, accuracy = _transition_focus_metrics( + metrics = _transition_focus_metrics( ord("e"), ord("o"), transitions, target, ) - assert speed > 0 - assert accuracy > 0 + assert metrics.speed > 0 + assert metrics.accuracy > 0 def test_build_lesson_eo_not_zero_confidence_when_counts_zeroed(): @@ -309,7 +309,7 @@ def test_weak_transition_focus_confidence_matches_displayed_speed(): [{eo_key: old}, {eo_key: empty}, {eo_key: empty}], session_recency_weights(3), )[eo_key] - speed, accuracy = _transition_focus_metrics( + metrics = _transition_focus_metrics( ord("e"), ord("o"), {eo_key: merged}, @@ -321,8 +321,8 @@ def test_weak_transition_focus_confidence_matches_displayed_speed(): {eo_key: merged}, target, ) - assert speed >= 1.0 - assert accuracy == 1.0 + assert metrics.speed >= 1.0 + assert metrics.accuracy == 1.0 assert confidence > 0.0 assert confidence < 1.0 # still weak due to sample ramp diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index 2d7228d..3958ed3 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -13,7 +13,6 @@ compute_wpm, confidence_window_session_baseline, count_words_completed, - format_session_stats_line, previous_session_header, ) from keystrike.domain.aggregate import combine_sessions @@ -27,6 +26,7 @@ from keystrike.domain.models import SessionResult, Settings from keystrike.domain.unlock import compute_unlocked from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS +from keystrike.presentation.formatting.trends import format_session_stats_line from tests.fakes import ( FakeClock, FakeIdGenerator, diff --git a/tests/fakes.py b/tests/fakes.py index db5d841..7c9a86a 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from dataclasses import dataclass, field from itertools import count @@ -62,6 +62,12 @@ def append_keystroke(self, session_id: str, started_at: float, k: Keystroke) -> _ = started_at # not used by the fake, but keeps protocol shape self.keystrokes.setdefault(session_id, []).append(k) + def append_keystrokes( + self, session_id: str, started_at: float, keystrokes: Iterable[Keystroke] + ) -> None: + _ = started_at # not used by the fake, but keeps protocol shape + self.keystrokes.setdefault(session_id, []).extend(keystrokes) + def save_header(self, header: SessionResult) -> None: self.headers.append(header) diff --git a/tests/infrastructure/test_atomic_write.py b/tests/infrastructure/test_atomic_write.py new file mode 100644 index 0000000..4e32125 --- /dev/null +++ b/tests/infrastructure/test_atomic_write.py @@ -0,0 +1,54 @@ +import os +from contextlib import suppress +from unittest.mock import patch + +from keystrike.infrastructure.atomic_write import atomic_write_text + + +def test_writes_contents_and_replaces_atomically(tmp_path): + path = tmp_path / "config.toml" + atomic_write_text(path, "a = 1\n") + assert path.read_text(encoding="utf-8") == "a = 1\n" + + atomic_write_text(path, "a = 2\n") + assert path.read_text(encoding="utf-8") == "a = 2\n" + + +def test_no_leftover_temp_files(tmp_path): + path = tmp_path / "config.toml" + atomic_write_text(path, "a = 1\n") + assert list(tmp_path.iterdir()) == [path] + + +def test_new_file_gets_0644_permissions(tmp_path): + path = tmp_path / "config.toml" + atomic_write_text(path, "a = 1\n") + assert (path.stat().st_mode & 0o777) == 0o644 + + +def test_preserves_existing_file_permissions(tmp_path): + path = tmp_path / "config.toml" + atomic_write_text(path, "a = 1\n") + os.chmod(path, 0o600) + + atomic_write_text(path, "a = 2\n") + assert (path.stat().st_mode & 0o777) == 0o600 + + +def test_fsync_is_called_before_replace(tmp_path): + path = tmp_path / "config.toml" + with patch("keystrike.infrastructure.atomic_write.os.fsync") as mock_fsync: + atomic_write_text(path, "a = 1\n") + mock_fsync.assert_called_once() + assert path.read_text(encoding="utf-8") == "a = 1\n" + + +def test_temp_file_cleaned_up_on_write_failure(tmp_path): + path = tmp_path / "config.toml" + with ( + patch("keystrike.infrastructure.atomic_write.os.fsync", side_effect=OSError("disk full")), + suppress(OSError), + ): + atomic_write_text(path, "a = 1\n") + assert list(tmp_path.iterdir()) == [] + assert not path.exists() diff --git a/tests/infrastructure/test_session_repo_jsonl.py b/tests/infrastructure/test_session_repo_jsonl.py index a719658..c81d5e3 100644 --- a/tests/infrastructure/test_session_repo_jsonl.py +++ b/tests/infrastructure/test_session_repo_jsonl.py @@ -181,3 +181,31 @@ def test_keystrokes_persisted_with_header_at_finish(paths): list(repo2.iter_headers("qwerty")) ks = list(repo2.load_keystrokes("S2")) assert len(ks) == 1 + + +def test_append_keystrokes_bulk_writes_all_in_one_open(paths): + repo = JsonlSessionRepository(paths) + header = _header(sid="S3") + keystrokes = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100, correct=True), + Keystroke(codepoint=ord("c"), typed=ord("c"), t_ns=200, correct=False), + ] + repo.append_keystrokes(header.session_id, header.started_at, keystrokes) + repo.save_header(header) + + repo2 = JsonlSessionRepository(paths) + list(repo2.iter_headers("qwerty")) + ks = list(repo2.load_keystrokes("S3")) + assert [k.codepoint for k in ks] == [ord("a"), ord("b"), ord("c")] + assert ks[2].correct is False + + +def test_load_keystrokes_scan_fallback_when_sessions_dir_missing(tmp_path): + empty_paths = Paths( + config_dir=tmp_path / "config2", + data_dir=tmp_path / "data2", + log_dir=tmp_path / "logs2", + ) + repo = JsonlSessionRepository(empty_paths) + assert list(repo.load_keystrokes("does-not-exist")) == [] diff --git a/tests/infrastructure/test_settings_repo_toml.py b/tests/infrastructure/test_settings_repo_toml.py index cf08daf..0d60651 100644 --- a/tests/infrastructure/test_settings_repo_toml.py +++ b/tests/infrastructure/test_settings_repo_toml.py @@ -3,7 +3,7 @@ from keystrike.domain.enums import TargetSpeedUnit from keystrike.domain.models import Settings from keystrike.infrastructure.paths import Paths -from keystrike.infrastructure.settings_repo_toml import TomlSettingsRepository +from keystrike.infrastructure.settings_repo_toml import TomlSettingsRepository, _coerce_field @pytest.fixture @@ -99,3 +99,16 @@ def test_malformed_enum_value_falls_back_to_default(paths): ) s = TomlSettingsRepository(paths).load() assert s.target_speed_unit == Settings().target_speed_unit + + +def test_coerce_field_rejects_non_bool_for_bool_default(): + # bool("false") is True in Python — a quoted-string bool must not be + # silently coerced, or it would flip the setting instead of falling + # back to the default via load()'s except (ValueError, TypeError). + with pytest.raises(TypeError): + _coerce_field(True, "false") + + +def test_coerce_field_accepts_real_bool(): + assert _coerce_field(True, False) is False + assert _coerce_field(False, True) is True diff --git a/tests/infrastructure/test_wordlist_store.py b/tests/infrastructure/test_wordlist_store.py index 1b6da3e..f20cf0e 100644 --- a/tests/infrastructure/test_wordlist_store.py +++ b/tests/infrastructure/test_wordlist_store.py @@ -92,6 +92,16 @@ def __exit__(self, *args: object) -> None: FileWordListStore(paths).download_and_cache(url) +def test_download_rejects_non_http_scheme(paths, monkeypatch): + def _open(*args: object, **kwargs: object) -> object: + raise AssertionError("urlopen should not be called for a rejected scheme") + + monkeypatch.setattr("keystrike.infrastructure.wordlist_store.urllib.request.urlopen", _open) + + with pytest.raises(ValueError, match="unsupported URL scheme"): + FileWordListStore(paths).download_and_cache("file:///etc/passwd") + + def test_download_rejects_empty_usable_words(paths, monkeypatch): url = "https://example.com/bad.txt" diff --git a/tests/presentation/test_home_screen.py b/tests/presentation/test_home_screen.py index 4c420aa..1868f69 100644 --- a/tests/presentation/test_home_screen.py +++ b/tests/presentation/test_home_screen.py @@ -11,6 +11,7 @@ from keystrike.domain.version import __version__ from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from keystrike.presentation.screens.home import HomeScreen +from keystrike.presentation.services import HomeServices from tests.fakes import ( FakeClock, FakeLayoutRepository, @@ -41,9 +42,11 @@ def _build_screen( ) return ( HomeScreen( - settings_repo=settings_repo, - cycle_layout=cycle_layout, - get_daily_learn_budget=get_daily_learn_budget, + services=HomeServices( + settings_repo=settings_repo, + cycle_layout=cycle_layout, + get_daily_learn_budget=get_daily_learn_budget, + ), app_version=app_version, ), settings_repo, diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index d12e6c5..e63530a 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -8,6 +8,7 @@ from keystrike.application.learn_budget_use_cases import GetDailyLearnBudget from keystrike.application.prepare_practice import PreparePracticeSession from keystrike.application.session_use_cases import ( + AbortSession, FinishSession, GetSessionBaseline, RecordKeystroke, @@ -35,13 +36,13 @@ from keystrike.presentation.screens.practice import PracticeScreen from keystrike.presentation.screens.settings import SettingsScreen from keystrike.presentation.screens.stats import StatsScreen -from keystrike.presentation.textual_app import ( +from keystrike.presentation.services import ( HomeServices, - KeystrikeApp, PracticeServices, SettingsServices, StatsServices, ) +from keystrike.presentation.textual_app import KeystrikeApp from keystrike.presentation.widgets.hud import HUD from keystrike.presentation.widgets.kb_heatmap import KbHeatmap from tests.fakes import ( @@ -107,9 +108,8 @@ def _build_app( ) app = KeystrikeApp( - settings_repo=settings_repo, - layout_repo=layout_repo, home=HomeServices( + settings_repo=settings_repo, cycle_layout=CycleLayout(settings_repo=settings_repo, layout_repo=layout_repo), get_daily_learn_budget=get_daily_learn_budget, ), @@ -123,12 +123,15 @@ def _build_app( settings_repo=settings_repo, layout_repo=layout_repo, ), + abort=AbortSession(), prepare_practice=prepare_practice, get_session_baseline=GetSessionBaseline(repo=session_repo, settings_repo=settings_repo), rebuild_aggregates=rebuild_aggregates, get_daily_learn_budget=get_daily_learn_budget, ), stats=StatsServices( + settings_repo=settings_repo, + layout_repo=layout_repo, rebuild_aggregates=rebuild_aggregates, get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo, clock=clock), get_history=GetHistory(repo=session_repo), @@ -142,6 +145,8 @@ def _build_app( ), ), settings=SettingsServices( + settings_repo=settings_repo, + layout_repo=layout_repo, update_settings=UpdateSettings(repo=settings_repo), import_wordlist=ImportWordList(store=wordlist_store, settings_repo=settings_repo), clear_wordlist=ClearWordList(settings_repo=settings_repo), diff --git a/tests/presentation/test_settings_screen.py b/tests/presentation/test_settings_screen.py index bd785d0..c0f42ac 100644 --- a/tests/presentation/test_settings_screen.py +++ b/tests/presentation/test_settings_screen.py @@ -14,6 +14,7 @@ from keystrike.domain.models import Settings from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from keystrike.presentation.screens.settings import SettingsScreen +from keystrike.presentation.services import SettingsServices from tests.fakes import FakeLayoutRepository, FakeSettingsRepository, FakeWordListStore @@ -26,12 +27,14 @@ def _build_screen(*, wordlist_store: FakeWordListStore | None = None): clear_wordlist = ClearWordList(settings_repo=settings_repo) get_wordlist_cache_status = GetWordListCacheStatus(store=store) screen = SettingsScreen( - settings_repo=settings_repo, - layout_repo=layout_repo, - update_settings=update_settings, - import_wordlist=import_wordlist, - clear_wordlist=clear_wordlist, - get_wordlist_cache_status=get_wordlist_cache_status, + services=SettingsServices( + settings_repo=settings_repo, + layout_repo=layout_repo, + update_settings=update_settings, + import_wordlist=import_wordlist, + clear_wordlist=clear_wordlist, + get_wordlist_cache_status=get_wordlist_cache_status, + ), ) return screen, settings_repo, store @@ -58,12 +61,14 @@ async def test_settings_screen_refreshes_layout_select_on_resume(): settings_repo = FakeSettingsRepository(Settings()) store = FakeWordListStore() screen = SettingsScreen( - settings_repo=settings_repo, - layout_repo=layout_repo, - update_settings=UpdateSettings(repo=settings_repo), - import_wordlist=ImportWordList(store=store, settings_repo=settings_repo), - clear_wordlist=ClearWordList(settings_repo=settings_repo), - get_wordlist_cache_status=GetWordListCacheStatus(store=store), + services=SettingsServices( + settings_repo=settings_repo, + layout_repo=layout_repo, + update_settings=UpdateSettings(repo=settings_repo), + import_wordlist=ImportWordList(store=store, settings_repo=settings_repo), + clear_wordlist=ClearWordList(settings_repo=settings_repo), + get_wordlist_cache_status=GetWordListCacheStatus(store=store), + ), ) async with app.run_test() as pilot: await app.push_screen(screen) @@ -115,12 +120,14 @@ async def test_save_persists_custom_layout_from_dropdown(): settings_repo = FakeSettingsRepository(Settings()) store = FakeWordListStore() screen = SettingsScreen( - settings_repo=settings_repo, - layout_repo=layout_repo, - update_settings=UpdateSettings(repo=settings_repo), - import_wordlist=ImportWordList(store=store, settings_repo=settings_repo), - clear_wordlist=ClearWordList(settings_repo=settings_repo), - get_wordlist_cache_status=GetWordListCacheStatus(store=store), + services=SettingsServices( + settings_repo=settings_repo, + layout_repo=layout_repo, + update_settings=UpdateSettings(repo=settings_repo), + import_wordlist=ImportWordList(store=store, settings_repo=settings_repo), + clear_wordlist=ClearWordList(settings_repo=settings_repo), + get_wordlist_cache_status=GetWordListCacheStatus(store=store), + ), ) async with app.run_test() as pilot: await app.push_screen(screen) @@ -234,12 +241,14 @@ async def test_loads_wpm_display_value(): clear_wordlist = ClearWordList(settings_repo=settings_repo) get_wordlist_cache_status = GetWordListCacheStatus(store=store) screen = SettingsScreen( - settings_repo=settings_repo, - layout_repo=layout_repo, - update_settings=update_settings, - import_wordlist=import_wordlist, - clear_wordlist=clear_wordlist, - get_wordlist_cache_status=get_wordlist_cache_status, + services=SettingsServices( + settings_repo=settings_repo, + layout_repo=layout_repo, + update_settings=update_settings, + import_wordlist=import_wordlist, + clear_wordlist=clear_wordlist, + get_wordlist_cache_status=get_wordlist_cache_status, + ), ) async with app.run_test() as pilot: await app.push_screen(screen) @@ -346,12 +355,14 @@ async def test_saved_url_without_cache_shows_markov_status(): layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) store = FakeWordListStore() screen = SettingsScreen( - settings_repo=settings_repo, - layout_repo=layout_repo, - update_settings=UpdateSettings(repo=settings_repo), - import_wordlist=ImportWordList(store=store, settings_repo=settings_repo), - clear_wordlist=ClearWordList(settings_repo=settings_repo), - get_wordlist_cache_status=GetWordListCacheStatus(store=store), + services=SettingsServices( + settings_repo=settings_repo, + layout_repo=layout_repo, + update_settings=UpdateSettings(repo=settings_repo), + import_wordlist=ImportWordList(store=store, settings_repo=settings_repo), + clear_wordlist=ClearWordList(settings_repo=settings_repo), + get_wordlist_cache_status=GetWordListCacheStatus(store=store), + ), ) async with app.run_test() as pilot: await app.push_screen(screen) @@ -369,12 +380,14 @@ async def test_clear_removes_wordlist_and_uses_markov(): layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) store = FakeWordListStore(by_url={url: ["hello"]}) screen = SettingsScreen( - settings_repo=settings_repo, - layout_repo=layout_repo, - update_settings=UpdateSettings(repo=settings_repo), - import_wordlist=ImportWordList(store=store, settings_repo=settings_repo), - clear_wordlist=ClearWordList(settings_repo=settings_repo), - get_wordlist_cache_status=GetWordListCacheStatus(store=store), + services=SettingsServices( + settings_repo=settings_repo, + layout_repo=layout_repo, + update_settings=UpdateSettings(repo=settings_repo), + import_wordlist=ImportWordList(store=store, settings_repo=settings_repo), + clear_wordlist=ClearWordList(settings_repo=settings_repo), + get_wordlist_cache_status=GetWordListCacheStatus(store=store), + ), ) async with app.run_test() as pilot: await app.push_screen(screen) diff --git a/tests/presentation/test_stats_screen.py b/tests/presentation/test_stats_screen.py index 367dfd3..02e7200 100644 --- a/tests/presentation/test_stats_screen.py +++ b/tests/presentation/test_stats_screen.py @@ -18,6 +18,7 @@ from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from keystrike.infrastructure.layout_toml import load_layout_toml from keystrike.presentation.screens.stats import StatsScreen +from keystrike.presentation.services import StatsServices from tests.fakes import ( FakeAggregatesCache, FakeClock, @@ -41,21 +42,24 @@ def _build_screen( layout_repo = layout_repo or FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) return StatsScreen( layout=layout, - layout_repo=layout_repo, - rebuild_aggregates=RebuildAggregates( - repo=repo, - cache=cache, - settings_repo=settings_repo, - ), - get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo, clock=FakeClock()), - get_history=GetHistory(repo=repo), - get_key_metric_trends=GetKeyMetricTrends( - repo=repo, - settings_repo=settings_repo, - ), - get_aggregate_metric_trends=GetAggregateMetricTrends( - repo=repo, + services=StatsServices( settings_repo=settings_repo, + layout_repo=layout_repo, + rebuild_aggregates=RebuildAggregates( + repo=repo, + cache=cache, + settings_repo=settings_repo, + ), + get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo, clock=FakeClock()), + get_history=GetHistory(repo=repo), + get_key_metric_trends=GetKeyMetricTrends( + repo=repo, + settings_repo=settings_repo, + ), + get_aggregate_metric_trends=GetAggregateMetricTrends( + repo=repo, + settings_repo=settings_repo, + ), ), current_target_speed_cpm=settings_repo.settings.target_speed_cpm, confidence_session_window=settings_repo.settings.confidence_session_window, @@ -313,7 +317,6 @@ async def test_stats_key_press_shows_key_detail(): screen = cast(StatsScreen, app.screen) assert screen._view == "key_detail" - assert screen._selected_cp == ord("e") detail = str(screen.query_one("#stats-trends", Static).content) assert "[bold]'e'[/]" in detail assert "confidence" in detail @@ -352,14 +355,12 @@ async def test_stats_key_detail_switches_key_without_overview(): await pilot.pause() screen = cast(StatsScreen, app.screen) assert screen._view == "key_detail" - assert screen._selected_cp == ord("a") assert "[bold]'a'[/]" in str(screen.query_one("#stats-trends", Static).content) await pilot.press("b") await pilot.pause() assert screen._view == "key_detail" - assert screen._selected_cp == ord("b") detail = str(screen.query_one("#stats-trends", Static).content) assert "[bold]'b'[/]" in detail assert "confidence" in detail From f5b8a1ca7b0e8524154658c6a7768819c812e0b9 Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 30 Jul 2026 16:52:46 +0300 Subject: [PATCH 22/47] refactor --- src/keystrike/app.py | 1 - .../application/learn_budget_use_cases.py | 2 +- .../application/session_use_cases.py | 27 ++-- src/keystrike/application/stats_use_cases.py | 117 ++++++++-------- src/keystrike/domain/aggregate.py | 81 +++-------- src/keystrike/domain/generator.py | 54 +++++--- src/keystrike/domain/protocols.py | 2 + src/keystrike/domain/sync_merge.py | 50 ++++--- .../infrastructure/aggregates_cache.py | 64 ++++----- src/keystrike/infrastructure/clock.py | 4 + src/keystrike/infrastructure/git_client.py | 16 +++ .../infrastructure/session_repo_jsonl.py | 20 ++- .../infrastructure/settings_repo_toml.py | 4 +- src/keystrike/infrastructure/sync_git.py | 10 +- src/keystrike/infrastructure/sync_merge_io.py | 16 ++- src/keystrike/infrastructure/toml_escape.py | 8 ++ .../presentation/formatting/trends.py | 4 +- .../presentation/screens/practice.py | 24 ++-- .../presentation/screens/settings.py | 24 ++-- src/keystrike/presentation/screens/stats.py | 28 ++-- src/keystrike/presentation/services.py | 1 - src/keystrike/presentation/textual_app.py | 2 +- .../presentation/widgets/kb_heatmap.py | 127 ++++++++---------- .../test_learn_budget_use_cases.py | 12 ++ tests/domain/test_aggregate.py | 71 ++-------- tests/domain/test_sync_merge.py | 16 +-- tests/fakes.py | 5 + .../infrastructure/test_session_repo_jsonl.py | 25 +++- tests/infrastructure/test_sync_git.py | 7 +- tests/presentation/test_kb_heatmap.py | 43 +++--- tests/presentation/test_practice_screen.py | 1 - tests/presentation/test_stats_screen.py | 1 - 32 files changed, 429 insertions(+), 438 deletions(-) create mode 100644 src/keystrike/infrastructure/toml_escape.py diff --git a/src/keystrike/app.py b/src/keystrike/app.py index 5977979..4559ae2 100644 --- a/src/keystrike/app.py +++ b/src/keystrike/app.py @@ -164,7 +164,6 @@ def build() -> KeystrikeApp: get_daily_learn_budget=get_daily_learn_budget, ), stats=StatsServices( - settings_repo=settings_repo, layout_repo=layout_repo, rebuild_aggregates=rebuild_aggregates, get_heatmap=get_heatmap, diff --git a/src/keystrike/application/learn_budget_use_cases.py b/src/keystrike/application/learn_budget_use_cases.py index d3253c3..8d772d8 100644 --- a/src/keystrike/application/learn_budget_use_cases.py +++ b/src/keystrike/application/learn_budget_use_cases.py @@ -22,7 +22,7 @@ class GetDailyLearnBudget: tz: dt.tzinfo | None = None def __call__(self, *, extra_ns: int = 0) -> DailyLearnBudget: - tz = self.tz or dt.datetime.now().astimezone().tzinfo or dt.UTC + tz = self.tz or self.clock.local_tzinfo() today = session_local_date(self.clock.wall_epoch(), tz) completed_ns = daily_learn_duration_ns(self.repo.iter_all_headers(), today, tz=tz) limit_minutes = self.settings_repo.load().learn_daily_minutes diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 02340dc..9a1f89a 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -1,7 +1,7 @@ from collections.abc import Iterable from dataclasses import dataclass, field, replace -from keystrike.domain.aggregate import combine_sessions, session_recency_weights +from keystrike.domain.aggregate import SessionTiming, combine_sessions, session_recency_weights from keystrike.domain.confidence import ( confidence_of, target_ms_per_char, @@ -114,6 +114,15 @@ def __call__(self, session: Session, char: str) -> bool: return session.finished +@dataclass(frozen=True, slots=True) +class _DraftTiming: + """Minimal `SessionTiming` for an in-progress session not yet persisted as + a `SessionResult` — only what `combine_sessions` actually reads.""" + + started_at: float + duration_ns: int + + def _snapshot_unlock_state( session: Session, duration_ns: int, @@ -124,24 +133,12 @@ def _snapshot_unlock_state( ) -> tuple[tuple[int, ...], dict[int, float]]: settings = settings_repo.load() layout = layout_repo.get(session.layout) - draft = SessionResult( - schema_version=3, - session_id=session.id, - started_at=session.started_at_wall, - duration_ns=duration_ns, - layout=session.layout, - mode=session.mode, - lesson_alphabet=(), - focus_key=session.focus_key, - total_keystrokes=session.total_count, - correct_keystrokes=session.correct_count, - lang=session.lang, - ) + draft = _DraftTiming(started_at=session.started_at_wall, duration_ns=duration_ns) prior_headers = sorted( repo.iter_headers(session.layout), key=lambda h: h.started_at, )[-(settings.confidence_session_window - 1) :] - sessions: list[tuple[SessionResult, Iterable[Keystroke]]] = [ + sessions: list[tuple[SessionTiming, Iterable[Keystroke]]] = [ (header, repo.load_keystrokes(header.session_id)) for header in prior_headers ] sessions.append((draft, session.keystrokes)) diff --git a/src/keystrike/application/stats_use_cases.py b/src/keystrike/application/stats_use_cases.py index 4be82ab..5b47174 100644 --- a/src/keystrike/application/stats_use_cases.py +++ b/src/keystrike/application/stats_use_cases.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Iterator, Mapping +from collections.abc import Callable, Iterator, Mapping from dataclasses import dataclass from keystrike.domain.aggregate import combine_sessions, per_key_deltas @@ -215,14 +215,20 @@ def _windowed_session_replays( yield header, combined, session_target -def _key_metric_trends( +def _accumulate_windowed_trends( repo: SessionRepository, settings_repo: SettingsRepository, layout: str, - codepoint: int, - *, - current_target_speed_cpm: int = 0, + current_target_speed_cpm: int, + compute: Callable[[Mapping[int, KeyStats], float, int], tuple[float, float, float]], ) -> tuple[list[float], list[float], list[float]]: + """Shared windowed-replay accumulation for per-session trend lines. + + ``compute`` receives the combined per-key stats, the session's own target + speed (ms/char), and the settings' min-confidence-attempts, and returns + (raw speed confidence, accuracy, confidence) for that session — the raw + speed is then normalized to the caller's current target goal. + """ settings = settings_repo.load() window = settings.confidence_session_window fallback_target = target_ms_per_char(settings.target_speed_cpm) @@ -244,15 +250,7 @@ def _key_metric_trends( window, fallback_target, ): - key_stats = combined.get(codepoint) - if key_stats is None or (key_stats.samples == 0 and key_stats.error_count == 0): - speeds.append(0.0) - accuracies.append(0.0) - confidences.append(0.0) - continue - raw_speed = round_confidence( - key_confidence(session_target, key_stats.mean_time_ns), - ) + raw_speed, accuracy, confidence = compute(combined, session_target, min_attempts) speed = round_confidence( _normalize_speed_to_current_goal( raw_speed, @@ -260,14 +258,6 @@ def _key_metric_trends( current_target_speed_cpm, ), ) - accuracy = round_confidence(accuracy_of(key_stats)) - confidence = confidence_of( - codepoint, - combined, - session_target, - min_attempts=min_attempts, - ) - speeds.append(speed) accuracies.append(accuracy) confidences.append(confidence) @@ -275,53 +265,66 @@ def _key_metric_trends( return speeds, accuracies, confidences -def _aggregate_metric_trends( +def _key_metric_trends( repo: SessionRepository, settings_repo: SettingsRepository, layout: str, + codepoint: int, *, current_target_speed_cpm: int = 0, ) -> tuple[list[float], list[float], list[float]]: - settings = settings_repo.load() - window = settings.confidence_session_window - fallback_target = target_ms_per_char(settings.target_speed_cpm) - min_attempts = settings.min_confidence_attempts - - all_headers = sorted( - repo.iter_headers(layout), - key=lambda h: h.started_at, - ) - if not all_headers: - return [], [], [] - - speeds: list[float] = [] - accuracies: list[float] = [] - confidences: list[float] = [] - for header, combined, session_target in _windowed_session_replays( - repo, - all_headers, - window, - fallback_target, - ): - speed, accuracy = _aggregate_speed_accuracy(combined, session_target) - speed = round_confidence( - _normalize_speed_to_current_goal( - speed, - header.target_speed_cpm, - current_target_speed_cpm, - ), + def compute( + combined: Mapping[int, KeyStats], + session_target: float, + min_attempts: int, + ) -> tuple[float, float, float]: + key_stats = combined.get(codepoint) + if key_stats is None or (key_stats.samples == 0 and key_stats.error_count == 0): + return 0.0, 0.0, 0.0 + raw_speed = round_confidence( + key_confidence(session_target, key_stats.mean_time_ns), ) - confidence = _aggregate_confidence( + accuracy = round_confidence(accuracy_of(key_stats)) + confidence = confidence_of( + codepoint, combined, session_target, - min_attempts, + min_attempts=min_attempts, ) + return raw_speed, accuracy, confidence - speeds.append(speed) - accuracies.append(accuracy) - confidences.append(confidence) + return _accumulate_windowed_trends( + repo, + settings_repo, + layout, + current_target_speed_cpm, + compute, + ) - return speeds, accuracies, confidences + +def _aggregate_metric_trends( + repo: SessionRepository, + settings_repo: SettingsRepository, + layout: str, + *, + current_target_speed_cpm: int = 0, +) -> tuple[list[float], list[float], list[float]]: + def compute( + combined: Mapping[int, KeyStats], + session_target: float, + min_attempts: int, + ) -> tuple[float, float, float]: + speed, accuracy = _aggregate_speed_accuracy(combined, session_target) + confidence = _aggregate_confidence(combined, session_target, min_attempts) + return speed, accuracy, confidence + + return _accumulate_windowed_trends( + repo, + settings_repo, + layout, + current_target_speed_cpm, + compute, + ) @dataclass(slots=True) diff --git a/src/keystrike/domain/aggregate.py b/src/keystrike/domain/aggregate.py index 2a9d248..8bd6b73 100644 --- a/src/keystrike/domain/aggregate.py +++ b/src/keystrike/domain/aggregate.py @@ -9,9 +9,22 @@ from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field +from typing import Protocol from .confidence import SESSION_RECENCY_DECAY, HasConfidenceFields, is_same_key_transition -from .models import Bigram, KeyStats, Keystroke, LayoutAggregates, SessionResult, TransitionStats +from .models import Bigram, KeyStats, Keystroke, LayoutAggregates, TransitionStats + + +class SessionTiming(Protocol): + """Structural shape `aggregate_session`/`aggregate_transitions`/ + `combine_sessions` need from a session header: only the fields required to + timestamp and weight aggregated stats, not a full persisted `SessionResult`.""" + + @property + def started_at(self) -> float: ... + + @property + def duration_ns(self) -> int: ... @dataclass(slots=True) @@ -45,7 +58,7 @@ def per_key_deltas(keystrokes: Iterable[Keystroke]) -> dict[int, list[int]]: def aggregate_session( - result: SessionResult, + result: SessionTiming, keystrokes: Iterable[Keystroke], ) -> dict[int, KeyStats]: partial: dict[int, _Partial] = {} @@ -179,7 +192,7 @@ def _combine_transition_maps_weighted( def combine_sessions( - sessions: Sequence[tuple[SessionResult, Iterable[Keystroke]]], + sessions: Sequence[tuple[SessionTiming, Iterable[Keystroke]]], *, recency_decay: float = SESSION_RECENCY_DECAY, ) -> LayoutAggregates: @@ -220,41 +233,6 @@ def infer_key_stat_attempt_count(samples: int, error_count: int, attempt_count: return attempt_count -def _merge_pair(a: HasConfidenceFields, b: HasConfidenceFields) -> MergedFields: - """Unweighted merge of two same-key stats: the weight=1.0 case of - `_weighted_merge_fields`.""" - return _weighted_merge_fields([(a, 1.0), (b, 1.0)]) - - -def merge_key_stats(a: KeyStats, b: KeyStats) -> KeyStats: - if a.codepoint != b.codepoint: - raise ValueError(f"codepoint mismatch: {a.codepoint} vs {b.codepoint}") - merged = _merge_pair(a, b) - return KeyStats( - codepoint=a.codepoint, - samples=merged.samples, - mean_time_ns=merged.mean_time_ns, - error_count=merged.error_count, - last_seen=merged.last_seen, - attempt_count=merged.attempt_count, - ) - - -def combine(*maps: dict[int, KeyStats]) -> dict[int, KeyStats]: - out: dict[int, KeyStats] = {} - for m in maps: - for cp, k in m.items(): - out[cp] = merge_key_stats(out[cp], k) if cp in out else k - return out - - -def transition_key(prev_cp: int, next_cp: int) -> str: - """Display form of a bigram, e.g. `transition_key(ord("a"), ord("b")) == - "ab"`. Internal transition dicts are keyed by `Bigram` instances, not this - string — use this only for display/logging.""" - return Bigram(prev_cp, next_cp).chars() - - def without_same_key_transitions( transitions: Mapping[Bigram, TransitionStats], ) -> dict[Bigram, TransitionStats]: @@ -290,7 +268,7 @@ def per_transition_deltas(keystrokes: Iterable[Keystroke]) -> dict[Bigram, list[ def aggregate_transitions( - result: SessionResult, + result: SessionTiming, keystrokes: Iterable[Keystroke], ) -> dict[Bigram, TransitionStats]: partial: dict[Bigram, _Partial] = {} @@ -334,28 +312,3 @@ def aggregate_transitions( for key, p in partial.items() } ) - - -def merge_transition_stats(a: TransitionStats, b: TransitionStats) -> TransitionStats: - if a.prev_cp != b.prev_cp or a.next_cp != b.next_cp: - raise ValueError( - f"transition mismatch: {a.prev_cp}→{a.next_cp} vs {b.prev_cp}→{b.next_cp}", - ) - merged = _merge_pair(a, b) - return TransitionStats( - prev_cp=a.prev_cp, - next_cp=a.next_cp, - samples=merged.samples, - mean_time_ns=merged.mean_time_ns, - error_count=merged.error_count, - last_seen=merged.last_seen, - attempt_count=merged.attempt_count, - ) - - -def combine_transitions(*maps: dict[Bigram, TransitionStats]) -> dict[Bigram, TransitionStats]: - out: dict[Bigram, TransitionStats] = {} - for m in maps: - for key, t in m.items(): - out[key] = merge_transition_stats(out[key], t) if key in out else t - return without_same_key_transitions(out) diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index 845dd52..af1fa4c 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -39,6 +39,19 @@ def __post_init__(self) -> None: object.__setattr__(self, "words", tuple(self.words)) +@dataclass(frozen=True, slots=True) +class WeightedWordlist: + """A wordlist paired with its precomputed per-word sampling weight. + + Built once per lesson (weights depend on the fixed focus char/bigram for + that lesson) so ``words`` and ``weights`` can't drift out of index + alignment the way two independently-threaded parallel lists could. + """ + + words: tuple[str, ...] + weights: tuple[float, ...] + + def wordlist_weight_for_word( word: str, *, @@ -94,11 +107,11 @@ def generate_word( alphabet: frozenset[str], weighting: LessonWeighting | None = None, *, - wordlist_weights: list[float] | None = None, + weighted_wordlist: WeightedWordlist | None = None, ) -> str: weighting = weighting or LessonWeighting() if weighting.words: - word = self._generate_word_from_wordlist(alphabet, weighting, wordlist_weights) + word = self._generate_word_from_wordlist(alphabet, weighting, weighted_wordlist) if word is not None: return word return self._generate_word_via_markov(alphabet, weighting) @@ -107,10 +120,10 @@ def _generate_word_from_wordlist( self, alphabet: frozenset[str], weighting: LessonWeighting, - wordlist_weights: list[float] | None, + weighted_wordlist: WeightedWordlist | None, ) -> str | None: """Sample a dictionary word, or None if it doesn't fit length/alphabet bounds.""" - word = self._sample_from_wordlist(weighting, wordlist_weights) + word = self._sample_from_wordlist(weighting, weighted_wordlist) if MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN and set(word) <= alphabet: return word return None @@ -140,19 +153,22 @@ def generate_lesson( focus_bigram_str: str | None = None if focus_bigram is not None: focus_bigram_str = focus_bigram.chars() - wordlist_weights: list[float] | None = None + weighted_wordlist: WeightedWordlist | None = None if weighting.words and (weighting.char_weights or weighting.transition_weights): - wordlist_weights = [ - wordlist_weight_for_word( - w, - weighting=weighting, - focus_char=focus_char, - focus_bigram=focus_bigram_str, - ) - for w in weighting.words - ] + weighted_wordlist = WeightedWordlist( + words=weighting.words, + weights=tuple( + wordlist_weight_for_word( + w, + weighting=weighting, + focus_char=focus_char, + focus_bigram=focus_bigram_str, + ) + for w in weighting.words + ), + ) lesson_words = [ - self.generate_word(alphabet, weighting, wordlist_weights=wordlist_weights) + self.generate_word(alphabet, weighting, weighted_wordlist=weighted_wordlist) for _ in range(word_count) ] if focus_bigram is not None: @@ -173,11 +189,13 @@ def generate_lesson( def _sample_from_wordlist( self, weighting: LessonWeighting, - wordlist_weights: list[float] | None = None, + weighted_wordlist: WeightedWordlist | None = None, ) -> str: + if weighted_wordlist is not None: + return self.rng.choices( + weighted_wordlist.words, weights=weighted_wordlist.weights, k=1 + )[0] words = weighting.words or [] - if wordlist_weights is not None: - return self.rng.choices(words, weights=wordlist_weights, k=1)[0] if not weighting.char_weights and not weighting.transition_weights: return self.rng.choice(words) weights = [wordlist_weight_for_word(w, weighting=weighting) for w in words] diff --git a/src/keystrike/domain/protocols.py b/src/keystrike/domain/protocols.py index 9412656..3b581cb 100644 --- a/src/keystrike/domain/protocols.py +++ b/src/keystrike/domain/protocols.py @@ -1,3 +1,4 @@ +import datetime as dt from collections.abc import Iterable, Iterator, Mapping from typing import Protocol @@ -17,6 +18,7 @@ class Clock(Protocol): def now_ns(self) -> int: ... def wall_epoch(self) -> float: ... + def local_tzinfo(self) -> dt.tzinfo: ... class IdGenerator(Protocol): diff --git a/src/keystrike/domain/sync_merge.py b/src/keystrike/domain/sync_merge.py index 0655c84..ef2687d 100644 --- a/src/keystrike/domain/sync_merge.py +++ b/src/keystrike/domain/sync_merge.py @@ -18,27 +18,34 @@ from typing import Literal -def _entry_str(entry: Mapping[str, object], key: str) -> str: - """Read a required string field off a parsed session-index entry.""" - return str(entry[key]) - +@dataclass(frozen=True, slots=True) +class SessionIndexEntry: + """One parsed row of a sessions index file (`session_id`/`layout`/`started_at`).""" -def _entry_epoch(entry: Mapping[str, object], key: str) -> float: - """Read a required numeric (epoch seconds) field off a parsed session-index entry.""" - value = entry[key] - if not isinstance(value, (int, float)): - raise TypeError(f"expected numeric {key!r}, got {type(value).__name__}") - return float(value) + session_id: str + layout: str + started_at: float + + @classmethod + def from_dict(cls, raw: Mapping[str, object]) -> SessionIndexEntry: + started_at = raw["started_at"] + if not isinstance(started_at, (int, float)): + raise TypeError(f"expected numeric 'started_at', got {type(started_at).__name__}") + return cls( + session_id=str(raw["session_id"]), + layout=str(raw["layout"]), + started_at=float(started_at), + ) -def index_session_ids(entries: Sequence[Mapping[str, object]]) -> set[str]: +def index_session_ids(entries: Sequence[SessionIndexEntry]) -> set[str]: """Session ids present in a parsed sessions index.""" - return {_entry_str(entry, "session_id") for entry in entries} + return {entry.session_id for entry in entries} -def index_layouts(entries: Sequence[Mapping[str, object]]) -> set[str]: +def index_layouts(entries: Sequence[SessionIndexEntry]) -> set[str]: """Distinct layout names referenced by a parsed sessions index.""" - return {_entry_str(entry, "layout") for entry in entries} + return {entry.layout for entry in entries} @dataclass(frozen=True, slots=True) @@ -54,24 +61,23 @@ class SessionImportPlan: def plan_missing_sessions( *, local_session_ids: set[str], - remote_entries: Sequence[Mapping[str, object]], + remote_entries: Sequence[SessionIndexEntry], remote_lines: list[str], ) -> list[SessionImportPlan]: """Decide which remote sessions are missing locally and need importing. - `remote_entries`/`remote_lines` are the parsed-JSON and matching raw - (stripped) text of each line in the remote index, in file order. This does - not check whether the session's `.jsonl` file actually exists on disk — - the infrastructure executor skips any plan entry whose source file is - missing. + `remote_entries`/`remote_lines` are the parsed and matching raw (stripped) + text of each line in the remote index, in file order. This does not check + whether the session's `.jsonl` file actually exists on disk — the + infrastructure executor skips any plan entry whose source file is missing. """ seen = set(local_session_ids) plans: list[SessionImportPlan] = [] for entry, line in zip(remote_entries, remote_lines, strict=True): - session_id = _entry_str(entry, "session_id") + session_id = entry.session_id if session_id in seen: continue - started_at = _entry_epoch(entry, "started_at") + started_at = entry.started_at month = dt.datetime.fromtimestamp(started_at, tz=dt.UTC).strftime("%Y-%m") plans.append( SessionImportPlan( diff --git a/src/keystrike/infrastructure/aggregates_cache.py b/src/keystrike/infrastructure/aggregates_cache.py index 7725863..cf9e0cd 100644 --- a/src/keystrike/infrastructure/aggregates_cache.py +++ b/src/keystrike/infrastructure/aggregates_cache.py @@ -31,6 +31,34 @@ def _coerce_attempt_count(entry: dict[str, object], samples: int) -> int: return infer_key_stat_attempt_count(samples, errors, stored) +def _parse_transition_entry(entry: dict[str, object]) -> tuple[Bigram, TransitionStats]: + samples = _coerce_samples(entry) + return Bigram( + require_int(entry, "prev_cp"), + require_int(entry, "next_cp"), + ), TransitionStats( + prev_cp=require_int(entry, "prev_cp"), + next_cp=require_int(entry, "next_cp"), + samples=samples, + mean_time_ns=require_float(entry, "mean_time_ns"), + error_count=require_int(entry, "error_count"), + last_seen=require_float(entry, "last_seen"), + attempt_count=_coerce_attempt_count(entry, samples), + ) + + +def _parse_key_entry(cp: str, entry: dict[str, object]) -> KeyStats: + samples = _coerce_samples(entry) + return KeyStats( + codepoint=int(cp), + samples=samples, + mean_time_ns=require_float(entry, "mean_time_ns"), + error_count=require_int(entry, "error_count"), + last_seen=require_float(entry, "last_seen"), + attempt_count=_coerce_attempt_count(entry, samples), + ) + + class FileAggregatesCache: def __init__(self, paths: Paths) -> None: self._paths = paths @@ -47,41 +75,15 @@ def get(self, layout: str) -> LayoutAggregates | None: data = json.loads(file.read_text(encoding="utf-8")) keys = data.get("keys", {}) transitions = data.get("transitions", {}) - - def _transition(entry: dict[str, object]) -> tuple[Bigram, TransitionStats]: - samples = _coerce_samples(entry) - return Bigram( - require_int(entry, "prev_cp"), - require_int(entry, "next_cp"), - ), TransitionStats( - prev_cp=require_int(entry, "prev_cp"), - next_cp=require_int(entry, "next_cp"), - samples=samples, - mean_time_ns=require_float(entry, "mean_time_ns"), - error_count=require_int(entry, "error_count"), - last_seen=require_float(entry, "last_seen"), - attempt_count=_coerce_attempt_count(entry, samples), - ) - - def _key(cp: str, entry: dict[str, object]) -> KeyStats: - samples = _coerce_samples(entry) - return KeyStats( - codepoint=int(cp), - samples=samples, - mean_time_ns=require_float(entry, "mean_time_ns"), - error_count=require_int(entry, "error_count"), - last_seen=require_float(entry, "last_seen"), - attempt_count=_coerce_attempt_count(entry, samples), - ) - - parsed_transitions = dict(_transition(entry) for entry in transitions.values()) - aggregates = LayoutAggregates( - keys={int(cp): _key(cp, entry) for cp, entry in keys.items()}, + parsed_transitions = dict( + _parse_transition_entry(entry) for entry in transitions.values() + ) + return LayoutAggregates( + keys={int(cp): _parse_key_entry(cp, entry) for cp, entry in keys.items()}, transitions=without_same_key_transitions(parsed_transitions), ) except (json.JSONDecodeError, KeyError, TypeError, ValueError): return None - return aggregates def put(self, layout: str, aggregates: LayoutAggregates) -> None: transitions = without_same_key_transitions(aggregates.transitions) diff --git a/src/keystrike/infrastructure/clock.py b/src/keystrike/infrastructure/clock.py index abc70c2..7dce071 100644 --- a/src/keystrike/infrastructure/clock.py +++ b/src/keystrike/infrastructure/clock.py @@ -1,3 +1,4 @@ +import datetime as dt import time @@ -7,3 +8,6 @@ def now_ns(self) -> int: def wall_epoch(self) -> float: return time.time() + + def local_tzinfo(self) -> dt.tzinfo: + return dt.datetime.now().astimezone().tzinfo or dt.UTC diff --git a/src/keystrike/infrastructure/git_client.py b/src/keystrike/infrastructure/git_client.py index 6ac90ab..d69b0b0 100644 --- a/src/keystrike/infrastructure/git_client.py +++ b/src/keystrike/infrastructure/git_client.py @@ -9,6 +9,7 @@ import subprocess from pathlib import Path +from typing import Protocol # Network-bound git ops (clone/pull/push) can otherwise hang indefinitely # waiting on credentials, freezing the whole TUI. @@ -25,6 +26,21 @@ def __init__(self, message: str, *, stderr: str = "") -> None: self.stderr = stderr +class GitRunner(Protocol): + """Shape of `GitClient` as consumed by `GitSyncGateway` — an infra-internal + seam so tests can fake git subprocess calls without subclassing `GitClient`.""" + + def clone(self, url: str, dest: Path) -> None: ... + def init(self, cwd: Path) -> None: ... + def add_remote(self, cwd: Path, url: str) -> None: ... + def pull_ff_only(self, cwd: Path) -> None: ... + def add(self, cwd: Path, *paths: str) -> None: ... + def commit(self, cwd: Path, message: str) -> None: ... + def push(self, cwd: Path) -> None: ... + def status_porcelain(self, cwd: Path) -> str: ... + def status_short(self, cwd: Path) -> str: ... + + class GitClient: """Thin subprocess wrapper around the `git` CLI. diff --git a/src/keystrike/infrastructure/session_repo_jsonl.py b/src/keystrike/infrastructure/session_repo_jsonl.py index db10cff..0d794ea 100644 --- a/src/keystrike/infrastructure/session_repo_jsonl.py +++ b/src/keystrike/infrastructure/session_repo_jsonl.py @@ -123,13 +123,19 @@ def _read_keystrokes(file: Path) -> Iterator[Keystroke]: line = raw.strip() if not line: continue - d = json.loads(line) - yield Keystroke( - codepoint=d["codepoint"], - typed=d["typed"], - t_ns=d["t_ns"], - correct=d["correct"], - ) + try: + d = json.loads(line) + keystroke = Keystroke( + codepoint=d["codepoint"], + typed=d["typed"], + t_ns=d["t_ns"], + correct=d["correct"], + ) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + # Skip a corrupt/truncated keystroke row (e.g. a crash mid-append) + # rather than aborting the whole session's keystroke stream. + continue + yield keystroke def _header_to_dict(h: SessionResult) -> dict[str, object]: diff --git a/src/keystrike/infrastructure/settings_repo_toml.py b/src/keystrike/infrastructure/settings_repo_toml.py index 19a1a46..1a672a5 100644 --- a/src/keystrike/infrastructure/settings_repo_toml.py +++ b/src/keystrike/infrastructure/settings_repo_toml.py @@ -12,6 +12,7 @@ from .atomic_write import atomic_write_text from .paths import Paths +from .toml_escape import escape_toml_string def _fmt_scalar(v: object) -> str: @@ -22,8 +23,7 @@ def _fmt_scalar(v: object) -> str: if isinstance(v, float): return repr(v) if isinstance(v, str): - escaped = v.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' + return f'"{escape_toml_string(v)}"' raise TypeError(f"unsupported settings value type: {type(v).__name__}") diff --git a/src/keystrike/infrastructure/sync_git.py b/src/keystrike/infrastructure/sync_git.py index 8591ad0..a34f5aa 100644 --- a/src/keystrike/infrastructure/sync_git.py +++ b/src/keystrike/infrastructure/sync_git.py @@ -13,7 +13,7 @@ from keystrike.domain.models import SyncStatusReport from keystrike.domain.protocols import StatsRebuilder -from keystrike.infrastructure.git_client import GitClient, GitSyncError +from keystrike.infrastructure.git_client import GitClient, GitRunner, GitSyncError from keystrike.infrastructure.paths import Paths from keystrike.infrastructure.sync_merge_io import ( copy_file_if_exists, @@ -24,6 +24,7 @@ read_index_session_ids, resolve_settings_lww, ) +from keystrike.infrastructure.toml_escape import escape_toml_string from .atomic_write import atomic_write_text @@ -64,7 +65,7 @@ class SyncConfig: class GitSyncGateway: - def __init__(self, paths: Paths, *, client: GitClient | None = None) -> None: + def __init__(self, paths: Paths, *, client: GitRunner | None = None) -> None: self._paths = paths self._config_path = paths.sync_config_file self._clone_dir = paths.sync_clone_dir @@ -131,15 +132,12 @@ def status(self) -> SyncStatusReport: only_clone=len(clone_ids - local_ids), ) - def push_remote(self, message: str = "keystrike sync") -> bool: - return self._push_remote(message) - def _load_config(self) -> SyncConfig: raw = tomllib.loads(self._config_path.read_text(encoding="utf-8")) return SyncConfig(remote_url=str(raw["remote_url"])) def _save_config(self, config: SyncConfig) -> None: - escaped = config.remote_url.replace("\\", "\\\\").replace('"', '\\"') + escaped = escape_toml_string(config.remote_url) atomic_write_text(self._config_path, f'remote_url = "{escaped}"\n') def _clone(self, url: str) -> None: diff --git a/src/keystrike/infrastructure/sync_merge_io.py b/src/keystrike/infrastructure/sync_merge_io.py index 0c2b19d..977c8fc 100644 --- a/src/keystrike/infrastructure/sync_merge_io.py +++ b/src/keystrike/infrastructure/sync_merge_io.py @@ -12,6 +12,7 @@ from pathlib import Path from keystrike.domain.sync_merge import ( + SessionIndexEntry, decide_settings_winner, index_layouts, index_session_ids, @@ -21,18 +22,25 @@ ) -def _read_index(path: Path) -> tuple[list[dict[str, object]], list[str]]: - """Load a sessions index file into parsed entries + matching raw lines.""" +def _read_index(path: Path) -> tuple[list[SessionIndexEntry], list[str]]: + """Load a sessions index file into parsed entries + matching raw lines. + + Corrupt/truncated rows are skipped rather than aborting the whole sync. + """ if not path.is_file(): return [], [] - entries: list[dict[str, object]] = [] + entries: list[SessionIndexEntry] = [] lines: list[str] = [] with path.open(encoding="utf-8") as fh: for raw in fh: line = raw.strip() if not line: continue - entries.append(json.loads(line)) + try: + entry = SessionIndexEntry.from_dict(json.loads(line)) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + continue + entries.append(entry) lines.append(line) return entries, lines diff --git a/src/keystrike/infrastructure/toml_escape.py b/src/keystrike/infrastructure/toml_escape.py new file mode 100644 index 0000000..cf0f714 --- /dev/null +++ b/src/keystrike/infrastructure/toml_escape.py @@ -0,0 +1,8 @@ +"""Shared TOML basic-string escaping for hand-written TOML writers.""" + +from __future__ import annotations + + +def escape_toml_string(value: str) -> str: + """Escape `value` for embedding in a TOML basic string (`"..."`).""" + return value.replace("\\", "\\\\").replace('"', '\\"') diff --git a/src/keystrike/presentation/formatting/trends.py b/src/keystrike/presentation/formatting/trends.py index b34484e..33ce61f 100644 --- a/src/keystrike/presentation/formatting/trends.py +++ b/src/keystrike/presentation/formatting/trends.py @@ -483,14 +483,12 @@ def _format_metric_delta( current: float, previous: float, *, - higher_is_better: bool = True, suffix: str = "", ) -> str: delta = current - previous if round(abs(delta), 1) == 0: return "" - improved = delta > 0 if higher_is_better else delta < 0 - color = STYLE_DELTA_IMPROVE if improved else STYLE_DELTA_REGRESS + color = STYLE_DELTA_IMPROVE if delta > 0 else STYLE_DELTA_REGRESS arrow = "↑" if delta > 0 else "↓" return f" [{color}]{arrow}{abs(delta):.1f}{suffix}[/]" diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index 7c78f1d..3d8fcf4 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -15,7 +15,9 @@ from keystrike.presentation.services import PracticeServices from keystrike.presentation.widgets.hud import HUD from keystrike.presentation.widgets.kb_heatmap import ( + HeatmapDisplay, KbHeatmap, + build_heatmap_display, focus_transition_pair, format_focus_note, ) @@ -73,11 +75,12 @@ def compose(self) -> ComposeResult: yield self._typing_area if self._prep.layout_obj is not None and self._prep.lesson_heatmap is not None: self._kb_heatmap = KbHeatmap( - self._prep.layout_obj, - self._prep.lesson_heatmap, - focus=self._prep.focus_key, - urgency=None, - focus_transition=focus_transition_pair(self._prep.focus_reason), + HeatmapDisplay( + self._prep.layout_obj, + self._prep.lesson_heatmap, + focus=self._prep.focus_key, + focus_transition=focus_transition_pair(self._prep.focus_reason), + ) ) yield self._kb_heatmap yield Static( @@ -164,11 +167,12 @@ def _begin_session(self, prep: SessionPrep) -> None: ) KbHeatmap.update_or_none( self._kb_heatmap, - prep.layout_obj, - prep.lesson_heatmap, - focus=prep.focus_key, - urgency=None, - focus_transition=focus_transition_pair(prep.focus_reason), + build_heatmap_display( + prep.layout_obj, + prep.lesson_heatmap, + focus=prep.focus_key, + focus_transition=focus_transition_pair(prep.focus_reason), + ), ) self._refresh_focus_note() diff --git a/src/keystrike/presentation/screens/settings.py b/src/keystrike/presentation/screens/settings.py index 5894c96..c44734d 100644 --- a/src/keystrike/presentation/screens/settings.py +++ b/src/keystrike/presentation/screens/settings.py @@ -107,10 +107,10 @@ def action_clear_wordlist(self) -> None: self._do_clear() def _collect_form_values(self) -> SettingsUpdate | None: - speed_raw = self.query_one("#settings-speed", Input).value - target_speed_value = self._parse_int_field(speed_raw) + target_speed_value = self._required_int( + "#settings-speed", "Target speed must be an integer." + ) if target_speed_value is None: - self._show_error("Target speed must be an integer.") return None speed_unit_select = cast( @@ -126,16 +126,16 @@ def _collect_form_values(self) -> SettingsUpdate | None: else target_speed_value ) - alphabet_size_raw = self.query_one("#settings-alphabet-size", Input).value - alphabet_size = self._parse_int_field(alphabet_size_raw) + alphabet_size = self._required_int( + "#settings-alphabet-size", "Number of letters must be an integer." + ) if alphabet_size is None: - self._show_error("Number of letters must be an integer.") return None - learn_daily_minutes_raw = self.query_one("#settings-learn-daily-minutes", Input).value - learn_daily_minutes = self._parse_int_field(learn_daily_minutes_raw) + learn_daily_minutes = self._required_int( + "#settings-learn-daily-minutes", "Daily learn minutes must be an integer." + ) if learn_daily_minutes is None: - self._show_error("Daily learn minutes must be an integer.") return None layout_select = cast("Select[str]", self.query_one("#settings-layout", Select)) @@ -219,6 +219,12 @@ def _refresh_layout_select(self) -> None: def _show_error(self, message: str) -> None: self.query_one("#settings-error", Static).update(f"[bold red]{message}[/]") + def _required_int(self, widget_id: str, error: str) -> int | None: + value = self._parse_int_field(self.query_one(widget_id, Input).value) + if value is None: + self._show_error(error) + return value + @staticmethod def _parse_int_field(raw: str) -> int | None: try: diff --git a/src/keystrike/presentation/screens/stats.py b/src/keystrike/presentation/screens/stats.py index 69cafc2..8e2e6ed 100644 --- a/src/keystrike/presentation/screens/stats.py +++ b/src/keystrike/presentation/screens/stats.py @@ -17,7 +17,11 @@ format_key_metric_trend_block, ) from keystrike.presentation.services import StatsServices -from keystrike.presentation.widgets.kb_heatmap import KbHeatmap +from keystrike.presentation.widgets.kb_heatmap import ( + HeatmapDisplay, + KbHeatmap, + build_heatmap_display, +) _View = Literal["overview", "key_detail"] @@ -72,9 +76,7 @@ def on_mount(self) -> None: self.query_one("#stats-title", Static).update(title) self._kb_heatmap = KbHeatmap( - layout, - heatmap_view.confidence, - urgency=heatmap_view.urgency, + HeatmapDisplay(layout, heatmap_view.confidence, urgency=heatmap_view.urgency) ) self.query_one(Vertical).mount( Static("[dim]vs current goal[/]", id="stats-heatmap-caption"), @@ -170,9 +172,11 @@ def _show_overview(self) -> None: self._render_overview() KbHeatmap.update_or_none( self._kb_heatmap, - self._layout, - self._heatmap.confidence if self._heatmap is not None else None, - urgency=self._heatmap.urgency if self._heatmap is not None else None, + build_heatmap_display( + self._layout, + self._heatmap.confidence if self._heatmap is not None else None, + urgency=self._heatmap.urgency if self._heatmap is not None else None, + ), ) def _show_key_detail(self, codepoint: int) -> None: @@ -180,8 +184,10 @@ def _show_key_detail(self, codepoint: int) -> None: self._render_key_detail(codepoint) KbHeatmap.update_or_none( self._kb_heatmap, - self._layout, - self._heatmap.confidence if self._heatmap is not None else None, - focus=codepoint, - urgency=self._heatmap.urgency if self._heatmap is not None else None, + build_heatmap_display( + self._layout, + self._heatmap.confidence if self._heatmap is not None else None, + focus=codepoint, + urgency=self._heatmap.urgency if self._heatmap is not None else None, + ), ) diff --git a/src/keystrike/presentation/services.py b/src/keystrike/presentation/services.py index a9ab94e..c80fee3 100644 --- a/src/keystrike/presentation/services.py +++ b/src/keystrike/presentation/services.py @@ -57,7 +57,6 @@ class PracticeServices: @dataclass(frozen=True, slots=True) class StatsServices: - settings_repo: SettingsRepository layout_repo: LayoutRepository rebuild_aggregates: StatsRebuilder get_heatmap: GetHeatmap diff --git a/src/keystrike/presentation/textual_app.py b/src/keystrike/presentation/textual_app.py index c7a775e..c382180 100644 --- a/src/keystrike/presentation/textual_app.py +++ b/src/keystrike/presentation/textual_app.py @@ -59,7 +59,7 @@ def on_home_screen_start_practice(self, _: HomeScreen.StartPractice) -> None: self.push_screen(PracticeScreen(services=self._practice, initial=initial)) def on_home_screen_open_stats(self, _: HomeScreen.OpenStats) -> None: - settings = self._stats.settings_repo.load() + settings = self._home.settings_repo.load() self.push_screen( StatsScreen( layout=settings.layout, diff --git a/src/keystrike/presentation/widgets/kb_heatmap.py b/src/keystrike/presentation/widgets/kb_heatmap.py index c7fea0e..33419ee 100644 --- a/src/keystrike/presentation/widgets/kb_heatmap.py +++ b/src/keystrike/presentation/widgets/kb_heatmap.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from rich.text import Text from textual.app import ComposeResult from textual.widget import Widget @@ -72,27 +74,59 @@ def _key_style( return style -def render_heatmap( - layout: Layout, - heatmap: dict[int, float], +@dataclass(frozen=True, slots=True) +class HeatmapDisplay: + """Everything needed to render/refresh a keyboard heatmap, bundled so it + travels as one value instead of five loose, always-parallel parameters.""" + + layout: Layout + heatmap: dict[int, float] + focus: int | None = None + urgency: dict[int, float] | None = None + focus_transition: tuple[int, int] | None = None + + +def build_heatmap_display( + layout: Layout | None, + heatmap: dict[int, float] | None, + *, focus: int | None = None, urgency: dict[int, float] | None = None, - *, focus_transition: tuple[int, int] | None = None, -) -> Text: +) -> HeatmapDisplay | None: + """`HeatmapDisplay(...)`, or None if the layout/heatmap data isn't ready yet.""" + if layout is None or heatmap is None: + return None + return HeatmapDisplay( + layout, + heatmap, + focus=focus, + urgency=urgency, + focus_transition=focus_transition, + ) + + +def render_heatmap(display: HeatmapDisplay) -> Text: """Render the layout's alpha rows as a 3x10 ASCII grid, colored by confidence. Staggered (traditional) layouts get a 2-space indent per row, approximating the physical row-shift of a regular keyboard. Ortholinear layouts render with columns aligned instead — there's no physical stagger to show. - `focus`, if given, adds an underline on top of the key's confidence color — - cyan when mastered (confidence >= 1.0), plain underline when still weak. - Used by Practice to call out today's lesson focus without hiding yellow/red. + `display.focus`, if given, adds an underline on top of the key's confidence + color — cyan when mastered (confidence >= 1.0), plain underline when still + weak. Used by Practice to call out today's lesson focus without hiding + yellow/red. Keys with review urgency > 0 get a magenta underline on top of their confidence color so stale-but-mastered keys stand apart from merely weak ones. """ + layout = display.layout + heatmap = display.heatmap + focus = display.focus + urgency = display.urgency + focus_transition = display.focus_transition + grid: list[list[tuple[str, str] | None]] = [[None] * _COLS for _ in range(_ROWS)] for cp, pos in layout.keys.items(): if pos.row >= _ROWS: @@ -171,79 +205,24 @@ class KbHeatmap(Widget): } """ - def __init__( - self, - layout: Layout, - heatmap: dict[int, float], - *, - focus: int | None = None, - urgency: dict[int, float] | None = None, - focus_transition: tuple[int, int] | None = None, - ) -> None: + def __init__(self, display: HeatmapDisplay) -> None: super().__init__() - self._layout = layout - self._heatmap = heatmap - self._focus = focus - self._urgency = urgency - self._focus_transition = focus_transition + self._display = display def compose(self) -> ComposeResult: - yield Static( - render_heatmap( - self._layout, - self._heatmap, - self._focus, - self._urgency, - focus_transition=self._focus_transition, - ), - id="kb-heatmap-text", - ) + yield Static(render_heatmap(self._display), id="kb-heatmap-text") - def refresh_heatmap( - self, - layout: Layout, - heatmap: dict[int, float], - *, - focus: int | None = None, - urgency: dict[int, float] | None = None, - focus_transition: tuple[int, int] | None = None, - ) -> None: - self._layout = layout - self._heatmap = heatmap - self._focus = focus - self._urgency = urgency - self._focus_transition = focus_transition - self.query_one("#kb-heatmap-text", Static).update( - render_heatmap( - layout, - heatmap, - focus, - urgency, - focus_transition=focus_transition, - ), - ) + def refresh_heatmap(self, display: HeatmapDisplay) -> None: + self._display = display + self.query_one("#kb-heatmap-text", Static).update(render_heatmap(display)) @staticmethod - def update_or_none( - widget: "KbHeatmap | None", - layout: Layout | None, - heatmap: dict[int, float] | None, - *, - focus: int | None = None, - urgency: dict[int, float] | None = None, - focus_transition: tuple[int, int] | None = None, - ) -> None: + def update_or_none(widget: "KbHeatmap | None", display: HeatmapDisplay | None) -> None: """Refresh `widget` if it and the required layout/heatmap data are present. - Centralizes the "if self._kb_heatmap is not None and layout/heatmap - present" guard that would otherwise be duplicated at every call site. + Centralizes the "if self._kb_heatmap is not None and display present" + guard that would otherwise be duplicated at every call site. """ - if widget is None or layout is None or heatmap is None: + if widget is None or display is None: return - widget.refresh_heatmap( - layout, - heatmap, - focus=focus, - urgency=urgency, - focus_transition=focus_transition, - ) + widget.refresh_heatmap(display) diff --git a/tests/application/test_learn_budget_use_cases.py b/tests/application/test_learn_budget_use_cases.py index 76f94be..ed3dd1d 100644 --- a/tests/application/test_learn_budget_use_cases.py +++ b/tests/application/test_learn_budget_use_cases.py @@ -48,3 +48,15 @@ def test_get_daily_learn_budget_includes_extra_ns_for_active_session(): )(extra_ns=2 * 60 * 1_000_000_000) assert budget.limit_reached + + +def test_get_daily_learn_budget_falls_back_to_clocks_local_tzinfo(): + clock = FakeClock(wall=dt.datetime(2026, 7, 28, 18, 0, tzinfo=_TZ).timestamp(), tz=_TZ) + repo = FakeSessionRepository(headers=[_adaptive_header(5 * 60 * 1_000_000_000)]) + settings_repo = FakeSettingsRepository(Settings(learn_daily_minutes=10)) + + budget = GetDailyLearnBudget(clock=clock, repo=repo, settings_repo=settings_repo)() + + assert budget.limited + assert budget.remaining_ns == 5 * 60 * 1_000_000_000 + assert not budget.limit_reached diff --git a/tests/domain/test_aggregate.py b/tests/domain/test_aggregate.py index df9575b..4b912e1 100644 --- a/tests/domain/test_aggregate.py +++ b/tests/domain/test_aggregate.py @@ -1,16 +1,12 @@ from keystrike.domain.aggregate import ( + _combine_key_maps_weighted, _combine_transition_maps_weighted, aggregate_session, aggregate_transitions, - combine, combine_sessions, - combine_transitions, - merge_key_stats, - merge_transition_stats, per_key_deltas, per_transition_deltas, session_recency_weights, - transition_key, ) from keystrike.domain.confidence import ( SESSION_RECENCY_DECAY, @@ -19,7 +15,7 @@ transition_confidence_of, ) from keystrike.domain.enums import Mode -from keystrike.domain.models import Bigram, KeyStats, Keystroke, SessionResult, TransitionStats +from keystrike.domain.models import Bigram, Keystroke, SessionResult, TransitionStats def _session( @@ -120,29 +116,6 @@ def test_per_key_deltas_separates_by_codepoint(): assert deltas[ord("b")] == [50_000_000] -def test_merge_weighted_mean(): - a = KeyStats(codepoint=ord("x"), samples=2, mean_time_ns=100.0, error_count=1, last_seen=100.0) - b = KeyStats(codepoint=ord("x"), samples=3, mean_time_ns=200.0, error_count=2, last_seen=200.0) - m = merge_key_stats(a, b) - assert m.samples == 5 - # (2*100 + 3*200) / 5 = 160 - assert abs(m.mean_time_ns - 160.0) < 1e-9 - assert m.error_count == 3 - assert m.last_seen == 200.0 - - -def test_combine_multiple_maps(): - a = {ord("x"): KeyStats(ord("x"), 1, 100.0, 0, 1.0)} - b = { - ord("x"): KeyStats(ord("x"), 1, 300.0, 1, 2.0), - ord("y"): KeyStats(ord("y"), 2, 50.0, 0, 3.0), - } - out = combine(a, b) - assert out[ord("x")].samples == 2 - assert abs(out[ord("x")].mean_time_ns - 200.0) < 1e-9 - assert out[ord("y")].samples == 2 - - def test_session_recency_weights_newest_is_one(): assert session_recency_weights(1) == [1.0] assert session_recency_weights(3) == [ @@ -202,9 +175,12 @@ def test_combine_sessions_recent_errors_weigh_more_on_confidence(): Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=150_000_000, correct=True), ] stats = combine_sessions([(s1, keys1), (s2, keys2)]).keys - equal_weight = combine( - {ord("a"): aggregate_session(s1, keys1)[ord("a")]}, - {ord("a"): aggregate_session(s2, keys2)[ord("a")]}, + equal_weight = _combine_key_maps_weighted( + [ + {ord("a"): aggregate_session(s1, keys1)[ord("a")]}, + {ord("a"): aggregate_session(s2, keys2)[ord("a")]}, + ], + [1.0, 1.0], ) assert confidence_of(ord("a"), stats, target=200.0) < confidence_of( ord("a"), @@ -281,41 +257,14 @@ def test_aggregate_transitions_skips_error_on_first_keystroke(): assert transitions == {} -def test_merge_transition_stats_weighted_mean(): - a = TransitionStats(ord("a"), ord("b"), 2, 100.0, 1, 100.0) - b = TransitionStats(ord("a"), ord("b"), 3, 200.0, 2, 200.0) - merged = merge_transition_stats(a, b) - assert merged.samples == 5 - assert abs(merged.mean_time_ns - 160.0) < 1e-9 - assert merged.error_count == 3 - - -def test_combine_transitions_multiple_maps(): - ab_key = Bigram(ord("a"), ord("b")) - bc_key = Bigram(ord("b"), ord("c")) - a = {ab_key: TransitionStats(ord("a"), ord("b"), 1, 100.0, 0, 1.0)} - b = { - ab_key: TransitionStats(ord("a"), ord("b"), 1, 300.0, 1, 2.0), - bc_key: TransitionStats(ord("b"), ord("c"), 2, 50.0, 0, 3.0), - } - out = combine_transitions(a, b) - assert out[ab_key].samples == 2 - assert abs(out[ab_key].mean_time_ns - 200.0) < 1e-9 - assert out[bc_key].samples == 2 - - -def test_combine_transitions_drops_same_key_pairs(): +def test_combine_transition_maps_weighted_drops_same_key_pairs(): stale = {Bigram(ord("a"), ord("a")): TransitionStats(ord("a"), ord("a"), 1, 100.0, 0, 1.0)} valid = {Bigram(ord("a"), ord("b")): TransitionStats(ord("a"), ord("b"), 1, 100.0, 0, 1.0)} - out = combine_transitions(stale, valid) + out = _combine_transition_maps_weighted([stale, valid], [1.0, 1.0]) assert Bigram(ord("a"), ord("a")) not in out assert Bigram(ord("a"), ord("b")) in out -def test_transition_key_format(): - assert transition_key(ord("a"), ord("b")) == "ab" - - def test_combine_sessions_keeps_transition_samples_when_weight_rounds_down(): s1 = _session("s1", started_at=1.0) s2 = _session("s2", started_at=2.0) diff --git a/tests/domain/test_sync_merge.py b/tests/domain/test_sync_merge.py index 61fc8bb..c05698c 100644 --- a/tests/domain/test_sync_merge.py +++ b/tests/domain/test_sync_merge.py @@ -1,4 +1,5 @@ from keystrike.domain.sync_merge import ( + SessionIndexEntry, decide_settings_winner, index_layouts, index_session_ids, @@ -10,19 +11,8 @@ def _entry( sid: str, started_at: float = 1_700_000_000.0, layout: str = "qwerty" -) -> dict[str, object]: - return { - "schema_version": 1, - "session_id": sid, - "started_at": started_at, - "duration_ns": 1, - "layout": layout, - "mode": "adaptive", - "lesson_alphabet": [97], - "focus_key": None, - "total_keystrokes": 1, - "correct_keystrokes": 1, - } +) -> SessionIndexEntry: + return SessionIndexEntry(session_id=sid, layout=layout, started_at=started_at) def test_index_session_ids_extracts_ids(): diff --git a/tests/fakes.py b/tests/fakes.py index 7c9a86a..77ad861 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime as dt from collections.abc import Iterable, Iterator from dataclasses import dataclass, field from itertools import count @@ -22,6 +23,7 @@ class FakeClock: t_ns: int = 0 wall: float = 1_700_000_000.0 + tz: dt.tzinfo = dt.UTC def now_ns(self) -> int: return self.t_ns @@ -29,6 +31,9 @@ def now_ns(self) -> int: def wall_epoch(self) -> float: return self.wall + def local_tzinfo(self) -> dt.tzinfo: + return self.tz + def advance(self, ns: int) -> None: self.t_ns += ns diff --git a/tests/infrastructure/test_session_repo_jsonl.py b/tests/infrastructure/test_session_repo_jsonl.py index c81d5e3..f53e3cc 100644 --- a/tests/infrastructure/test_session_repo_jsonl.py +++ b/tests/infrastructure/test_session_repo_jsonl.py @@ -3,7 +3,7 @@ from keystrike.domain.enums import Mode from keystrike.domain.models import Keystroke, SessionResult from keystrike.infrastructure.paths import Paths -from keystrike.infrastructure.session_repo_jsonl import JsonlSessionRepository +from keystrike.infrastructure.session_repo_jsonl import JsonlSessionRepository, _session_file @pytest.fixture @@ -201,6 +201,29 @@ def test_append_keystrokes_bulk_writes_all_in_one_open(paths): assert ks[2].correct is False +def test_corrupt_keystroke_line_is_skipped_not_fatal(paths): + repo = JsonlSessionRepository(paths) + header = _header(sid="S6") + repo.append_keystroke( + header.session_id, + header.started_at, + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + ) + with _session_file(paths, header).open("a", encoding="utf-8") as fh: + fh.write("{not valid json\n") + fh.write('{"codepoint": 1}\n') # valid JSON, missing required fields + repo.append_keystroke( + header.session_id, + header.started_at, + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100, correct=True), + ) + repo.save_header(header) + + repo2 = JsonlSessionRepository(paths) + ks = list(repo2.load_keystrokes("S6")) + assert [k.codepoint for k in ks] == [ord("a"), ord("b")] + + def test_load_keystrokes_scan_fallback_when_sessions_dir_missing(tmp_path): empty_paths = Paths( config_dir=tmp_path / "config2", diff --git a/tests/infrastructure/test_sync_git.py b/tests/infrastructure/test_sync_git.py index 8b59d6a..10f8b40 100644 --- a/tests/infrastructure/test_sync_git.py +++ b/tests/infrastructure/test_sync_git.py @@ -274,17 +274,14 @@ def test_copy_file_if_exists_false_when_missing(tree: dict[str, Path]) -> None: assert not dest.exists() -class _FakeGitClient(GitClient): - """Records calls and raises canned errors instead of shelling out to git.""" +class _FakeGitClient: + """Fakes the `GitRunner` seam: records calls, raises canned errors, no real git.""" def __init__(self) -> None: self.calls: list[tuple[str, ...]] = [] self.clone_error: GitSyncError | None = None self.porcelain_status = " M settings.toml" - def run(self, *args: str, cwd: Path | None = None) -> str: # pragma: no cover - raise AssertionError(f"unexpected raw run() call: {args}") - def clone(self, url: str, dest: Path) -> None: self.calls.append(("clone", url, str(dest))) if self.clone_error is not None: diff --git a/tests/presentation/test_kb_heatmap.py b/tests/presentation/test_kb_heatmap.py index 537694c..6081f28 100644 --- a/tests/presentation/test_kb_heatmap.py +++ b/tests/presentation/test_kb_heatmap.py @@ -4,6 +4,7 @@ from keystrike.infrastructure.bundled_layouts.colemak_dh import LAYOUT as COLEMAK_DH from keystrike.infrastructure.bundled_layouts.qwerty import LAYOUT as QWERTY from keystrike.presentation.widgets.kb_heatmap import ( + HeatmapDisplay, focus_transition_pair, format_focus_note, render_heatmap, @@ -11,20 +12,20 @@ def test_render_heatmap_includes_every_alpha_key_once(): - text = render_heatmap(QWERTY, {}) + text = render_heatmap(HeatmapDisplay(QWERTY, {})) plain = text.plain for ch in "asdfghjkl": assert plain.count(ch) == 1 def test_render_heatmap_excludes_space_row(): - text = render_heatmap(QWERTY, {}) + text = render_heatmap(HeatmapDisplay(QWERTY, {})) assert text.plain.count("\n") == 3 def test_render_heatmap_colors_by_confidence(): - high = render_heatmap(QWERTY, {ord("a"): 1.5}) - low = render_heatmap(QWERTY, {ord("a"): 0.1}) + high = render_heatmap(HeatmapDisplay(QWERTY, {ord("a"): 1.5})) + low = render_heatmap(HeatmapDisplay(QWERTY, {ord("a"): 0.1})) a_span_high = next(s for s in high.spans if high.plain[s.start : s.end].strip() == "a") a_span_low = next(s for s in low.spans if low.plain[s.start : s.end].strip() == "a") assert a_span_high.style != a_span_low.style @@ -32,20 +33,20 @@ def test_render_heatmap_colors_by_confidence(): def test_staggered_layout_indents_each_row(): # Base cell padding is 1 leading space; staggered rows add 2 more per row. - lines = render_heatmap(QWERTY, {}).plain.splitlines() + lines = render_heatmap(HeatmapDisplay(QWERTY, {})).plain.splitlines() leading_spaces = [len(line) - len(line.lstrip(" ")) for line in lines] assert leading_spaces == [1, 3, 5] def test_ortholinear_layout_has_no_row_indent(): # Columns line up: only the base cell padding, no extra per-row offset. - lines = render_heatmap(COLEMAK_DH, {}).plain.splitlines() + lines = render_heatmap(HeatmapDisplay(COLEMAK_DH, {})).plain.splitlines() leading_spaces = [len(line) - len(line.lstrip(" ")) for line in lines] assert leading_spaces == [1, 1, 1] def test_focus_mastered_key_keeps_green_with_cyan_underline(): - text = render_heatmap(QWERTY, {ord("a"): 1.5}, focus=ord("a")) + text = render_heatmap(HeatmapDisplay(QWERTY, {ord("a"): 1.5}, focus=ord("a"))) span = next(s for s in text.spans if text.plain[s.start : s.end].strip() == "a") style = str(span.style) assert "green" in style @@ -53,7 +54,7 @@ def test_focus_mastered_key_keeps_green_with_cyan_underline(): def test_focus_weak_key_keeps_confidence_color(): - text = render_heatmap(QWERTY, {ord("a"): 0.89}, focus=ord("a")) + text = render_heatmap(HeatmapDisplay(QWERTY, {ord("a"): 0.89}, focus=ord("a"))) span = next(s for s in text.spans if text.plain[s.start : s.end].strip() == "a") style = str(span.style) assert "yellow" in style @@ -63,7 +64,7 @@ def test_focus_weak_key_keeps_confidence_color(): def test_focus_key_style_applies_even_without_heatmap_entry(): # A locked/never-practiced key can still be the focus (e.g. freshly unlocked). - text = render_heatmap(QWERTY, {}, focus=ord("a")) + text = render_heatmap(HeatmapDisplay(QWERTY, {}, focus=ord("a"))) span = next(s for s in text.spans if text.plain[s.start : s.end].strip() == "a") style = str(span.style) assert "grey37" in style @@ -72,7 +73,7 @@ def test_focus_key_style_applies_even_without_heatmap_entry(): def test_render_heatmap_marks_due_for_review_with_magenta_underline(): - text = render_heatmap(QWERTY, {ord("a"): 1.5}, urgency={ord("a"): 0.5}) + text = render_heatmap(HeatmapDisplay(QWERTY, {ord("a"): 1.5}, urgency={ord("a"): 0.5})) span = next(s for s in text.spans if text.plain[s.start : s.end].strip() == "a") style = str(span.style) assert "magenta" in style @@ -80,7 +81,7 @@ def test_render_heatmap_marks_due_for_review_with_magenta_underline(): def test_render_heatmap_weak_key_without_urgency_stays_red_only(): - text = render_heatmap(QWERTY, {ord("a"): 0.1}, urgency={ord("a"): 0.0}) + text = render_heatmap(HeatmapDisplay(QWERTY, {ord("a"): 0.1}, urgency={ord("a"): 0.0})) span = next(s for s in text.spans if text.plain[s.start : s.end].strip() == "a") style = str(span.style) assert "magenta" not in style @@ -89,10 +90,12 @@ def test_render_heatmap_weak_key_without_urgency_stays_red_only(): def test_render_heatmap_focus_style_layers_on_review_underline(): text = render_heatmap( - QWERTY, - {ord("a"): 1.5}, - focus=ord("a"), - urgency={ord("a"): 1.0}, + HeatmapDisplay( + QWERTY, + {ord("a"): 1.5}, + focus=ord("a"), + urgency={ord("a"): 1.0}, + ) ) span = next(s for s in text.spans if text.plain[s.start : s.end].strip() == "a") style = str(span.style) @@ -154,10 +157,12 @@ def test_focus_transition_pair_returns_pair_for_transition_kinds(): def test_render_heatmap_highlights_both_transition_keys(): text = render_heatmap( - QWERTY, - {ord("e"): 0.89, ord("o"): 0.89}, - focus=ord("o"), - focus_transition=(ord("e"), ord("o")), + HeatmapDisplay( + QWERTY, + {ord("e"): 0.89, ord("o"): 0.89}, + focus=ord("o"), + focus_transition=(ord("e"), ord("o")), + ) ) for ch in "eo": span = next(s for s in text.spans if text.plain[s.start : s.end].strip() == ch) diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index e63530a..d2eafa1 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -130,7 +130,6 @@ def _build_app( get_daily_learn_budget=get_daily_learn_budget, ), stats=StatsServices( - settings_repo=settings_repo, layout_repo=layout_repo, rebuild_aggregates=rebuild_aggregates, get_heatmap=GetHeatmap(cache=cache, settings_repo=settings_repo, clock=clock), diff --git a/tests/presentation/test_stats_screen.py b/tests/presentation/test_stats_screen.py index 02e7200..c4cbd57 100644 --- a/tests/presentation/test_stats_screen.py +++ b/tests/presentation/test_stats_screen.py @@ -43,7 +43,6 @@ def _build_screen( return StatsScreen( layout=layout, services=StatsServices( - settings_repo=settings_repo, layout_repo=layout_repo, rebuild_aggregates=RebuildAggregates( repo=repo, From 14a7450a1ac3efda8ae43e6e0f5b70b801acdbaa Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 30 Jul 2026 17:25:37 +0300 Subject: [PATCH 23/47] refactor --- src/keystrike/application/build_lesson.py | 43 ++++-- src/keystrike/application/prepare_practice.py | 3 - .../application/session_use_cases.py | 8 - src/keystrike/application/stats_use_cases.py | 61 +++----- src/keystrike/cli.py | 29 ++-- src/keystrike/domain/confidence.py | 4 - src/keystrike/domain/daily_learn.py | 9 +- src/keystrike/domain/markov.py | 6 +- src/keystrike/domain/sync_merge.py | 21 ++- .../infrastructure/aggregates_cache.py | 4 +- src/keystrike/infrastructure/layout_repo.py | 5 +- src/keystrike/infrastructure/paths.py | 9 ++ .../infrastructure/session_repo_jsonl.py | 7 +- .../presentation/formatting/trends.py | 139 ----------------- .../presentation/screens/practice.py | 17 +-- src/keystrike/presentation/screens/stats.py | 12 +- tests/application/test_stats_use_cases.py | 53 ------- tests/domain/test_markov.py | 20 +-- tests/domain/test_sync_merge.py | 95 ++++++++++-- tests/infrastructure/test_layout_repo.py | 18 +++ .../infrastructure/test_session_repo_jsonl.py | 95 +++++++++--- tests/infrastructure/test_sync_git.py | 89 +++++++++-- tests/presentation/test_formatting_trends.py | 144 ------------------ 23 files changed, 390 insertions(+), 501 deletions(-) diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 8eb07c6..ba5ac69 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -69,6 +69,14 @@ class FocusExplanation: accuracy: float | None = None +@dataclass(slots=True, frozen=True) +class LessonProgress: + unlocked: tuple[int, ...] + focus: int + state: LessonState + focus_bigram: Bigram | None + + def _key_focus_metrics( focus: int, stats: Mapping[int, KeyStats], @@ -204,7 +212,7 @@ def heatmap(self) -> dict[int, float]: def _lesson_progress( layout_name: str, ctx: _LessonContext, -) -> tuple[tuple[int, ...], int, LessonState, Bigram | None]: +) -> LessonProgress: order = keyboard_order(ctx.layout) unlocked = compute_unlocked( order, @@ -263,7 +271,12 @@ def _lesson_progress( alphabet_size=ctx.settings.alphabet_size, target_speed_cpm=ctx.settings.target_speed_cpm, ) - return unlocked, focus, state, focus_bigram + return LessonProgress( + unlocked=unlocked, + focus=focus, + state=state, + focus_bigram=focus_bigram, + ) def _compute_weights( @@ -341,30 +354,32 @@ class BuildLesson: def __call__(self, layout_name: str) -> Lesson: ctx = self._load_context(layout_name) - unlocked, focus, state, focus_bigram = _lesson_progress(layout_name, ctx) - focus_confidence = _resolve_focus_confidence(focus, focus_bigram, ctx) + progress = _lesson_progress(layout_name, ctx) + focus_confidence = _resolve_focus_confidence(progress.focus, progress.focus_bigram, ctx) char_weights, transition_weights = _compute_weights( - state, - focus, - focus_bigram, + progress.state, + progress.focus, + progress.focus_bigram, focus_confidence, - unlocked=unlocked, + unlocked=progress.unlocked, ctx=ctx, ) - alphabet_chars = frozenset(chr(cp) for cp in unlocked) + alphabet_chars = frozenset(chr(cp) for cp in progress.unlocked) text = self._generate_text( ctx, alphabet_chars, - focus, - focus_bigram, + progress.focus, + progress.focus_bigram, char_weights=char_weights, transition_weights=transition_weights, ) - urgency = _compute_urgency(ctx.stats, unlocked, ctx.now) - explanation = _compute_focus_explanation(focus, focus_bigram, focus_confidence, ctx) + urgency = _compute_urgency(ctx.stats, progress.unlocked, ctx.now) + explanation = _compute_focus_explanation( + progress.focus, progress.focus_bigram, focus_confidence, ctx + ) return Lesson( text=text, - state=state, + state=progress.state, urgency=urgency, focus_reason=explanation.reason, focus_confidence=focus_confidence if explanation.reason else None, diff --git a/src/keystrike/application/prepare_practice.py b/src/keystrike/application/prepare_practice.py index 9e24683..a581e15 100644 --- a/src/keystrike/application/prepare_practice.py +++ b/src/keystrike/application/prepare_practice.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass from keystrike.application.build_lesson import BuildLesson @@ -17,8 +16,6 @@ SettingsRepository, ) -PrepareNextSession = Callable[[], "SessionPrep | None"] - @dataclass(frozen=True, slots=True) class SessionPrep: diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 9a1f89a..6683d4f 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -236,14 +236,6 @@ def count_words_completed(text: str, position: int) -> int: return text[:position].count(" ") -def compute_cpm(result: SessionResult) -> float: - """Correct characters per minute.""" - minutes = result.duration_ns / 1e9 / 60.0 - if minutes <= 0: - return 0.0 - return result.correct_keystrokes / minutes - - def _words_for_wpm(result: SessionResult) -> float: if result.words_completed > 0: return float(result.words_completed) diff --git a/src/keystrike/application/stats_use_cases.py b/src/keystrike/application/stats_use_cases.py index 5b47174..324be87 100644 --- a/src/keystrike/application/stats_use_cases.py +++ b/src/keystrike/application/stats_use_cases.py @@ -4,8 +4,9 @@ from collections.abc import Callable, Iterator, Mapping from dataclasses import dataclass +from typing import NamedTuple -from keystrike.domain.aggregate import combine_sessions, per_key_deltas +from keystrike.domain.aggregate import combine_sessions from keystrike.domain.confidence import ( accuracy_of, confidence_of, @@ -22,11 +23,18 @@ SessionRepository, SettingsRepository, ) -from keystrike.domain.regression import estimate_sessions_to_goal _NS_PER_MS = 1e6 +class SessionMetrics(NamedTuple): + """Per-session metrics: speed confidence, accuracy, and overall confidence.""" + + speed: float + accuracy: float + confidence: float + + @dataclass(slots=True) class RebuildAggregates: """Command: replay the last N sessions (from settings) into the cache.""" @@ -118,28 +126,6 @@ def __call__(self, layout: str, limit: int = 20) -> list[SessionResult]: return headers[:limit] -@dataclass(slots=True) -class GetLearningRate: - """Sessions-to-goal estimate for one key: fit a curve to its most recent - per-attempt timings (across the last few sessions) and see how many more - attempts, at the observed trend, until it reaches the target speed.""" - - repo: SessionRepository - settings_repo: SettingsRepository - - def __call__(self, layout: str, codepoint: int, max_sessions: int = 10) -> int | None: - target_ns = target_ms_per_char(self.settings_repo.load().target_speed_cpm) * _NS_PER_MS - headers = sorted(self.repo.iter_headers(layout), key=lambda h: h.started_at) - recent_headers = headers[-max_sessions:] - - samples: list[float] = [] - for header in recent_headers: - deltas = per_key_deltas(self.repo.load_keystrokes(header.session_id)) - samples.extend(deltas.get(codepoint, [])) - - return estimate_sessions_to_goal(samples, target_ns) - - def _normalize_speed_to_current_goal( speed: float, stored_target_cpm: int, @@ -221,7 +207,7 @@ def _accumulate_windowed_trends( layout: str, current_target_speed_cpm: int, compute: Callable[[Mapping[int, KeyStats], float, int], tuple[float, float, float]], -) -> tuple[list[float], list[float], list[float]]: +) -> list[SessionMetrics]: """Shared windowed-replay accumulation for per-session trend lines. ``compute`` receives the combined per-key stats, the session's own target @@ -239,11 +225,9 @@ def _accumulate_windowed_trends( key=lambda h: h.started_at, ) if not all_headers: - return [], [], [] + return [] - speeds: list[float] = [] - accuracies: list[float] = [] - confidences: list[float] = [] + metrics: list[SessionMetrics] = [] for header, combined, session_target in _windowed_session_replays( repo, all_headers, @@ -258,11 +242,9 @@ def _accumulate_windowed_trends( current_target_speed_cpm, ), ) - speeds.append(speed) - accuracies.append(accuracy) - confidences.append(confidence) + metrics.append(SessionMetrics(speed=speed, accuracy=accuracy, confidence=confidence)) - return speeds, accuracies, confidences + return metrics def _key_metric_trends( @@ -272,7 +254,7 @@ def _key_metric_trends( codepoint: int, *, current_target_speed_cpm: int = 0, -) -> tuple[list[float], list[float], list[float]]: +) -> list[SessionMetrics]: def compute( combined: Mapping[int, KeyStats], session_target: float, @@ -308,7 +290,7 @@ def _aggregate_metric_trends( layout: str, *, current_target_speed_cpm: int = 0, -) -> tuple[list[float], list[float], list[float]]: +) -> list[SessionMetrics]: def compute( combined: Mapping[int, KeyStats], session_target: float, @@ -346,13 +328,15 @@ def __call__( *, current_target_speed_cpm: int = 0, ) -> tuple[list[float], list[float]]: - speeds, accuracies, _ = _key_metric_trends( + metrics = _key_metric_trends( self.repo, self.settings_repo, layout, codepoint, current_target_speed_cpm=current_target_speed_cpm, ) + speeds = [m.speed for m in metrics] + accuracies = [m.accuracy for m in metrics] return speeds, accuracies @@ -373,10 +357,13 @@ def __call__( *, current_target_speed_cpm: int = 0, ) -> tuple[list[float], list[float], list[float]]: - speeds, accuracies, confidences = _aggregate_metric_trends( + metrics = _aggregate_metric_trends( self.repo, self.settings_repo, layout, current_target_speed_cpm=current_target_speed_cpm, ) + confidences = [m.confidence for m in metrics] + speeds = [m.speed for m in metrics] + accuracies = [m.accuracy for m in metrics] return confidences, speeds, accuracies diff --git a/src/keystrike/cli.py b/src/keystrike/cli.py index 0873847..b42ec58 100644 --- a/src/keystrike/cli.py +++ b/src/keystrike/cli.py @@ -1,4 +1,5 @@ import subprocess +from collections.abc import Callable from typing import NoReturn import typer @@ -40,45 +41,41 @@ def _sync_err(exc: BaseException) -> NoReturn: raise typer.Exit(code=1) from exc +def _run_sync[T](fn: Callable[[], T]) -> T: + """Wrap a sync operation with consistent error handling.""" + try: + return fn() + except (RuntimeError, subprocess.CalledProcessError, OSError) as exc: + _sync_err(exc) + + @sync_app.command("init") def sync_init( repo_url: str = typer.Argument(..., help="Private git remote URL (HTTPS or SSH)"), ) -> None: """One-time setup: clone remote, merge local data, write sync.toml.""" - try: - build_sync().init(repo_url) - except (RuntimeError, subprocess.CalledProcessError, OSError) as exc: - _sync_err(exc) + _run_sync(lambda: build_sync().init(repo_url)) typer.echo(f"sync initialized ({repo_url})") @sync_app.command() def pull() -> None: """Pull remote, union-merge sessions/settings locally, rebuild stats cache.""" - try: - imported = build_sync().pull() - except (RuntimeError, subprocess.CalledProcessError, OSError) as exc: - _sync_err(exc) + imported = _run_sync(lambda: build_sync().pull()) typer.echo(f"pull complete: {imported} session(s) imported") @sync_app.command() def push() -> None: """Merge local data into clone and push to remote.""" - try: - pushed = build_sync().push() - except (RuntimeError, subprocess.CalledProcessError, OSError) as exc: - _sync_err(exc) + pushed = _run_sync(lambda: build_sync().push()) typer.echo("push complete" if pushed else "push skipped (nothing to commit)") @sync_app.command() def status() -> None: """Show sync remote and session diff summary.""" - try: - st = build_sync().status() - except (RuntimeError, subprocess.CalledProcessError, OSError) as exc: - _sync_err(exc) + st = _run_sync(lambda: build_sync().status()) if not st.configured: typer.echo("sync not configured — run: keystrike sync init ") raise typer.Exit(code=1) diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index 857469b..e555f31 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -126,10 +126,6 @@ def key_attempts(key_stats: KeyStats) -> int: return _attempts(key_stats) -def transition_attempts(transition_stats: TransitionStats) -> int: - return _attempts(transition_stats) - - def is_same_key_transition(prev_cp: int, next_cp: int) -> bool: """Same physical key twice (e.g. ee, ss) — excluded from transition analysis.""" return prev_cp == next_cp diff --git a/src/keystrike/domain/daily_learn.py b/src/keystrike/domain/daily_learn.py index a38094f..2f2a98c 100644 --- a/src/keystrike/domain/daily_learn.py +++ b/src/keystrike/domain/daily_learn.py @@ -21,8 +21,13 @@ class DailyLearnBudget: limit_reached: bool +def _resolve_tz(tz: dt.tzinfo | None) -> dt.tzinfo: + """Resolve timezone fallback: explicit value, system local, or UTC.""" + return tz or dt.datetime.now().astimezone().tzinfo or dt.UTC + + def session_local_date(started_at: float, tz: dt.tzinfo | None = None) -> dt.date: - tz = tz or dt.datetime.now().astimezone().tzinfo or dt.UTC + tz = _resolve_tz(tz) return dt.datetime.fromtimestamp(started_at, tz=tz).date() @@ -32,7 +37,7 @@ def daily_learn_duration_ns( *, tz: dt.tzinfo | None = None, ) -> int: - tz = tz or dt.datetime.now().astimezone().tzinfo or dt.UTC + tz = _resolve_tz(tz) total = 0 for header in headers: if header.mode is not Mode.ADAPTIVE: diff --git a/src/keystrike/domain/markov.py b/src/keystrike/domain/markov.py index 2d7f220..73f1395 100644 --- a/src/keystrike/domain/markov.py +++ b/src/keystrike/domain/markov.py @@ -14,7 +14,7 @@ _DIFFERENT_FINGER_SAME_HAND_BOOST = 1.2 -def transition_practice_weight(prev_cp: int, next_cp: int, layout: Layout) -> float: +def ergonomic_transition_boost(prev_cp: int, next_cp: int, layout: Layout) -> float: """Ergonomics bias for bigram transitions during practice text sampling.""" prev = layout.keys.get(prev_cp) nxt = layout.keys.get(next_cp) @@ -59,7 +59,7 @@ def sample( frequencies entirely. When `layout` is set, each candidate is also scaled by - `transition_practice_weight` from the last char in `context`. + `ergonomic_transition_boost` from the last char in `context`. `transition_weights` (keyed by `Bigram(prev_cp, next_cp)`) multiplies each candidate by the learner's measured confidence on that pair. @@ -80,7 +80,7 @@ def sample( for c, w in candidates: weight = w * (char_weights.get(c, 1.0) if char_weights else 1.0) if layout is not None and prev_cp is not None: - weight *= transition_practice_weight(prev_cp, ord(c), layout) + weight *= ergonomic_transition_boost(prev_cp, ord(c), layout) if transition_weights is not None and prev_cp is not None: weight *= transition_weights.get(Bigram(prev_cp, ord(c)), 1.0) weights.append(weight) diff --git a/src/keystrike/domain/sync_merge.py b/src/keystrike/domain/sync_merge.py index ef2687d..b097124 100644 --- a/src/keystrike/domain/sync_merge.py +++ b/src/keystrike/domain/sync_merge.py @@ -17,6 +17,23 @@ from dataclasses import dataclass from typing import Literal +# Crockford base32 alphabet used in ULID generation (from infrastructure/id_gen.py) +_ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +_ULID_LENGTH = 26 + + +def validate_session_id(session_id: str) -> None: + """Validate that session_id is a properly-formatted ULID. + + ULIDs are 26 characters of Crockford base32 (no path separators, no `.`, + no `..` sequences that could enable path traversal). Rejects malformed + session_ids at the domain layer so they don't reach filesystem operations. + """ + if len(session_id) != _ULID_LENGTH: + raise ValueError(f"session_id must be {_ULID_LENGTH} characters, got {len(session_id)}") + if not all(c in _ULID_ALPHABET for c in session_id): + raise ValueError(f"session_id contains invalid characters; only {_ULID_ALPHABET!r} allowed") + @dataclass(frozen=True, slots=True) class SessionIndexEntry: @@ -31,8 +48,10 @@ def from_dict(cls, raw: Mapping[str, object]) -> SessionIndexEntry: started_at = raw["started_at"] if not isinstance(started_at, (int, float)): raise TypeError(f"expected numeric 'started_at', got {type(started_at).__name__}") + session_id = str(raw["session_id"]) + validate_session_id(session_id) return cls( - session_id=str(raw["session_id"]), + session_id=session_id, layout=str(raw["layout"]), started_at=float(started_at), ) diff --git a/src/keystrike/infrastructure/aggregates_cache.py b/src/keystrike/infrastructure/aggregates_cache.py index cf9e0cd..4990399 100644 --- a/src/keystrike/infrastructure/aggregates_cache.py +++ b/src/keystrike/infrastructure/aggregates_cache.py @@ -16,7 +16,7 @@ from .atomic_write import atomic_write_text from .json_coerce import require_float, require_int -from .paths import Paths +from .paths import Paths, sanitize_layout_name def _coerce_samples(entry: dict[str, object]) -> int: @@ -64,7 +64,7 @@ def __init__(self, paths: Paths) -> None: self._paths = paths def _file(self, layout: str) -> Path: - safe = layout.replace("/", "_").replace("\\", "_") + safe = sanitize_layout_name(layout) return self._paths.cache_dir / f"aggregates-{safe}.json" def get(self, layout: str) -> LayoutAggregates | None: diff --git a/src/keystrike/infrastructure/layout_repo.py b/src/keystrike/infrastructure/layout_repo.py index 731c15b..e572afa 100644 --- a/src/keystrike/infrastructure/layout_repo.py +++ b/src/keystrike/infrastructure/layout_repo.py @@ -13,7 +13,7 @@ from .bundled_layouts.dvorak import LAYOUT as _DVORAK from .bundled_layouts.qwerty import LAYOUT as _QWERTY from .layout_toml import load_layout_toml -from .paths import Paths +from .paths import Paths, sanitize_layout_name BUNDLED_LAYOUTS: Mapping[str, Layout] = MappingProxyType( {layout.name: layout for layout in (_QWERTY, _DVORAK, _COLEMAK, _COLEMAK_DH)}, @@ -35,7 +35,8 @@ def get(self, name: str) -> Layout: bundled = self._bundled.get(name) if bundled is not None: return bundled - file = self._paths.layouts_dir / f"{name}.toml" + safe_name = sanitize_layout_name(name) + file = self._paths.layouts_dir / f"{safe_name}.toml" if not file.exists(): raise KeyError(f"unknown layout: {name!r}") return load_layout_toml(file) diff --git a/src/keystrike/infrastructure/paths.py b/src/keystrike/infrastructure/paths.py index cf35f86..c047b9d 100644 --- a/src/keystrike/infrastructure/paths.py +++ b/src/keystrike/infrastructure/paths.py @@ -50,6 +50,15 @@ def default_paths() -> Paths: ) +def sanitize_layout_name(name: str) -> str: + """Sanitize layout name to prevent path traversal attacks. + + Removes path separators and parent directory references. + This ensures layout names cannot escape their designated directory. + """ + return name.replace("..", "_").replace("/", "_").replace("\\", "_") + + def ensure_dirs(paths: Paths) -> None: for p in ( paths.config_dir, diff --git a/src/keystrike/infrastructure/session_repo_jsonl.py b/src/keystrike/infrastructure/session_repo_jsonl.py index 0d794ea..a4289f0 100644 --- a/src/keystrike/infrastructure/session_repo_jsonl.py +++ b/src/keystrike/infrastructure/session_repo_jsonl.py @@ -11,6 +11,7 @@ from keystrike.domain.enums import Mode, migrate_legacy_mode from keystrike.domain.models import Keystroke, SessionResult +from keystrike.domain.sync_merge import validate_session_id from .json_coerce import coerce_float, coerce_int, require_float, require_int, require_str from .paths import Paths @@ -39,6 +40,7 @@ def append_keystroke(self, session_id: str, started_at: float, k: Keystroke) -> def append_keystrokes( self, session_id: str, started_at: float, keystrokes: Iterable[Keystroke] ) -> None: + validate_session_id(session_id) # Reject path-traversal attempts file = self._paths.sessions_dir / _month_dir(started_at) / f"{session_id}.jsonl" file.parent.mkdir(parents=True, exist_ok=True) with file.open("a", encoding="utf-8") as fh: @@ -97,6 +99,7 @@ def _ensure_index(self) -> None: self._indexed = True def load_keystrokes(self, session_id: str) -> Iterator[Keystroke]: + validate_session_id(session_id) # Reject path-traversal attempts self._ensure_index() header = self._session_index.get(session_id) if header is None: @@ -189,9 +192,11 @@ def _parse_key_confidence(raw: object) -> dict[int, float]: def _header_from_dict(d: dict[str, object]) -> SessionResult: + session_id = require_str(d, "session_id") + validate_session_id(session_id) # Reject path-traversal attempts return SessionResult( schema_version=require_int(d, "schema_version"), - session_id=require_str(d, "session_id"), + session_id=session_id, started_at=require_float(d, "started_at"), duration_ns=require_int(d, "duration_ns"), layout=require_str(d, "layout"), diff --git a/src/keystrike/presentation/formatting/trends.py b/src/keystrike/presentation/formatting/trends.py index 33ce61f..eb0a8cf 100644 --- a/src/keystrike/presentation/formatting/trends.py +++ b/src/keystrike/presentation/formatting/trends.py @@ -46,14 +46,6 @@ def value_sparkline(values: Sequence[float]) -> str: ) -def wpm_sparkline(headers: Sequence[SessionResult], *, limit: int = 20) -> str: - """Unicode sparkline of WPM per session, oldest→newest.""" - ordered = _recent(headers, limit) - if not ordered: - return "" - return value_sparkline([compute_wpm(h) for h in ordered]) - - def _display_confidence( stored_conf: float, stored_target_cpm: int, @@ -66,40 +58,6 @@ def _display_confidence( ) -def focus_confidence_values( - headers: Sequence[SessionResult], - *, - limit: int = 20, - current_target_speed_cpm: int = 0, -) -> list[float]: - ordered = _recent(headers, limit) - return [ - _display_confidence( - h.key_confidence.get(h.focus_key, 0.0) if h.focus_key is not None else 0.0, - h.target_speed_cpm, - current_target_speed_cpm, - ) - for h in ordered - ] - - -def focus_confidence_sparkline( - headers: Sequence[SessionResult], - *, - limit: int = 20, - current_target_speed_cpm: int = 0, -) -> str: - """Unicode sparkline of focus-key confidence per session, oldest→newest.""" - values = focus_confidence_values( - headers, - limit=limit, - current_target_speed_cpm=current_target_speed_cpm, - ) - if not values: - return "" - return value_sparkline(values) - - def key_confidence_values( headers: Sequence[SessionResult], codepoint: int, @@ -147,18 +105,6 @@ def _focus_char_label(focus_key: int | None) -> str: return char_label(focus_key) -def format_wpm_trend_line(headers: Sequence[SessionResult], *, limit: int = 20) -> str: - ordered = _recent(headers, limit) - if not ordered: - return "" - wpms = [compute_wpm(h) for h in ordered] - spark = wpm_sparkline(headers, limit=limit) - return ( - f"[bold]WPM trend[/] ({len(wpms)} sessions) {spark} " - f"[dim]latest {wpms[-1]:.0f} peak {max(wpms):.0f}[/]" - ) - - def format_focus_confidence_trend_line( headers: Sequence[SessionResult], *, @@ -218,26 +164,6 @@ def _assemble_metric_line( return line -def _format_metric_trend_line_inline( - label: str, - color: str, - values: Sequence[float], - spark: str, - *, - format_value: Callable[[float], str] | None = None, - suffix: str = "", -) -> str: - """One metric per line, session count inline, sparkline unpadded.""" - if not values: - return "" - fmt = format_value or _default_metric_value - session_part = f" ({len(values)} sessions) " - values_part = f"latest {fmt(values[-1])} peak {fmt(max(values))}" - return _assemble_metric_line( - label, session_part, spark, values_part, color=color, suffix=suffix - ) - - def _format_metric_trend_line_grid( label: str, color: str, @@ -263,15 +189,6 @@ def _format_metric_trend_line_grid( ) -def format_confidence_trend_line(values: Sequence[float]) -> str: - return _format_metric_trend_line_inline( - "confidence", - STYLE_TREND_CONFIDENCE, - values, - value_sparkline(values), - ) - - def _format_confidence_trend_line_grid(values: Sequence[float], *, spark_width: int = 20) -> str: return _format_metric_trend_line_grid( "confidence", @@ -282,42 +199,6 @@ def _format_confidence_trend_line_grid(values: Sequence[float], *, spark_width: ) -def format_key_confidence_trend_line( - headers: Sequence[SessionResult], - codepoint: int, - *, - limit: int = 20, - current_target_speed_cpm: int = 0, - cumulative: float | None = None, -) -> str: - ordered = _recent(headers, limit) - if not ordered: - return "" - values = key_confidence_values( - headers, - codepoint, - limit=limit, - current_target_speed_cpm=current_target_speed_cpm, - ) - spark = key_confidence_sparkline( - headers, - codepoint, - limit=limit, - current_target_speed_cpm=current_target_speed_cpm, - ) - char = char_label(codepoint) - suffix = "" - if cumulative is not None: - suffix = f"[dim {STYLE_TREND_CONFIDENCE}]cumulative {cumulative:.2f}[/]" - return _format_metric_trend_line_inline( - f"'{char}' confidence", - STYLE_TREND_CONFIDENCE, - values, - spark, - suffix=suffix, - ) - - def _format_key_confidence_trend_line_grid( headers: Sequence[SessionResult], codepoint: int, @@ -357,15 +238,6 @@ def _format_key_confidence_trend_line_grid( ) -def format_key_speed_trend_line(values: Sequence[float]) -> str: - return _format_metric_trend_line_inline( - "speed", - STYLE_TREND_SPEED, - values, - value_sparkline(values), - ) - - def _format_key_speed_trend_line_grid(values: Sequence[float], *, spark_width: int = 20) -> str: return _format_metric_trend_line_grid( "speed", @@ -376,17 +248,6 @@ def _format_key_speed_trend_line_grid(values: Sequence[float], *, spark_width: i ) -def format_key_accuracy_trend_line(values: Sequence[float]) -> str: - pct_values = [v * 100 for v in values] - return _format_metric_trend_line_inline( - "accuracy", - STYLE_TREND_ACCURACY, - pct_values, - value_sparkline(pct_values), - format_value=lambda v: f"{v:.1f}%", - ) - - def _format_key_accuracy_trend_line_grid(values: Sequence[float], *, spark_width: int = 20) -> str: pct_values = [v * 100 for v in values] return _format_metric_trend_line_grid( diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index 3d8fcf4..73cd271 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -15,7 +15,6 @@ from keystrike.presentation.services import PracticeServices from keystrike.presentation.widgets.hud import HUD from keystrike.presentation.widgets.kb_heatmap import ( - HeatmapDisplay, KbHeatmap, build_heatmap_display, focus_transition_pair, @@ -74,15 +73,15 @@ def compose(self) -> ComposeResult: yield self._hud yield self._typing_area if self._prep.layout_obj is not None and self._prep.lesson_heatmap is not None: - self._kb_heatmap = KbHeatmap( - HeatmapDisplay( - self._prep.layout_obj, - self._prep.lesson_heatmap, - focus=self._prep.focus_key, - focus_transition=focus_transition_pair(self._prep.focus_reason), - ) + display = build_heatmap_display( + self._prep.layout_obj, + self._prep.lesson_heatmap, + focus=self._prep.focus_key, + focus_transition=focus_transition_pair(self._prep.focus_reason), ) - yield self._kb_heatmap + if display is not None: + self._kb_heatmap = KbHeatmap(display) + yield self._kb_heatmap yield Static( self._focus_note_text(), id="focus-note", diff --git a/src/keystrike/presentation/screens/stats.py b/src/keystrike/presentation/screens/stats.py index 8e2e6ed..04c2098 100644 --- a/src/keystrike/presentation/screens/stats.py +++ b/src/keystrike/presentation/screens/stats.py @@ -18,7 +18,6 @@ ) from keystrike.presentation.services import StatsServices from keystrike.presentation.widgets.kb_heatmap import ( - HeatmapDisplay, KbHeatmap, build_heatmap_display, ) @@ -75,17 +74,22 @@ def on_mount(self) -> None: title += " [dim](ortholinear)[/]" self.query_one("#stats-title", Static).update(title) - self._kb_heatmap = KbHeatmap( - HeatmapDisplay(layout, heatmap_view.confidence, urgency=heatmap_view.urgency) + display = build_heatmap_display( + layout, + heatmap_view.confidence, + urgency=heatmap_view.urgency, ) + if display is not None: + self._kb_heatmap = KbHeatmap(display) self.query_one(Vertical).mount( Static("[dim]vs current goal[/]", id="stats-heatmap-caption"), Static( "[dim]Press a key on the heatmap for letter stats · Esc to return[/]", id="stats-heatmap-hint", ), - self._kb_heatmap, ) + if self._kb_heatmap is not None: + self.query_one(Vertical).mount(self._kb_heatmap) self._trend_history = self._services.get_history( self._layout_name, diff --git a/tests/application/test_stats_use_cases.py b/tests/application/test_stats_use_cases.py index c65665b..78a4ada 100644 --- a/tests/application/test_stats_use_cases.py +++ b/tests/application/test_stats_use_cases.py @@ -3,7 +3,6 @@ GetHeatmap, GetHistory, GetKeyMetricTrends, - GetLearningRate, GetOrRebuildAggregates, RebuildAggregates, ) @@ -326,58 +325,6 @@ def test_get_heatmap_urgency_from_last_seen(): assert view.urgency[ord("a")] == 1.0 -def test_get_learning_rate_no_data_returns_none(): - repo = FakeSessionRepository() - settings_repo = FakeSettingsRepository() - get_rate = GetLearningRate(repo=repo, settings_repo=settings_repo) - assert get_rate("qwerty", ord("a")) is None - - -def test_get_learning_rate_reads_deltas_across_sessions_chronologically(): - repo = FakeSessionRepository() - settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=300)) # target 200ms - - for i, delta_ms in enumerate([300, 250, 210]): - session_id = f"s{i}" - repo.save_header(_header(session_id, started_at=float(i))) - repo.keystrokes[session_id] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke( - codepoint=ord("a"), - typed=ord("a"), - t_ns=delta_ms * 1_000_000, - correct=True, - ), - ] - - get_rate = GetLearningRate(repo=repo, settings_repo=settings_repo) - result = get_rate("qwerty", ord("a")) - - assert result is not None - assert result > 0 - - -def test_get_learning_rate_already_at_target_returns_zero(): - repo = FakeSessionRepository() - settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=300)) # target 200ms - - for i, delta_ms in enumerate([200, 190]): - session_id = f"s{i}" - repo.save_header(_header(session_id, started_at=float(i))) - repo.keystrokes[session_id] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke( - codepoint=ord("a"), - typed=ord("a"), - t_ns=delta_ms * 1_000_000, - correct=True, - ), - ] - - get_rate = GetLearningRate(repo=repo, settings_repo=settings_repo) - assert get_rate("qwerty", ord("a")) == 0 - - def test_get_history_sorted_newest_first_and_limited(): repo = FakeSessionRepository() for i in range(5): diff --git a/tests/domain/test_markov.py b/tests/domain/test_markov.py index cbfc4e5..0a3083d 100644 --- a/tests/domain/test_markov.py +++ b/tests/domain/test_markov.py @@ -1,6 +1,6 @@ from random import Random -from keystrike.domain.markov import TransitionTable, transition_practice_weight +from keystrike.domain.markov import TransitionTable, ergonomic_transition_boost from keystrike.domain.models import Bigram, Layout from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS @@ -53,25 +53,25 @@ def test_sample_char_weights_ignores_chars_outside_row(): assert table.sample("a", frozenset("ab"), rng, char_weights={"z": 100.0}) == "b" -def test_transition_practice_weight_prefers_different_hand(): +def test_ergonomic_transition_boost_prefers_different_hand(): layout = BUNDLED_LAYOUTS["qwerty"] - assert transition_practice_weight(ord("a"), ord("j"), layout) == 1.5 - assert transition_practice_weight(ord("j"), ord("a"), layout) == 1.5 + assert ergonomic_transition_boost(ord("a"), ord("j"), layout) == 1.5 + assert ergonomic_transition_boost(ord("j"), ord("a"), layout) == 1.5 -def test_transition_practice_weight_boosts_different_finger_same_hand(): +def test_ergonomic_transition_boost_boosts_different_finger_same_hand(): layout = BUNDLED_LAYOUTS["qwerty"] - assert transition_practice_weight(ord("a"), ord("f"), layout) == 1.2 + assert ergonomic_transition_boost(ord("a"), ord("f"), layout) == 1.2 -def test_transition_practice_weight_same_finger_is_baseline(): +def test_ergonomic_transition_boost_same_finger_is_baseline(): layout = BUNDLED_LAYOUTS["qwerty"] - assert transition_practice_weight(ord("a"), ord("q"), layout) == 1.0 + assert ergonomic_transition_boost(ord("a"), ord("q"), layout) == 1.0 -def test_transition_practice_weight_missing_key_is_baseline(): +def test_ergonomic_transition_boost_missing_key_is_baseline(): layout = Layout(name="tiny", keys={}, learn_order=()) - assert transition_practice_weight(ord("a"), ord("b"), layout) == 1.0 + assert ergonomic_transition_boost(ord("a"), ord("b"), layout) == 1.0 def test_sample_prefers_different_hand_with_equal_language_weights(): diff --git a/tests/domain/test_sync_merge.py b/tests/domain/test_sync_merge.py index c05698c..7bb85c6 100644 --- a/tests/domain/test_sync_merge.py +++ b/tests/domain/test_sync_merge.py @@ -1,3 +1,5 @@ +import pytest + from keystrike.domain.sync_merge import ( SessionIndexEntry, decide_settings_winner, @@ -8,6 +10,11 @@ settings_epoch_from_toml, ) +# Valid 26-character ULIDs for testing (Crockford base32 alphabet) +_VALID_ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAV" +_VALID_ULID_B = "01ARZ3NDEKTSV4RRFFQ69G5FBV" +_VALID_ULID_C = "01ARZ3NDEKTSV4RRFFQ69G5FCV" + def _entry( sid: str, started_at: float = 1_700_000_000.0, layout: str = "qwerty" @@ -16,37 +23,37 @@ def _entry( def test_index_session_ids_extracts_ids(): - entries = [_entry("A"), _entry("B")] - assert index_session_ids(entries) == {"A", "B"} + entries = [_entry(_VALID_ULID_A), _entry(_VALID_ULID_B)] + assert index_session_ids(entries) == {_VALID_ULID_A, _VALID_ULID_B} def test_index_layouts_extracts_distinct_layouts(): entries = [ - _entry("A", layout="qwerty"), - _entry("B", layout="dvorak"), - _entry("C", layout="qwerty"), + _entry(_VALID_ULID_A, layout="qwerty"), + _entry(_VALID_ULID_B, layout="dvorak"), + _entry(_VALID_ULID_C, layout="qwerty"), ] assert index_layouts(entries) == {"qwerty", "dvorak"} def test_plan_missing_sessions_skips_ids_already_local(): - remote_entries = [_entry("A"), _entry("B")] - remote_lines = ['{"session_id": "A"}', '{"session_id": "B"}'] + remote_entries = [_entry(_VALID_ULID_A), _entry(_VALID_ULID_B)] + remote_lines = [f'{{"session_id": "{_VALID_ULID_A}"}}', f'{{"session_id": "{_VALID_ULID_B}"}}'] plans = plan_missing_sessions( - local_session_ids={"A"}, + local_session_ids={_VALID_ULID_A}, remote_entries=remote_entries, remote_lines=remote_lines, ) - assert [p.session_id for p in plans] == ["B"] - assert plans[0].index_line == '{"session_id": "B"}' - assert plans[0].filename == "B.jsonl" + assert [p.session_id for p in plans] == [_VALID_ULID_B] + assert plans[0].index_line == f'{{"session_id": "{_VALID_ULID_B}"}}' + assert plans[0].filename == f"{_VALID_ULID_B}.jsonl" def test_plan_missing_sessions_derives_month_from_started_at(): started_at = 1_700_000_000.0 # 2023-11-14 ~ - remote_entries = [_entry("B", started_at=started_at)] + remote_entries = [_entry(_VALID_ULID_B, started_at=started_at)] remote_lines = ["line"] plans = plan_missing_sessions( @@ -59,7 +66,7 @@ def test_plan_missing_sessions_derives_month_from_started_at(): def test_plan_missing_sessions_ignores_duplicate_remote_entries(): - remote_entries = [_entry("A"), _entry("A")] + remote_entries = [_entry(_VALID_ULID_A), _entry(_VALID_ULID_A)] remote_lines = ["first", "second"] plans = plan_missing_sessions( @@ -68,7 +75,7 @@ def test_plan_missing_sessions_ignores_duplicate_remote_entries(): remote_lines=remote_lines, ) - assert [p.session_id for p in plans] == ["A"] + assert [p.session_id for p in plans] == [_VALID_ULID_A] assert plans[0].index_line == "first" @@ -165,3 +172,63 @@ def test_plan_layouts_to_copy_empty_when_nothing_missing(): dest_names={"qwerty.toml", "dvorak.toml"}, ) assert missing == set() + + +def test_session_index_entry_rejects_path_traversal_session_id(): + """Path traversal attempts in session_id are rejected at domain layer.""" + with pytest.raises(ValueError, match="26 characters"): + SessionIndexEntry.from_dict( + { + "session_id": "../../etc/passwd", + "layout": "qwerty", + "started_at": 1_700_000_000.0, + } + ) + + +def test_session_index_entry_rejects_session_id_with_slashes(): + """Forward slashes in session_id are rejected.""" + with pytest.raises(ValueError, match="26 characters"): + SessionIndexEntry.from_dict( + { + "session_id": "ABC/DEF0123456789ABCDEF", + "layout": "qwerty", + "started_at": 1_700_000_000.0, + } + ) + + +def test_session_index_entry_rejects_session_id_wrong_length(): + """Session IDs must be exactly 26 characters (ULID length).""" + with pytest.raises(ValueError, match="26 characters"): + SessionIndexEntry.from_dict( + { + "session_id": "ABC", + "layout": "qwerty", + "started_at": 1_700_000_000.0, + } + ) + + +def test_session_index_entry_rejects_session_id_invalid_chars(): + """Session IDs must only contain Crockford base32 alphabet.""" + with pytest.raises(ValueError, match="invalid characters"): + SessionIndexEntry.from_dict( + { + "session_id": "ABC!DEF0123456789ABCDEFGHI", # '!' is invalid, 26 chars total + "layout": "qwerty", + "started_at": 1_700_000_000.0, + } + ) + + +def test_session_index_entry_accepts_valid_ulid(): + """Valid 26-char ULIDs with only base32 alphabet are accepted.""" + entry = SessionIndexEntry.from_dict( + { + "session_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "layout": "qwerty", + "started_at": 1_700_000_000.0, + } + ) + assert entry.session_id == "01ARZ3NDEKTSV4RRFFQ69G5FAV" diff --git a/tests/infrastructure/test_layout_repo.py b/tests/infrastructure/test_layout_repo.py index 40942f5..9110912 100644 --- a/tests/infrastructure/test_layout_repo.py +++ b/tests/infrastructure/test_layout_repo.py @@ -78,3 +78,21 @@ def test_uses_filename_stem_as_id_not_name_field(paths: Paths): assert "display_name" not in repo.list_available() layout = repo.get("file_stem") assert layout.name == "display_name" + + +def test_path_traversal_attack_rejected(paths: Paths): + """Regression test: path traversal in layout names is sanitized. + + A malicious layout name like "../../evil" should not escape layouts_dir. + The sanitization should replace ".." with "_" so it becomes safe. + """ + paths.layouts_dir.mkdir(parents=True) + # Create a decoy file outside layouts_dir that we should NOT be able to read + (paths.config_dir / "secret.toml").write_text( + 'name = "secret"\nlearn_order = "a"\n', encoding="utf-8" + ) + + repo = CompositeLayoutRepository(paths) + # Attempt to read the secret file via path traversal should fail gracefully + with pytest.raises(KeyError): + repo.get("../../secret") diff --git a/tests/infrastructure/test_session_repo_jsonl.py b/tests/infrastructure/test_session_repo_jsonl.py index f53e3cc..917df4c 100644 --- a/tests/infrastructure/test_session_repo_jsonl.py +++ b/tests/infrastructure/test_session_repo_jsonl.py @@ -5,6 +5,14 @@ from keystrike.infrastructure.paths import Paths from keystrike.infrastructure.session_repo_jsonl import JsonlSessionRepository, _session_file +# Valid 26-character ULIDs for testing (Crockford base32 alphabet) +_VALID_ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAV" +_VALID_ULID_B = "01ARZ3NDEKTSV4RRFFQ69G5FBV" +_VALID_ULID_C = "01ARZ3NDEKTSV4RRFFQ69G5FCV" +_VALID_ULID_D = "01ARZ3NDEKTSV4RRFFQ69G5FDV" +_VALID_ULID_E = "01ARZ3NDEKTSV4RRFFQ69G5FEV" +_VALID_ULID_F = "01ARZ3NDEKTSV4RRFFQ69G5FFV" + @pytest.fixture def paths(tmp_path): @@ -18,7 +26,7 @@ def paths(tmp_path): return p -def _header(sid: str = "S1", layout: str = "qwerty", started_at: float = 1_700_000_000.0): +def _header(sid: str = _VALID_ULID_A, layout: str = "qwerty", started_at: float = 1_700_000_000.0): return SessionResult( schema_version=1, session_id=sid, @@ -52,9 +60,9 @@ def test_round_trip_single_session(paths): repo2 = JsonlSessionRepository(paths) headers = list(repo2.iter_headers("qwerty")) assert len(headers) == 1 - assert headers[0].session_id == "S1" + assert headers[0].session_id == _VALID_ULID_A - keystrokes = list(repo2.load_keystrokes("S1")) + keystrokes = list(repo2.load_keystrokes(_VALID_ULID_A)) assert len(keystrokes) == 2 assert keystrokes[0].codepoint == ord("a") assert keystrokes[1].t_ns == 100 @@ -62,19 +70,19 @@ def test_round_trip_single_session(paths): def test_iter_headers_filters_by_layout(paths): repo = JsonlSessionRepository(paths) - repo.save_header(_header(sid="A", layout="qwerty")) - repo.save_header(_header(sid="B", layout="dvorak")) - repo.save_header(_header(sid="C", layout="qwerty")) + repo.save_header(_header(sid=_VALID_ULID_A, layout="qwerty")) + repo.save_header(_header(sid=_VALID_ULID_B, layout="dvorak")) + repo.save_header(_header(sid=_VALID_ULID_C, layout="qwerty")) qwerty = [h.session_id for h in JsonlSessionRepository(paths).iter_headers("qwerty")] - assert qwerty == ["A", "C"] + assert qwerty == [_VALID_ULID_A, _VALID_ULID_C] def test_round_trip_unlocked_keys(paths): repo = JsonlSessionRepository(paths) header = SessionResult( schema_version=2, - session_id="S3", + session_id=_VALID_ULID_C, started_at=1_700_000_000.0, duration_ns=1_000_000_000, layout="qwerty", @@ -95,7 +103,7 @@ def test_round_trip_key_confidence(paths): repo = JsonlSessionRepository(paths) header = SessionResult( schema_version=3, - session_id="S4", + session_id=_VALID_ULID_D, started_at=1_700_000_000.0, duration_ns=1_000_000_000, layout="qwerty", @@ -141,7 +149,7 @@ def test_round_trip_target_speed_cpm(paths): repo = JsonlSessionRepository(paths) header = SessionResult( schema_version=3, - session_id="S5", + session_id=_VALID_ULID_E, started_at=1_700_000_000.0, duration_ns=1_000_000_000, layout="qwerty", @@ -160,32 +168,32 @@ def test_round_trip_target_speed_cpm(paths): def test_corrupt_index_line_is_skipped_not_fatal(paths): repo = JsonlSessionRepository(paths) - repo.save_header(_header(sid="A")) + repo.save_header(_header(sid=_VALID_ULID_A)) with paths.sessions_index.open("a", encoding="utf-8") as fh: fh.write("{not valid json\n") fh.write('{"schema_version": 1}\n') # valid JSON, missing required fields - repo.save_header(_header(sid="B")) + repo.save_header(_header(sid=_VALID_ULID_B)) headers = [h.session_id for h in JsonlSessionRepository(paths).iter_headers("qwerty")] - assert headers == ["A", "B"] + assert headers == [_VALID_ULID_A, _VALID_ULID_B] def test_keystrokes_persisted_with_header_at_finish(paths): repo = JsonlSessionRepository(paths) - header = _header(sid="S2") + header = _header(sid=_VALID_ULID_B) k = Keystroke(codepoint=ord("x"), typed=ord("x"), t_ns=0, correct=True) repo.append_keystroke(header.session_id, header.started_at, k) repo.save_header(header) repo2 = JsonlSessionRepository(paths) list(repo2.iter_headers("qwerty")) - ks = list(repo2.load_keystrokes("S2")) + ks = list(repo2.load_keystrokes(_VALID_ULID_B)) assert len(ks) == 1 def test_append_keystrokes_bulk_writes_all_in_one_open(paths): repo = JsonlSessionRepository(paths) - header = _header(sid="S3") + header = _header(sid=_VALID_ULID_C) keystrokes = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100, correct=True), @@ -196,14 +204,14 @@ def test_append_keystrokes_bulk_writes_all_in_one_open(paths): repo2 = JsonlSessionRepository(paths) list(repo2.iter_headers("qwerty")) - ks = list(repo2.load_keystrokes("S3")) + ks = list(repo2.load_keystrokes(_VALID_ULID_C)) assert [k.codepoint for k in ks] == [ord("a"), ord("b"), ord("c")] assert ks[2].correct is False def test_corrupt_keystroke_line_is_skipped_not_fatal(paths): repo = JsonlSessionRepository(paths) - header = _header(sid="S6") + header = _header(sid=_VALID_ULID_F) repo.append_keystroke( header.session_id, header.started_at, @@ -220,7 +228,7 @@ def test_corrupt_keystroke_line_is_skipped_not_fatal(paths): repo.save_header(header) repo2 = JsonlSessionRepository(paths) - ks = list(repo2.load_keystrokes("S6")) + ks = list(repo2.load_keystrokes(_VALID_ULID_F)) assert [k.codepoint for k in ks] == [ord("a"), ord("b")] @@ -231,4 +239,51 @@ def test_load_keystrokes_scan_fallback_when_sessions_dir_missing(tmp_path): log_dir=tmp_path / "logs2", ) repo = JsonlSessionRepository(empty_paths) - assert list(repo.load_keystrokes("does-not-exist")) == [] + # Use a valid ULID that doesn't exist in the repo + assert list(repo.load_keystrokes(_VALID_ULID_A)) == [] + + +def test_path_traversal_session_id_rejected_in_header_parse(paths): + """Regression: path-traversal session_id like '../../evil' should be rejected.""" + repo = JsonlSessionRepository(paths) + # Write an index line with a malicious session_id + with paths.sessions_index.open("a", encoding="utf-8") as fh: + fh.write( + '{"schema_version": 1, "session_id": "../../evil", "layout": "qwerty", ' + '"started_at": 1700000000.0, "duration_ns": 1000000000, "mode": "ADAPTIVE", ' + '"lesson_alphabet": [], "focus_key": null, "total_keystrokes": 0, ' + '"correct_keystrokes": 0, "lang": "en"}\n' + ) + + # The malicious line should be skipped during parsing + headers = list(repo.iter_all_headers()) + assert len(headers) == 0 + + +def test_path_traversal_session_id_rejected_in_append_keystrokes(paths): + """Regression: path-traversal session_id should be rejected in append_keystrokes.""" + repo = JsonlSessionRepository(paths) + with pytest.raises(ValueError, match=r"session_id.*26 characters"): + repo.append_keystrokes( + session_id="../../evil", + started_at=1_700_000_000.0, + keystrokes=[], + ) + + +def test_path_traversal_session_id_rejected_in_load_keystrokes(paths): + """Regression: path-traversal session_id should be rejected in load_keystrokes.""" + repo = JsonlSessionRepository(paths) + with pytest.raises(ValueError, match=r"session_id.*26 characters"): + list(repo.load_keystrokes("../../evil")) + + +def test_invalid_chars_session_id_rejected_in_append_keystrokes(paths): + """Regression: session_id with invalid characters should be rejected.""" + repo = JsonlSessionRepository(paths) + with pytest.raises(ValueError, match="invalid characters"): + repo.append_keystrokes( + session_id="01ARZ3NDEKTSV4RRFFQ69G5F!V", # '!' is not Crockford base32 + started_at=1_700_000_000.0, + keystrokes=[], + ) diff --git a/tests/infrastructure/test_sync_git.py b/tests/infrastructure/test_sync_git.py index 10f8b40..5050d3b 100644 --- a/tests/infrastructure/test_sync_git.py +++ b/tests/infrastructure/test_sync_git.py @@ -47,6 +47,11 @@ def _write_index(path: Path, *entries: dict[str, object]) -> None: path.write_text("\n".join(lines) + "\n", encoding="utf-8") +# Valid 26-character ULIDs for testing (Crockford base32 alphabet) +_VALID_ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAV" +_VALID_ULID_B = "01ARZ3NDEKTSV4RRFFQ69G5FBV" + + def _header(sid: str, layout: str = "qwerty") -> dict[str, object]: return { "schema_version": 1, @@ -67,21 +72,25 @@ def test_read_index_session_ids_missing_file_is_empty(tree: dict[str, Path]) -> def test_read_index_session_ids_reads_existing_ids(tree: dict[str, Path]) -> None: - _write_index(tree["local_index"], _header("A"), _header("B")) - assert read_index_session_ids(tree["local_index"]) == {"A", "B"} + _write_index(tree["local_index"], _header(_VALID_ULID_A), _header(_VALID_ULID_B)) + assert read_index_session_ids(tree["local_index"]) == {_VALID_ULID_A, _VALID_ULID_B} def test_iter_layouts_from_index(tree: dict[str, Path]) -> None: - _write_index(tree["local_index"], _header("A", layout="qwerty"), _header("B", layout="dvorak")) + _write_index( + tree["local_index"], + _header(_VALID_ULID_A, layout="qwerty"), + _header(_VALID_ULID_B, layout="dvorak"), + ) assert iter_layouts_from_index(tree["local_index"]) == {"qwerty", "dvorak"} def test_session_union_imports_missing_remote_only(tree: dict[str, Path]) -> None: - _write_index(tree["local_index"], _header("A")) - _write_index(tree["remote_index"], _header("A"), _header("B")) + _write_index(tree["local_index"], _header(_VALID_ULID_A)) + _write_index(tree["remote_index"], _header(_VALID_ULID_A), _header(_VALID_ULID_B)) month = "2023-11" (tree["remote_sessions"] / month).mkdir() - (tree["remote_sessions"] / month / "B.jsonl").write_text( + (tree["remote_sessions"] / month / f"{_VALID_ULID_B}.jsonl").write_text( '{"codepoint": 97, "typed": 97, "t_ns": 0, "correct": true}\n', encoding="utf-8", ) @@ -93,17 +102,19 @@ def test_session_union_imports_missing_remote_only(tree: dict[str, Path]) -> Non remote_index=tree["remote_index"], ) - assert imported == ["B"] - assert read_index_session_ids(tree["local_index"]) == {"A", "B"} - assert (tree["local_sessions"] / month / "B.jsonl").exists() + assert imported == [_VALID_ULID_B] + assert read_index_session_ids(tree["local_index"]) == {_VALID_ULID_A, _VALID_ULID_B} + assert (tree["local_sessions"] / month / f"{_VALID_ULID_B}.jsonl").exists() def test_session_union_skips_duplicate_local_ids(tree: dict[str, Path]) -> None: - _write_index(tree["local_index"], _header("A")) - _write_index(tree["remote_index"], _header("A"), _header("B")) + _write_index(tree["local_index"], _header(_VALID_ULID_A)) + _write_index(tree["remote_index"], _header(_VALID_ULID_A), _header(_VALID_ULID_B)) month = "2023-11" (tree["remote_sessions"] / month).mkdir() - (tree["remote_sessions"] / month / "B.jsonl").write_text("{}\n", encoding="utf-8") + (tree["remote_sessions"] / month / f"{_VALID_ULID_B}.jsonl").write_text( + "{}\n", encoding="utf-8" + ) import_missing_sessions( local_sessions_dir=tree["local_sessions"], @@ -113,12 +124,12 @@ def test_session_union_skips_duplicate_local_ids(tree: dict[str, Path]) -> None: ) lines = tree["local_index"].read_text().splitlines() - assert lines.count(json.dumps(_header("A"))) == 1 - assert json.dumps(_header("B")) in lines + assert lines.count(json.dumps(_header(_VALID_ULID_A))) == 1 + assert json.dumps(_header(_VALID_ULID_B)) in lines def test_session_union_skips_entries_whose_file_is_missing(tree: dict[str, Path]) -> None: - _write_index(tree["remote_index"], _header("B")) + _write_index(tree["remote_index"], _header(_VALID_ULID_B)) # No B.jsonl on disk anywhere. imported = import_missing_sessions( @@ -141,6 +152,54 @@ def test_session_union_no_remote_index_is_noop(tree: dict[str, Path]) -> None: assert imported == [] +def test_session_union_skips_path_traversal_session_ids(tree: dict[str, Path]) -> None: + """Path traversal attempts in session_id are skipped during import, not exploited.""" + # Valid entry that should be imported + valid_entry = _header(_VALID_ULID_A) + # Malicious entries with path traversal attempts (will fail validation in from_dict) + evil_entries: list[dict[str, object]] = [ + {"session_id": "../../etc/passwd", "started_at": 1_700_000_000.0, "layout": "qwerty"}, + {"session_id": "../../../tmp/bad", "started_at": 1_700_000_000.0, "layout": "qwerty"}, + {"session_id": "ABC", "started_at": 1_700_000_000.0, "layout": "qwerty"}, # too short + # invalid char + { + "session_id": "ABC!DEF0123456789ABCDEFG", + "started_at": 1_700_000_000.0, + "layout": "qwerty", + }, + ] + + # Write remote index with mix of valid and invalid entries + _write_index(tree["remote_index"], valid_entry, *evil_entries) + + # Create the session file for the valid entry + month = "2023-11" + (tree["remote_sessions"] / month).mkdir() + (tree["remote_sessions"] / month / f"{_VALID_ULID_A}.jsonl").write_text( + '{"codepoint": 97, "typed": 97, "t_ns": 0, "correct": true}\n', + encoding="utf-8", + ) + + # Import should succeed, but only with the valid entry + imported = import_missing_sessions( + local_sessions_dir=tree["local_sessions"], + remote_sessions_dir=tree["remote_sessions"], + local_index=tree["local_index"], + remote_index=tree["remote_index"], + ) + + # Only the valid ULID is imported; malicious entries are silently skipped + assert imported == [_VALID_ULID_A] + + # Local index should only contain the valid entry + local_ids = read_index_session_ids(tree["local_index"]) + assert local_ids == {_VALID_ULID_A} + + # Malicious paths are not created on disk + assert not (tree["local_sessions"] / "etc" / "passwd").exists() + assert not (tree["local_sessions"] / "tmp" / "bad").exists() + + def test_settings_lww_prefers_updated_at(tree: dict[str, Path]) -> None: tree["local_settings"].write_text( 'layout = "qwerty"\nupdated_at = "2024-01-01T00:00:00+00:00"\n', diff --git a/tests/presentation/test_formatting_trends.py b/tests/presentation/test_formatting_trends.py index 6f7de89..3c1fc75 100644 --- a/tests/presentation/test_formatting_trends.py +++ b/tests/presentation/test_formatting_trends.py @@ -3,53 +3,14 @@ from keystrike.domain.enums import Mode from keystrike.domain.models import SessionResult from keystrike.presentation.formatting.trends import ( - focus_confidence_sparkline, format_aggregate_metric_trend_block, format_focus_confidence_trend_line, - format_key_confidence_trend_line, format_key_metric_trend_block, - format_key_speed_trend_line, - format_wpm_trend_line, key_confidence_sparkline, key_confidence_values, - wpm_sparkline, ) -def test_focus_confidence_sparkline_uses_focus_key_per_session(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("a"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("a"): 0.5, ord("b"): 1.0}, - ), - SessionResult( - schema_version=3, - session_id="s2", - started_at=2.0, - duration_ns=30_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("b"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("a"): 0.5, ord("b"): 1.0}, - ), - ] - spark = focus_confidence_sparkline(headers) - assert len(spark) == 2 - assert spark[0] <= spark[1] # 0.5 then 1.0 - - def test_format_focus_confidence_trend_line_tracks_labeled_key(): headers = [ SessionResult( @@ -114,27 +75,6 @@ def test_format_focus_confidence_trend_line_includes_label_and_peak(): assert "peak 0.75" in line -def test_focus_confidence_missing_key_defaults_zero(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("z"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={}, - ), - ] - spark = focus_confidence_sparkline(headers) - assert len(spark) == 1 - assert spark[0] == "▁" - - def test_key_confidence_values_tracks_codepoint_across_sessions(): headers = [ SessionResult( @@ -235,29 +175,6 @@ def test_format_focus_confidence_trend_line_normalizes_to_current_goal(): assert "peak 0.50" in line -def test_format_key_confidence_trend_line_includes_cumulative(): - headers = [ - SessionResult( - schema_version=3, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=ord("e"), - total_keystrokes=50, - correct_keystrokes=50, - key_confidence={ord("e"): 0.75}, - ), - ] - line = format_key_confidence_trend_line(headers, ord("e"), cumulative=1.1) - assert "'e' confidence" in line - assert "[cyan]" in line - assert "latest 0.75" in line - assert "cumulative 1.10" in line - - def test_format_key_metric_trend_block_shows_key_once_with_colors(): headers = [ SessionResult( @@ -303,64 +220,3 @@ def test_format_aggregate_metric_trend_block_includes_title(): assert "[bold cyan]confidence[/]" in detail assert "[bold green]speed [/]" in detail assert "[bold yellow]accuracy [/]" in detail - - -def test_format_key_speed_trend_line_uses_green(): - line = format_key_speed_trend_line([100.0, 120.0]) - assert "[bold green]speed[/]" in line - assert "[green]" in line - - -def test_wpm_sparkline_scales_oldest_to_newest(): - headers = [ - SessionResult( - schema_version=2, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=None, - total_keystrokes=50, - correct_keystrokes=50, - words_completed=10, - ), - SessionResult( - schema_version=2, - session_id="s2", - started_at=2.0, - duration_ns=30_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=None, - total_keystrokes=50, - correct_keystrokes=50, - words_completed=10, - ), - ] - spark = wpm_sparkline(headers) - assert len(spark) == 2 - assert spark[0] <= spark[1] # 50 wpm then 100 wpm - - -def test_format_wpm_trend_line_includes_latest_and_peak(): - headers = [ - SessionResult( - schema_version=2, - session_id="s1", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(), - focus_key=None, - total_keystrokes=50, - correct_keystrokes=50, - words_completed=10, - ), - ] - line = format_wpm_trend_line(headers) - assert "WPM trend" in line - assert "latest 10" in line From 7699bf7224daf076fab9177035c63d462ace62fc Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 30 Jul 2026 17:36:41 +0300 Subject: [PATCH 24/47] refactor --- src/keystrike/infrastructure/atomic_write.py | 5 +++- src/keystrike/infrastructure/git_client.py | 7 +---- src/keystrike/infrastructure/paths.py | 10 +++++-- .../presentation/screens/practice.py | 23 +++++++++++---- src/keystrike/presentation/screens/stats.py | 29 +++++++++---------- 5 files changed, 42 insertions(+), 32 deletions(-) diff --git a/src/keystrike/infrastructure/atomic_write.py b/src/keystrike/infrastructure/atomic_write.py index e61ce03..ad05ee7 100644 --- a/src/keystrike/infrastructure/atomic_write.py +++ b/src/keystrike/infrastructure/atomic_write.py @@ -9,7 +9,10 @@ def atomic_write_text(path: Path, contents: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) - mode = path.stat().st_mode & 0o777 if path.exists() else 0o644 + try: + mode = path.stat().st_mode & 0o777 + except FileNotFoundError: + mode = 0o644 fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") tmp = Path(tmp_name) try: diff --git a/src/keystrike/infrastructure/git_client.py b/src/keystrike/infrastructure/git_client.py index d69b0b0..b0c2492 100644 --- a/src/keystrike/infrastructure/git_client.py +++ b/src/keystrike/infrastructure/git_client.py @@ -42,12 +42,7 @@ def status_short(self, cwd: Path) -> str: ... class GitClient: - """Thin subprocess wrapper around the `git` CLI. - - Isolates raw process plumbing so `GitSyncGateway`'s merge/sync - orchestration can be unit-tested against a fake client instead of - shelling out to real git. - """ + """Concrete implementation of GitRunner protocol for running git subprocesses.""" def __init__(self, timeout_s: float = _GIT_TIMEOUT_S) -> None: self._timeout_s = timeout_s diff --git a/src/keystrike/infrastructure/paths.py b/src/keystrike/infrastructure/paths.py index c047b9d..6b4912f 100644 --- a/src/keystrike/infrastructure/paths.py +++ b/src/keystrike/infrastructure/paths.py @@ -1,3 +1,4 @@ +import re from dataclasses import dataclass from pathlib import Path @@ -53,10 +54,13 @@ def default_paths() -> Paths: def sanitize_layout_name(name: str) -> str: """Sanitize layout name to prevent path traversal attacks. - Removes path separators and parent directory references. - This ensures layout names cannot escape their designated directory. + Uses an allowlist approach: only letters (a-z, A-Z), digits (0-9), + underscores, and hyphens are allowed. Other characters are replaced + with underscores. This prevents path injection attacks using + filesystem-specific escape characters (e.g., NTFS alternate data + streams via ':', escaped characters, etc.). """ - return name.replace("..", "_").replace("/", "_").replace("\\", "_") + return re.sub(r"[^A-Za-z0-9_-]", "_", name) def ensure_dirs(paths: Paths) -> None: diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index 73cd271..cb4a63d 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -15,6 +15,7 @@ from keystrike.presentation.services import PracticeServices from keystrike.presentation.widgets.hud import HUD from keystrike.presentation.widgets.kb_heatmap import ( + HeatmapDisplay, KbHeatmap, build_heatmap_display, focus_transition_pair, @@ -73,9 +74,7 @@ def compose(self) -> ComposeResult: yield self._hud yield self._typing_area if self._prep.layout_obj is not None and self._prep.lesson_heatmap is not None: - display = build_heatmap_display( - self._prep.layout_obj, - self._prep.lesson_heatmap, + display = self._current_heatmap_display( focus=self._prep.focus_key, focus_transition=focus_transition_pair(self._prep.focus_reason), ) @@ -101,6 +100,20 @@ def _focus_note_text(self) -> str: or "" ) + def _current_heatmap_display( + self, + *, + focus: int | None = None, + focus_transition: tuple[int, int] | None = None, + ) -> HeatmapDisplay | None: + """Build heatmap display using current session's layout and heatmap.""" + return build_heatmap_display( + self._prep.layout_obj, + self._prep.lesson_heatmap, + focus=focus, + focus_transition=focus_transition, + ) + def on_mount(self) -> None: self._refresh_focus_note() self.focus() @@ -166,9 +179,7 @@ def _begin_session(self, prep: SessionPrep) -> None: ) KbHeatmap.update_or_none( self._kb_heatmap, - build_heatmap_display( - prep.layout_obj, - prep.lesson_heatmap, + self._current_heatmap_display( focus=prep.focus_key, focus_transition=focus_transition_pair(prep.focus_reason), ), diff --git a/src/keystrike/presentation/screens/stats.py b/src/keystrike/presentation/screens/stats.py index 04c2098..52154b5 100644 --- a/src/keystrike/presentation/screens/stats.py +++ b/src/keystrike/presentation/screens/stats.py @@ -18,6 +18,7 @@ ) from keystrike.presentation.services import StatsServices from keystrike.presentation.widgets.kb_heatmap import ( + HeatmapDisplay, KbHeatmap, build_heatmap_display, ) @@ -74,11 +75,7 @@ def on_mount(self) -> None: title += " [dim](ortholinear)[/]" self.query_one("#stats-title", Static).update(title) - display = build_heatmap_display( - layout, - heatmap_view.confidence, - urgency=heatmap_view.urgency, - ) + display = self._current_heatmap_display() if display is not None: self._kb_heatmap = KbHeatmap(display) self.query_one(Vertical).mount( @@ -127,6 +124,15 @@ def _codepoint_from_key(self, event: events.Key) -> int | None: return None return codepoint + def _current_heatmap_display(self, *, focus: int | None = None) -> HeatmapDisplay | None: + """Build heatmap display using current layout and heatmap.""" + return build_heatmap_display( + self._layout, + self._heatmap.confidence if self._heatmap is not None else None, + focus=focus, + urgency=self._heatmap.urgency if self._heatmap is not None else None, + ) + def _render_overview(self) -> None: widget = self.query_one("#stats-trends", Static) limit = self._confidence_session_window @@ -176,11 +182,7 @@ def _show_overview(self) -> None: self._render_overview() KbHeatmap.update_or_none( self._kb_heatmap, - build_heatmap_display( - self._layout, - self._heatmap.confidence if self._heatmap is not None else None, - urgency=self._heatmap.urgency if self._heatmap is not None else None, - ), + self._current_heatmap_display(), ) def _show_key_detail(self, codepoint: int) -> None: @@ -188,10 +190,5 @@ def _show_key_detail(self, codepoint: int) -> None: self._render_key_detail(codepoint) KbHeatmap.update_or_none( self._kb_heatmap, - build_heatmap_display( - self._layout, - self._heatmap.confidence if self._heatmap is not None else None, - focus=codepoint, - urgency=self._heatmap.urgency if self._heatmap is not None else None, - ), + self._current_heatmap_display(focus=codepoint), ) From 06a2e6d24a04a4d3d8b06342527073246f81b1d4 Mon Sep 17 00:00:00 2001 From: Egno Date: Fri, 31 Jul 2026 11:16:09 +0300 Subject: [PATCH 25/47] Release 1.3.0: weak-focus word quota and lesson variety. Weak-focus lessons now guarantee a configurable share of focus-matching words, sample the wordlist without replacement, and fall back to Markov when the pool is exhausted. Adds lesson_word_count, focus_word_min_fraction, and max_word_repeats to settings.toml, fixes empty-transition rebuild loops, and hardens edge cases around invalid settings and tiny alphabets. Co-authored-by: Cursor --- docs/CHANGELOG.md | 14 + docs/wiki/Confidence-Tuning.md | 8 +- pyproject.toml | 2 +- src/keystrike/application/build_lesson.py | 20 +- src/keystrike/application/stats_use_cases.py | 13 +- src/keystrike/domain/generator.py | 365 +++++++++++++++++- src/keystrike/domain/models.py | 8 + src/keystrike/domain/version.py | 2 +- .../infrastructure/aggregates_cache.py | 1 + tests/application/test_build_lesson.py | 81 +++- tests/application/test_stats_use_cases.py | 32 ++ tests/domain/test_generator.py | 235 +++++++++++ tests/infrastructure/test_aggregates_cache.py | 28 ++ .../infrastructure/test_settings_repo_toml.py | 6 + uv.lock | 2 +- 15 files changed, 783 insertions(+), 34 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 00438e2..364abe0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,20 @@ 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. +## 1.3.0 + +- Weak-focus lessons guarantee a configurable fraction of words match the focus + key or bigram (default 60%); remaining quota filled via Markov when the wordlist + pool is too small. +- Focus-pool words sampled without replacement; per-lesson repeat cap (default 2) + limits streaks like repeated `toe` on tiny alphabets. +- Lesson tuning in `settings.toml`: `lesson_word_count`, `focus_word_min_fraction`, + `max_word_repeats`; documented on `Confidence-Tuning` wiki. +- Aggregate cache: stop rebuilding on every access when transitions are empty but + already computed (`transitions_computed` on load). +- Edge cases: clamp invalid fraction/word-count settings; repeat cap on confident + focus path; graceful fallback when vocabulary ceiling hit on small alphabets. + ## 1.2.3 - HUD Learn segment: activity (dim when paused or not started) and goal (green when diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index 9839f13..fe25070 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -22,8 +22,11 @@ layout, speed, or other UI settings will not change them. | Focus bigram word boost | `focus_bigram_word_boost` | `4.0` | Extra multiplier on words containing the focus letter pair (when transition focus is active). | | Focus transition boost | `focus_transition_boost` | `4.0` | Multiplier on the focus bigram's transition weight. | | Focus weak extra boost | `focus_weak_extra_boost` | `1.5` | Additional multiplier when focus confidence is below 1.0 (weak key or weak transition). | +| Lesson word count | `lesson_word_count` | `12` | Words generated per practice lesson. | +| Focus word min fraction | `focus_word_min_fraction` | `0.6` | When focus is weak, at least this fraction of lesson words must match the focus key or bigram (ceiling). | +| Max word repeats | `max_word_repeats` | `2` | Maximum times the same word may appear in one generated lesson. | -Valid ranges: window and both attempt floors are **1–100**. Boost multipliers should be **≥ 1.0**. +Valid ranges: window and both attempt floors are **1–100**. Boost multipliers should be **≥ 1.0**. `lesson_word_count` should be **≥ 1**. `focus_word_min_fraction` should be in **(0.0, 1.0]**. `max_word_repeats` should be **≥ 1**. Confidence uses **min(speed, accuracy)**, not their product: a key must be both fast enough and accurate enough to read as mastered. Speed is `target_ms / @@ -40,6 +43,9 @@ focus_word_boost = 3.0 focus_bigram_word_boost = 4.0 focus_transition_boost = 4.0 focus_weak_extra_boost = 1.5 +lesson_word_count = 12 +focus_word_min_fraction = 0.6 +max_word_repeats = 2 ``` ## What each setting affects diff --git a/pyproject.toml b/pyproject.toml index 562b540..18520ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keystrike" -version = "1.2.3" +version = "1.3.0" description = "Offline terminal typing tutor with keybr-style adaptive drills and per-layout stats" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index ba5ac69..31232f8 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -30,7 +30,12 @@ select_focus_transition, transition_practice_weight, ) -from keystrike.domain.generator import AdaptiveGenerator, LessonWeighting +from keystrike.domain.generator import ( + AdaptiveGenerator, + LessonWeighting, + effective_lesson_word_count, + weak_focus_word_quota, +) from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import ( Bigram, @@ -52,7 +57,6 @@ from keystrike.domain.unlock import compute_unlocked from keystrike.domain.wordlist import words_for_alphabet -WORD_COUNT = 12 _CONFIDENCE_GOOD = 1.0 @@ -370,6 +374,7 @@ def __call__(self, layout_name: str) -> Lesson: alphabet_chars, progress.focus, progress.focus_bigram, + focus_confidence=focus_confidence, char_weights=char_weights, transition_weights=transition_weights, ) @@ -409,6 +414,7 @@ def _generate_text( focus: int, focus_bigram: Bigram | None, *, + focus_confidence: float, char_weights: dict[str, float], transition_weights: dict[Bigram, float], ) -> str: @@ -422,12 +428,20 @@ def _generate_text( focus_word_boost=ctx.settings.focus_word_boost, focus_bigram_word_boost=ctx.settings.focus_bigram_word_boost, ) + word_count = effective_lesson_word_count(ctx.settings.lesson_word_count) + quota = ( + weak_focus_word_quota(word_count, ctx.settings.focus_word_min_fraction) + if focus_confidence < _CONFIDENCE_GOOD + else 1 + ) return generator.generate_lesson( alphabet_chars, chr(focus), - word_count=WORD_COUNT, + word_count=word_count, weighting=weighting, focus_bigram=focus_bigram, + min_focus_words=quota, + max_word_repeats=ctx.settings.max_word_repeats, ) def _resolve_dict_words( diff --git a/src/keystrike/application/stats_use_cases.py b/src/keystrike/application/stats_use_cases.py index 324be87..e7831af 100644 --- a/src/keystrike/application/stats_use_cases.py +++ b/src/keystrike/application/stats_use_cases.py @@ -70,10 +70,15 @@ class GetOrRebuildAggregates: def __call__(self, layout: str) -> Mapping[int, KeyStats]: cached = self.cache.get(layout) - if cached is not None and cached.transitions: - return cached.keys - if cached is not None and not any(self.repo.iter_headers(layout)): - return cached.keys + has_sessions = any(self.repo.iter_headers(layout)) + if cached is not None: + if cached.transitions or cached.transitions_computed or not has_sessions: + return cached.keys + self.rebuild(layout) + rebuilt = self.cache.get(layout) + return rebuilt.keys if rebuilt is not None else {} + if not has_sessions: + return {} self.rebuild(layout) rebuilt = self.cache.get(layout) return rebuilt.keys if rebuilt is not None else {} diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index af1fa4c..ba534f7 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -3,17 +3,141 @@ from __future__ import annotations +import math +from collections import Counter from collections.abc import Mapping from dataclasses import dataclass from random import Random from .focus import FOCUS_BIGRAM_WORD_BOOST, FOCUS_WORD_BOOST from .markov import TransitionTable -from .models import Bigram, Layout +from .models import ( + FOCUS_WORD_MIN_FRACTION, + MAX_WORD_REPEATS, + Bigram, + Layout, +) +from .models import ( + LESSON_WORD_COUNT as DEFAULT_WORD_COUNT, +) from .word_bounds import MAX_WORD_LEN, MIN_WORD_LEN MAX_RETRIES = 5 -DEFAULT_WORD_COUNT = 12 +MAX_REPEAT_RESAMPLE = 64 + + +def min_focus_words(word_count: int, fraction: float = FOCUS_WORD_MIN_FRACTION) -> int: + """Minimum focus-matching words required for weak-focus lessons.""" + return math.ceil(word_count * fraction) + + +def clamp_focus_word_fraction(fraction: float) -> float: + """Keep hand-edited settings.toml values within (0, 1].""" + return min(1.0, max(0.0, fraction)) + + +def weak_focus_word_quota(word_count: int, fraction: float) -> int: + """Weak-focus slot count that satisfies ``generate_lesson`` preconditions.""" + return min(min_focus_words(word_count, clamp_focus_word_fraction(fraction)), word_count) + + +def effective_max_word_repeats(max_repeats: int) -> int: + """Per-lesson repeat cap must be at least 1 or no word can be appended.""" + return max(1, max_repeats) + + +def effective_lesson_word_count(word_count: int) -> int: + """Hand-edited settings may set 0; practice needs at least one word.""" + return max(1, word_count) + + +def word_matches_focus( + word: str, + *, + focus_char: str, + focus_bigram: str | None, +) -> bool: + """True when word satisfies the active focus criterion.""" + if focus_bigram is not None: + return focus_bigram in word + return focus_char in word + + +def _assert_lesson_focus_quota( + words: list[str], + *, + min_focus_words: int, + focus_char: str, + focus_bigram: str | None, +) -> None: + """Ensures: when min_focus_words > 1, at least that many words match focus.""" + if min_focus_words <= 1: + return + matching = sum( + 1 for w in words if word_matches_focus(w, focus_char=focus_char, focus_bigram=focus_bigram) + ) + assert matching >= min_focus_words, ( + f"focus quota unmet: {matching}/{len(words)} words match (required {min_focus_words})" + ) + + +def _wordlist_word_fits(word: str, alphabet: frozenset[str]) -> bool: + return MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN and set(word) <= alphabet + + +def _focus_pool_from_wordlist( + words: tuple[str, ...] | None, + alphabet: frozenset[str], + *, + focus_char: str, + focus_bigram: str | None, +) -> tuple[str, ...]: + if not words: + return () + return tuple( + w + for w in words + if _wordlist_word_fits(w, alphabet) + and word_matches_focus(w, focus_char=focus_char, focus_bigram=focus_bigram) + ) + + +def _sample_focus_words_without_replacement( + rng: Random, + focus_pool: tuple[str, ...], + focus_weighted: WeightedWordlist | None, + count: int, +) -> list[str]: + """Draw up to ``count`` distinct focus-pool words without replacement. + + Ensures: returned words are unique, each from ``focus_pool``, + len <= min(count, len(focus_pool)). + """ + if count <= 0 or not focus_pool: + return [] + pool = list(focus_pool) + weights = list(focus_weighted.weights) if focus_weighted is not None else [1.0] * len(pool) + sampled: list[str] = [] + for _ in range(min(count, len(pool))): + word = rng.choices(pool, weights=weights, k=1)[0] + idx = pool.index(word) + sampled.append(word) + pool.pop(idx) + weights.pop(idx) + return sampled + + +def _append_word_if_under_repeat_cap( + lesson_words: list[str], + word: str, + *, + max_repeats: int = MAX_WORD_REPEATS, +) -> bool: + """Append ``word`` when it would stay under the per-lesson repeat cap.""" + if Counter(lesson_words)[word] >= max_repeats: + return False + lesson_words.append(word) + return True @dataclass(frozen=True, slots=True) @@ -148,7 +272,19 @@ def generate_lesson( word_count: int = DEFAULT_WORD_COUNT, weighting: LessonWeighting | None = None, focus_bigram: Bigram | None = None, + min_focus_words: int = 1, + max_word_repeats: int = MAX_WORD_REPEATS, ) -> str: + """Generate practice text with focus-word guarantees. + + Requires: ``0 <= min_focus_words <= word_count``. + Ensures: at least ``min_focus_words`` words match focus when ``min_focus_words > 1``; + always at least one focus match when focus is specified. + """ + word_count = effective_lesson_word_count(word_count) + min_focus_words = min(min_focus_words, word_count) + assert 0 <= min_focus_words <= word_count + max_word_repeats = effective_max_word_repeats(max_word_repeats) weighting = weighting or LessonWeighting() focus_bigram_str: str | None = None if focus_bigram is not None: @@ -167,25 +303,220 @@ def generate_lesson( for w in weighting.words ), ) - lesson_words = [ - self.generate_word(alphabet, weighting, weighted_wordlist=weighted_wordlist) - for _ in range(word_count) - ] - if focus_bigram is not None: - prev_char, next_char = chr(focus_bigram.prev_cp), chr(focus_bigram.next_cp) - bigram = prev_char + next_char - if not any(bigram in w for w in lesson_words): + focus_pool = _focus_pool_from_wordlist( + weighting.words, + alphabet, + focus_char=focus_char, + focus_bigram=focus_bigram_str, + ) + focus_weighted: WeightedWordlist | None = None + if focus_pool and weighted_wordlist is not None: + pool_set = frozenset(focus_pool) + focus_weighted = WeightedWordlist( + words=focus_pool, + weights=tuple( + w + for word, w in zip( + weighted_wordlist.words, weighted_wordlist.weights, strict=True + ) + if word in pool_set + ), + ) + if min_focus_words <= 1: + lesson_words: list[str] = [] + while len(lesson_words) < word_count: + self._append_general_word_capped( + lesson_words, + alphabet, + weighting, + weighted_wordlist=weighted_wordlist, + max_word_repeats=max_word_repeats, + ) + if focus_bigram is not None: + prev_char, next_char = chr(focus_bigram.prev_cp), chr(focus_bigram.next_cp) + bigram = prev_char + next_char + if not any(bigram in w for w in lesson_words): + idx = self.rng.randrange(len(lesson_words)) + lesson_words[idx] = self._inject_focus_bigram( + lesson_words[idx], + prev_char, + next_char, + ) + elif not any(focus_char in w for w in lesson_words): idx = self.rng.randrange(len(lesson_words)) - lesson_words[idx] = self._inject_focus_bigram( - lesson_words[idx], - prev_char, - next_char, + lesson_words[idx] = self._inject_focus(lesson_words[idx], focus_char) + else: + focus_quota = min(min_focus_words, word_count) + lesson_words: list[str] = [] + pool_draws = _sample_focus_words_without_replacement( + self.rng, + focus_pool, + focus_weighted, + focus_quota, + ) + for word in pool_draws: + _append_word_if_under_repeat_cap(lesson_words, word, max_repeats=max_word_repeats) + while ( + sum( + 1 + for w in lesson_words + if word_matches_focus(w, focus_char=focus_char, focus_bigram=focus_bigram_str) + ) + < focus_quota + ): + self._append_focus_word_capped( + lesson_words, + alphabet, + weighting, + focus_char=focus_char, + focus_bigram=focus_bigram, + focus_bigram_str=focus_bigram_str, + use_wordlist=False, + max_word_repeats=max_word_repeats, + ) + while len(lesson_words) < word_count: + self._append_general_word_capped( + lesson_words, + alphabet, + weighting, + weighted_wordlist=weighted_wordlist, + max_word_repeats=max_word_repeats, ) - elif not any(focus_char in w for w in lesson_words): - idx = self.rng.randrange(len(lesson_words)) - lesson_words[idx] = self._inject_focus(lesson_words[idx], focus_char) + self.rng.shuffle(lesson_words) + _assert_lesson_focus_quota( + lesson_words, + min_focus_words=focus_quota, + focus_char=focus_char, + focus_bigram=focus_bigram_str, + ) return " ".join(lesson_words) + def _append_focus_word_capped( + self, + lesson_words: list[str], + alphabet: frozenset[str], + weighting: LessonWeighting, + *, + focus_char: str, + focus_bigram: Bigram | None, + focus_bigram_str: str | None, + use_wordlist: bool, + focus_pool: tuple[str, ...] = (), + focus_weighted: WeightedWordlist | None = None, + max_word_repeats: int = MAX_WORD_REPEATS, + ) -> None: + """Add one focus-matching word, respecting the per-lesson repeat cap.""" + for _ in range(MAX_REPEAT_RESAMPLE): + word = self._generate_focus_word( + alphabet, + weighting, + focus_char=focus_char, + focus_bigram=focus_bigram, + focus_bigram_str=focus_bigram_str, + focus_pool=focus_pool if use_wordlist else (), + focus_weighted=focus_weighted if use_wordlist else None, + ) + if _append_word_if_under_repeat_cap(lesson_words, word, max_repeats=max_word_repeats): + return + word = self._generate_focus_word( + alphabet, + weighting, + focus_char=focus_char, + focus_bigram=focus_bigram, + focus_bigram_str=focus_bigram_str, + focus_pool=(), + focus_weighted=None, + ) + self._append_word_mutating_until_under_cap( + lesson_words, + word, + alphabet, + focus_char=focus_char, + focus_bigram=focus_bigram, + max_word_repeats=max_word_repeats, + ) + + def _append_general_word_capped( + self, + lesson_words: list[str], + alphabet: frozenset[str], + weighting: LessonWeighting, + *, + weighted_wordlist: WeightedWordlist | None, + max_word_repeats: int = MAX_WORD_REPEATS, + ) -> None: + """Add one general word, respecting the per-lesson repeat cap.""" + for _ in range(MAX_REPEAT_RESAMPLE): + word = self.generate_word(alphabet, weighting, weighted_wordlist=weighted_wordlist) + if _append_word_if_under_repeat_cap(lesson_words, word, max_repeats=max_word_repeats): + return + word = self.generate_word(alphabet, weighting, weighted_wordlist=weighted_wordlist) + self._append_word_mutating_until_under_cap( + lesson_words, word, alphabet, max_word_repeats=max_word_repeats + ) + + def _append_word_mutating_until_under_cap( + self, + lesson_words: list[str], + word: str, + alphabet: frozenset[str], + *, + focus_char: str | None = None, + focus_bigram: Bigram | None = None, + max_word_repeats: int = MAX_WORD_REPEATS, + ) -> None: + """Ensures: word is appended without exceeding the repeat cap.""" + if _append_word_if_under_repeat_cap(lesson_words, word, max_repeats=max_word_repeats): + return + chars = sorted(alphabet) + for _ in range(MAX_REPEAT_RESAMPLE): + if len(word) < MAX_WORD_LEN: + word = word + self.rng.choice(chars) + elif focus_bigram is not None: + prev_char, next_char = chr(focus_bigram.prev_cp), chr(focus_bigram.next_cp) + word = self._inject_focus_bigram(word, prev_char, next_char) + elif focus_char is not None: + word = self._inject_focus(word, focus_char) + else: + pos = self.rng.randrange(len(word)) + word = word[:pos] + self.rng.choice(chars) + word[pos + 1 :] + if _append_word_if_under_repeat_cap(lesson_words, word, max_repeats=max_word_repeats): + return + # ponytail: small alphabet caps distinct words below lesson_word_count x max_repeats; + # append anyway rather than abort practice. + lesson_words.append(word) + + def _generate_focus_word( + self, + alphabet: frozenset[str], + weighting: LessonWeighting, + *, + focus_char: str, + focus_bigram: Bigram | None, + focus_bigram_str: str | None, + focus_pool: tuple[str, ...], + focus_weighted: WeightedWordlist | None, + ) -> str: + """Word guaranteed to match focus — wordlist pool, then Markov, then inject.""" + if focus_pool: + wordlist = WeightedWordlist(words=focus_pool, weights=tuple(1.0 for _ in focus_pool)) + if focus_weighted is not None: + wordlist = focus_weighted + word = self._generate_word_from_wordlist(alphabet, weighting, wordlist) + if word is not None and word_matches_focus( + word, focus_char=focus_char, focus_bigram=focus_bigram_str + ): + return word + for _ in range(MAX_RETRIES): + word = self._generate_word_via_markov(alphabet, weighting) + if word_matches_focus(word, focus_char=focus_char, focus_bigram=focus_bigram_str): + return word + word = self._generate_word_via_markov(alphabet, weighting) + if focus_bigram is not None: + prev_char, next_char = chr(focus_bigram.prev_cp), chr(focus_bigram.next_cp) + return self._inject_focus_bigram(word, prev_char, next_char) + return self._inject_focus(word, focus_char) + def _sample_from_wordlist( self, weighting: LessonWeighting, diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index ba51936..90ed013 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -17,6 +17,9 @@ FOCUS_BIGRAM_WORD_BOOST = 4.0 FOCUS_TRANSITION_BOOST = 4.0 FOCUS_WEAK_EXTRA_BOOST = 1.5 +LESSON_WORD_COUNT = 12 +FOCUS_WORD_MIN_FRACTION = 0.6 +MAX_WORD_REPEATS = 2 @dataclass(frozen=True, slots=True) @@ -100,6 +103,8 @@ def _empty_transitions() -> dict[Bigram, TransitionStats]: class LayoutAggregates: keys: Mapping[int, KeyStats] transitions: Mapping[Bigram, TransitionStats] = field(default_factory=_empty_transitions) + # False when loaded from a pre-transition cache file that omitted ``transitions``. + transitions_computed: bool = True def __post_init__(self) -> None: # Freezing the dataclass only blocks attribute rebinding — wrap the @@ -162,6 +167,9 @@ class Settings: focus_weak_extra_boost: float = FOCUS_WEAK_EXTRA_BOOST # extra multiplier when confidence < 1.0 lang: str = "en" learn_daily_minutes: int = 10 # adaptive mode daily goal (minutes); 0 = no goal + lesson_word_count: int = LESSON_WORD_COUNT # words per generated practice lesson + focus_word_min_fraction: float = FOCUS_WORD_MIN_FRACTION # weak-focus word quota fraction + max_word_repeats: int = MAX_WORD_REPEATS # per-word repeat cap in generated lessons wordlist_url: str = "" # non-empty + cached file → real words; else Markov updated_at: str | None = None # ISO-8601 UTC; sync LWW diff --git a/src/keystrike/domain/version.py b/src/keystrike/domain/version.py index 10aa336..67bc602 100644 --- a/src/keystrike/domain/version.py +++ b/src/keystrike/domain/version.py @@ -1 +1 @@ -__version__ = "1.2.3" +__version__ = "1.3.0" diff --git a/src/keystrike/infrastructure/aggregates_cache.py b/src/keystrike/infrastructure/aggregates_cache.py index 4990399..f22fa98 100644 --- a/src/keystrike/infrastructure/aggregates_cache.py +++ b/src/keystrike/infrastructure/aggregates_cache.py @@ -81,6 +81,7 @@ def get(self, layout: str) -> LayoutAggregates | None: return LayoutAggregates( keys={int(cp): _parse_key_entry(cp, entry) for cp, entry in keys.items()}, transitions=without_same_key_transitions(parsed_transitions), + transitions_computed="transitions" in data, ) except (json.JSONDecodeError, KeyError, TypeError, ValueError): return None diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 6d08507..e5dd5ec 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -5,6 +5,7 @@ from keystrike.domain.confidence import target_ms_per_char, transition_confidence_of from keystrike.domain.enums import FocusKind from keystrike.domain.focus import FocusReason +from keystrike.domain.generator import weak_focus_word_quota, word_matches_focus from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import Bigram, KeyStats, LayoutAggregates, Settings, TransitionStats from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS @@ -481,10 +482,11 @@ def test_lesson_wordlist_biases_weak_transition(): cache = FakeAggregatesCache( by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, ) + settings = Settings(alphabet_size=2, wordlist_url=url) builder = BuildLesson( layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), aggregates_cache=cache, - settings_repo=FakeSettingsRepository(Settings(alphabet_size=2, wordlist_url=url)), + settings_repo=FakeSettingsRepository(settings), language_provider=FakeLanguageProvider(), wordlist_store=FakeWordListStore(by_url={url: cached}), rng=Random(0), @@ -493,7 +495,11 @@ def test_lesson_wordlist_biases_weak_transition(): lesson = builder("qwerty") pair = chr(a) + chr(s) assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=Bigram(a, s)) - assert set(lesson.text.split()) <= set(cached) + words = lesson.text.split() + quota = weak_focus_word_quota(settings.lesson_word_count, settings.focus_word_min_fraction) + bigram_words = sum(1 for word in words if pair in word) + assert bigram_words >= quota + assert max(words.count(w) for w in set(words)) <= 2 ssa_count = 0 as_word_count = 0 @@ -504,7 +510,7 @@ def test_lesson_wordlist_biases_weak_transition(): ssa_count += 1 elif pair in word: as_word_count += 1 - assert as_word_count + ssa_count == 50 * 12 + assert as_word_count + ssa_count == 50 * Settings().lesson_word_count assert as_word_count > ssa_count * 2 @@ -573,9 +579,72 @@ def test_lesson_uses_cached_wordlist_when_configured(): clock=FakeClock(), ) lesson = builder("qwerty") - lesson_words = set(lesson.text.split()) - assert lesson_words <= set(cached) - assert len(lesson_words) >= 1 + words = lesson.text.split() + from_wordlist = [w for w in words if w in cached] + assert len(from_wordlist) >= settings.lesson_word_count // 2 + assert max(words.count(w) for w in set(words)) <= 2 + + +def test_weak_key_focus_meets_word_quota_in_build_lesson(): + """Weak key confidence wires min_focus_words=ceil(0.6 * word_count).""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + focus_cp = order[0] + now = 1_700_000_000.0 + slow = 400_000_000.0 + keys = {focus_cp: KeyStats(focus_cp, 10, slow, 0, now, attempt_count=10)} + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=1)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + ) + settings = Settings() + quota = weak_focus_word_quota(settings.lesson_word_count, settings.focus_word_min_fraction) + focus_char = chr(focus_cp) + for seed in range(30): + builder.rng = Random(seed) + lesson = builder("qwerty") + assert lesson.focus_reason == FocusReason(kind=FocusKind.KEY_WEAK) + words = lesson.text.split() + focus_words = sum( + 1 for w in words if word_matches_focus(w, focus_char=focus_char, focus_bigram=None) + ) + assert focus_words >= quota, f"seed={seed}: {focus_words}/{len(words)} focus words" + + +def test_build_lesson_survives_invalid_focus_word_min_fraction(): + """Hand-edited settings.toml can set fraction outside (0, 1]; quota must clamp.""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + focus_cp = order[0] + now = 1_700_000_000.0 + slow = 400_000_000.0 + keys = {focus_cp: KeyStats(focus_cp, 10, slow, 0, now, attempt_count=10)} + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, + ) + settings = Settings(alphabet_size=1, focus_word_min_fraction=1.5) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(settings), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + ) + for seed in range(10): + builder.rng = Random(seed) + lesson = builder("qwerty") + assert lesson.focus_reason == FocusReason(kind=FocusKind.KEY_WEAK) + assert len(lesson.text.split()) == settings.lesson_word_count def test_lesson_falls_back_to_markov_when_cache_missing(): diff --git a/tests/application/test_stats_use_cases.py b/tests/application/test_stats_use_cases.py index 78a4ada..2b9669a 100644 --- a/tests/application/test_stats_use_cases.py +++ b/tests/application/test_stats_use_cases.py @@ -169,6 +169,7 @@ def test_ensure_rebuilds_when_transitions_missing_but_sessions_exist(): "qwerty": LayoutAggregates( keys={ord("a"): KeyStats(ord("a"), 1, 100_000_000.0, 0, 1.0, attempt_count=1)}, transitions={}, + transitions_computed=False, ), }, ) @@ -191,6 +192,37 @@ def test_ensure_rebuilds_when_transitions_missing_but_sessions_exist(): assert cached.transitions.get(Bigram(ord("a"), ord("b"))) is not None +def test_ensure_skips_rebuild_when_transitions_computed_empty(): + repo = FakeSessionRepository() + cache = FakeAggregatesCache( + by_layout={ + "qwerty": LayoutAggregates( + keys={ord("a"): KeyStats(ord("a"), 1, 100_000_000.0, 0, 1.0, attempt_count=1)}, + transitions={}, + transitions_computed=True, + ), + }, + ) + repo.save_header(_header("s1", 1_700_000_000.0)) + repo.keystrokes["s1"] = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), + ] + rebuild = RebuildAggregates( + repo=repo, + cache=cache, + settings_repo=FakeSettingsRepository(), + ) + ensure = GetOrRebuildAggregates(repo=repo, cache=cache, rebuild=rebuild) + + ensure("qwerty") + ensure("qwerty") + + cached = cache.get("qwerty") + assert cached is not None + assert cached.transitions == {} + + def test_rebuild_aggregates_drops_sessions_outside_window(): repo = FakeSessionRepository() cache = FakeAggregatesCache() diff --git a/tests/domain/test_generator.py b/tests/domain/test_generator.py index 8a66a1a..1bc82fe 100644 --- a/tests/domain/test_generator.py +++ b/tests/domain/test_generator.py @@ -1,10 +1,15 @@ from random import Random from keystrike.domain.generator import ( + FOCUS_WORD_MIN_FRACTION, AdaptiveGenerator, LessonWeighting, + clamp_focus_word_fraction, cpm_from_wpm, + min_focus_words, typical_chars_per_word, + weak_focus_word_quota, + word_matches_focus, wordlist_weight_for_word, wpm_from_cpm, ) @@ -133,6 +138,7 @@ def test_generate_lesson_wordlist_transition_weights_bias(): frozenset("as"), focus_char="s", word_count=12, + max_word_repeats=12, weighting=LessonWeighting( transition_weights={ Bigram(ord("a"), ord("s")): 100.0, @@ -196,3 +202,232 @@ def test_wpm_cpm_conversion_uses_typical_word_length(): avg = typical_chars_per_word() assert cpm_from_wpm(80) == round(80 * avg) assert wpm_from_cpm(round(80 * avg)) == 80 + + +def test_min_focus_words_uses_ceiling(): + assert min_focus_words(12, FOCUS_WORD_MIN_FRACTION) == 8 + assert min_focus_words(5, FOCUS_WORD_MIN_FRACTION) == 3 + + +def test_clamp_focus_word_fraction_bounds(): + assert clamp_focus_word_fraction(1.5) == 1.0 + assert clamp_focus_word_fraction(-0.1) == 0.0 + + +def test_weak_focus_word_quota_clamps_invalid_fraction(): + assert weak_focus_word_quota(12, 1.5) == 12 + assert weak_focus_word_quota(12, -0.1) == 0 + + +def test_word_matches_focus_char_and_bigram(): + assert word_matches_focus("cab", focus_char="a", focus_bigram=None) + assert not word_matches_focus("bbb", focus_char="a", focus_bigram=None) + assert word_matches_focus("cab", focus_char="x", focus_bigram="ab") + assert not word_matches_focus("bbb", focus_char="x", focus_bigram="ab") + + +def test_generate_lesson_weak_guarantees_focus_word_fraction(): + generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) + quota = min_focus_words(12, FOCUS_WORD_MIN_FRACTION) + for seed in range(50): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + frozenset("ab"), + focus_char="a", + word_count=12, + min_focus_words=quota, + weighting=LessonWeighting(char_weights={"a": 1.75, "b": 1.0}), + ) + words = lesson.split() + focus_words = sum( + 1 for w in words if word_matches_focus(w, focus_char="a", focus_bigram=None) + ) + assert focus_words >= quota, f"seed={seed}: {focus_words}/{len(words)} focus words" + + +def test_focus_quota_falls_back_to_markov_when_wordlist_sparse(): + """Tiny wordlist with one focus word — quota still met via Markov/inject.""" + generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) + words = ("aaa", "bbb", "bbb") # only one focus word in pool + quota = min_focus_words(12, FOCUS_WORD_MIN_FRACTION) + for seed in range(20): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + frozenset("ab"), + focus_char="a", + word_count=12, + min_focus_words=quota, + weighting=LessonWeighting(words=words, char_weights={"a": 2.0, "b": 1.0}), + ) + focus_words = sum( + 1 for w in lesson.split() if word_matches_focus(w, focus_char="a", focus_bigram=None) + ) + assert focus_words >= quota, f"seed={seed}: {focus_words} focus words" + + +def test_generate_lesson_strong_focus_keeps_minimum_one(): + """min_focus_words=1 — probabilistic bias, not 60% quota.""" + # "z" is outside the Markov table; it only appears via injection. + generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) + below_quota_seeds = 0 + for seed in range(50): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + frozenset("abz"), + focus_char="z", + word_count=12, + min_focus_words=1, + ) + words = lesson.split() + focus_words = sum( + 1 for w in words if word_matches_focus(w, focus_char="z", focus_bigram=None) + ) + assert focus_words >= 1 + if focus_words < min_focus_words(12, FOCUS_WORD_MIN_FRACTION): + below_quota_seeds += 1 + assert below_quota_seeds > 0 # not all seeds hit 60% quota + + +def test_focus_quota_samples_without_replacement(): + """Focus pool words appear at most once when pool size >= quota.""" + generator = AdaptiveGenerator(table=_uniform_table("abcdefgh"), rng=Random(0)) + words = ("toe", "doe", "foe", "hoe") + for seed in range(50): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + frozenset("abcdefgho"), + focus_char="o", + word_count=4, + min_focus_words=4, + weighting=LessonWeighting(words=words), + ) + split = lesson.split() + for w in words: + assert split.count(w) <= 1, f"seed={seed}: {w} repeated in {split}" + + +def test_focus_quota_markov_fills_when_pool_smaller_than_quota(): + """Tiny focus pool — without-replacement draw then Markov fills quota.""" + alphabet = frozenset("arstneio") + generator = AdaptiveGenerator(table=_uniform_table("arstneio"), rng=Random(0)) + words = ("toe", "toes", "are", "not", "sea", "rat") + quota = 8 + for seed in range(50): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + alphabet, + focus_char="e", + word_count=12, + min_focus_words=quota, + focus_bigram=Bigram(ord("o"), ord("e")), + weighting=LessonWeighting( + words=words, + transition_weights={Bigram(ord("o"), ord("e")): 50.0}, + ), + ) + split = lesson.split() + focus_words = sum( + 1 for w in split if word_matches_focus(w, focus_char="e", focus_bigram="oe") + ) + assert focus_words >= quota, f"seed={seed}: {focus_words}/{quota} focus words" + assert max(split.count(w) for w in set(split)) <= 2, f"seed={seed}: repeat cap broken" + assert len(set(split)) > 2, f"seed={seed}: lesson too repetitive: {split}" + + +def test_lesson_caps_word_repeats(): + """No word appears more than twice in a lesson (focus + general slots).""" + generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) + words = ("aaa", "bbb", "aba", "bab") + quota = min_focus_words(12, FOCUS_WORD_MIN_FRACTION) + for seed in range(50): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + frozenset("ab"), + focus_char="a", + word_count=12, + min_focus_words=quota, + weighting=LessonWeighting(char_weights={"a": 5.0, "b": 1.0}, words=words), + ) + split = lesson.split() + for w in set(split): + assert split.count(w) <= 2, f"seed={seed}: {w} appears {split.count(w)} times" + + +def test_lesson_caps_word_repeats_on_strong_focus_path(): + """Repeat cap applies when min_focus_words=1 (confident focus).""" + generator = AdaptiveGenerator(table=_uniform_table("a"), rng=Random(0)) + words = ("aaa",) + for seed in range(20): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + frozenset("a"), + focus_char="a", + word_count=8, + min_focus_words=1, + weighting=LessonWeighting(words=words), + ) + split = lesson.split() + assert max(split.count(w) for w in set(split)) <= 2, f"seed={seed}: {split}" + + +def test_generate_lesson_clamps_max_word_repeats_zero(): + """max_word_repeats=0 would reject every word; clamp to 1 instead of crashing.""" + generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) + lesson = generator.generate_lesson( + frozenset("ab"), + focus_char="a", + word_count=6, + min_focus_words=min_focus_words(6, FOCUS_WORD_MIN_FRACTION), + max_word_repeats=0, + weighting=LessonWeighting(char_weights={"a": 3.0, "b": 1.0}), + ) + assert len(lesson.split()) == 6 + + +def test_generate_lesson_survives_small_alphabet_repeat_cap(): + """One-letter alphabet has few distinct words; must not crash when cap is tight.""" + generator = AdaptiveGenerator(table=_uniform_table("a"), rng=Random(0)) + lesson = generator.generate_lesson( + frozenset("a"), + focus_char="a", + word_count=12, + min_focus_words=1, + max_word_repeats=1, + ) + assert len(lesson.split()) == 12 + + +def test_generate_lesson_clamps_zero_word_count(): + generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) + lesson = generator.generate_lesson( + frozenset("ab"), + focus_char="a", + word_count=0, + min_focus_words=1, + ) + assert len(lesson.split()) == 1 + + +def test_generate_lesson_weak_guarantees_focus_bigram_fraction(): + generator = AdaptiveGenerator(table=_uniform_table("abc"), rng=Random(0)) + bigram = Bigram(ord("a"), ord("b")) + bigram_str = bigram.chars() + quota = min_focus_words(12, FOCUS_WORD_MIN_FRACTION) + for seed in range(50): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + frozenset("abc"), + focus_char="b", + word_count=12, + min_focus_words=quota, + focus_bigram=bigram, + weighting=LessonWeighting( + transition_weights={bigram: 50.0}, + words=("cab", "abc", "bab", "bbb", "ccc"), + ), + ) + words = lesson.split() + focus_words = sum( + 1 for w in words if word_matches_focus(w, focus_char="b", focus_bigram=bigram_str) + ) + assert focus_words >= quota, f"seed={seed}: {focus_words}/{len(words)} bigram words" diff --git a/tests/infrastructure/test_aggregates_cache.py b/tests/infrastructure/test_aggregates_cache.py index b5c3cb0..71bf993 100644 --- a/tests/infrastructure/test_aggregates_cache.py +++ b/tests/infrastructure/test_aggregates_cache.py @@ -52,6 +52,34 @@ def test_get_old_cache_without_transitions(paths): loaded = cache.get("qwerty") assert loaded is not None assert loaded.transitions == {} + assert loaded.transitions_computed is True + + +def test_get_pre_transition_cache_sets_transitions_computed_false(paths): + cache = FileAggregatesCache(paths) + cache._file("qwerty").write_text( + json.dumps( + { + "schema_version": 1, + "layout": "qwerty", + "keys": { + "97": { + "codepoint": 97, + "samples": 1, + "mean_time_ns": 100.0, + "error_count": 0, + "last_seen": 1.0, + "attempt_count": 1, + }, + }, + } + ), + encoding="utf-8", + ) + loaded = cache.get("qwerty") + assert loaded is not None + assert loaded.transitions == {} + assert loaded.transitions_computed is False def test_layout_isolation(paths): diff --git a/tests/infrastructure/test_settings_repo_toml.py b/tests/infrastructure/test_settings_repo_toml.py index 0d60651..1681817 100644 --- a/tests/infrastructure/test_settings_repo_toml.py +++ b/tests/infrastructure/test_settings_repo_toml.py @@ -37,6 +37,9 @@ def test_round_trip(paths): focus_bigram_word_boost=6.0, focus_transition_boost=3.5, focus_weak_extra_boost=2.0, + lesson_word_count=15, + focus_word_min_fraction=0.75, + max_word_repeats=3, ) repo.save(original) loaded = repo.load() @@ -50,6 +53,9 @@ def test_round_trip(paths): assert loaded.focus_bigram_word_boost == 6.0 assert loaded.focus_transition_boost == 3.5 assert loaded.focus_weak_extra_boost == 2.0 + assert loaded.lesson_word_count == 15 + assert loaded.focus_word_min_fraction == 0.75 + assert loaded.max_word_repeats == 3 assert loaded.updated_at is not None diff --git a/uv.lock b/uv.lock index 5cd3d3b..b5e8a4f 100644 --- a/uv.lock +++ b/uv.lock @@ -79,7 +79,7 @@ wheels = [ [[package]] name = "keystrike" -version = "1.2.3" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "platformdirs" }, From 8eec76f29f5912c5094ac773ea96144f68809518 Mon Sep 17 00:00:00 2001 From: Egno Date: Fri, 31 Jul 2026 11:30:17 +0300 Subject: [PATCH 26/47] Add configurable generated word length for lessons and WPM math. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can set min/max generated word length in settings; the generator, Markov fallback padding, and WPM↔CPM conversion use those bounds so target speed matches the text they actually type. Co-authored-by: Cursor --- docs/assets/demo.gif | Bin 52839 -> 51676 bytes docs/wiki/Confidence-Tuning.md | 6 +- src/keystrike/application/build_lesson.py | 7 + src/keystrike/domain/generator.py | 150 +++++++++++++++--- src/keystrike/domain/models.py | 4 + src/keystrike/domain/word_bounds.py | 2 +- .../presentation/screens/settings.py | 23 ++- tests/domain/test_generator.py | 74 ++++++++- .../infrastructure/test_settings_repo_toml.py | 4 + .../test_practice_screen_snapshot.svg | 4 +- 10 files changed, 247 insertions(+), 27 deletions(-) diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif index f5e03df3bd005e7f0bd7ddac28a866bfd382f445..2c59cc6f317dd9d9e6a2c272bbb1d25a5f8c5806 100644 GIT binary patch literal 51676 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�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 diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index fe25070..d8b95e6 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -25,8 +25,10 @@ layout, speed, or other UI settings will not change them. | Lesson word count | `lesson_word_count` | `12` | Words generated per practice lesson. | | Focus word min fraction | `focus_word_min_fraction` | `0.6` | When focus is weak, at least this fraction of lesson words must match the focus key or bigram (ceiling). | | Max word repeats | `max_word_repeats` | `2` | Maximum times the same word may appear in one generated lesson. | +| Generated word min length | `generated_word_min_len` | `3` | Minimum length for Markov-generated words (dictionary words still use 3–10). | +| Generated word max length | `generated_word_max_len` | `10` | Maximum length for Markov-generated words. | -Valid ranges: window and both attempt floors are **1–100**. Boost multipliers should be **≥ 1.0**. `lesson_word_count` should be **≥ 1**. `focus_word_min_fraction` should be in **(0.0, 1.0]**. `max_word_repeats` should be **≥ 1**. +Valid ranges: window and both attempt floors are **1–100**. Boost multipliers should be **≥ 1.0**. `lesson_word_count` should be **≥ 1**. `focus_word_min_fraction` should be in **(0.0, 1.0]**. `max_word_repeats` should be **≥ 1**. `generated_word_min_len` and `generated_word_max_len` should be **≥ 1** with min ≤ max (invalid pairs are clamped at lesson build time). Confidence uses **min(speed, accuracy)**, not their product: a key must be both fast enough and accurate enough to read as mastered. Speed is `target_ms / @@ -46,6 +48,8 @@ focus_weak_extra_boost = 1.5 lesson_word_count = 12 focus_word_min_fraction = 0.6 max_word_repeats = 2 +generated_word_min_len = 3 +generated_word_max_len = 10 ``` ## What each setting affects diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 31232f8..3b61343 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -33,6 +33,7 @@ from keystrike.domain.generator import ( AdaptiveGenerator, LessonWeighting, + effective_generated_word_bounds, effective_lesson_word_count, weak_focus_word_quota, ) @@ -434,6 +435,10 @@ def _generate_text( if focus_confidence < _CONFIDENCE_GOOD else 1 ) + generated_min_len, generated_max_len = effective_generated_word_bounds( + ctx.settings.generated_word_min_len, + ctx.settings.generated_word_max_len, + ) return generator.generate_lesson( alphabet_chars, chr(focus), @@ -442,6 +447,8 @@ def _generate_text( focus_bigram=focus_bigram, min_focus_words=quota, max_word_repeats=ctx.settings.max_word_repeats, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, ) def _resolve_dict_words( diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index ba534f7..c25bbc3 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -13,6 +13,8 @@ from .markov import TransitionTable from .models import ( FOCUS_WORD_MIN_FRACTION, + GENERATED_WORD_MAX_LEN, + GENERATED_WORD_MIN_LEN, MAX_WORD_REPEATS, Bigram, Layout, @@ -51,6 +53,14 @@ def effective_lesson_word_count(word_count: int) -> int: return max(1, word_count) +def effective_generated_word_bounds(min_len: int, max_len: int) -> tuple[int, int]: + """Clamp hand-edited settings; ensure min <= max and both are at least 1.""" + min_len = max(1, min_len) + max_len = max(1, max_len) + min_len = min(min_len, max_len) + return min_len, max_len + + def word_matches_focus( word: str, *, @@ -205,20 +215,68 @@ def wordlist_weight_for_word( return weight -def typical_chars_per_word() -> float: +def typical_chars_per_word( + *, + generated_min_len: int = GENERATED_WORD_MIN_LEN, + generated_max_len: int = GENERATED_WORD_MAX_LEN, +) -> float: """Mean chars per generated word (spaces excluded). ponytail: midpoint of accepted word lengths; upgrade to measured corpus avg. """ - return (MIN_WORD_LEN + MAX_WORD_LEN) / 2.0 + min_len, max_len = effective_generated_word_bounds(generated_min_len, generated_max_len) + return (min_len + max_len) / 2.0 + + +def cpm_from_wpm( + wpm: int, + *, + generated_min_len: int = GENERATED_WORD_MIN_LEN, + generated_max_len: int = GENERATED_WORD_MAX_LEN, +) -> int: + return round( + wpm + * typical_chars_per_word( + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, + ) + ) -def cpm_from_wpm(wpm: int) -> int: - return round(wpm * typical_chars_per_word()) +def wpm_from_cpm( + cpm: int, + *, + generated_min_len: int = GENERATED_WORD_MIN_LEN, + generated_max_len: int = GENERATED_WORD_MAX_LEN, +) -> int: + return int( + cpm + / typical_chars_per_word( + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, + ) + ) -def wpm_from_cpm(cpm: int) -> int: - return int(cpm / typical_chars_per_word()) +def _ensure_generated_word_len( + word: str, + alphabet: frozenset[str], + rng: Random, + *, + generated_min_len: int, + generated_max_len: int, +) -> str: + """Pad or trim ``word`` into ``[generated_min_len, generated_max_len]``.""" + chars = sorted(alphabet) + if not chars: + return word + if not word: + word = "".join(rng.choice(chars) for _ in range(generated_min_len)) + while len(word) < generated_min_len: + word += rng.choice(chars) + if len(word) > generated_max_len: + word = word[:generated_max_len] + return word @dataclass(slots=True) @@ -232,13 +290,17 @@ def generate_word( weighting: LessonWeighting | None = None, *, weighted_wordlist: WeightedWordlist | None = None, + generated_min_len: int = GENERATED_WORD_MIN_LEN, + generated_max_len: int = GENERATED_WORD_MAX_LEN, ) -> str: weighting = weighting or LessonWeighting() if weighting.words: word = self._generate_word_from_wordlist(alphabet, weighting, weighted_wordlist) if word is not None: return word - return self._generate_word_via_markov(alphabet, weighting) + return self._generate_word_via_markov( + alphabet, weighting, generated_min_len, generated_max_len + ) def _generate_word_from_wordlist( self, @@ -256,13 +318,21 @@ def _generate_word_via_markov( self, alphabet: frozenset[str], weighting: LessonWeighting, + generated_min_len: int, + generated_max_len: int, ) -> str: word = "" for _ in range(MAX_RETRIES): - word = self._sample_word(alphabet, weighting) - if MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN: + word = self._sample_word(alphabet, weighting, generated_max_len) + if generated_min_len <= len(word) <= generated_max_len: return word - return word + return _ensure_generated_word_len( + word, + alphabet, + self.rng, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, + ) def generate_lesson( self, @@ -274,6 +344,8 @@ def generate_lesson( focus_bigram: Bigram | None = None, min_focus_words: int = 1, max_word_repeats: int = MAX_WORD_REPEATS, + generated_min_len: int = GENERATED_WORD_MIN_LEN, + generated_max_len: int = GENERATED_WORD_MAX_LEN, ) -> str: """Generate practice text with focus-word guarantees. @@ -285,6 +357,9 @@ def generate_lesson( min_focus_words = min(min_focus_words, word_count) assert 0 <= min_focus_words <= word_count max_word_repeats = effective_max_word_repeats(max_word_repeats) + generated_min_len, generated_max_len = effective_generated_word_bounds( + generated_min_len, generated_max_len + ) weighting = weighting or LessonWeighting() focus_bigram_str: str | None = None if focus_bigram is not None: @@ -331,6 +406,8 @@ def generate_lesson( weighting, weighted_wordlist=weighted_wordlist, max_word_repeats=max_word_repeats, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, ) if focus_bigram is not None: prev_char, next_char = chr(focus_bigram.prev_cp), chr(focus_bigram.next_cp) @@ -373,6 +450,8 @@ def generate_lesson( focus_bigram_str=focus_bigram_str, use_wordlist=False, max_word_repeats=max_word_repeats, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, ) while len(lesson_words) < word_count: self._append_general_word_capped( @@ -381,6 +460,8 @@ def generate_lesson( weighting, weighted_wordlist=weighted_wordlist, max_word_repeats=max_word_repeats, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, ) self.rng.shuffle(lesson_words) _assert_lesson_focus_quota( @@ -404,6 +485,8 @@ def _append_focus_word_capped( focus_pool: tuple[str, ...] = (), focus_weighted: WeightedWordlist | None = None, max_word_repeats: int = MAX_WORD_REPEATS, + generated_min_len: int = GENERATED_WORD_MIN_LEN, + generated_max_len: int = GENERATED_WORD_MAX_LEN, ) -> None: """Add one focus-matching word, respecting the per-lesson repeat cap.""" for _ in range(MAX_REPEAT_RESAMPLE): @@ -415,6 +498,8 @@ def _append_focus_word_capped( focus_bigram_str=focus_bigram_str, focus_pool=focus_pool if use_wordlist else (), focus_weighted=focus_weighted if use_wordlist else None, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, ) if _append_word_if_under_repeat_cap(lesson_words, word, max_repeats=max_word_repeats): return @@ -426,6 +511,8 @@ def _append_focus_word_capped( focus_bigram_str=focus_bigram_str, focus_pool=(), focus_weighted=None, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, ) self._append_word_mutating_until_under_cap( lesson_words, @@ -434,6 +521,7 @@ def _append_focus_word_capped( focus_char=focus_char, focus_bigram=focus_bigram, max_word_repeats=max_word_repeats, + generated_max_len=generated_max_len, ) def _append_general_word_capped( @@ -444,15 +532,33 @@ def _append_general_word_capped( *, weighted_wordlist: WeightedWordlist | None, max_word_repeats: int = MAX_WORD_REPEATS, + generated_min_len: int = GENERATED_WORD_MIN_LEN, + generated_max_len: int = GENERATED_WORD_MAX_LEN, ) -> None: """Add one general word, respecting the per-lesson repeat cap.""" for _ in range(MAX_REPEAT_RESAMPLE): - word = self.generate_word(alphabet, weighting, weighted_wordlist=weighted_wordlist) + word = self.generate_word( + alphabet, + weighting, + weighted_wordlist=weighted_wordlist, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, + ) if _append_word_if_under_repeat_cap(lesson_words, word, max_repeats=max_word_repeats): return - word = self.generate_word(alphabet, weighting, weighted_wordlist=weighted_wordlist) + word = self.generate_word( + alphabet, + weighting, + weighted_wordlist=weighted_wordlist, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, + ) self._append_word_mutating_until_under_cap( - lesson_words, word, alphabet, max_word_repeats=max_word_repeats + lesson_words, + word, + alphabet, + max_word_repeats=max_word_repeats, + generated_max_len=generated_max_len, ) def _append_word_mutating_until_under_cap( @@ -464,13 +570,14 @@ def _append_word_mutating_until_under_cap( focus_char: str | None = None, focus_bigram: Bigram | None = None, max_word_repeats: int = MAX_WORD_REPEATS, + generated_max_len: int = GENERATED_WORD_MAX_LEN, ) -> None: """Ensures: word is appended without exceeding the repeat cap.""" if _append_word_if_under_repeat_cap(lesson_words, word, max_repeats=max_word_repeats): return chars = sorted(alphabet) for _ in range(MAX_REPEAT_RESAMPLE): - if len(word) < MAX_WORD_LEN: + if len(word) < generated_max_len: word = word + self.rng.choice(chars) elif focus_bigram is not None: prev_char, next_char = chr(focus_bigram.prev_cp), chr(focus_bigram.next_cp) @@ -496,6 +603,8 @@ def _generate_focus_word( focus_bigram_str: str | None, focus_pool: tuple[str, ...], focus_weighted: WeightedWordlist | None, + generated_min_len: int = GENERATED_WORD_MIN_LEN, + generated_max_len: int = GENERATED_WORD_MAX_LEN, ) -> str: """Word guaranteed to match focus — wordlist pool, then Markov, then inject.""" if focus_pool: @@ -508,10 +617,14 @@ def _generate_focus_word( ): return word for _ in range(MAX_RETRIES): - word = self._generate_word_via_markov(alphabet, weighting) + word = self._generate_word_via_markov( + alphabet, weighting, generated_min_len, generated_max_len + ) if word_matches_focus(word, focus_char=focus_char, focus_bigram=focus_bigram_str): return word - word = self._generate_word_via_markov(alphabet, weighting) + word = self._generate_word_via_markov( + alphabet, weighting, generated_min_len, generated_max_len + ) if focus_bigram is not None: prev_char, next_char = chr(focus_bigram.prev_cp), chr(focus_bigram.next_cp) return self._inject_focus_bigram(word, prev_char, next_char) @@ -536,10 +649,11 @@ def _sample_word( self, alphabet: frozenset[str], weighting: LessonWeighting, + generated_max_len: int, ) -> str: chars: list[str] = [] - while len(chars) < MAX_WORD_LEN: - p_stop = min(1.0, 1.3 ** len(chars) / MAX_WORD_LEN) + while len(chars) < generated_max_len: + p_stop = min(1.0, 1.3 ** len(chars) / generated_max_len) if chars and self.rng.random() < p_stop: break ch = self.table.sample( diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index 90ed013..5fd9c48 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -20,6 +20,8 @@ LESSON_WORD_COUNT = 12 FOCUS_WORD_MIN_FRACTION = 0.6 MAX_WORD_REPEATS = 2 +GENERATED_WORD_MIN_LEN = 3 +GENERATED_WORD_MAX_LEN = 10 @dataclass(frozen=True, slots=True) @@ -170,6 +172,8 @@ class Settings: lesson_word_count: int = LESSON_WORD_COUNT # words per generated practice lesson focus_word_min_fraction: float = FOCUS_WORD_MIN_FRACTION # weak-focus word quota fraction max_word_repeats: int = MAX_WORD_REPEATS # per-word repeat cap in generated lessons + generated_word_min_len: int = GENERATED_WORD_MIN_LEN # Markov word length floor + generated_word_max_len: int = GENERATED_WORD_MAX_LEN # Markov word length ceiling wordlist_url: str = "" # non-empty + cached file → real words; else Markov updated_at: str | None = None # ISO-8601 UTC; sync LWW diff --git a/src/keystrike/domain/word_bounds.py b/src/keystrike/domain/word_bounds.py index 63a96f0..2832c74 100644 --- a/src/keystrike/domain/word_bounds.py +++ b/src/keystrike/domain/word_bounds.py @@ -1,4 +1,4 @@ -"""Shared word length bounds for Markov generation and word-list filtering.""" +"""Word length bounds for dictionary word-list filtering (not Markov generation).""" MIN_WORD_LEN = 3 MAX_WORD_LEN = 10 diff --git a/src/keystrike/presentation/screens/settings.py b/src/keystrike/presentation/screens/settings.py index c44734d..396db81 100644 --- a/src/keystrike/presentation/screens/settings.py +++ b/src/keystrike/presentation/screens/settings.py @@ -10,7 +10,7 @@ from keystrike.application.settings_use_cases import SettingsUpdate, SettingsValidationError from keystrike.application.wordlist_use_cases import DEFAULT_WORDLIST_URL, WordListError from keystrike.domain.enums import TargetSpeedUnit -from keystrike.domain.generator import cpm_from_wpm, wpm_from_cpm +from keystrike.domain.generator import cpm_from_wpm, effective_generated_word_bounds, wpm_from_cpm from keystrike.presentation.bindings import BACK_BINDINGS, SAVE from keystrike.presentation.services import SettingsServices @@ -41,6 +41,10 @@ def __init__(self, *, services: SettingsServices) -> None: def compose(self) -> ComposeResult: settings = self._services.settings_repo.load() layouts = self._layout_select_options() + gen_min, gen_max = effective_generated_word_bounds( + settings.generated_word_min_len, + settings.generated_word_max_len, + ) with Vertical(): yield Static("[bold]Settings[/] [dim](Ctrl+S save, Esc/q back)[/]") yield Label("Layout") @@ -48,7 +52,11 @@ def compose(self) -> ComposeResult: yield Label("Target speed") yield Input( value=str( - wpm_from_cpm(settings.target_speed_cpm) + wpm_from_cpm( + settings.target_speed_cpm, + generated_min_len=gen_min, + generated_max_len=gen_max, + ) if settings.target_speed_unit == TargetSpeedUnit.WPM else settings.target_speed_cpm ), @@ -120,8 +128,17 @@ def _collect_form_values(self) -> SettingsUpdate | None: target_speed_unit = speed_unit_select.value if isinstance(target_speed_unit, NoSelection): return None + settings = self._services.settings_repo.load() + gen_min, gen_max = effective_generated_word_bounds( + settings.generated_word_min_len, + settings.generated_word_max_len, + ) target_speed_cpm = ( - cpm_from_wpm(target_speed_value) + cpm_from_wpm( + target_speed_value, + generated_min_len=gen_min, + generated_max_len=gen_max, + ) if target_speed_unit == TargetSpeedUnit.WPM else target_speed_value ) diff --git a/tests/domain/test_generator.py b/tests/domain/test_generator.py index 1bc82fe..bc5cc0b 100644 --- a/tests/domain/test_generator.py +++ b/tests/domain/test_generator.py @@ -1,4 +1,5 @@ from random import Random +from unittest.mock import patch from keystrike.domain.generator import ( FOCUS_WORD_MIN_FRACTION, @@ -6,6 +7,7 @@ LessonWeighting, clamp_focus_word_fraction, cpm_from_wpm, + effective_generated_word_bounds, min_focus_words, typical_chars_per_word, weak_focus_word_quota, @@ -14,7 +16,7 @@ wpm_from_cpm, ) from keystrike.domain.markov import TransitionTable -from keystrike.domain.models import Bigram +from keystrike.domain.models import GENERATED_WORD_MAX_LEN, GENERATED_WORD_MIN_LEN, Bigram from keystrike.domain.word_bounds import MAX_WORD_LEN, MIN_WORD_LEN @@ -27,7 +29,40 @@ def test_generate_word_length_within_bounds(): generator = AdaptiveGenerator(table=_uniform_table("abc"), rng=Random(0)) for _ in range(20): word = generator.generate_word(frozenset("abc")) - assert MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN + assert GENERATED_WORD_MIN_LEN <= len(word) <= GENERATED_WORD_MAX_LEN + + +def test_generate_markov_word_respects_custom_generated_bounds(): + generator = AdaptiveGenerator(table=_uniform_table("abc"), rng=Random(0)) + for seed in range(30): + generator.rng = Random(seed) + word = generator.generate_word( + frozenset("abc"), + generated_min_len=2, + generated_max_len=4, + ) + assert 2 <= len(word) <= 4 + + +def test_effective_generated_word_bounds_clamps_invalid(): + assert effective_generated_word_bounds(0, 10) == (1, 10) + assert effective_generated_word_bounds(5, 0) == (1, 1) + assert effective_generated_word_bounds(8, 4) == (4, 4) + + +def test_generate_lesson_markov_respects_custom_generated_bounds(): + generator = AdaptiveGenerator(table=_uniform_table("abc"), rng=Random(0)) + for seed in range(20): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + frozenset("abc"), + focus_char="a", + word_count=8, + generated_min_len=2, + generated_max_len=4, + ) + for word in lesson.split(): + assert 2 <= len(word) <= 4, f"seed={seed}: {word!r}" def test_generate_word_only_uses_alphabet_chars(): @@ -70,6 +105,21 @@ def test_generate_word_uses_wordlist_when_provided(): assert word in words +def test_wordlist_still_uses_dictionary_bounds_not_generated(): + """Dictionary sampling keeps word_bounds (3-10) even when Markov bounds differ.""" + generator = AdaptiveGenerator(table=_uniform_table("abc"), rng=Random(0)) + words = ("abc", "abcd", "abcde", "abcdef", "abcdefg", "abcdefgh", "abcdefghi", "abcdefghij") + for _ in range(20): + word = generator.generate_word( + frozenset("abcdefghij"), + LessonWeighting(words=words), + generated_min_len=2, + generated_max_len=4, + ) + assert MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN + assert word in words + + def test_generate_word_falls_back_to_markov_without_wordlist(): generator = AdaptiveGenerator(table=_uniform_table("abc"), rng=Random(0)) word = generator.generate_word(frozenset("abc")) @@ -204,6 +254,26 @@ def test_wpm_cpm_conversion_uses_typical_word_length(): assert wpm_from_cpm(round(80 * avg)) == 80 +def test_wpm_cpm_conversion_uses_custom_generated_bounds(): + avg = typical_chars_per_word(generated_min_len=2, generated_max_len=4) + assert avg == 3.0 + assert cpm_from_wpm(80, generated_min_len=2, generated_max_len=4) == 240 + assert wpm_from_cpm(240, generated_min_len=2, generated_max_len=4) == 80 + + +def test_markov_fallback_pads_to_generated_min_len(): + generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) + with patch.object( + AdaptiveGenerator, + "_sample_word", + autospec=True, + return_value="ab", + ): + word = generator.generate_word(frozenset("ab"), generated_min_len=5, generated_max_len=8) + assert 5 <= len(word) <= 8 + assert set(word) <= {"a", "b"} + + def test_min_focus_words_uses_ceiling(): assert min_focus_words(12, FOCUS_WORD_MIN_FRACTION) == 8 assert min_focus_words(5, FOCUS_WORD_MIN_FRACTION) == 3 diff --git a/tests/infrastructure/test_settings_repo_toml.py b/tests/infrastructure/test_settings_repo_toml.py index 1681817..17dba43 100644 --- a/tests/infrastructure/test_settings_repo_toml.py +++ b/tests/infrastructure/test_settings_repo_toml.py @@ -40,6 +40,8 @@ def test_round_trip(paths): lesson_word_count=15, focus_word_min_fraction=0.75, max_word_repeats=3, + generated_word_min_len=2, + generated_word_max_len=5, ) repo.save(original) loaded = repo.load() @@ -56,6 +58,8 @@ def test_round_trip(paths): assert loaded.lesson_word_count == 15 assert loaded.focus_word_min_fraction == 0.75 assert loaded.max_word_repeats == 3 + assert loaded.generated_word_min_len == 2 + assert loaded.generated_word_max_len == 5 assert loaded.updated_at is not None diff --git a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg index 7e00743..c028094 100644 --- a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg +++ b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg @@ -131,12 +131,12 @@ - + Acc: 100.0%   Learn: 0.0/10 min   Focus: aweak -gsrfhgod·kfogh·skhad·ufeg·oaoft·raufjd·dda·wkgru·fkksjf·llaa·aeku·ldie +sdag·aoagt·aekl·igsal·dra·ltgar·tawk·dlaa·kwaks·jafea·fihwskal·ash  q  w  e  r  t  y  u  i  o  p  From 3e3e3ff0ed92c29e1dc0df0acfa9ce012ace266f Mon Sep 17 00:00:00 2001 From: Egno Date: Fri, 31 Jul 2026 11:45:32 +0300 Subject: [PATCH 27/47] Align wordlist sampling with generated word bounds and default 2-4. Dictionary words in lessons now honor generated_word_min/max (same as Markov), defaults tighten to 2-4 for shorter practice text, with integration tests for settings.toml and WPM conversion paths. Co-authored-by: Cursor --- docs/PLAN.md | 2 +- docs/wiki/Confidence-Tuning.md | 8 +-- src/keystrike/domain/generator.py | 49 +++++++++++-- src/keystrike/domain/models.py | 6 +- tests/application/test_build_lesson.py | 48 ++++++++++++- tests/domain/test_generator.py | 16 ++--- .../infrastructure/test_settings_repo_toml.py | 45 ++++++++++++ tests/presentation/test_settings_screen.py | 71 +++++++++++++++++++ 8 files changed, 219 insertions(+), 26 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index fb068dd..14b3f91 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -229,7 +229,7 @@ all fixed before M3 began (95 tests passing at that point) — see `domain/markov.py` (`TransitionTable` with order-2 sampling + backoff to shorter contexts, filtered to the unlocked alphabet), `domain/generator.py` -(`AdaptiveGenerator`: word length 3–10, `p_stop = min(1, 1.3**length/max_len)`, +(`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` — faithful to §6 below), `application/build_lesson.py` (`BuildLesson` ties diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index d8b95e6..4935931 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -25,8 +25,8 @@ layout, speed, or other UI settings will not change them. | Lesson word count | `lesson_word_count` | `12` | Words generated per practice lesson. | | Focus word min fraction | `focus_word_min_fraction` | `0.6` | When focus is weak, at least this fraction of lesson words must match the focus key or bigram (ceiling). | | Max word repeats | `max_word_repeats` | `2` | Maximum times the same word may appear in one generated lesson. | -| Generated word min length | `generated_word_min_len` | `3` | Minimum length for Markov-generated words (dictionary words still use 3–10). | -| Generated word max length | `generated_word_max_len` | `10` | Maximum length for Markov-generated words. | +| Generated word min length | `generated_word_min_len` | `2` | Minimum length for Markov-generated words (dictionary import still filters 3–10). | +| Generated word max length | `generated_word_max_len` | `4` | Maximum length for Markov-generated words. | Valid ranges: window and both attempt floors are **1–100**. Boost multipliers should be **≥ 1.0**. `lesson_word_count` should be **≥ 1**. `focus_word_min_fraction` should be in **(0.0, 1.0]**. `max_word_repeats` should be **≥ 1**. `generated_word_min_len` and `generated_word_max_len` should be **≥ 1** with min ≤ max (invalid pairs are clamped at lesson build time). @@ -48,8 +48,8 @@ focus_weak_extra_boost = 1.5 lesson_word_count = 12 focus_word_min_fraction = 0.6 max_word_repeats = 2 -generated_word_min_len = 3 -generated_word_max_len = 10 +generated_word_min_len = 2 +generated_word_max_len = 4 ``` ## What each setting affects diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index c25bbc3..0f59777 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -22,7 +22,6 @@ from .models import ( LESSON_WORD_COUNT as DEFAULT_WORD_COUNT, ) -from .word_bounds import MAX_WORD_LEN, MIN_WORD_LEN MAX_RETRIES = 5 MAX_REPEAT_RESAMPLE = 64 @@ -91,8 +90,15 @@ def _assert_lesson_focus_quota( ) -def _wordlist_word_fits(word: str, alphabet: frozenset[str]) -> bool: - return MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN and set(word) <= alphabet +def _wordlist_word_fits( + word: str, + alphabet: frozenset[str], + *, + min_len: int, + max_len: int, +) -> bool: + """True when ``word`` length is in ``[min_len, max_len]`` and chars ⊆ alphabet.""" + return min_len <= len(word) <= max_len and set(word) <= alphabet def _focus_pool_from_wordlist( @@ -101,13 +107,20 @@ def _focus_pool_from_wordlist( *, focus_char: str, focus_bigram: str | None, + generated_min_len: int, + generated_max_len: int, ) -> tuple[str, ...]: if not words: return () return tuple( w for w in words - if _wordlist_word_fits(w, alphabet) + if _wordlist_word_fits( + w, + alphabet, + min_len=generated_min_len, + max_len=generated_max_len, + ) and word_matches_focus(w, focus_char=focus_char, focus_bigram=focus_bigram) ) @@ -295,7 +308,13 @@ def generate_word( ) -> str: weighting = weighting or LessonWeighting() if weighting.words: - word = self._generate_word_from_wordlist(alphabet, weighting, weighted_wordlist) + word = self._generate_word_from_wordlist( + alphabet, + weighting, + weighted_wordlist, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, + ) if word is not None: return word return self._generate_word_via_markov( @@ -307,10 +326,18 @@ def _generate_word_from_wordlist( alphabet: frozenset[str], weighting: LessonWeighting, weighted_wordlist: WeightedWordlist | None, + *, + generated_min_len: int, + generated_max_len: int, ) -> str | None: """Sample a dictionary word, or None if it doesn't fit length/alphabet bounds.""" word = self._sample_from_wordlist(weighting, weighted_wordlist) - if MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN and set(word) <= alphabet: + if _wordlist_word_fits( + word, + alphabet, + min_len=generated_min_len, + max_len=generated_max_len, + ): return word return None @@ -383,6 +410,8 @@ def generate_lesson( alphabet, focus_char=focus_char, focus_bigram=focus_bigram_str, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, ) focus_weighted: WeightedWordlist | None = None if focus_pool and weighted_wordlist is not None: @@ -611,7 +640,13 @@ def _generate_focus_word( wordlist = WeightedWordlist(words=focus_pool, weights=tuple(1.0 for _ in focus_pool)) if focus_weighted is not None: wordlist = focus_weighted - word = self._generate_word_from_wordlist(alphabet, weighting, wordlist) + word = self._generate_word_from_wordlist( + alphabet, + weighting, + wordlist, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, + ) if word is not None and word_matches_focus( word, focus_char=focus_char, focus_bigram=focus_bigram_str ): diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index 5fd9c48..5a6e9ea 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -20,8 +20,8 @@ LESSON_WORD_COUNT = 12 FOCUS_WORD_MIN_FRACTION = 0.6 MAX_WORD_REPEATS = 2 -GENERATED_WORD_MIN_LEN = 3 -GENERATED_WORD_MAX_LEN = 10 +GENERATED_WORD_MIN_LEN = 2 +GENERATED_WORD_MAX_LEN = 4 @dataclass(frozen=True, slots=True) @@ -156,7 +156,7 @@ def __post_init__(self) -> None: class Settings: schema_version: int = 1 layout: str = "qwerty" - target_speed_cpm: int = 300 # ~46 wpm at typical generated word length + target_speed_cpm: int = 300 # ~100 wpm at typical generated word length (2-4) target_speed_unit: TargetSpeedUnit = TargetSpeedUnit.WPM alphabet_size: int = 16 # letters force-unlocked from cold start confidence_session_window: int = CONFIDENCE_SESSION_WINDOW # sessions in rolling stats diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index e5dd5ec..a766065 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -647,9 +647,51 @@ def test_build_lesson_survives_invalid_focus_word_min_fraction(): assert len(lesson.text.split()) == settings.lesson_word_count +def test_build_lesson_markov_respects_generated_word_bounds_from_settings(): + """Settings.generated_word_* must reach the generator on the Markov path.""" + settings = Settings( + generated_word_min_len=2, + generated_word_max_len=4, + wordlist_url="", + alphabet_size=8, + ) + builder = _build_lesson(settings) + for seed in range(30): + builder.rng = Random(seed) + words = builder("qwerty").text.split() + for word in words: + assert 2 <= len(word) <= 4, f"seed={seed}: {word!r}" + + +def test_build_lesson_wordlist_respects_generated_bounds(): + """Cached wordlist words must fall within generated_word_min/max.""" + url = "https://example.com/words.txt" + cached = ["the", "and", "for", "are", "but", "not", "you", "all"] + settings = Settings( + generated_word_min_len=2, + generated_word_max_len=4, + wordlist_url=url, + alphabet_size=26, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=FakeAggregatesCache(), + settings_repo=FakeSettingsRepository(settings), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(by_url={url: cached}), + rng=Random(42), + clock=FakeClock(), + ) + words = builder("qwerty").text.split() + assert all(2 <= len(w) <= 4 for w in words) + dict_words = [w for w in words if w in cached] + assert len(dict_words) >= settings.lesson_word_count // 2 + assert all(len(w) == 3 for w in dict_words) + + def test_lesson_falls_back_to_markov_when_cache_missing(): url = "https://example.com/words.txt" - exclusive = ["abcdefgh"] * 20 + exclusive = ["abcd"] * 20 settings = Settings(wordlist_url=url, alphabet_size=26) with_cache = BuildLesson( layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), @@ -660,7 +702,7 @@ def test_lesson_falls_back_to_markov_when_cache_missing(): rng=Random(42), clock=FakeClock(), ) - assert "abcdefgh" in with_cache("qwerty").text + assert "abcd" in with_cache("qwerty").text without_cache = BuildLesson( layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), @@ -671,7 +713,7 @@ def test_lesson_falls_back_to_markov_when_cache_missing(): rng=Random(42), clock=FakeClock(), ) - assert "abcdefgh" not in without_cache("qwerty").text + assert "abcd" not in without_cache("qwerty").text def test_lesson_falls_back_when_alphabet_filters_all_words(): diff --git a/tests/domain/test_generator.py b/tests/domain/test_generator.py index bc5cc0b..898a290 100644 --- a/tests/domain/test_generator.py +++ b/tests/domain/test_generator.py @@ -17,7 +17,6 @@ ) from keystrike.domain.markov import TransitionTable from keystrike.domain.models import GENERATED_WORD_MAX_LEN, GENERATED_WORD_MIN_LEN, Bigram -from keystrike.domain.word_bounds import MAX_WORD_LEN, MIN_WORD_LEN def _uniform_table(alphabet: str) -> TransitionTable: @@ -105,8 +104,8 @@ def test_generate_word_uses_wordlist_when_provided(): assert word in words -def test_wordlist_still_uses_dictionary_bounds_not_generated(): - """Dictionary sampling keeps word_bounds (3-10) even when Markov bounds differ.""" +def test_wordlist_respects_generated_bounds(): + """Dictionary sampling honors generated_word_min/max, not dictionary 3-10.""" generator = AdaptiveGenerator(table=_uniform_table("abc"), rng=Random(0)) words = ("abc", "abcd", "abcde", "abcdef", "abcdefg", "abcdefgh", "abcdefghi", "abcdefghij") for _ in range(20): @@ -116,8 +115,9 @@ def test_wordlist_still_uses_dictionary_bounds_not_generated(): generated_min_len=2, generated_max_len=4, ) - assert MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN - assert word in words + assert 2 <= len(word) <= 4 + if word in words: + assert len(word) <= 4 def test_generate_word_falls_back_to_markov_without_wordlist(): @@ -425,12 +425,12 @@ def test_lesson_caps_word_repeats(): def test_lesson_caps_word_repeats_on_strong_focus_path(): """Repeat cap applies when min_focus_words=1 (confident focus).""" - generator = AdaptiveGenerator(table=_uniform_table("a"), rng=Random(0)) - words = ("aaa",) + generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) + words = ("aaa", "bbb") for seed in range(20): generator.rng = Random(seed) lesson = generator.generate_lesson( - frozenset("a"), + frozenset("ab"), focus_char="a", word_count=8, min_focus_words=1, diff --git a/tests/infrastructure/test_settings_repo_toml.py b/tests/infrastructure/test_settings_repo_toml.py index 17dba43..20c8866 100644 --- a/tests/infrastructure/test_settings_repo_toml.py +++ b/tests/infrastructure/test_settings_repo_toml.py @@ -1,9 +1,20 @@ +from random import Random + import pytest +from keystrike.application.build_lesson import BuildLesson from keystrike.domain.enums import TargetSpeedUnit from keystrike.domain.models import Settings +from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from keystrike.infrastructure.paths import Paths from keystrike.infrastructure.settings_repo_toml import TomlSettingsRepository, _coerce_field +from tests.fakes import ( + FakeAggregatesCache, + FakeClock, + FakeLanguageProvider, + FakeLayoutRepository, + FakeWordListStore, +) @pytest.fixture @@ -22,6 +33,40 @@ def test_load_defaults_when_no_file(paths): assert s == Settings() +def test_load_generated_word_bounds_from_hand_edited_toml(paths): + paths.settings_file.write_text( + "schema_version = 1\ngenerated_word_min_len = 2\ngenerated_word_max_len = 5\n", + encoding="utf-8", + ) + loaded = TomlSettingsRepository(paths).load() + assert loaded.generated_word_min_len == 2 + assert loaded.generated_word_max_len == 5 + + +def test_toml_generated_word_bounds_affect_markov_lesson_words(paths): + """Full path: settings.toml → repo.load() → BuildLesson → word lengths.""" + paths.settings_file.write_text( + "schema_version = 1\n" + "generated_word_min_len = 2\n" + "generated_word_max_len = 4\n" + 'wordlist_url = ""\n', + encoding="utf-8", + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=FakeAggregatesCache(), + settings_repo=TomlSettingsRepository(paths), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + ) + for seed in range(20): + builder.rng = Random(seed) + for word in builder("qwerty").text.split(): + assert 2 <= len(word) <= 4, f"seed={seed}: {word!r}" + + def test_round_trip(paths): repo = TomlSettingsRepository(paths) original = Settings( diff --git a/tests/presentation/test_settings_screen.py b/tests/presentation/test_settings_screen.py index c0f42ac..827d486 100644 --- a/tests/presentation/test_settings_screen.py +++ b/tests/presentation/test_settings_screen.py @@ -228,6 +228,77 @@ async def test_save_converts_wpm_to_cpm(): assert app.screen_stack[-1] is not screen +@pytest.mark.asyncio +async def test_wpm_display_uses_custom_generated_bounds(): + """WPM shown on load must use settings.generated_word_* not default bounds.""" + app = App() + settings_repo = FakeSettingsRepository( + Settings( + target_speed_cpm=390, + target_speed_unit=TargetSpeedUnit.WPM, + generated_word_min_len=3, + generated_word_max_len=10, + ), + ) + layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) + store = FakeWordListStore() + screen = SettingsScreen( + services=SettingsServices( + settings_repo=settings_repo, + layout_repo=layout_repo, + update_settings=UpdateSettings(repo=settings_repo), + import_wordlist=ImportWordList(store=store, settings_repo=settings_repo), + clear_wordlist=ClearWordList(settings_repo=settings_repo), + get_wordlist_cache_status=GetWordListCacheStatus(store=store), + ), + ) + async with app.run_test() as pilot: + await app.push_screen(screen) + await pilot.pause() + + custom_wpm = wpm_from_cpm(390, generated_min_len=3, generated_max_len=10) + default_wpm = wpm_from_cpm(390) + assert custom_wpm != default_wpm + assert app.screen.query_one("#settings-speed", Input).value == str(custom_wpm) + + +@pytest.mark.asyncio +async def test_save_converts_wpm_using_custom_generated_bounds(): + app = App() + settings_repo = FakeSettingsRepository( + Settings(generated_word_min_len=3, generated_word_max_len=10), + ) + layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) + store = FakeWordListStore() + screen = SettingsScreen( + services=SettingsServices( + settings_repo=settings_repo, + layout_repo=layout_repo, + update_settings=UpdateSettings(repo=settings_repo), + import_wordlist=ImportWordList(store=store, settings_repo=settings_repo), + clear_wordlist=ClearWordList(settings_repo=settings_repo), + get_wordlist_cache_status=GetWordListCacheStatus(store=store), + ), + ) + async with app.run_test() as pilot: + await app.push_screen(screen) + await pilot.pause() + + app.screen.query_one("#settings-speed", Input).value = "80" + app.screen.query_one("#settings-speed-unit", Select).value = TargetSpeedUnit.WPM + await pilot.pause() + await pilot.press("ctrl+s") + await pilot.pause() + + assert settings_repo.settings.target_speed_cpm == cpm_from_wpm( + 80, + generated_min_len=3, + generated_max_len=10, + ) + assert settings_repo.settings.target_speed_cpm != cpm_from_wpm(80) + assert app.screen_stack[-1] is not screen + + @pytest.mark.asyncio async def test_loads_wpm_display_value(): app = App() From 5d7ed3bad560491e8f67cc93be611ffddf1bd4a2 Mon Sep 17 00:00:00 2001 From: Egno Date: Fri, 31 Jul 2026 13:28:00 +0300 Subject: [PATCH 28/47] fix: restore CI after v1.3.0 merge artifacts Remove duplicated session timer helpers, refresh practice/settings snapshots, and skip Unix permission tests on Windows where chmod semantics differ. Co-authored-by: Cursor --- src/keystrike/domain/session.py | 35 ------------------- tests/infrastructure/test_atomic_write.py | 8 +++++ .../test_practice_screen_snapshot.svg | 4 +-- .../test_settings_screen_snapshot.svg | 2 +- 4 files changed, 11 insertions(+), 38 deletions(-) diff --git a/src/keystrike/domain/session.py b/src/keystrike/domain/session.py index 8f1eb8e..aa99d93 100644 --- a/src/keystrike/domain/session.py +++ b/src/keystrike/domain/session.py @@ -91,41 +91,6 @@ def session_accuracy(session: Session) -> float: return 1.0 return session.correct_count / session.total_count -LEARN_IDLE_PAUSE_NS = 5 * 1_000_000_000 # pause learn timer after this idle gap - - -def note_keystroke_for_timer(session: Session, now_ns: int) -> None: - """Fold inter-keystroke time into active duration (capped at idle threshold).""" - if session.last_keystroke_at_ns is not None: - gap = now_ns - session.last_keystroke_at_ns - session.active_duration_ns += min(gap, LEARN_IDLE_PAUSE_NS) - session.last_keystroke_at_ns = now_ns - - -def active_typing_duration_ns( - session: Session, - now_ns: int, - *, - idle_pause_ns: int = LEARN_IDLE_PAUSE_NS, -) -> int: - """Active typing time excluding idle gaps longer than ``idle_pause_ns``.""" - if session.typing_started_at_ns is None or session.last_keystroke_at_ns is None: - return 0 - gap = now_ns - session.last_keystroke_at_ns - return session.active_duration_ns + min(gap, idle_pause_ns) - - -def is_typing_idle( - session: Session, - now_ns: int, - *, - idle_pause_ns: int = LEARN_IDLE_PAUSE_NS, -) -> bool: - """True once the learn timer would stop counting (gap >= ``idle_pause_ns``).""" - if session.last_keystroke_at_ns is None: - return False - return now_ns - session.last_keystroke_at_ns >= idle_pause_ns - @dataclass(slots=True) class Session: diff --git a/tests/infrastructure/test_atomic_write.py b/tests/infrastructure/test_atomic_write.py index 4e32125..bc31033 100644 --- a/tests/infrastructure/test_atomic_write.py +++ b/tests/infrastructure/test_atomic_write.py @@ -1,7 +1,10 @@ import os +import sys from contextlib import suppress from unittest.mock import patch +import pytest + from keystrike.infrastructure.atomic_write import atomic_write_text @@ -20,12 +23,17 @@ def test_no_leftover_temp_files(tmp_path): assert list(tmp_path.iterdir()) == [path] +_SKIP_WIN32 = pytest.mark.skipif(sys.platform == "win32", reason="no Unix mode bits on Windows") + + +@_SKIP_WIN32 def test_new_file_gets_0644_permissions(tmp_path): path = tmp_path / "config.toml" atomic_write_text(path, "a = 1\n") assert (path.stat().st_mode & 0o777) == 0o644 +@_SKIP_WIN32 def test_preserves_existing_file_permissions(tmp_path): path = tmp_path / "config.toml" atomic_write_text(path, "a = 1\n") diff --git a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg index c028094..60192c6 100644 --- a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg +++ b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg @@ -131,12 +131,12 @@ - + Acc: 100.0%   Learn: 0.0/10 min   Focus: aweak -sdag·aoagt·aekl·igsal·dra·ltgar·tawk·dlaa·kwaks·jafea·fihwskal·ash +asa·uk·ltg·as·iagr·aoag·skal·uda·fea·ta·ajg·aj  q  w  e  r  t  y  u  i  o  p  diff --git a/tests/presentation/__snapshots__/test_snapshots/test_settings_screen_snapshot.svg b/tests/presentation/__snapshots__/test_snapshots/test_settings_screen_snapshot.svg index 492a6f5..60ec5ec 100644 --- a/tests/presentation/__snapshots__/test_snapshots/test_settings_screen_snapshot.svg +++ b/tests/presentation/__snapshots__/test_snapshots/test_settings_screen_snapshot.svg @@ -139,7 +139,7 @@ Target speed ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ -▊46                                                ▎ +▊100                                               ▎ ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ ▊WPM (words/min)▼▎ From e3c724b474b482eff5a77be2c71a0d96ff2cd9ff Mon Sep 17 00:00:00 2001 From: Egno Date: Fri, 31 Jul 2026 15:10:01 +0300 Subject: [PATCH 29/47] fix: calibrating focus display and skill heatmap without lesson bias Heatmap and HUD used attempt-ramped confidence, so calibrating keys could look mastered while lessons still treated them as uncertain. skill_of keeps display honest without changing scheduling; calibrating is first-class in the UI with shorter HUD copy and Focus-States wiki docs. Co-authored-by: Cursor --- docs/wiki/Confidence-Tuning.md | 1 + docs/wiki/Focus-States.md | 67 ++++++++++++++ docs/wiki/Home.md | 1 + src/keystrike/application/build_lesson.py | 41 ++++++++- src/keystrike/application/prepare_practice.py | 6 +- src/keystrike/domain/confidence.py | 17 ++++ src/keystrike/domain/enums.py | 2 + src/keystrike/domain/focus.py | 6 +- .../presentation/screens/practice.py | 2 + src/keystrike/presentation/widgets/hud.py | 8 +- .../presentation/widgets/kb_heatmap.py | 87 +++++++++---------- tests/application/test_build_lesson.py | 5 +- tests/domain/test_confidence.py | 27 ++++++ .../test_practice_screen_snapshot.svg | 9 +- tests/presentation/test_hud.py | 14 ++- tests/presentation/test_kb_heatmap.py | 64 +++++++++++--- tests/presentation/test_practice_screen.py | 11 +-- 17 files changed, 289 insertions(+), 79 deletions(-) create mode 100644 docs/wiki/Focus-States.md diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index b072af6..6cb115c 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -124,5 +124,6 @@ aggregates rebuild from the new window size. ## Related docs +- [Focus states](Focus-States) — compact HUD labels and practice-screen metrics. - [Word lists](Word-Lists) — optional dictionary drills (same confidence engine). - [Git sync](Git-sync) — settings (including these fields) sync with your repo. diff --git a/docs/wiki/Focus-States.md b/docs/wiki/Focus-States.md new file mode 100644 index 0000000..74f2441 --- /dev/null +++ b/docs/wiki/Focus-States.md @@ -0,0 +1,67 @@ +# Focus states + +During adaptive practice, Keystrike picks one **focus** — a single key or a +letter pair (bigram) — and biases lesson text toward it. The HUD and practice +screen show compact labels; this page explains what they mean. + +See [Confidence tuning](Confidence-Tuning) for how focus is selected and how +confidence thresholds interact with unlocks. + +## HUD labels + +The practice HUD shows `Focus: · `. + +| Short | Full meaning | +| --- | --- | +| `wk` | **Weak** — confidence is below the mastery goal (default 1.0). The key or pair needs more deliberate practice. | +| `cal` | **Calibrating** — not enough presses yet for full confidence weight. Confidence ramps linearly until `min_confidence_attempts` (keys) or `min_transition_confidence_attempts` (bigrams) is reached. | +| `rev` | **Review** — confidence meets the goal, but the key or pair has not been practiced recently. Review urgency pushes it back into focus before it fades. | + +For **transition focus**, the HUD shows the two-letter pair (e.g. `eo`) instead +of a single key; the reason suffix is still `wk`, `cal`, or `rev`. + +Examples: + +- `Focus: t · cal` — key **t** is the focus; still calibrating (fewer than the minimum attempts). +- `Focus: eo · wk` — bigram **e→o** is the focus; transition confidence is below goal. + +## Practice note (bottom line) + +Below the keyboard heatmap, a single compact line repeats the focus subject, +reason, and live metrics: + +`t · cal 9/10 · 1.59 · 100% · 0.90` + +Reading left to right: + +1. **Subject** — focus key (`t`) or bigram (`at`). +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. + +## Key vs transition focus + +**Key focus** applies while any unlocked key is below the confidence threshold. +The lesson emphasizes the weakest unlocked key (by confidence, adjusted for +review urgency). + +**Transition focus** applies only when every unlocked key is confident. The +lesson then emphasizes the weakest measured cross-key bigram among unlocked +keys. Same-key repeats (double letters) never count as transitions. + +## Heatmap underline + +The underlined key(s) on the practice heatmap match the HUD focus: + +- One key underlined — key focus. +- Two keys underlined — transition focus (both letters of the pair). + +Underline color: cyan when confidence ≥ 1.0, plain underline when still weak. +Keys due for review also get a magenta urgency underline on top of their +confidence color. + +## Related docs + +- [Confidence tuning](Confidence-Tuning) — session window, attempt floors, focus boosts. +- [Typing pedagogy](https://github.com/egno/keystrike/wiki/Typing-Pedagogy) — why weak-key and spaced review matter. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 066f84b..7decb8f 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -8,6 +8,7 @@ Documentation for [Keystrike](https://github.com/egno/keystrike) — an offline - **[Custom layouts](Custom-Layouts)** — add keyboard layouts via TOML files in your layouts directory. - **[Word lists](Word-Lists)** — import a remote dictionary for real-word adaptive drills (Settings, optional). - **[Confidence tuning](Confidence-Tuning)** — session window and attempt floors in `settings.toml` (not the Settings screen). +- **[Focus states](Focus-States)** — HUD and practice-screen focus labels (`wk`, `cal`, `rev`) and metrics. ## Project links diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 3b61343..e795aa3 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -10,11 +10,14 @@ from keystrike.domain.confidence import ( accuracy_of, + attempts_of, confidence_of, is_same_key_transition, key_confidence, review_urgency, round_confidence, + skill_from_stats, + skill_of, target_ms_per_char, transition_accuracy_of, transition_confidence, @@ -72,6 +75,8 @@ class FocusExplanation: reason: FocusReason | None speed: float | None = None accuracy: float | None = None + attempts: int | None = None + min_attempts: int | None = None @dataclass(slots=True, frozen=True) @@ -80,6 +85,7 @@ class LessonProgress: focus: int state: LessonState focus_bigram: Bigram | None + skill_heatmap: dict[int, float] def _key_focus_metrics( @@ -113,9 +119,11 @@ def _transition_focus_metrics( def _focus_reason_for_confidence( confidence: float, + skill: float, urgency: float, *, weak_kind: FocusKind, + calibrating_kind: FocusKind, review_kind: FocusKind, pair: Bigram | None, ) -> FocusReason | None: @@ -124,6 +132,8 @@ def _focus_reason_for_confidence( if urgency > 0 and confidence >= _CONFIDENCE_GOOD: return FocusReason(kind=review_kind, pair=pair) if confidence < _CONFIDENCE_GOOD: + if skill >= _CONFIDENCE_GOOD: + return FocusReason(kind=calibrating_kind, pair=pair) return FocusReason(kind=weak_kind, pair=pair) return None @@ -163,10 +173,13 @@ def _compute_focus_explanation( if focus_bigram is not None: t_stats = ctx.transitions.get(focus_bigram) urgency = review_urgency(t_stats.last_seen if t_stats else 0.0, ctx.now) + skill = skill_from_stats(t_stats, ctx.target) reason = _focus_reason_for_confidence( focus_confidence, + skill, urgency, weak_kind=FocusKind.TRANSITION_WEAK, + calibrating_kind=FocusKind.TRANSITION_CALIBRATING, review_kind=FocusKind.TRANSITION_REVIEW, pair=focus_bigram, ) @@ -178,21 +191,38 @@ def _compute_focus_explanation( ctx.transitions, ctx.target, ) - return FocusExplanation(reason=reason, speed=metrics.speed, accuracy=metrics.accuracy) + attempts = attempts_of(t_stats) if t_stats is not None else 0 + return FocusExplanation( + reason=reason, + speed=metrics.speed, + accuracy=metrics.accuracy, + attempts=attempts, + min_attempts=ctx.settings.min_transition_confidence_attempts, + ) key_stats = ctx.stats.get(focus) urgency = review_urgency(key_stats.last_seen if key_stats else 0.0, ctx.now) + skill = skill_of(focus, ctx.stats, ctx.target) reason = _focus_reason_for_confidence( focus_confidence, + skill, urgency, weak_kind=FocusKind.KEY_WEAK, + calibrating_kind=FocusKind.KEY_CALIBRATING, review_kind=FocusKind.KEY_REVIEW, pair=None, ) if reason is None: return FocusExplanation(reason=None) metrics = _key_focus_metrics(focus, ctx.stats, ctx.target) - return FocusExplanation(reason=reason, speed=metrics.speed, accuracy=metrics.accuracy) + attempts = attempts_of(key_stats) if key_stats is not None else 0 + return FocusExplanation( + reason=reason, + speed=metrics.speed, + accuracy=metrics.accuracy, + attempts=attempts, + min_attempts=ctx.settings.min_confidence_attempts, + ) @dataclass(slots=True) @@ -201,9 +231,12 @@ class Lesson: state: LessonState urgency: dict[int, float] focus_reason: FocusReason | None + skill_heatmap: dict[int, float] focus_confidence: float | None = None focus_speed: float | None = None focus_accuracy: float | None = None + focus_attempts: int | None = None + focus_min_attempts: int | None = None @property def focus_key(self) -> int: @@ -281,6 +314,7 @@ def _lesson_progress( focus=focus, state=state, focus_bigram=focus_bigram, + skill_heatmap={cp: skill_of(cp, ctx.stats, ctx.target) for cp in unlocked}, ) @@ -388,9 +422,12 @@ def __call__(self, layout_name: str) -> Lesson: state=progress.state, urgency=urgency, focus_reason=explanation.reason, + skill_heatmap=progress.skill_heatmap, focus_confidence=focus_confidence if explanation.reason else None, focus_speed=explanation.speed, focus_accuracy=explanation.accuracy, + focus_attempts=explanation.attempts, + focus_min_attempts=explanation.min_attempts, ) def _load_context(self, layout_name: str) -> _LessonContext: diff --git a/src/keystrike/application/prepare_practice.py b/src/keystrike/application/prepare_practice.py index a581e15..96a0504 100644 --- a/src/keystrike/application/prepare_practice.py +++ b/src/keystrike/application/prepare_practice.py @@ -27,6 +27,8 @@ class SessionPrep: focus_confidence: float | None focus_speed: float | None focus_accuracy: float | None + focus_attempts: int | None + focus_min_attempts: int | None layout_obj: Layout | None lesson_heatmap: dict[int, float] | None lesson_urgency: dict[int, float] | None @@ -53,7 +55,9 @@ def __call__(self) -> SessionPrep | None: focus_confidence=lesson.focus_confidence, focus_speed=lesson.focus_speed, focus_accuracy=lesson.focus_accuracy, + focus_attempts=lesson.focus_attempts, + focus_min_attempts=lesson.focus_min_attempts, layout_obj=self.layout_repo.get(settings.layout), - lesson_heatmap=lesson.heatmap, + lesson_heatmap=lesson.skill_heatmap, lesson_urgency=lesson.urgency, ) diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index e555f31..a4b87e1 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -126,6 +126,10 @@ def key_attempts(key_stats: KeyStats) -> int: return _attempts(key_stats) +def attempts_of(stats: HasConfidenceFields) -> int: + return _attempts(stats) + + def is_same_key_transition(prev_cp: int, next_cp: int) -> bool: """Same physical key twice (e.g. ee, ss) — excluded from transition analysis.""" return prev_cp == next_cp @@ -162,6 +166,14 @@ def confidence_from_stats( return round_confidence(raw * confidence_sample_factor(_attempts(stats), minimum=min_attempts)) +def skill_from_stats(stats: HasConfidenceFields | None, target: float) -> float: + """Performance skill without attempt ramp — for display only.""" + if stats is None: + return 0.0 + raw = min(key_confidence(target, stats.mean_time_ns), _accuracy(stats)) + return round_confidence(raw) + + def confidence_of( codepoint: int, stats: Mapping[int, KeyStats], @@ -175,6 +187,11 @@ def confidence_of( return confidence_from_stats(stats.get(codepoint), target, min_attempts=min_attempts) +def skill_of(codepoint: int, stats: Mapping[int, KeyStats], target: float) -> float: + """Live skill for one key — min(speed, accuracy) without attempt ramp.""" + return skill_from_stats(stats.get(codepoint), target) + + def review_urgency(last_seen: float, now: float) -> float: """How urgently a key needs re-testing before forgetting (0.0-1.0). diff --git a/src/keystrike/domain/enums.py b/src/keystrike/domain/enums.py index b7deae1..ea429fa 100644 --- a/src/keystrike/domain/enums.py +++ b/src/keystrike/domain/enums.py @@ -54,6 +54,8 @@ class FocusKind(Enum): presentation code had to parse via substring/suffix matching.""" KEY_WEAK = auto() + KEY_CALIBRATING = auto() KEY_REVIEW = auto() TRANSITION_WEAK = auto() + TRANSITION_CALIBRATING = auto() TRANSITION_REVIEW = auto() diff --git a/src/keystrike/domain/focus.py b/src/keystrike/domain/focus.py index 7e5059b..5fc3546 100644 --- a/src/keystrike/domain/focus.py +++ b/src/keystrike/domain/focus.py @@ -29,7 +29,11 @@ # this module rather than `domain.models` directly. __all__ = ["FOCUS_BIGRAM_WORD_BOOST", "FOCUS_WORD_BOOST"] -_TRANSITION_KINDS = (FocusKind.TRANSITION_WEAK, FocusKind.TRANSITION_REVIEW) +_TRANSITION_KINDS = ( + FocusKind.TRANSITION_WEAK, + FocusKind.TRANSITION_CALIBRATING, + FocusKind.TRANSITION_REVIEW, +) @dataclass(frozen=True, slots=True) diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index cb4a63d..ca0dc7d 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -96,6 +96,8 @@ def _focus_note_text(self) -> str: confidence=self._prep.focus_confidence, speed=self._prep.focus_speed, accuracy=self._prep.focus_accuracy, + attempts=self._prep.focus_attempts, + min_attempts=self._prep.focus_min_attempts, ) or "" ) diff --git a/src/keystrike/presentation/widgets/hud.py b/src/keystrike/presentation/widgets/hud.py index ea51355..77720fb 100644 --- a/src/keystrike/presentation/widgets/hud.py +++ b/src/keystrike/presentation/widgets/hud.py @@ -13,7 +13,10 @@ session_accuracy, ) from keystrike.presentation.theme import STYLE_IDLE -from keystrike.presentation.widgets.kb_heatmap import focus_reason_label, focus_transition_pair +from keystrike.presentation.widgets.kb_heatmap import ( + focus_reason_label_short, + focus_transition_pair, +) def learn_timer_dimmed(session: Session, now_ns: int) -> bool: @@ -36,7 +39,8 @@ def _format_focus_segment(focus_key: int | None, focus_reason: FocusReason | Non return "" transition = focus_transition_pair(focus_reason) label = transition.chars() if transition is not None else chr(focus_key) - return f" Focus: [bold]{label}[/] [dim]{focus_reason_label(focus_reason)}[/]" + reason = focus_reason_label_short(focus_reason) + return f" Focus: [bold]{label}[/] · [dim]{reason}[/]" def _format_hud( diff --git a/src/keystrike/presentation/widgets/kb_heatmap.py b/src/keystrike/presentation/widgets/kb_heatmap.py index 33419ee..805ad47 100644 --- a/src/keystrike/presentation/widgets/kb_heatmap.py +++ b/src/keystrike/presentation/widgets/kb_heatmap.py @@ -13,12 +13,15 @@ _COLS = 10 _CONFIDENCE_GOOD = 1.0 _CONFIDENCE_OK = 0.6 -_CONFIDENCE_GOAL = _CONFIDENCE_GOOD _FOCUS_MASTERED_STYLE = "underline cyan" _FOCUS_STYLE = "underline" _REVIEW_STYLE = "underline magenta" -_TRANSITION_KINDS = (FocusKind.TRANSITION_WEAK, FocusKind.TRANSITION_REVIEW) +_TRANSITION_KINDS = ( + FocusKind.TRANSITION_WEAK, + FocusKind.TRANSITION_CALIBRATING, + FocusKind.TRANSITION_REVIEW, +) def focus_transition_pair(focus_reason: FocusReason | None) -> Bigram | None: @@ -29,20 +32,28 @@ def focus_transition_pair(focus_reason: FocusReason | None) -> Bigram | None: return focus_reason.pair -def focus_reason_label(focus_reason: FocusReason) -> str: - """Display text for a focus reason, e.g. "weak", "review", or - "eo weak transition" — matches the old ad-hoc focus-reason strings.""" +_FULL_REASON_WORD = {"wk": "weak", "cal": "calibrating", "rev": "review"} + + +def focus_reason_label_short(focus_reason: FocusReason) -> str: + """Compact HUD/note label: wk, cal, rev.""" match focus_reason.kind: - case FocusKind.KEY_WEAK: - return "weak" - case FocusKind.KEY_REVIEW: - return "review" - case FocusKind.TRANSITION_WEAK: - assert focus_reason.pair is not None - return f"{focus_reason.pair.chars()} weak transition" - case FocusKind.TRANSITION_REVIEW: - assert focus_reason.pair is not None - return f"{focus_reason.pair.chars()} review transition" + case FocusKind.KEY_WEAK | FocusKind.TRANSITION_WEAK: + return "wk" + case FocusKind.KEY_CALIBRATING | FocusKind.TRANSITION_CALIBRATING: + return "cal" + case FocusKind.KEY_REVIEW | FocusKind.TRANSITION_REVIEW: + return "rev" + + +def focus_reason_label(focus_reason: FocusReason) -> str: + """Full display text for a focus reason (wiki/docs). UI uses + `focus_reason_label_short` instead.""" + word = _FULL_REASON_WORD[focus_reason_label_short(focus_reason)] + if focus_reason.kind in _TRANSITION_KINDS: + assert focus_reason.pair is not None + return f"{focus_reason.pair.chars()} {word} transition" + return word def _confidence_style(confidence: float | None) -> str: @@ -161,40 +172,28 @@ def format_focus_note( confidence: float | None = None, speed: float | None = None, accuracy: float | None = None, - goal: float = _CONFIDENCE_GOAL, + attempts: int | None = None, + min_attempts: int | None = None, ) -> str | None: if focus_key is None or focus_reason is None: return None - actual = f"{confidence:.2f}" if confidence is not None else "0.00" - goal_s = f"{goal:.2f}" - parts: list[str] = [] + transition = focus_transition_pair(focus_reason) + subject = transition.chars() if transition is not None else chr(focus_key) + reason = focus_reason_label_short(focus_reason) + if ( + focus_reason.kind in (FocusKind.KEY_CALIBRATING, FocusKind.TRANSITION_CALIBRATING) + and attempts is not None + and min_attempts is not None + ): + reason = f"{reason} {attempts}/{min_attempts}" + parts = [subject, reason] if speed is not None: - parts.append(f"speed {speed:.2f}") + parts.append(f"{speed:.2f}") if accuracy is not None: - parts.append(f"accuracy {accuracy * 100:.1f}%") - parts.append(f"confidence {actual} / {goal_s}") - metrics = ", ".join(parts) - label = focus_reason_label(focus_reason) - - match focus_reason.kind: - case FocusKind.KEY_WEAK: - return f"[dim]Focus [bold]{chr(focus_key)}[/] ({label}): {metrics}.[/]" - case FocusKind.KEY_REVIEW: - return ( - f"[dim]Focus [bold]{chr(focus_key)}[/] ({label}): {metrics}. " - "Resurfacing before it fades.[/]" - ) - case FocusKind.TRANSITION_WEAK: - assert focus_reason.pair is not None - pair = focus_reason.pair.chars() - return ( - f"[dim]Focus [bold]{pair}[/] ({label}): {metrics}. " - "Practice text favors this pair.[/]" - ) - case FocusKind.TRANSITION_REVIEW: - assert focus_reason.pair is not None - pair = focus_reason.pair.chars() - return f"[dim]Focus [bold]{pair}[/] ({label}): {metrics}. Transition due for review.[/]" + parts.append(f"{accuracy * 100:.0f}%") + conf_s = f"{confidence:.2f}" if confidence is not None else "0.00" + parts.append(conf_s) + return f"[dim]{' · '.join(parts)}[/]" class KbHeatmap(Widget): diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index a766065..8324b95 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -284,7 +284,10 @@ def test_build_lesson_eo_not_zero_confidence_when_counts_zeroed(): clock=FakeClock(), ) lesson = builder(layout_name) - assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=eo_key) + assert lesson.focus_reason == FocusReason( + kind=FocusKind.TRANSITION_CALIBRATING, + pair=eo_key, + ) assert lesson.focus_speed is not None assert lesson.focus_speed >= 1.0 assert lesson.focus_accuracy == 1.0 diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index cf29d48..44d5eaf 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -5,6 +5,7 @@ key_attempts, key_confidence, review_urgency, + skill_of, target_ms_per_char, transition_accuracy_of, transition_confidence_of, @@ -99,6 +100,32 @@ def test_confidence_of_scales_down_with_few_attempts(): assert confidence_of(ord("a"), stats, target=200.0) == 0.1 +def test_skill_of_ignores_attempt_ramp(): + stats = { + ord("t"): KeyStats( + codepoint=ord("t"), + samples=9, + mean_time_ns=126_000_000.0, # speed ~1.59 at target 200ms + error_count=0, + last_seen=0.0, + attempt_count=9, + ), + } + target = 200.0 + assert skill_of(ord("t"), stats, target=target) == 1.0 + assert confidence_of(ord("t"), stats, target=target) == 0.9 + + +def test_skill_of_matches_confidence_at_minimum_attempts(): + stats = {ord("a"): _stats(ord("a"), mean_time_ns=200_000_000.0, error_count=0)} + target = 200.0 + assert skill_of(ord("a"), stats, target=target) == confidence_of(ord("a"), stats, target=target) + + +def test_skill_of_unseen_key_is_zero(): + assert skill_of(ord("a"), {}, target=200.0) == 0.0 + + def test_confidence_of_reaches_full_value_at_minimum_attempts(): stats = {ord("a"): _stats(ord("a"), mean_time_ns=200_000_000.0, error_count=0)} assert key_attempts(stats[ord("a")]) == MIN_CONFIDENCE_ATTEMPTS diff --git a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg index 60192c6..7a49ead 100644 --- a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg +++ b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg @@ -44,8 +44,7 @@ .terminal-r10 { fill: #e0e0e0 } .terminal-r11 { fill: #3b3b3b;font-weight: bold;text-decoration: underline; } .terminal-r12 { fill: #6f6f6f } -.terminal-r13 { fill: #6f6f6f;font-weight: bold } -.terminal-r14 { fill: #a0a0a0 } +.terminal-r13 { fill: #a0a0a0 } @@ -131,10 +130,10 @@ - + -Acc: 100.0%   Learn: 0.0/10 min   Focus: aweak +Acc: 100.0%   Learn: 0.0/10 min   Focus: a · wk asa·uk·ltg·as·iagr·aoag·skal·uda·fea·ta·ajg·aj @@ -144,7 +143,7 @@  z  x  c  v  b  n  m  ,  .  /  -Focus a (weak): speed 0.00, accuracy 0.0%, confidence 0.00 / 1.00. +a · wk · 0.00 · 0% · 0.00 diff --git a/tests/presentation/test_hud.py b/tests/presentation/test_hud.py index 5416647..c97baee 100644 --- a/tests/presentation/test_hud.py +++ b/tests/presentation/test_hud.py @@ -74,14 +74,14 @@ def test_hud_shows_transition_pair_in_focus_label(): assert "Focus:" in text assert "[bold]eo[/]" in text assert "[bold]o[/]" not in text - assert "eo weak transition" in text + assert "· [dim]wk[/]" in text def test_hud_shows_focus_reason_when_given(): reason = FocusReason(kind=FocusKind.KEY_REVIEW) text = _format_hud(_session(), _UNLIMITED, focus_reason=reason) assert "Focus:" in text - assert "review" in text + assert "rev" in text def test_hud_shows_single_key_focus_for_non_transition(): @@ -92,6 +92,14 @@ def test_hud_shows_single_key_focus_for_non_transition(): assert "[bold]a[/]" in text +def test_hud_shows_calibrating_focus_reason(): + reason = FocusReason(kind=FocusKind.KEY_CALIBRATING) + text = _format_hud(_session(), _UNLIMITED, focus_reason=reason) + assert "Focus:" in text + assert "cal" in text + assert "wk" not in text + + def test_hud_omits_focus_when_reason_missing(): text = _format_hud(_session(), _UNLIMITED, focus_reason=None) assert "Focus:" not in text @@ -149,7 +157,7 @@ def test_hud_distinct_labels_for_daily_budget_and_focus(): assert "/10 min" in text assert "left" not in text assert "Focus:" in text - assert "weak" in text + assert "wk" in text assert text.count("Goal:") == 0 diff --git a/tests/presentation/test_kb_heatmap.py b/tests/presentation/test_kb_heatmap.py index 6081f28..b59010f 100644 --- a/tests/presentation/test_kb_heatmap.py +++ b/tests/presentation/test_kb_heatmap.py @@ -5,6 +5,8 @@ from keystrike.infrastructure.bundled_layouts.qwerty import LAYOUT as QWERTY from keystrike.presentation.widgets.kb_heatmap import ( HeatmapDisplay, + focus_reason_label, + focus_reason_label_short, focus_transition_pair, format_focus_note, render_heatmap, @@ -108,21 +110,14 @@ def test_format_focus_note_weak_includes_confidence_numbers(): reason = FocusReason(kind=FocusKind.KEY_WEAK) note = format_focus_note(ord("a"), reason, confidence=0.89, speed=0.95, accuracy=0.92) assert note is not None - assert "speed 0.95" in note - assert "accuracy 92.0%" in note - assert "0.89" in note - assert "1.00" in note - assert "weak" in note + assert "a · wk · 0.95 · 92% · 0.89" in note def test_format_focus_note_review_includes_confidence_numbers(): reason = FocusReason(kind=FocusKind.KEY_REVIEW) note = format_focus_note(ord("e"), reason, confidence=1.12, speed=1.15, accuracy=0.98) assert note is not None - assert "speed 1.15" in note - assert "accuracy 98.0%" in note - assert "1.12" in note - assert "1.00" in note + assert "e · rev · 1.15 · 98% · 1.12" in note def test_format_focus_note_weak_transition(): @@ -135,16 +130,59 @@ def test_format_focus_note_weak_transition(): accuracy=0.80, ) assert note is not None - assert "at" in note - assert "speed 0.50" in note - assert "accuracy 80.0%" in note - assert "0.45" in note + assert "at · wk · 0.50 · 80% · 0.45" in note def test_format_focus_note_none_without_reason(): assert format_focus_note(ord("a"), None, confidence=0.5) is None +def test_format_focus_note_calibrating_includes_press_count(): + reason = FocusReason(kind=FocusKind.KEY_CALIBRATING) + note = format_focus_note( + ord("t"), + reason, + confidence=0.90, + speed=1.59, + accuracy=1.0, + attempts=9, + min_attempts=10, + ) + assert note is not None + assert "t · cal 9/10 · 1.59 · 100% · 0.90" in note + assert "wk" not in note + + +def test_focus_reason_label_calibrating(): + assert focus_reason_label(FocusReason(kind=FocusKind.KEY_CALIBRATING)) == "calibrating" + pair = FocusReason( + kind=FocusKind.TRANSITION_CALIBRATING, + pair=Bigram(ord("e"), ord("o")), + ) + assert focus_reason_label(pair) == "eo calibrating transition" + + +def test_focus_reason_label_short(): + assert focus_reason_label_short(FocusReason(kind=FocusKind.KEY_WEAK)) == "wk" + assert focus_reason_label_short(FocusReason(kind=FocusKind.KEY_CALIBRATING)) == "cal" + assert focus_reason_label_short(FocusReason(kind=FocusKind.KEY_REVIEW)) == "rev" + pair = FocusReason( + kind=FocusKind.TRANSITION_WEAK, + pair=Bigram(ord("e"), ord("o")), + ) + assert focus_reason_label_short(pair) == "wk" + + +def test_render_heatmap_calibrating_key_shows_green_not_yellow(): + # Ramped confidence 0.9 would be yellow; skill 1.0 should be green. + text = render_heatmap(HeatmapDisplay(QWERTY, {ord("t"): 1.0}, focus=ord("t"))) + span = next(s for s in text.spans if text.plain[s.start : s.end].strip() == "t") + style = str(span.style) + assert "green" in style + assert "yellow" not in style + assert "cyan" in style + + def test_focus_transition_pair_returns_pair_for_transition_kinds(): weak = FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=Bigram(ord("e"), ord("o"))) review = FocusReason(kind=FocusKind.TRANSITION_REVIEW, pair=Bigram(ord("a"), ord("t"))) diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index d2eafa1..9b845d1 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -297,11 +297,9 @@ async def test_adaptive_practice_shows_weak_key_focus_note(): practice = app.screen assert isinstance(practice, PracticeScreen) note = str(practice.query_one("#focus-note", Static).content) - assert "(weak)" in note - assert "[bold]a[/]" in note + assert " · wk ·" in note + assert "[bold]" not in note assert "weak transition" not in note - assert "speed " in note - assert "accuracy " in note @pytest.mark.asyncio @@ -314,9 +312,8 @@ async def test_adaptive_practice_focus_note_shows_speed_and_accuracy(): assert isinstance(practice, PracticeScreen) note = str(practice.query_one("#focus-note", Static).content) if practice._prep.focus_reason: - assert "speed " in note - assert "accuracy " in note - assert "confidence " in note + assert " · " in note + assert "%" in note app, _clock, _repo, _settings = _build_app() async with app.run_test() as pilot: await pilot.press("enter") From c0117192db7f3e9bf5cf86cfd31cacf4d628dcf8 Mon Sep 17 00:00:00 2001 From: Egno Date: Fri, 31 Jul 2026 15:43:19 +0300 Subject: [PATCH 30/47] refactor: split skill from confidence for unlock and focus Unlock the next key from per-key skill plus attempt floor, not ramped confidence or bigram thresholds. Transition focus uses skill-only gating so calibrating keys no longer block bigram focus; lesson sampling adds coverage-deficit weight for under-sampled keys. Co-authored-by: Cursor --- docs/wiki/Confidence-Tuning.md | 94 ++++++++---- docs/wiki/Focus-States.md | 17 ++- src/keystrike/application/build_lesson.py | 41 ++--- .../application/session_use_cases.py | 2 - src/keystrike/domain/focus.py | 28 +++- src/keystrike/domain/unlock.py | 67 +++------ tests/application/test_build_lesson.py | 101 +++++++++++-- tests/domain/test_confidence.py | 140 ++++++++---------- 8 files changed, 302 insertions(+), 188 deletions(-) diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index 6cb115c..420e77f 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -1,10 +1,11 @@ # Confidence tuning -Keystrike's adaptive engine uses **confidence** — min(speed, accuracy), scaled by how -much you've practiced — to decide which keys unlock, which key or bigram gets -focus, and how the stats heatmap colors each key. Settings in this page control how -aggressively the engine trusts your recent performance and how strongly it -biases lesson text toward the current focus key or bigram. +Keystrike's adaptive engine uses per-key **skill** (min(speed, accuracy) without +attempt ramp) plus an attempt floor to decide which keys unlock, and **confidence** +(skill scaled by how much you've practiced) for focus, HUD labels, and heatmap +coloring. Settings on this page control how aggressively the engine trusts your +recent performance and how strongly it biases lesson text toward the current +focus key or bigram. Edit `{config_dir}/settings.toml` directly (see [Git sync](Git-sync) for the path on your OS). These fields are **not** on the Settings screen — saving @@ -14,7 +15,7 @@ layout, speed, or other UI settings will not change them. | 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 confidence, unlocks, focus, and the heatmap. | +| 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 | `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 bigram attempts | `min_transition_confidence_attempts` | `4` | Same ramp for letter-pair (transition) confidence. Default is lower because bigrams are practiced less often than single keys. | | Focus char boost | `focus_char_boost` | `3.0` | Multiplier on the focus key's char weight when building lesson sampling weights. | @@ -82,27 +83,26 @@ The first **N** keys in layout `learn_order` are always unlocked, where **N** is **Letters unlocked up front** in Settings (`alphabet_size`; see [README — Settings](https://github.com/egno/keystrike#settings)). Each further key in `learn_order` unlocks only when **every** currently unlocked key meets the -confidence threshold (default 1.0) **and** every **measured** cross-key bigram -among unlocked keys meets the same threshold. Unmeasured pairs do not block -unlock — you are not required to practice every possible letter pair before the -next key opens. - -Same-key repeats (double letters such as `ee`, `ss`) are excluded from unlock -checks; only prev→next pairs on **different** keys count. - -**Focus selection** is letter-first: while any unlocked key is below threshold -(`has_weak_unlocked_key`), the lesson emphasizes the weakest unlocked **key** -(by confidence, with review urgency). Transition (bigram) focus activates only -when all unlocked keys are confident; then the weakest measured cross-key bigram -among the unlocked set drives focus. If no transition data exists yet, focus -falls back to the weakest key. +skill threshold (default 1.0: min(speed, accuracy) without attempt ramp) **and** +has at least `min_confidence_attempts` presses in the session window. Ramped +confidence still drives HUD labels (`cal` vs `wk`) and focus weighting. Bigrams +affect focus and lesson text only — they do not gate which letter opens next. + +**Focus selection** is letter-first: while any unlocked key is below performance +skill (`blocks_transition_focus`), the lesson emphasizes the weakest unlocked +**key** (by ramped confidence, with review urgency). Transition (bigram) focus +activates when every unlocked key meets the skill threshold (speed and accuracy +without the attempt ramp), even if some keys are still calibrating on press +count; then the weakest measured cross-key bigram among unlocked keys drives +focus. If no transition data exists yet, focus falls back to the weakest key. ### Transition stats Same-key / double-letter bigrams are never aggregated into session stats, stored -in the stats cache, or used in unlock checks, focus selection, or lesson -transition weights. Only cross-key pairs (e.g. `th`, `he`) participate in -transition confidence and bigram-weighted lesson text. +in the stats cache, or used in unlock checks. Only cross-key pairs (e.g. `th`, +`he`) participate in transition confidence, focus selection, and bigram-weighted +lesson text. Transition sampling weights apply only to **measured** unlocked +cross-key bigrams; unmeasured pairs keep the generator default (1.0). ## Tradeoffs @@ -118,9 +118,53 @@ transition confidence and bigram-weighted lesson text. already typed many times. - **Transition floor** — Raise it if bigram focus switches too eagerly on thin data; lower it if weak pairs never get targeted. +- **Coverage deficit** — Lesson sampling multiplies char (and transition) + weights by a session-scale boost when in-window attempts are below + `min_confidence_attempts` (peaking at zero attempts). This is separate + from performance weakness (`practice_weight`) and day-scale review urgency; + it helps large unlocked sets get enough window samples without widening the + session window. There is no settings knob — the boost is fixed in code + (`coverage_deficit_factor` in `domain/focus.py`). + +## Large alphabet (40+ keys) + +With the default session window (`10`) and lesson length (`12`), each practice +session only touches a fraction of a 40–50 key unlocked set. Keys can drop out +of the rolling window or stay below `min_confidence_attempts`, which stalls +the next unlock and leaves heatmap gaps even when you are typing well. +**Coverage-deficit weighting** (above) addresses much of this automatically; +defaults may suffice once you have been practicing for a while. + +If unlocks still feel stuck or the heatmap looks sparse, widen the window and +lesson manually in `{config_dir}/settings.toml` (these fields are **not** on +the Settings screen): + +```toml +confidence_session_window = 18 +lesson_word_count = 20 +``` + +Starting points for 40–50 unlocked keys: + +| Knob | Default | Large-alphabet starting point | +| --- | --- | --- | +| `confidence_session_window` | `10` | `15`–`20` | +| `lesson_word_count` | `12` | `18`–`24` | + +**Tradeoffs:** + +- **Wider window** — More keys stay represented in rolling stats, but + confidence reacts more slowly to recent form (see [window too long](#tradeoffs) + above). +- **Longer lessons** — More keys sampled per session, but each drill takes + longer. +- **`focus_word_min_fraction`** — Lower slightly (e.g. `0.5`) if strict focus + quotas make generated text repetitive at large N; raising it keeps weak-focus + keys more prominent at the cost of variety. -After changing the session window, run a practice session or open Stats so -aggregates rebuild from the new window size. +After edits, run a practice session or open Stats so aggregates rebuild. See +[Focus states](Focus-States) for how key vs transition focus interacts with +calibration at scale. ## Related docs diff --git a/docs/wiki/Focus-States.md b/docs/wiki/Focus-States.md index 74f2441..57aebdd 100644 --- a/docs/wiki/Focus-States.md +++ b/docs/wiki/Focus-States.md @@ -42,13 +42,16 @@ Reading left to right: ## Key vs transition focus -**Key focus** applies while any unlocked key is below the confidence threshold. -The lesson emphasizes the weakest unlocked key (by confidence, adjusted for -review urgency). - -**Transition focus** applies only when every unlocked key is confident. The -lesson then emphasizes the weakest measured cross-key bigram among unlocked -keys. Same-key repeats (double letters) never count as transitions. +**Key focus** applies while any unlocked key is below the performance skill +threshold (see `blocks_transition_focus` in the codebase). The lesson +emphasizes the weakest unlocked key by ramped confidence, adjusted for review +urgency. + +**Transition focus** applies when every unlocked key meets the skill threshold +(performance without attempt ramp). Keys still gathering presses show `cal` but +no longer block bigram focus. The lesson then emphasizes the weakest measured +cross-key bigram among unlocked keys. Same-key repeats (double letters) never +count as transitions. ## Heatmap underline diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index e795aa3..8d11d95 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -26,8 +26,9 @@ from keystrike.domain.enums import FocusKind from keystrike.domain.focus import ( FocusReason, + blocks_transition_focus, + coverage_deficit_factor, focus_key_from_transition, - has_weak_unlocked_key, practice_weight, select_focus, select_focus_transition, @@ -258,15 +259,12 @@ def _lesson_progress( ctx.stats, ctx.target, min_attempts=ctx.settings.min_confidence_attempts, - transitions=ctx.transitions, - min_transition_attempts=ctx.settings.min_transition_confidence_attempts, ) - keys_need_focus = has_weak_unlocked_key( + keys_need_focus = blocks_transition_focus( unlocked, ctx.stats, ctx.target, threshold=_CONFIDENCE_GOOD, - min_attempts=ctx.settings.min_confidence_attempts, ) focus_bigram: Bigram | None = None if not keys_need_focus and ctx.transitions: @@ -330,6 +328,8 @@ def _compute_weights( """Per-char and per-transition sampling weights for practice-text generation, biased toward weak/stale keys and boosted further for today's focus (see `domain.focus.practice_weight`).""" + min_key_attempts = ctx.settings.min_confidence_attempts + min_transition_attempts = ctx.settings.min_transition_confidence_attempts char_weights = { chr(k.codepoint): practice_weight( k.confidence, @@ -338,28 +338,33 @@ def _compute_weights( ctx.now, ), ) + * coverage_deficit_factor( + attempts_of(ctx.stats[k.codepoint]) if k.codepoint in ctx.stats else 0, + min_attempts=min_key_attempts, + ) for k in state.keys } char_weights[chr(focus)] *= ctx.settings.focus_char_boost + unlocked_set = frozenset(unlocked) transition_weights = { - Bigram(prev, nxt): transition_practice_weight( + key: transition_practice_weight( transition_confidence_of( - prev, - nxt, + key.prev_cp, + key.next_cp, ctx.transitions, ctx.target, - min_attempts=ctx.settings.min_transition_confidence_attempts, - ), - urgency=review_urgency( - ctx.transitions[Bigram(prev, nxt)].last_seen - if Bigram(prev, nxt) in ctx.transitions - else 0.0, - ctx.now, + min_attempts=min_transition_attempts, ), + urgency=review_urgency(ctx.transitions[key].last_seen, ctx.now), + ) + * coverage_deficit_factor( + attempts_of(ctx.transitions[key]), + min_attempts=min_transition_attempts, ) - for prev in unlocked - for nxt in unlocked - if not is_same_key_transition(prev, nxt) + for key in ctx.transitions + if key.prev_cp in unlocked_set + and key.next_cp in unlocked_set + and not is_same_key_transition(key.prev_cp, key.next_cp) } if focus_bigram is not None: transition_weights[focus_bigram] *= ctx.settings.focus_transition_boost diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 6683d4f..f128cf3 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -150,8 +150,6 @@ def _snapshot_unlock_state( combined.keys, target, min_attempts=settings.min_confidence_attempts, - transitions=combined.transitions, - min_transition_attempts=settings.min_transition_confidence_attempts, ) return unlocked, { cp: confidence_of(cp, combined.keys, target, min_attempts=settings.min_confidence_attempts) diff --git a/src/keystrike/domain/focus.py b/src/keystrike/domain/focus.py index 5fc3546..debabbf 100644 --- a/src/keystrike/domain/focus.py +++ b/src/keystrike/domain/focus.py @@ -12,9 +12,9 @@ MIN_TRANSITION_CONFIDENCE_ATTEMPTS, HasConfidenceFields, confidence_from_stats, - confidence_of, is_same_key_transition, review_urgency, + skill_of, ) from .enums import FocusKind from .models import ( @@ -88,18 +88,15 @@ def _focus_score( ) -def has_weak_unlocked_key( +def blocks_transition_focus( unlocked: Sequence[int], stats: Mapping[int, KeyStats], target: float, *, threshold: float = 1.0, - min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, ) -> bool: - """True when any unlocked key is below mastery threshold.""" - return any( - confidence_of(cp, stats, target, min_attempts=min_attempts) < threshold for cp in unlocked - ) + """True when any unlocked key is below performance skill (ignores attempt ramp).""" + return any(skill_of(cp, stats, target) < threshold for cp in unlocked) def select_focus( @@ -185,6 +182,23 @@ def focus_key_from_transition(_prev_cp: int, next_cp: int) -> int: return next_cp +def coverage_deficit_factor( + attempts: int, + *, + min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, + max_boost: float = 2.0, +) -> float: + """Session-scale boost when in-window attempts are below ``min_attempts``. + + Separate from performance weakness (``practice_weight``) and day-scale + ``review_urgency`` — targets keys that need more window samples to unlock + or calibrate, peaking at zero attempts.""" + if min_attempts <= 0 or attempts >= min_attempts: + return 1.0 + deficit = 1.0 - attempts / min_attempts + return 1.0 + max_boost * deficit + + def practice_weight( confidence: float, *, diff --git a/src/keystrike/domain/unlock.py b/src/keystrike/domain/unlock.py index 82a68a6..1421f53 100644 --- a/src/keystrike/domain/unlock.py +++ b/src/keystrike/domain/unlock.py @@ -1,47 +1,30 @@ """Key-unlock policy: which keys in `learn_order` are currently unlocked, -gated on measured per-key and per-transition confidence (§6 of PLAN.md).""" +gated on per-key skill and attempt floor (§6 of PLAN.md). Bigrams affect focus +and lesson text, not which letters open next.""" from __future__ import annotations from collections.abc import Mapping, Sequence -from .confidence import ( - MIN_CONFIDENCE_ATTEMPTS, - MIN_TRANSITION_CONFIDENCE_ATTEMPTS, - confidence_of, - is_same_key_transition, - transition_confidence_of, -) -from .models import Bigram, KeyStats, TransitionStats +from .confidence import MIN_CONFIDENCE_ATTEMPTS, attempts_of, skill_of +from .models import KeyStats -def _measured_transitions_meet_threshold( - unlocked: Sequence[int], - transitions: Mapping[Bigram, TransitionStats], +def _key_meets_unlock_threshold( + codepoint: int, + stats: Mapping[int, KeyStats], target: float, *, threshold: float, min_attempts: int, ) -> bool: - """True when every measured bigram among unlocked keys meets threshold.""" - for prev in unlocked: - for nxt in unlocked: - if is_same_key_transition(prev, nxt): - continue - if Bigram(prev, nxt) not in transitions: - continue - if ( - transition_confidence_of( - prev, - nxt, - transitions, - target, - min_attempts=min_attempts, - ) - < threshold - ): - return False - return True + """Performance (skill) and evidence (attempts) — ramp affects display only.""" + key_stats = stats.get(codepoint) + if key_stats is None: + return False + return ( + skill_of(codepoint, stats, target) >= threshold and attempts_of(key_stats) >= min_attempts + ) def compute_unlocked( @@ -52,27 +35,23 @@ def compute_unlocked( *, threshold: float = 1.0, min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, - transitions: Mapping[Bigram, TransitionStats] | None = None, - min_transition_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, ) -> tuple[int, ...]: """The first `alphabet_size` keys are always unlocked; each further key in - `learn_order` unlocks only once every currently-unlocked key and every - measured bigram among them meets `threshold`.""" + `learn_order` unlocks only once every currently-unlocked key meets skill + ``threshold`` with at least ``min_attempts`` presses in window.""" forced_count = min(alphabet_size, len(learn_order)) unlocked = list(learn_order[:forced_count]) for codepoint in learn_order[forced_count:]: if not all( - confidence_of(k, stats, target, min_attempts=min_attempts) >= threshold + _key_meets_unlock_threshold( + k, + stats, + target, + threshold=threshold, + min_attempts=min_attempts, + ) for k in unlocked ): break - if transitions is not None and not _measured_transitions_meet_threshold( - unlocked, - transitions, - target, - threshold=threshold, - min_attempts=min_transition_attempts, - ): - break unlocked.append(codepoint) return tuple(unlocked) diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 8324b95..05908e7 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -8,6 +8,7 @@ from keystrike.domain.generator import weak_focus_word_quota, word_matches_focus from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import Bigram, KeyStats, LayoutAggregates, Settings, TransitionStats +from keystrike.domain.unlock import compute_unlocked from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from tests.fakes import ( FakeAggregatesCache, @@ -187,9 +188,10 @@ def test_lesson_uses_transition_focus_when_transitions_weak(): five_days = 5 * 86_400.0 fast = 100_000_000.0 at_target = 200_000_000.0 + slow = 400_000_000.0 keys = { a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), - s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 9, at_target, 0, now, attempt_count=9), } transitions = { Bigram(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), @@ -197,7 +199,7 @@ def test_lesson_uses_transition_focus_when_transitions_weak(): a, s, 10, - 400_000_000.0, + slow, 0, now - five_days, attempt_count=10, @@ -256,14 +258,26 @@ def test_build_lesson_eo_not_zero_confidence_when_counts_zeroed(): order = keyboard_order(layout) now = 1_700_000_000.0 at_target = 200_000_000.0 - keys = {cp: KeyStats(cp, 10, at_target, 0, now, attempt_count=10) for cp in order[:4]} + o = order[3] + keys = { + cp: KeyStats( + cp, + 9 if cp == o else 10, + at_target, + 0, + now, + attempt_count=9 if cp == o else 10, + ) + for cp in order[:4] + } eo_key = Bigram(ord("e"), ord("o")) + slow = 566_631_000.0 transitions = { eo_key: TransitionStats( ord("e"), ord("o"), 0, - 566_631_000.0, + slow, 0, now, attempt_count=0, @@ -346,13 +360,13 @@ def test_lesson_uses_transition_review_when_stale_and_mastered(): now = 1_700_000_000.0 five_days = 5 * 86_400.0 at_target = 200_000_000.0 - # s→h stays weak enough to block unlocking the next key; stale a→s still + # s→h stays weak; h still calibrating so unlock stops at a,s,h. Stale a→s # wins transition focus via review scoring (see test_select_focus_transition_*). slow = 380_000_000.0 keys = { a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), - h: KeyStats(h, 10, at_target, 0, now, attempt_count=10), + h: KeyStats(h, 9, at_target, 0, now, attempt_count=9), } transitions = { Bigram(a, a): TransitionStats( @@ -462,9 +476,10 @@ def test_lesson_wordlist_biases_weak_transition(): five_days = 5 * 86_400.0 fast = 100_000_000.0 at_target = 200_000_000.0 + slow = 400_000_000.0 keys = { a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), - s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 9, at_target, 0, now, attempt_count=9), } transitions = { Bigram(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), @@ -472,7 +487,7 @@ def test_lesson_wordlist_biases_weak_transition(): a, s, 10, - 400_000_000.0, + slow, 0, now - five_days, attempt_count=10, @@ -525,9 +540,10 @@ def test_weak_transition_focus_over_represented_in_lesson_words(): now = 1_700_000_000.0 fast = 100_000_000.0 at_target = 200_000_000.0 + slow = 400_000_000.0 keys = { a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), - s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 9, at_target, 0, now, attempt_count=9), } transitions = { Bigram(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), @@ -535,7 +551,7 @@ def test_weak_transition_focus_over_represented_in_lesson_words(): a, s, 10, - 400_000_000.0, + slow, 0, now, attempt_count=10, @@ -754,3 +770,68 @@ def test_lesson_uses_markov_when_url_saved_without_cache(): assert all( set(word) <= {chr(k.codepoint) for k in lesson.state.keys} for word in lesson.text.split() ) + + +def test_lesson_boosts_coverage_starved_key_among_mastered_alphabet(): + """Under-sampled keys get extra weight vs fully-sampled mastered keys.""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + now = 1_700_000_000.0 + at_target = 200_000_000.0 + min_attempts = 10 + alphabet_size = 30 + unlocked = order[:alphabet_size] + starved = unlocked[-1] + keys = { + cp: KeyStats(cp, 10, at_target, 0, now, attempt_count=min_attempts) for cp in unlocked[:-1] + } + keys[starved] = KeyStats(starved, 3, at_target, 0, now, attempt_count=3) + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, + ) + settings = Settings(alphabet_size=alphabet_size, min_confidence_attempts=min_attempts) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(settings), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + ) + starved_char = chr(starved) + starved_hits = 0 + total_chars = 0 + for seed in range(50): + builder.rng = Random(seed) + text = builder("qwerty").text.replace(" ", "") + total_chars += len(text) + starved_hits += text.count(starved_char) + share = starved_hits / total_chars + expected_uniform = 1 / alphabet_size + assert share > expected_uniform * 2, ( + f"starved key share {share:.1%} vs uniform {expected_uniform:.1%}" + ) + + +def test_large_alphabet_unlock_advances_when_keys_rotated(): + """26+ keys with sparse window stats still unlock next key once all meet floor.""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + now = 1_700_000_000.0 + at_target = 200_000_000.0 + min_attempts = 3 + forced = 26 + target = target_ms_per_char(300) + stats: dict[int, KeyStats] = { + cp: KeyStats(cp, 0, at_target, 0, now, attempt_count=0) for cp in order[:forced] + } + assert len(compute_unlocked(order, forced, stats, target, min_attempts=min_attempts)) == forced + for i, cp in enumerate(order[:forced]): + stats[cp] = KeyStats(cp, 0, at_target, 0, now, attempt_count=min_attempts) + unlocked = compute_unlocked(order, forced, stats, target, min_attempts=min_attempts) + if i < forced - 1: + assert len(unlocked) == forced + else: + assert len(unlocked) == forced + 1 + assert unlocked[-1] == order[forced] diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index 44d5eaf..77d34b7 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -11,8 +11,9 @@ transition_confidence_of, ) from keystrike.domain.focus import ( + blocks_transition_focus, + coverage_deficit_factor, focus_key_from_transition, - has_weak_unlocked_key, practice_weight, select_focus, select_focus_transition, @@ -132,6 +133,36 @@ def test_confidence_of_reaches_full_value_at_minimum_attempts(): assert confidence_of(ord("a"), stats, target=200.0) == 1.0 +def test_compute_unlocked_stalls_when_calibrating_on_attempts(): + learn_order = (1, 2, 3) + stats = { + 1: _stats(1, mean_time_ns=100_000_000.0), + 2: KeyStats( + 2, + samples=9, + mean_time_ns=100_000_000.0, + error_count=0, + last_seen=0.0, + attempt_count=9, + ), + } + target = 200.0 + assert skill_of(2, stats, target=target) == 1.0 + assert confidence_of(2, stats, target=target) == 0.9 + unlocked = compute_unlocked(learn_order, alphabet_size=2, stats=stats, target=target) + assert unlocked == (1, 2) + + +def test_compute_unlocked_advances_when_skill_and_attempts_met(): + learn_order = (1, 2, 3) + stats = { + 1: _stats(1, mean_time_ns=100_000_000.0), + 2: _stats(2, mean_time_ns=100_000_000.0), + } + unlocked = compute_unlocked(learn_order, alphabet_size=2, stats=stats, target=200.0) + assert unlocked == (1, 2, 3) + + def test_compute_unlocked_stalls_when_sparse_key_looks_fast(): learn_order = (1, 2, 3) stats = { @@ -282,73 +313,14 @@ def test_compute_unlocked_stalls_when_threshold_not_met(): assert unlocked == (1,) -def test_compute_unlocked_ignores_weak_same_key_transition(): - learn_order = tuple(ord(c) for c in "eabcdfghm") - stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in learn_order[:8]} - transitions = { - Bigram(ord("e"), ord("e")): TransitionStats( - ord("e"), - ord("e"), - samples=10, - mean_time_ns=400_000_000.0, - error_count=0, - last_seen=0.0, - attempt_count=10, - ), - } - unlocked = compute_unlocked( - learn_order, - alphabet_size=8, - stats=stats, - target=200.0, - transitions=transitions, - ) - assert unlocked == learn_order[:9] - assert unlocked[-1] == ord("m") - - -def test_compute_unlocked_stalls_on_weak_cross_key_transition(): - learn_order = tuple(ord(c) for c in "eabcdfghm") - stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in learn_order[:8]} - transitions = { - Bigram(ord("e"), ord("a")): TransitionStats( - ord("e"), - ord("a"), - samples=10, - mean_time_ns=400_000_000.0, - error_count=0, - last_seen=0.0, - attempt_count=10, - ), - } - unlocked = compute_unlocked( - learn_order, - alphabet_size=8, - stats=stats, - target=200.0, - transitions=transitions, - ) - assert unlocked == learn_order[:8] - assert ord("m") not in unlocked - - -def test_compute_unlocked_advances_when_measured_transitions_meet_threshold(): +def test_compute_unlocked_ignores_weak_transitions(): learn_order = tuple(ord(c) for c in "eabcdfghm") stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in learn_order[:8]} - transitions = { - Bigram(ord("e"), ord("e")): _transition( - ord("e"), - ord("e"), - 100_000_000.0, - attempt_count=10, - ), - } unlocked = compute_unlocked( learn_order, alphabet_size=8, stats=stats, target=200.0, - transitions=transitions, ) assert unlocked == learn_order[:9] assert unlocked[-1] == ord("m") @@ -362,25 +334,26 @@ def test_select_focus_picks_weakest_unlocked_key(): assert select_focus((1, 2), stats, target=200.0, now=1000.0) == 2 -def test_has_weak_unlocked_key_true_when_any_below_threshold(): - stats = { - 1: _stats(1, mean_time_ns=200_000_000.0), - 2: _stats(2, mean_time_ns=400_000_000.0), - } - assert has_weak_unlocked_key((1, 2), stats, target=200.0) is True - - -def test_has_weak_unlocked_key_false_when_all_confident(): +def test_blocks_transition_focus_false_when_key_calibrating(): stats = { - 1: _stats(1, mean_time_ns=200_000_000.0), - 2: _stats(2, mean_time_ns=100_000_000.0), + ord("t"): KeyStats( + ord("t"), + samples=9, + mean_time_ns=125_000_000.0, + error_count=0, + last_seen=0.0, + attempt_count=9, + ), } - assert has_weak_unlocked_key((1, 2), stats, target=200.0) is False + target = target_ms_per_char(300) + assert skill_of(ord("t"), stats, target=target) == 1.0 + assert confidence_of(ord("t"), stats, target=target) == 0.9 + assert blocks_transition_focus((ord("t"),), stats, target=target) is False -def test_has_weak_unlocked_key_true_for_never_practiced(): +def test_blocks_transition_focus_true_for_never_practiced(): stats = {1: _stats(1, mean_time_ns=200_000_000.0)} - assert has_weak_unlocked_key((1, 2), stats, target=200.0) is True + assert blocks_transition_focus((1, 2), stats, target=200.0) is True def test_select_focus_prefers_never_practiced_key(): @@ -435,6 +408,23 @@ def test_practice_weight_scales_linearly_between_zero_and_one(): assert practice_weight(0.5, max_bias=3.0) == 2.5 +def test_coverage_deficit_factor_at_floor_is_baseline(): + assert coverage_deficit_factor(10, min_attempts=10) == 1.0 + assert coverage_deficit_factor(15, min_attempts=10) == 1.0 + + +def test_coverage_deficit_factor_peaks_at_zero_attempts(): + assert coverage_deficit_factor(0, min_attempts=10, max_boost=2.0) == 3.0 + + +def test_coverage_deficit_factor_ramps_linearly(): + assert coverage_deficit_factor(5, min_attempts=10, max_boost=2.0) == 2.0 + + +def test_coverage_deficit_factor_disabled_when_min_zero(): + assert coverage_deficit_factor(0, min_attempts=0) == 1.0 + + def _transition( prev_cp: int, next_cp: int, From 94546494686176305d28ec0b7915de6d213424a0 Mon Sep 17 00:00:00 2001 From: Egno Date: Fri, 31 Jul 2026 15:56:22 +0300 Subject: [PATCH 31/47] fix: keep dictionary word bounds separate from Markov bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wordlist sampling was using generated_word_min/max, so valid imported words were dropped or capped incorrectly while Markov fill should stay on its own 2–4 range. Co-authored-by: Cursor --- src/keystrike/domain/generator.py | 62 ++++++++++++++------------ src/keystrike/domain/word_bounds.py | 11 +++++ tests/application/test_build_lesson.py | 11 ++--- tests/domain/test_generator.py | 45 ++++++++++++++++--- tests/domain/test_wordlist.py | 8 +++- 5 files changed, 96 insertions(+), 41 deletions(-) diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index 0f59777..d086951 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -22,6 +22,7 @@ from .models import ( LESSON_WORD_COUNT as DEFAULT_WORD_COUNT, ) +from .word_bounds import MAX_WORD_LEN, MIN_WORD_LEN, effective_wordlist_bounds MAX_RETRIES = 5 MAX_REPEAT_RESAMPLE = 64 @@ -90,14 +91,28 @@ def _assert_lesson_focus_quota( ) +def _assert_wordlist_word_len(word: str, *, min_len: int, max_len: int) -> None: + """Ensures: imported word length is within dictionary bounds.""" + assert min_len <= len(word) <= max_len, ( + f"wordlist word {word!r} length {len(word)} not in [{min_len}, {max_len}]" + ) + + +def _assert_generated_word_len(word: str, *, min_len: int, max_len: int) -> None: + """Ensures: Markov-generated word length is within generated bounds.""" + assert min_len <= len(word) <= max_len, ( + f"generated word {word!r} length {len(word)} not in [{min_len}, {max_len}]" + ) + + def _wordlist_word_fits( word: str, alphabet: frozenset[str], *, - min_len: int, - max_len: int, + min_len: int = MIN_WORD_LEN, + max_len: int = MAX_WORD_LEN, ) -> bool: - """True when ``word`` length is in ``[min_len, max_len]`` and chars ⊆ alphabet.""" + """True when ``word`` length is in dictionary bounds and chars ⊆ alphabet.""" return min_len <= len(word) <= max_len and set(word) <= alphabet @@ -107,20 +122,15 @@ def _focus_pool_from_wordlist( *, focus_char: str, focus_bigram: str | None, - generated_min_len: int, - generated_max_len: int, ) -> tuple[str, ...]: + """Build focus-matching pool from imported words (dictionary bounds, not generated).""" if not words: return () + wordlist_min, wordlist_max = effective_wordlist_bounds() return tuple( w for w in words - if _wordlist_word_fits( - w, - alphabet, - min_len=generated_min_len, - max_len=generated_max_len, - ) + if _wordlist_word_fits(w, alphabet, min_len=wordlist_min, max_len=wordlist_max) and word_matches_focus(w, focus_char=focus_char, focus_bigram=focus_bigram) ) @@ -312,8 +322,6 @@ def generate_word( alphabet, weighting, weighted_wordlist, - generated_min_len=generated_min_len, - generated_max_len=generated_max_len, ) if word is not None: return word @@ -326,18 +334,15 @@ def _generate_word_from_wordlist( alphabet: frozenset[str], weighting: LessonWeighting, weighted_wordlist: WeightedWordlist | None, - *, - generated_min_len: int, - generated_max_len: int, ) -> str | None: - """Sample a dictionary word, or None if it doesn't fit length/alphabet bounds.""" + """Sample a dictionary word, or None if it doesn't fit dictionary bounds/alphabet. + + Ensures: when not None, word length is in [MIN_WORD_LEN, MAX_WORD_LEN]. + """ word = self._sample_from_wordlist(weighting, weighted_wordlist) - if _wordlist_word_fits( - word, - alphabet, - min_len=generated_min_len, - max_len=generated_max_len, - ): + wordlist_min, wordlist_max = effective_wordlist_bounds() + if _wordlist_word_fits(word, alphabet, min_len=wordlist_min, max_len=wordlist_max): + _assert_wordlist_word_len(word, min_len=wordlist_min, max_len=wordlist_max) return word return None @@ -352,14 +357,19 @@ def _generate_word_via_markov( for _ in range(MAX_RETRIES): word = self._sample_word(alphabet, weighting, generated_max_len) if generated_min_len <= len(word) <= generated_max_len: + _assert_generated_word_len( + word, min_len=generated_min_len, max_len=generated_max_len + ) return word - return _ensure_generated_word_len( + word = _ensure_generated_word_len( word, alphabet, self.rng, generated_min_len=generated_min_len, generated_max_len=generated_max_len, ) + _assert_generated_word_len(word, min_len=generated_min_len, max_len=generated_max_len) + return word def generate_lesson( self, @@ -410,8 +420,6 @@ def generate_lesson( alphabet, focus_char=focus_char, focus_bigram=focus_bigram_str, - generated_min_len=generated_min_len, - generated_max_len=generated_max_len, ) focus_weighted: WeightedWordlist | None = None if focus_pool and weighted_wordlist is not None: @@ -644,8 +652,6 @@ def _generate_focus_word( alphabet, weighting, wordlist, - generated_min_len=generated_min_len, - generated_max_len=generated_max_len, ) if word is not None and word_matches_focus( word, focus_char=focus_char, focus_bigram=focus_bigram_str diff --git a/src/keystrike/domain/word_bounds.py b/src/keystrike/domain/word_bounds.py index 2832c74..a3f18c7 100644 --- a/src/keystrike/domain/word_bounds.py +++ b/src/keystrike/domain/word_bounds.py @@ -2,3 +2,14 @@ MIN_WORD_LEN = 3 MAX_WORD_LEN = 10 + + +def effective_wordlist_bounds( + min_len: int = MIN_WORD_LEN, + max_len: int = MAX_WORD_LEN, +) -> tuple[int, int]: + """Clamp dictionary bounds; ensure min <= max and both are at least 1.""" + min_len = max(1, min_len) + max_len = max(1, max_len) + min_len = min(min_len, max_len) + return min_len, max_len diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 05908e7..6063f21 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -682,10 +682,10 @@ def test_build_lesson_markov_respects_generated_word_bounds_from_settings(): assert 2 <= len(word) <= 4, f"seed={seed}: {word!r}" -def test_build_lesson_wordlist_respects_generated_bounds(): - """Cached wordlist words must fall within generated_word_min/max.""" +def test_build_lesson_wordlist_uses_dictionary_bounds_not_generated(): + """Imported words use dictionary 3-10 bounds; Markov fill uses generated 2-4.""" url = "https://example.com/words.txt" - cached = ["the", "and", "for", "are", "but", "not", "you", "all"] + cached = ["the", "and", "for", "are", "but", "not", "you", "all", "because"] settings = Settings( generated_word_min_len=2, generated_word_max_len=4, @@ -702,10 +702,11 @@ def test_build_lesson_wordlist_respects_generated_bounds(): clock=FakeClock(), ) words = builder("qwerty").text.split() - assert all(2 <= len(w) <= 4 for w in words) dict_words = [w for w in words if w in cached] assert len(dict_words) >= settings.lesson_word_count // 2 - assert all(len(w) == 3 for w in dict_words) + assert all(3 <= len(w) <= 10 for w in dict_words) + markov_words = [w for w in words if w not in cached] + assert all(2 <= len(w) <= 4 for w in markov_words) def test_lesson_falls_back_to_markov_when_cache_missing(): diff --git a/tests/domain/test_generator.py b/tests/domain/test_generator.py index 898a290..e6e96bb 100644 --- a/tests/domain/test_generator.py +++ b/tests/domain/test_generator.py @@ -17,6 +17,7 @@ ) from keystrike.domain.markov import TransitionTable from keystrike.domain.models import GENERATED_WORD_MAX_LEN, GENERATED_WORD_MIN_LEN, Bigram +from keystrike.domain.word_bounds import MAX_WORD_LEN, MIN_WORD_LEN def _uniform_table(alphabet: str) -> TransitionTable: @@ -104,20 +105,50 @@ def test_generate_word_uses_wordlist_when_provided(): assert word in words -def test_wordlist_respects_generated_bounds(): - """Dictionary sampling honors generated_word_min/max, not dictionary 3-10.""" +def test_wordlist_respects_dictionary_bounds_not_generated(): + """Imported words use dictionary 3-10 bounds, not generated_word_min/max.""" + generator = AdaptiveGenerator(table=_uniform_table("abcdefghijk"), rng=Random(0)) + words = ("abc", "abcde", "abcdef", "abcdefgh", "abcdefghij") + seen: set[str] = set() + for _ in range(50): + word = generator.generate_word( + frozenset("abcdefghijk"), + LessonWeighting(words=words), + generated_min_len=2, + generated_max_len=4, + ) + if word in words: + assert MIN_WORD_LEN <= len(word) <= MAX_WORD_LEN + seen.add(word) + assert seen & {"abcdefgh", "abcdefghij"} # longer than generated max 4 + + +def test_wordlist_allows_longer_words_than_generated_max(): + """Dictionary may sample words longer than generated_word_max_len.""" + generator = AdaptiveGenerator(table=_uniform_table("abcdefgh"), rng=Random(0)) + words = ("abcdefgh",) # len 8 — within dictionary bounds, above generated max 4 + word = generator.generate_word( + frozenset("abcdefgh"), + LessonWeighting(words=words), + generated_min_len=2, + generated_max_len=4, + ) + assert word == "abcdefgh" + + +def test_markov_still_respects_generated_bounds_with_wordlist_present(): + """When wordlist words do not fit alphabet, Markov fallback keeps generated bounds.""" generator = AdaptiveGenerator(table=_uniform_table("abc"), rng=Random(0)) - words = ("abc", "abcd", "abcde", "abcdef", "abcdefg", "abcdefgh", "abcdefghi", "abcdefghij") - for _ in range(20): + words = ("xyz", "qrs") # outside alphabet — forces Markov + for seed in range(20): + generator.rng = Random(seed) word = generator.generate_word( - frozenset("abcdefghij"), + frozenset("abc"), LessonWeighting(words=words), generated_min_len=2, generated_max_len=4, ) assert 2 <= len(word) <= 4 - if word in words: - assert len(word) <= 4 def test_generate_word_falls_back_to_markov_without_wordlist(): diff --git a/tests/domain/test_wordlist.py b/tests/domain/test_wordlist.py index 1bf85dc..98bf10e 100644 --- a/tests/domain/test_wordlist.py +++ b/tests/domain/test_wordlist.py @@ -1,6 +1,6 @@ from pathlib import Path -from keystrike.domain.word_bounds import MAX_WORD_LEN, MIN_WORD_LEN +from keystrike.domain.word_bounds import MAX_WORD_LEN, MIN_WORD_LEN, effective_wordlist_bounds from keystrike.domain.wordlist import parse_wordlist_text, words_for_alphabet @@ -17,6 +17,12 @@ def test_parse_wordlist_text_matches_fixture(): assert "a1b" not in words +def test_effective_wordlist_bounds_clamps_invalid(): + assert effective_wordlist_bounds() == (MIN_WORD_LEN, MAX_WORD_LEN) + assert effective_wordlist_bounds(0, 10) == (1, 10) + assert effective_wordlist_bounds(5, 0) == (1, 1) + + def test_words_for_alphabet_filters_length_and_chars(): words = ["ab", "abc", "abcd", "xyz", "cat", "dog"] alphabet = frozenset("abcd") From d869e393b58b649a5cc3375f40d986eb21597f85 Mon Sep 17 00:00:00 2001 From: Egno Date: Fri, 31 Jul 2026 16:30:38 +0300 Subject: [PATCH 32/47] Fix transition focus when measured keys are ready but bigrams lag. Unpracticed auto-unlocked keys no longer block transition focus; two-key drills get an unmeasured-pair fallback and sparse transition weighting. Co-authored-by: Cursor --- docs/wiki/Focus-States.md | 15 ++-- src/keystrike/application/build_lesson.py | 15 +++- src/keystrike/domain/focus.py | 55 +++++++++++--- tests/application/test_build_lesson.py | 92 ++++++++++++++++++++++- tests/domain/test_confidence.py | 61 ++++++++++++++- 5 files changed, 211 insertions(+), 27 deletions(-) diff --git a/docs/wiki/Focus-States.md b/docs/wiki/Focus-States.md index 57aebdd..5bb4e51 100644 --- a/docs/wiki/Focus-States.md +++ b/docs/wiki/Focus-States.md @@ -42,16 +42,17 @@ Reading left to right: ## Key vs transition focus -**Key focus** applies while any unlocked key is below the performance skill -threshold (see `blocks_transition_focus` in the codebase). The lesson +**Key focus** applies while any unlocked key with measured stats is below the +performance skill threshold (see `blocks_transition_focus` in the codebase). +Never-typed keys in the unlocked set do not block transition focus. The lesson emphasizes the weakest unlocked key by ramped confidence, adjusted for review urgency. -**Transition focus** applies when every unlocked key meets the skill threshold -(performance without attempt ramp). Keys still gathering presses show `cal` but -no longer block bigram focus. The lesson then emphasizes the weakest measured -cross-key bigram among unlocked keys. Same-key repeats (double letters) never -count as transitions. +**Transition focus** applies when every measured unlocked key meets the skill +threshold (performance without attempt ramp). Keys still gathering presses show +`cal` but no longer block bigram focus. The lesson then emphasizes the weakest +measured cross-key bigram among unlocked keys. Same-key repeats (double letters) +never count as transitions. ## Heatmap underline diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 8d11d95..f15b393 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -184,6 +184,10 @@ def _compute_focus_explanation( review_kind=FocusKind.TRANSITION_REVIEW, pair=focus_bigram, ) + attempts = attempts_of(t_stats) if t_stats is not None else 0 + min_attempts = ctx.settings.min_transition_confidence_attempts + if reason is None and attempts < min_attempts: + reason = FocusReason(kind=FocusKind.TRANSITION_CALIBRATING, pair=focus_bigram) if reason is None: return FocusExplanation(reason=None) metrics = _transition_focus_metrics( @@ -192,13 +196,12 @@ def _compute_focus_explanation( ctx.transitions, ctx.target, ) - attempts = attempts_of(t_stats) if t_stats is not None else 0 return FocusExplanation( reason=reason, speed=metrics.speed, accuracy=metrics.accuracy, attempts=attempts, - min_attempts=ctx.settings.min_transition_confidence_attempts, + min_attempts=min_attempts, ) key_stats = ctx.stats.get(focus) @@ -267,12 +270,13 @@ def _lesson_progress( threshold=_CONFIDENCE_GOOD, ) focus_bigram: Bigram | None = None - if not keys_need_focus and ctx.transitions: + if not keys_need_focus: focus_bigram = select_focus_transition( unlocked, ctx.transitions, ctx.target, ctx.now, + key_stats=ctx.stats, min_attempts=ctx.settings.min_transition_confidence_attempts, ) @@ -367,6 +371,11 @@ def _compute_weights( and not is_same_key_transition(key.prev_cp, key.next_cp) } if focus_bigram is not None: + transition_weights.setdefault( + focus_bigram, + transition_practice_weight(focus_confidence, urgency=0.0) + * coverage_deficit_factor(0, min_attempts=min_transition_attempts), + ) transition_weights[focus_bigram] *= ctx.settings.focus_transition_boost if focus_confidence < _CONFIDENCE_GOOD: transition_weights[focus_bigram] *= ctx.settings.focus_weak_extra_boost diff --git a/src/keystrike/domain/focus.py b/src/keystrike/domain/focus.py index debabbf..84635cc 100644 --- a/src/keystrike/domain/focus.py +++ b/src/keystrike/domain/focus.py @@ -29,6 +29,8 @@ # this module rather than `domain.models` directly. __all__ = ["FOCUS_BIGRAM_WORD_BOOST", "FOCUS_WORD_BOOST"] +_UNMEASURED_PAIR_FALLBACK_UNLOCKED_SIZE = 2 + _TRANSITION_KINDS = ( FocusKind.TRANSITION_WEAK, FocusKind.TRANSITION_CALIBRATING, @@ -95,8 +97,11 @@ def blocks_transition_focus( *, threshold: float = 1.0, ) -> bool: - """True when any unlocked key is below performance skill (ignores attempt ramp).""" - return any(skill_of(cp, stats, target) < threshold for cp in unlocked) + """True when any unlocked key with measured stats is below performance skill. + + Never-practiced keys (absent from stats) do not block — e.g. a key just + auto-unlocked before its first press. Keys in stats with skill 0 still block.""" + return any(cp in stats and skill_of(cp, stats, target) < threshold for cp in unlocked) def select_focus( @@ -143,28 +148,54 @@ def _transition_focus_score( ) +def _unlocked_cross_key_pairs(unlocked: Sequence[int]) -> list[Bigram]: + return [ + Bigram(prev, nxt) + for prev in unlocked + for nxt in unlocked + if not is_same_key_transition(prev, nxt) + ] + + def select_focus_transition( unlocked: Sequence[int], transitions: Mapping[Bigram, TransitionStats], target: float, now: float, *, + key_stats: Mapping[int, KeyStats] | None = None, review_penalty: float = 0.5, min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, ) -> Bigram | None: - """Weakest unlocked bigram by transition confidence; None when no transition data.""" - if not transitions: + """Weakest unlocked bigram by transition confidence. + + Measured pairs are preferred. When none exist yet but every unlocked key + has been practiced (typical two-key drills before cross-key stats land), + fall back to the weakest-scoring unmeasured cross-key pair.""" + pairs = [pair for pair in _unlocked_cross_key_pairs(unlocked) if pair in transitions] + if pairs: + return min( + pairs, + key=lambda p: _transition_focus_score( + p.prev_cp, + p.next_cp, + transitions, + target, + now, + review_penalty=review_penalty, + min_attempts=min_attempts, + ), + ) + if key_stats is None or not all(cp in key_stats for cp in unlocked): return None - pairs = [ - Bigram(prev, nxt) - for prev in unlocked - for nxt in unlocked - if not is_same_key_transition(prev, nxt) and Bigram(prev, nxt) in transitions - ] - if not pairs: + # ponytail: two-key drills only; larger alphabets should wait for measured pairs + if len(unlocked) != _UNMEASURED_PAIR_FALLBACK_UNLOCKED_SIZE: + return None + candidates = _unlocked_cross_key_pairs(unlocked) + if not candidates: return None return min( - pairs, + candidates, key=lambda p: _transition_focus_score( p.prev_cp, p.next_cp, diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 6063f21..21f52a8 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -98,7 +98,12 @@ def test_lesson_prefers_weak_key_over_weak_transition(): a, s = order[0], order[1] now = 1_700_000_000.0 fast = 100_000_000.0 + at_target = 200_000_000.0 slow = 400_000_000.0 + keys = { + a: KeyStats(a, 10, slow, 0, now, attempt_count=10), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + } transitions = { Bigram(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), Bigram(a, s): TransitionStats( @@ -114,7 +119,7 @@ def test_lesson_prefers_weak_key_over_weak_transition(): Bigram(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), } cache = FakeAggregatesCache( - by_layout={"qwerty": LayoutAggregates(keys={}, transitions=transitions)}, + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, ) builder = BuildLesson( layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), @@ -227,6 +232,91 @@ def test_lesson_uses_transition_focus_when_transitions_weak(): assert pair in lesson.text.replace(" ", "") +def test_lesson_uses_transition_focus_for_colemak_two_keys_before_third_unlocks(): + """alphabet_size=2 on Colemak-DH: e+t at skill goal but still calibrating.""" + layout = BUNDLED_LAYOUTS["colemak_dh"] + order = keyboard_order(layout) + e, t, a = order[0], order[1], order[2] + assert chr(e) == "e" + assert chr(t) == "t" + now = 1_700_000_000.0 + at_target = 200_000_000.0 + keys = { + e: KeyStats(e, 8, at_target, 0, now, attempt_count=8), + t: KeyStats(t, 8, at_target, 0, now, attempt_count=8), + } + assert a not in compute_unlocked(order, alphabet_size=2, stats=keys, target=200.0) + cache = FakeAggregatesCache( + by_layout={"colemak_dh": LayoutAggregates(keys=keys, transitions={})}, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=2)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + ) + lesson = builder("colemak_dh") + pair = Bigram(e, t) + assert lesson.focus_key == t + assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=pair) + assert pair.chars() in lesson.text.replace(" ", "") + + +def test_lesson_uses_transition_focus_when_newly_unlocked_key_unpracticed(): + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s, h, d = order[0], order[1], order[2], order[3] + now = 1_700_000_000.0 + fast = 100_000_000.0 + at_target = 200_000_000.0 + slow = 400_000_000.0 + keys = { + a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + h: KeyStats(h, 10, at_target, 0, now, attempt_count=10), + } + unlocked = compute_unlocked(order, alphabet_size=3, stats=keys, target=200.0) + assert d in unlocked # auto-unlocked once a,s,h are mastered; d has no stats yet + transitions = { + Bigram(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), + Bigram(a, s): TransitionStats( + a, + s, + 10, + slow, + 0, + now, + attempt_count=10, + ), + Bigram(a, h): TransitionStats(a, h, 10, fast, 0, now, attempt_count=10), + Bigram(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), + Bigram(s, s): TransitionStats(s, s, 10, fast, 0, now, attempt_count=10), + Bigram(s, h): TransitionStats(s, h, 10, fast, 0, now, attempt_count=10), + Bigram(h, a): TransitionStats(h, a, 10, fast, 0, now, attempt_count=10), + Bigram(h, s): TransitionStats(h, s, 10, fast, 0, now, attempt_count=10), + Bigram(h, h): TransitionStats(h, h, 10, fast, 0, now, attempt_count=10), + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=3)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + ) + lesson = builder("qwerty") + pair_bigram = Bigram(a, s) + assert lesson.focus_key == s + assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=pair_bigram) + + def test_transition_focus_metrics_reports_accuracy_when_speed_measured(): target = target_ms_per_char(300) eo_key = Bigram(ord("e"), ord("o")) diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index 77d34b7..c4ebd93 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -353,7 +353,23 @@ def test_blocks_transition_focus_false_when_key_calibrating(): def test_blocks_transition_focus_true_for_never_practiced(): stats = {1: _stats(1, mean_time_ns=200_000_000.0)} - assert blocks_transition_focus((1, 2), stats, target=200.0) is True + assert blocks_transition_focus((1, 2), stats, target=200.0) is False + + +def test_blocks_transition_focus_ignores_never_practiced_key(): + a, s, h = ord("a"), ord("s"), ord("h") + stats = { + a: _stats(a, mean_time_ns=200_000_000.0), + s: _stats(s, mean_time_ns=200_000_000.0), + } + assert blocks_transition_focus((a, s, h), stats, target=200.0) is False + + +def test_blocks_transition_focus_true_for_measured_weak_key(): + h = ord("h") + stats = {h: _stats(h, mean_time_ns=400_000_000.0)} + assert skill_of(h, stats, target=200.0) < 1.0 + assert blocks_transition_focus((h,), stats, target=200.0) is True def test_select_focus_prefers_never_practiced_key(): @@ -468,6 +484,21 @@ def test_select_focus_transition_ignores_same_key_pairs(): assert select_focus_transition(unlocked, transitions, 200.0, now) is None +def test_select_focus_transition_skips_unmeasured_when_unlocked_key_unpracticed(): + now = 1_700_000_000.0 + unlocked = (ord("e"), ord("a")) + transitions = { + Bigram(ord("e"), ord("e")): _transition( + ord("e"), + ord("e"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + } + assert select_focus_transition(unlocked, transitions, 200.0, now) is None + + def test_select_focus_transition_never_picks_same_key_even_when_weakest(): now = 1_700_000_000.0 unlocked = (ord("a"), ord("b")) @@ -526,7 +557,23 @@ def test_select_focus_transition_picks_stale_over_slightly_weaker_recent(): assert select_focus_transition(unlocked, transitions, 200.0, now) == (ord("a"), ord("b")) -def test_select_focus_transition_returns_none_without_data(): +def test_select_focus_transition_returns_none_without_unlocked_pairs(): + assert select_focus_transition((), {}, target=200.0, now=1000.0) is None + assert select_focus_transition((ord("a"),), {}, target=200.0, now=1000.0) is None + + +def test_select_focus_transition_picks_unmeasured_pair_when_all_keys_practiced(): + stats = { + 1: KeyStats(1, 10, 200_000_000.0, 0, 1.0, attempt_count=10), + 2: KeyStats(2, 10, 200_000_000.0, 0, 1.0, attempt_count=10), + } + assert select_focus_transition((1, 2), {}, target=200.0, now=1000.0, key_stats=stats) == ( + 1, + 2, + ) + + +def test_select_focus_transition_skips_unmeasured_pair_on_cold_start(): assert select_focus_transition((1, 2), {}, target=200.0, now=1000.0) is None @@ -553,8 +600,12 @@ def test_select_focus_transition_ignores_unmeasured_pairs(): assert select_focus_transition(unlocked, transitions, 200.0, now) == (ord("a"), ord("b")) -def test_select_focus_transition_returns_none_when_no_measured_unlocked_pairs(): +def test_select_focus_transition_falls_back_to_unmeasured_unlocked_pair(): unlocked = (ord("a"), ord("b")) + stats = { + ord("a"): KeyStats(ord("a"), 10, 200_000_000.0, 0, 1.0, attempt_count=10), + ord("b"): KeyStats(ord("b"), 10, 200_000_000.0, 0, 1.0, attempt_count=10), + } transitions = { Bigram(ord("y"), ord("z")): _transition( ord("y"), @@ -563,7 +614,9 @@ def test_select_focus_transition_returns_none_when_no_measured_unlocked_pairs(): last_seen=1_700_000_000.0, ), } - assert select_focus_transition(unlocked, transitions, 200.0, now=1_700_000_000.0) is None + assert select_focus_transition( + unlocked, transitions, 200.0, now=1_700_000_000.0, key_stats=stats + ) == (ord("a"), ord("b")) def test_focus_key_from_transition_uses_next_endpoint(): From 5b6ef2fb0ef952f8c2dd22e4518fb477a3dc8774 Mon Sep 17 00:00:00 2001 From: Egno Date: Tue, 4 Aug 2026 14:26:20 +0300 Subject: [PATCH 33/47] Prioritize bigram practice for newly-mastered keys, gate progression on it Give a just-mastered key's bigrams sampling weight and focus priority right away instead of waiting for them to appear by chance (newest_key_unmeasured_pairs, shared by focus selection and lesson weighting). Extend compute_unlocked with an optional transition gate so the next letter also waits on the newest key's single weakest measured bigram, bounded to one pair with a stall-attempts safety valve so a stuck pair can't block progression forever. Fix session_use_cases' unlock snapshot to pass transitions/gate settings into compute_unlocked too, so alphabet_size can't ratchet past what the gate actually cleared. Move the gate's shared "newest practiced key" primitives into a neutral domain/newest_key module so focus.py and unlock.py depend on it instead of each other. Co-Authored-By: Claude Sonnet 5 --- docs/wiki/Confidence-Tuning.md | 43 ++- src/keystrike/application/build_lesson.py | 15 +- .../application/session_use_cases.py | 7 +- src/keystrike/domain/focus.py | 78 +++-- src/keystrike/domain/newest_key.py | 59 ++++ src/keystrike/domain/unlock.py | 103 ++++++- tests/application/test_build_lesson.py | 114 +++++++- tests/application/test_session_use_cases.py | 60 ++++ tests/domain/test_confidence.py | 270 +++++++++++++++++- 9 files changed, 691 insertions(+), 58 deletions(-) create mode 100644 src/keystrike/domain/newest_key.py diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index 420e77f..c0610c6 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -85,8 +85,20 @@ The first **N** keys in layout `learn_order` are always unlocked, where **N** is key in `learn_order` unlocks only when **every** currently unlocked key meets the skill threshold (default 1.0: min(speed, accuracy) without attempt ramp) **and** has at least `min_confidence_attempts` presses in the session window. Ramped -confidence still drives HUD labels (`cal` vs `wk`) and focus weighting. Bigrams -affect focus and lesson text only — they do not gate which letter opens next. +confidence still drives HUD labels (`cal` vs `wk`) and focus weighting. + +Beyond solo-key mastery, the most-recently-unlocked key's bigrams with its +peers also gate the next key: it needs at least one measured cross-key pair, +and its single *weakest* measured pair must clear `transition_threshold` +(`domain.unlock.compute_unlocked`'s `transitions` argument, wired from +`build_lesson`/`session_use_cases` via `newest_key_clears_transition_gate`). +This is deliberately bounded to one pair, not every peer combination, so the +bar doesn't grow with alphabet depth — and an untouched peer pair never +counts against it, only pairs that have actually been typed at least once. +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 performance skill (`blocks_transition_focus`), the lesson emphasizes the weakest unlocked @@ -94,15 +106,32 @@ skill (`blocks_transition_focus`), the lesson emphasizes the weakest unlocked activates when every unlocked key meets the skill threshold (speed and accuracy without the attempt ramp), even if some keys are still calibrating on press count; then the weakest measured cross-key bigram among unlocked keys drives -focus. If no transition data exists yet, focus falls back to the weakest key. +focus — unless the most-recently-practiced unlocked key has no measured +transitions of its own yet, in which case its weakest unmeasured pair takes +priority instead (`newest_key_unmeasured_pairs`), so a freshly-mastered key +gets bigram focus before older, merely-weak measured pairs. This unmeasured-pair +fallback is what fires when no transition data exists at all, as long as the +newest practiced key has a practiced peer to pair with — only when it has no +such peer (or nothing's been practiced yet) does focus fall further back to +the weakest key. ### Transition stats Same-key / double-letter bigrams are never aggregated into session stats, stored -in the stats cache, or used in unlock checks. Only cross-key pairs (e.g. `th`, -`he`) participate in transition confidence, focus selection, and bigram-weighted -lesson text. Transition sampling weights apply only to **measured** unlocked -cross-key bigrams; unmeasured pairs keep the generator default (1.0). +in the stats cache, or used in unlock checks — including the transition gate +above. Only cross-key pairs (e.g. `th`, `he`) participate in transition +confidence, focus selection, the unlock gate, and bigram-weighted lesson +text. Transition sampling weights apply to **measured** unlocked +cross-key bigrams; other unmeasured pairs keep the generator default (1.0) — +except pairs between the most-recently-*practiced* unlocked key and other +already-practiced unlocked keys, which get an explicit zero-attempt weight +(`transition_practice_weight(0.0, ...) * coverage_deficit_factor(0, ...)`, +12.0 at default settings) instead of 1.0, so a newly-mastered key's bigrams +show up in generated text right away rather than waiting on chance. This +stops as soon as any of that key's pairs gets measured data — see +`domain.focus.newest_key_unmeasured_pairs`, the single source of truth for +this "newest key" candidate set shared by focus selection and lesson +weighting. ## Tradeoffs diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index f15b393..6482120 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -29,6 +29,7 @@ blocks_transition_focus, coverage_deficit_factor, focus_key_from_transition, + newest_key_unmeasured_pairs, practice_weight, select_focus, select_focus_transition, @@ -59,7 +60,7 @@ SettingsRepository, WordListStore, ) -from keystrike.domain.unlock import compute_unlocked +from keystrike.domain.unlock import compute_unlocked, default_transition_stall_attempts_cap from keystrike.domain.wordlist import words_for_alphabet _CONFIDENCE_GOOD = 1.0 @@ -262,6 +263,11 @@ def _lesson_progress( ctx.stats, ctx.target, min_attempts=ctx.settings.min_confidence_attempts, + transitions=ctx.transitions, + transition_min_attempts=ctx.settings.min_transition_confidence_attempts, + transition_stall_attempts_cap=default_transition_stall_attempts_cap( + ctx.settings.min_transition_confidence_attempts + ), ) keys_need_focus = blocks_transition_focus( unlocked, @@ -370,6 +376,13 @@ def _compute_weights( and key.next_cp in unlocked_set and not is_same_key_transition(key.prev_cp, key.next_cp) } + newest_pairs = newest_key_unmeasured_pairs(unlocked, ctx.transitions, ctx.stats) + if newest_pairs: + default_weight = transition_practice_weight(0.0, urgency=0.0) * coverage_deficit_factor( + 0, min_attempts=min_transition_attempts + ) + for pair in newest_pairs: + transition_weights.setdefault(pair, default_weight) if focus_bigram is not None: transition_weights.setdefault( focus_bigram, diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index f128cf3..a2335ae 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -30,7 +30,7 @@ note_keystroke_for_timer, skip_leading_whitespace, ) -from keystrike.domain.unlock import compute_unlocked +from keystrike.domain.unlock import compute_unlocked, default_transition_stall_attempts_cap @dataclass(slots=True) @@ -150,6 +150,11 @@ def _snapshot_unlock_state( combined.keys, target, min_attempts=settings.min_confidence_attempts, + transitions=combined.transitions, + transition_min_attempts=settings.min_transition_confidence_attempts, + transition_stall_attempts_cap=default_transition_stall_attempts_cap( + settings.min_transition_confidence_attempts + ), ) return unlocked, { cp: confidence_of(cp, combined.keys, target, min_attempts=settings.min_confidence_attempts) diff --git a/src/keystrike/domain/focus.py b/src/keystrike/domain/focus.py index 84635cc..015a01f 100644 --- a/src/keystrike/domain/focus.py +++ b/src/keystrike/domain/focus.py @@ -12,7 +12,6 @@ MIN_TRANSITION_CONFIDENCE_ATTEMPTS, HasConfidenceFields, confidence_from_stats, - is_same_key_transition, review_urgency, skill_of, ) @@ -24,13 +23,12 @@ KeyStats, TransitionStats, ) +from .newest_key import newest_practiced_key_pairs, unlocked_cross_key_pairs # Re-exported for domain.generator, which imports these boost defaults from # this module rather than `domain.models` directly. __all__ = ["FOCUS_BIGRAM_WORD_BOOST", "FOCUS_WORD_BOOST"] -_UNMEASURED_PAIR_FALLBACK_UNLOCKED_SIZE = 2 - _TRANSITION_KINDS = ( FocusKind.TRANSITION_WEAK, FocusKind.TRANSITION_CALIBRATING, @@ -148,13 +146,23 @@ def _transition_focus_score( ) -def _unlocked_cross_key_pairs(unlocked: Sequence[int]) -> list[Bigram]: - return [ - Bigram(prev, nxt) - for prev in unlocked - for nxt in unlocked - if not is_same_key_transition(prev, nxt) - ] +def newest_key_unmeasured_pairs( + unlocked: Sequence[int], + transitions: Mapping[Bigram, TransitionStats], + key_stats: Mapping[int, KeyStats] | None, +) -> list[Bigram]: + """Cross-key pairs between the most-recently-*practiced* unlocked key and + its other already-practiced unlocked peers, when none of those pairs + have measured transition data yet. + + Built on `domain.newest_key.newest_practiced_key_pairs`, the single + source of truth shared with `domain.unlock.newest_key_clears_transition_gate`. + Returns empty once the newest key has *any* measured transition + (ordinary weakest-pair scoring takes over from there for focus + selection), when no unlocked key has been practiced yet, or when it has + no other practiced key to pair with.""" + unmeasured, measured = newest_practiced_key_pairs(unlocked, transitions, key_stats) + return [] if measured else unmeasured def select_focus_transition( @@ -169,43 +177,29 @@ def select_focus_transition( ) -> Bigram | None: """Weakest unlocked bigram by transition confidence. - Measured pairs are preferred. When none exist yet but every unlocked key - has been practiced (typical two-key drills before cross-key stats land), - fall back to the weakest-scoring unmeasured cross-key pair.""" - pairs = [pair for pair in _unlocked_cross_key_pairs(unlocked) if pair in transitions] - if pairs: - return min( - pairs, - key=lambda p: _transition_focus_score( - p.prev_cp, - p.next_cp, - transitions, - target, - now, - review_penalty=review_penalty, - min_attempts=min_attempts, - ), - ) - if key_stats is None or not all(cp in key_stats for cp in unlocked): - return None - # ponytail: two-key drills only; larger alphabets should wait for measured pairs - if len(unlocked) != _UNMEASURED_PAIR_FALLBACK_UNLOCKED_SIZE: - return None - candidates = _unlocked_cross_key_pairs(unlocked) - if not candidates: - return None - return min( - candidates, - key=lambda p: _transition_focus_score( - p.prev_cp, - p.next_cp, + Measured pairs are preferred. But when the newest practiced key has no + measured transitions of its own yet (`newest_key_unmeasured_pairs`), fall + back to its weakest-scoring unmeasured pair — so a freshly-opened letter + gets bigram focus right away instead of waiting for its pairs to appear + by chance.""" + + def _score(pair: Bigram) -> float: + return _transition_focus_score( + pair.prev_cp, + pair.next_cp, transitions, target, now, review_penalty=review_penalty, min_attempts=min_attempts, - ), - ) + ) + + candidates = newest_key_unmeasured_pairs(unlocked, transitions, key_stats) + if candidates: + return min(candidates, key=_score) + + measured = [pair for pair in unlocked_cross_key_pairs(unlocked) if pair in transitions] + return min(measured, key=_score) if measured else None def focus_key_from_transition(_prev_cp: int, next_cp: int) -> int: diff --git a/src/keystrike/domain/newest_key.py b/src/keystrike/domain/newest_key.py new file mode 100644 index 0000000..b2c4593 --- /dev/null +++ b/src/keystrike/domain/newest_key.py @@ -0,0 +1,59 @@ +"""Shared "what does the newest unlocked key still need bigram attention on" +primitives. + +Used by both `domain.focus` (focus selection, lesson weighting) and +`domain.unlock` (the transition gate) — kept in a neutral module so neither +has to import the other's policy: focus selection isn't an unlock concern, +and unlock gating isn't a focus concern, but both need to agree on the same +"newest practiced key" and "its cross-key pairs" definitions.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from .confidence import is_same_key_transition +from .models import Bigram, KeyStats, TransitionStats + + +def unlocked_cross_key_pairs(unlocked: Sequence[int]) -> list[Bigram]: + """All directed cross-key pairs among currently-unlocked keys (same-key + pairs like `aa` excluded — see `is_same_key_transition`).""" + return [ + Bigram(prev, nxt) + for prev in unlocked + for nxt in unlocked + if not is_same_key_transition(prev, nxt) + ] + + +def newest_practiced_key_pairs( + unlocked: Sequence[int], + transitions: Mapping[Bigram, TransitionStats], + key_stats: Mapping[int, KeyStats] | None, +) -> tuple[list[Bigram], list[Bigram]]: + """(unmeasured, measured) cross-key pairs between the most-recently- + *practiced* unlocked key (skipping any further, not-yet-touched key + added by an unlock-threshold cascade) and its other already-practiced + unlocked peers. Both empty when no unlocked key has been practiced yet, + or the newest practiced key has no practiced peer to pair with. + + Single source of truth for "what does the newest key still need bigram + attention on" — shared by `domain.focus.newest_key_unmeasured_pairs` + (focus selection, lesson weighting) and + `domain.unlock.newest_key_clears_transition_gate` (unlock), so the two + can't drift on what counts as newest or which pairs are in play.""" + practiced = [cp for cp in unlocked if key_stats is not None and cp in key_stats] + if not practiced: + return [], [] + newest = practiced[-1] + practiced_set = frozenset(practiced) + pairs = [ + pair + for pair in unlocked_cross_key_pairs(unlocked) + if newest in (pair.prev_cp, pair.next_cp) + and pair.prev_cp in practiced_set + and pair.next_cp in practiced_set + ] + measured = [pair for pair in pairs if pair in transitions] + unmeasured = [pair for pair in pairs if pair not in transitions] + return unmeasured, measured diff --git a/src/keystrike/domain/unlock.py b/src/keystrike/domain/unlock.py index 1421f53..817be53 100644 --- a/src/keystrike/domain/unlock.py +++ b/src/keystrike/domain/unlock.py @@ -1,13 +1,85 @@ """Key-unlock policy: which keys in `learn_order` are currently unlocked, -gated on per-key skill and attempt floor (§6 of PLAN.md). Bigrams affect focus -and lesson text, not which letters open next.""" +gated on per-key skill and attempt floor, plus (optionally) the newest key's +single weakest bigram (§6 of PLAN.md).""" from __future__ import annotations from collections.abc import Mapping, Sequence -from .confidence import MIN_CONFIDENCE_ATTEMPTS, attempts_of, skill_of -from .models import KeyStats +from .confidence import ( + MIN_CONFIDENCE_ATTEMPTS, + MIN_TRANSITION_CONFIDENCE_ATTEMPTS, + attempts_of, + skill_of, + transition_confidence_of, +) +from .models import Bigram, KeyStats, TransitionStats +from .newest_key import newest_practiced_key_pairs + +# ponytail: fixed multiplier; upgrade to a Settings field if a stuck gating +# bigram turns out to need per-user tuning. +TRANSITION_STALL_ATTEMPTS_MULTIPLIER = 3 + + +def default_transition_stall_attempts_cap(min_attempts: int) -> int: + """Default `transition_stall_attempts_cap` for `compute_unlocked`: give a + stuck gating bigram this many times the normal calibration floor before + releasing it anyway. Single source of truth so callers that need the + gate (`build_lesson`, `session_use_cases`) can't compute this + differently and drift apart.""" + return min_attempts * TRANSITION_STALL_ATTEMPTS_MULTIPLIER + + +def newest_key_clears_transition_gate( + unlocked: Sequence[int], + transitions: Mapping[Bigram, TransitionStats], + target: float, + key_stats: Mapping[int, KeyStats] | None = None, + *, + threshold: float = 1.0, + min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, + stall_attempts_cap: int | None = None, +) -> bool: + """Whether the most-recently-*practiced* unlocked key's bigrams with its + peers are solid enough to let the next key open — the unlock half of + `compute_unlocked`'s transition gate. + + Built on `domain.newest_key.newest_practiced_key_pairs`, the same split + `domain.focus.newest_key_unmeasured_pairs` uses for focus selection, so + gating and focus never disagree on what "newest" means. While that key + has *no* measured pair with any peer yet, the gate simply isn't clear + (nothing to release on — lesson weighting is already pushing the user to + type one). Once it has *any* measured pair, only measured pairs are + weighed against each other — an untouched peer pair no longer counts + against it, so this can never regress into requiring every combinatorial + pair, only whichever ones have actually been engaged with (bounded, + "weakest of what's been tried so far", not "weakest of everything + possible"). + + `stall_attempts_cap` is a safety valve: a measured pair stuck below + threshold no matter how much it's drilled would otherwise block + progression forever. Once its attempts pass the cap, the gate clears + anyway. Because the weakest candidate is always drawn from *measured* + pairs, it always has real attempt data to check the cap against.""" + unmeasured, measured = newest_practiced_key_pairs(unlocked, transitions, key_stats) + if not unmeasured and not measured: + return True + if not measured: + return False + weakest = min( + measured, + key=lambda p: transition_confidence_of( + p.prev_cp, p.next_cp, transitions, target, min_attempts=min_attempts + ), + ) + weakest_confidence = transition_confidence_of( + weakest.prev_cp, weakest.next_cp, transitions, target, min_attempts=min_attempts + ) + if weakest_confidence >= threshold: + return True + if stall_attempts_cap is None: + return False + return attempts_of(transitions[weakest]) >= stall_attempts_cap def _key_meets_unlock_threshold( @@ -35,10 +107,21 @@ def compute_unlocked( *, threshold: float = 1.0, min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, + transitions: Mapping[Bigram, TransitionStats] | None = None, + transition_threshold: float = 1.0, + transition_min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, + transition_stall_attempts_cap: int | None = None, ) -> tuple[int, ...]: """The first `alphabet_size` keys are always unlocked; each further key in `learn_order` unlocks only once every currently-unlocked key meets skill - ``threshold`` with at least ``min_attempts`` presses in window.""" + ``threshold`` with at least ``min_attempts`` presses in window. + + When `transitions` is given, a further key also waits on the current + last-unlocked key's single weakest cross-key bigram + (`newest_key_clears_transition_gate`) — a little bigram practice before + the next letter opens, bounded to one pair so the bar doesn't grow with + alphabet depth. Pass `None` (the default) to skip this and unlock purely + on solo-key mastery, as before.""" forced_count = min(alphabet_size, len(learn_order)) unlocked = list(learn_order[:forced_count]) for codepoint in learn_order[forced_count:]: @@ -53,5 +136,15 @@ def compute_unlocked( for k in unlocked ): break + if transitions is not None and not newest_key_clears_transition_gate( + unlocked, + transitions, + target, + stats, + threshold=transition_threshold, + min_attempts=transition_min_attempts, + stall_attempts_cap=transition_stall_attempts_cap, + ): + break unlocked.append(codepoint) return tuple(unlocked) diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 21f52a8..850d1af 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -1,6 +1,12 @@ from random import Random -from keystrike.application.build_lesson import BuildLesson, _transition_focus_metrics +from keystrike.application.build_lesson import ( + BuildLesson, + _compute_weights, + _lesson_progress, + _resolve_focus_confidence, + _transition_focus_metrics, +) from keystrike.domain.aggregate import _combine_transition_maps_weighted, session_recency_weights from keystrike.domain.confidence import target_ms_per_char, transition_confidence_of from keystrike.domain.enums import FocusKind @@ -317,6 +323,112 @@ def test_lesson_uses_transition_focus_when_newly_unlocked_key_unpracticed(): assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=pair_bigram) +def test_lesson_focuses_newest_key_bigram_over_older_weak_measured_pair(): + """Once the newly-unlocked key has been practiced solo but has no + measured transitions of its own, its bigrams should win lesson focus + over an older, merely-weak (already-measured) pair -- not wait for one + of its pairs to show up in generated text by chance.""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s, h, d = order[0], order[1], order[2], order[3] + now = 1_700_000_000.0 + fast = 100_000_000.0 + at_target = 200_000_000.0 + slow = 400_000_000.0 + keys = { + a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + h: KeyStats(h, 10, at_target, 0, now, attempt_count=10), + d: KeyStats(d, 10, at_target, 0, now, attempt_count=10), + } + # Without a transitions gate, a, s, h, d being all mastered would cascade + # the next key open too (sanity check on the key-only unlock policy). + unlocked = compute_unlocked(order, alphabet_size=4, stats=keys, target=200.0) + assert d in unlocked + assert len(unlocked) == 5 + transitions = { + Bigram(a, s): TransitionStats(a, s, 10, slow, 0, now, attempt_count=10), + Bigram(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), + Bigram(a, h): TransitionStats(a, h, 10, fast, 0, now, attempt_count=10), + Bigram(h, a): TransitionStats(h, a, 10, fast, 0, now, attempt_count=10), + Bigram(s, h): TransitionStats(s, h, 10, fast, 0, now, attempt_count=10), + Bigram(h, s): TransitionStats(h, s, 10, fast, 0, now, attempt_count=10), + # d is unlocked and practiced solo, but none of its transitions are + # measured yet. + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=4)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + ) + lesson = builder("qwerty") + assert lesson.focus_reason is not None + assert lesson.focus_reason.kind == FocusKind.TRANSITION_WEAK + focus_pair = lesson.focus_reason.pair + assert focus_pair is not None + assert d in focus_pair + # With the transitions gate wired in, the next key does NOT cascade open + # until d's weakest bigram clears -- unlike the bare compute_unlocked + # call above. + assert {k.codepoint for k in lesson.state.keys} == {a, s, h, d} + + +def test_compute_weights_gives_newest_key_pairs_a_default_weight(): + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s, h, d = order[0], order[1], order[2], order[3] + now = 1_700_000_000.0 + fast = 100_000_000.0 + at_target = 200_000_000.0 + keys = { + a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + h: KeyStats(h, 10, at_target, 0, now, attempt_count=10), + d: KeyStats(d, 10, at_target, 0, now, attempt_count=10), + } + transitions = { + Bigram(a, s): TransitionStats(a, s, 10, fast, 0, now, attempt_count=10), + Bigram(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=4)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + ) + ctx = builder._load_context("qwerty") + progress = _lesson_progress("qwerty", ctx) + focus_confidence = _resolve_focus_confidence(progress.focus, progress.focus_bigram, ctx) + _, transition_weights = _compute_weights( + progress.state, + progress.focus, + progress.focus_bigram, + focus_confidence, + unlocked=progress.unlocked, + ctx=ctx, + ) + # d is unlocked but has no measured transitions -- it should still get a + # default (non-boosted) weight for each pair with an older unlocked key, + # not be entirely absent from the weighting map. + assert Bigram(d, a) in transition_weights + assert Bigram(a, d) in transition_weights + assert Bigram(d, s) in transition_weights + assert Bigram(d, h) in transition_weights + + def test_transition_focus_metrics_reports_accuracy_when_speed_measured(): target = target_ms_per_char(300) eo_key = Bigram(ord("e"), ord("o")) diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index 3958ed3..cacb555 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -1,8 +1,10 @@ from __future__ import annotations from dataclasses import replace +from random import Random from typing import TypedDict +from keystrike.application.build_lesson import BuildLesson from keystrike.application.session_use_cases import ( AbortSession, FinishSession, @@ -15,6 +17,7 @@ count_words_completed, previous_session_header, ) +from keystrike.application.stats_use_cases import RebuildAggregates from keystrike.domain.aggregate import combine_sessions from keystrike.domain.confidence import ( CONFIDENCE_SESSION_WINDOW, @@ -28,11 +31,14 @@ from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from keystrike.presentation.formatting.trends import format_session_stats_line from tests.fakes import ( + FakeAggregatesCache, FakeClock, FakeIdGenerator, + FakeLanguageProvider, FakeLayoutRepository, FakeSessionRepository, FakeSettingsRepository, + FakeWordListStore, ) @@ -570,3 +576,57 @@ def test_finish_session_persists_target_speed_cpm(clock, id_gen): assert result.target_speed_cpm == 400 assert repo.headers[0].target_speed_cpm == 400 + + +def test_finish_session_alphabet_bump_respects_transition_gate(clock, id_gen): + """Regression: solo-mastering four keys without ever typing a bigram + between them must not bump alphabet_size -- the old `_snapshot_unlock_state` + computed `unlocked_keys` without passing `transitions` at all, so it + silently persisted an ungated count. The next `BuildLesson` call then + force-unlocked that many keys via `forced_count`, bypassing the + transition gate entirely regardless of what `compute_unlocked` would + otherwise require.""" + layout_name = "qwerty" + layout = BUNDLED_LAYOUTS[layout_name] + order = keyboard_order(layout) + settings_repo = FakeSettingsRepository(Settings(alphabet_size=4)) + layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) + repo = FakeSessionRepository() + finish = FinishSession( + clock=clock, + repo=repo, + settings_repo=settings_repo, + layout_repo=layout_repo, + ) + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + + # Master a, s, h, d solo -- each in its OWN session, repeating a single + # character, so no cross-key bigram is ever produced (same-key deltas + # are dropped, see `without_same_key_transitions`). + for cp in order[:4]: + text = chr(cp) * 10 + session = start(text, layout=layout_name, mode=Mode.ADAPTIVE, focus_key=cp) + for c in text: + clock.advance(50_000_000) + record(session, c) + finish(session) + + assert settings_repo.settings.alphabet_size == 4 + + aggregates_cache = FakeAggregatesCache() + RebuildAggregates(repo=repo, cache=aggregates_cache, settings_repo=settings_repo)(layout_name) + + builder = BuildLesson( + layout_repo=layout_repo, + aggregates_cache=aggregates_cache, + settings_repo=settings_repo, + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=clock, + ) + lesson = builder(layout_name) + unlocked_cps = {k.codepoint for k in lesson.state.keys} + assert unlocked_cps == set(order[:4]) + assert order[4] not in unlocked_cps diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index c4ebd93..17fb20b 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -14,12 +14,13 @@ blocks_transition_focus, coverage_deficit_factor, focus_key_from_transition, + newest_key_unmeasured_pairs, practice_weight, select_focus, select_focus_transition, ) from keystrike.domain.models import Bigram, KeyStats, TransitionStats -from keystrike.domain.unlock import compute_unlocked +from keystrike.domain.unlock import compute_unlocked, newest_key_clears_transition_gate def test_target_ms_per_char(): @@ -326,6 +327,77 @@ def test_compute_unlocked_ignores_weak_transitions(): assert unlocked[-1] == ord("m") +def test_compute_unlocked_gate_blocks_next_key_on_weak_newest_bigram(): + """With a transitions gate, the newest key's weakest bigram must clear + before the next key opens -- unlike the transitions-agnostic default.""" + e, a, b = ord("e"), ord("a"), ord("b") + learn_order = (e, a, b) + now = 1_700_000_000.0 + stats = { + e: _stats(e, mean_time_ns=100_000_000.0, last_seen=now), + a: _stats(a, mean_time_ns=100_000_000.0, last_seen=now), + } + transitions = { + Bigram(e, a): _transition(e, a, 400_000_000.0, last_seen=now, attempt_count=4), + Bigram(a, e): _transition(a, e, 100_000_000.0, last_seen=now, attempt_count=4), + } + unlocked = compute_unlocked( + learn_order, + alphabet_size=2, + stats=stats, + target=200.0, + transitions=transitions, + ) + assert unlocked == (e, a) + + +def test_compute_unlocked_gate_allows_next_key_once_newest_bigram_clears(): + e, a, b = ord("e"), ord("a"), ord("b") + learn_order = (e, a, b) + now = 1_700_000_000.0 + stats = { + e: _stats(e, mean_time_ns=100_000_000.0, last_seen=now), + a: _stats(a, mean_time_ns=100_000_000.0, last_seen=now), + } + transitions = { + Bigram(e, a): _transition(e, a, 100_000_000.0, last_seen=now, attempt_count=4), + Bigram(a, e): _transition(a, e, 100_000_000.0, last_seen=now, attempt_count=4), + } + unlocked = compute_unlocked( + learn_order, + alphabet_size=2, + stats=stats, + target=200.0, + transitions=transitions, + ) + assert unlocked == (e, a, b) + + +def test_compute_unlocked_gate_stall_cap_unblocks_stuck_bigram(): + """A bigram that never clears threshold shouldn't block progression + forever once it's been drilled past the stall cap.""" + e, a, b = ord("e"), ord("a"), ord("b") + learn_order = (e, a, b) + now = 1_700_000_000.0 + stats = { + e: _stats(e, mean_time_ns=100_000_000.0, last_seen=now), + a: _stats(a, mean_time_ns=100_000_000.0, last_seen=now), + } + transitions = { + Bigram(e, a): _transition(e, a, 400_000_000.0, last_seen=now, attempt_count=12), + Bigram(a, e): _transition(a, e, 100_000_000.0, last_seen=now, attempt_count=12), + } + unlocked = compute_unlocked( + learn_order, + alphabet_size=2, + stats=stats, + target=200.0, + transitions=transitions, + transition_stall_attempts_cap=12, + ) + assert unlocked == (e, a, b) + + def test_select_focus_picks_weakest_unlocked_key(): stats = { 1: _stats(1, mean_time_ns=100_000_000.0), # confidence 2.0 @@ -600,6 +672,202 @@ def test_select_focus_transition_ignores_unmeasured_pairs(): assert select_focus_transition(unlocked, transitions, 200.0, now) == (ord("a"), ord("b")) +def test_select_focus_transition_prefers_newest_key_over_older_measured_pair(): + """Once a third key is unlocked, its bigrams should win focus over an + already-measured (and merely weak, not unmeasured) older pair, so a + freshly-opened letter gets bigram practice right away.""" + unlocked = (ord("a"), ord("b"), ord("c")) + now = 1_700_000_000.0 + stats = { + ord("a"): KeyStats(ord("a"), 10, 200_000_000.0, 0, now, attempt_count=10), + ord("b"): KeyStats(ord("b"), 10, 200_000_000.0, 0, now, attempt_count=10), + ord("c"): KeyStats(ord("c"), 10, 200_000_000.0, 0, now, attempt_count=10), + } + transitions = { + Bigram(ord("a"), ord("b")): _transition( + ord("a"), + ord("b"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + } + # c is unlocked and practiced solo, but none of its transitions are + # measured yet -- it should still win over the already-measured (a, b). + result = select_focus_transition(unlocked, transitions, 200.0, now, key_stats=stats) + assert result is not None + assert ord("c") in result + + +def test_select_focus_transition_uses_measured_pairs_once_newest_key_has_data(): + """Once the newest key has its own measured transition, ordinary + weakest-pair scoring resumes -- no permanent bias toward the newest key.""" + unlocked = (ord("a"), ord("b"), ord("c")) + now = 1_700_000_000.0 + fast = 100_000_000.0 + stats = { + ord("a"): KeyStats(ord("a"), 10, 200_000_000.0, 0, now, attempt_count=10), + ord("b"): KeyStats(ord("b"), 10, 200_000_000.0, 0, now, attempt_count=10), + ord("c"): KeyStats(ord("c"), 10, 200_000_000.0, 0, now, attempt_count=10), + } + transitions = { + Bigram(ord("a"), ord("b")): _transition( + ord("a"), + ord("b"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + Bigram(ord("b"), ord("c")): _transition( + ord("b"), + ord("c"), + fast, + last_seen=now, + attempt_count=10, + ), + } + assert select_focus_transition(unlocked, transitions, 200.0, now, key_stats=stats) == ( + ord("a"), + ord("b"), + ) + + +def test_newest_key_unmeasured_pairs_excludes_unpracticed_cascade_key(): + """A key unlocked by cascade (no KeyStats yet) shouldn't get bigram + candidacy just for sitting next to the newest practiced key.""" + unlocked = (ord("a"), ord("s"), ord("h"), ord("d"), ord("l")) + now = 1_700_000_000.0 + stats = { + cp: KeyStats(cp, 10, 200_000_000.0, 0, now, attempt_count=10) + for cp in (ord("a"), ord("s"), ord("h"), ord("d")) + } + pairs = newest_key_unmeasured_pairs(unlocked, {}, stats) + assert pairs + assert all(ord("l") not in (p.prev_cp, p.next_cp) for p in pairs) + assert all(ord("d") in (p.prev_cp, p.next_cp) for p in pairs) + + +def test_newest_key_unmeasured_pairs_empty_once_newest_has_any_measured_pair(): + unlocked = (ord("a"), ord("s"), ord("d")) + now = 1_700_000_000.0 + stats = { + cp: KeyStats(cp, 10, 200_000_000.0, 0, now, attempt_count=10) + for cp in (ord("a"), ord("s"), ord("d")) + } + transitions = { + Bigram(ord("s"), ord("d")): _transition( + ord("s"), ord("d"), 200_000_000.0, last_seen=now, attempt_count=10 + ), + } + assert newest_key_unmeasured_pairs(unlocked, transitions, stats) == [] + + +def test_newest_key_clears_transition_gate_vacuous_with_one_key(): + now = 1_700_000_000.0 + stats = {ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now)} + assert newest_key_clears_transition_gate((ord("a"),), {}, 200.0, stats) is True + + +def test_newest_key_clears_transition_gate_false_when_no_pair_measured_yet(): + """Newest key practiced solo but hasn't typed any bigram with a peer + yet -- gate stays closed; lesson weighting is what pushes exposure.""" + unlocked = (ord("a"), ord("b")) + now = 1_700_000_000.0 + stats = { + ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), + ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), + } + assert newest_key_clears_transition_gate(unlocked, {}, 200.0, stats) is False + + +def test_newest_key_clears_transition_gate_false_when_weakest_pair_weak(): + unlocked = (ord("a"), ord("b")) + now = 1_700_000_000.0 + stats = { + ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), + ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), + } + transitions = { + Bigram(ord("a"), ord("b")): _transition( + ord("a"), ord("b"), 400_000_000.0, last_seen=now, attempt_count=4 + ), + Bigram(ord("b"), ord("a")): _transition( + ord("b"), ord("a"), 100_000_000.0, last_seen=now, attempt_count=4 + ), + } + assert newest_key_clears_transition_gate(unlocked, transitions, 200.0, stats) is False + + +def test_newest_key_clears_transition_gate_true_when_weakest_pair_solid(): + unlocked = (ord("a"), ord("b")) + now = 1_700_000_000.0 + stats = { + ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), + ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), + } + transitions = { + Bigram(ord("a"), ord("b")): _transition( + ord("a"), ord("b"), 100_000_000.0, last_seen=now, attempt_count=4 + ), + Bigram(ord("b"), ord("a")): _transition( + ord("b"), ord("a"), 100_000_000.0, last_seen=now, attempt_count=4 + ), + } + assert newest_key_clears_transition_gate(unlocked, transitions, 200.0, stats) is True + + +def test_newest_key_clears_transition_gate_ignores_untouched_peer_pair(): + """Regression: one measured (weak-but-clearable) pair and one entirely + untouched peer pair -- the gate must judge only the measured pair, not + treat the untouched one as an automatic (and un-releasable) failure.""" + unlocked = (ord("a"), ord("b"), ord("c")) + now = 1_700_000_000.0 + stats = { + ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), + ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), + ord("c"): _stats(ord("c"), mean_time_ns=100_000_000.0, last_seen=now), + } + # Only a<->c is measured (and solid); b<->c is never touched at all. + transitions = { + Bigram(ord("a"), ord("c")): _transition( + ord("a"), ord("c"), 100_000_000.0, last_seen=now, attempt_count=4 + ), + Bigram(ord("c"), ord("a")): _transition( + ord("c"), ord("a"), 100_000_000.0, last_seen=now, attempt_count=4 + ), + } + assert newest_key_clears_transition_gate(unlocked, transitions, 200.0, stats) is True + + +def test_newest_key_clears_transition_gate_stall_cap_overrides_weak_pair(): + unlocked = (ord("a"), ord("b")) + now = 1_700_000_000.0 + stats = { + ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), + ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), + } + transitions = { + Bigram(ord("a"), ord("b")): _transition( + ord("a"), ord("b"), 400_000_000.0, last_seen=now, attempt_count=12 + ), + Bigram(ord("b"), ord("a")): _transition( + ord("b"), ord("a"), 100_000_000.0, last_seen=now, attempt_count=12 + ), + } + assert ( + newest_key_clears_transition_gate( + unlocked, transitions, 200.0, stats, stall_attempts_cap=12 + ) + is True + ) + assert ( + newest_key_clears_transition_gate( + unlocked, transitions, 200.0, stats, stall_attempts_cap=13 + ) + is False + ) + + def test_select_focus_transition_falls_back_to_unmeasured_unlocked_pair(): unlocked = (ord("a"), ord("b")) stats = { From 6cba18948739755dc7a874d71e95c14905e5253c Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 5 Aug 2026 11:54:24 +0300 Subject: [PATCH 34/47] refactor: tighten skill/confidence split and lesson gating Keep unlock and focus on measured skill while confidence drives remedial pacing; extract session_queries and refactor BuildLesson gating/focus resolution. Persist word-length bounds on SessionResult (schema v4) for remedial WPM gates and split domain tests with doc updates. Co-authored-by: Cursor --- docs/wiki/Confidence-Tuning.md | 82 ++- docs/wiki/Focus-States.md | 41 +- src/keystrike/app.py | 4 + src/keystrike/application/build_lesson.py | 201 ++++-- src/keystrike/application/prepare_practice.py | 4 +- src/keystrike/application/session_queries.py | 73 +++ .../application/session_use_cases.py | 59 +- src/keystrike/domain/confidence.py | 18 + src/keystrike/domain/focus.py | 151 +++-- src/keystrike/domain/generator.py | 114 +++- src/keystrike/domain/models.py | 4 + src/keystrike/domain/newest_key.py | 32 +- src/keystrike/domain/unlock.py | 121 ++-- .../infrastructure/session_repo_jsonl.py | 11 +- .../presentation/formatting/trends.py | 7 +- .../presentation/screens/practice.py | 43 +- src/keystrike/presentation/services.py | 2 + .../presentation/widgets/kb_heatmap.py | 16 +- tests/application/test_build_lesson.py | 294 +++++++-- tests/application/test_session_use_cases.py | 96 ++- tests/domain/test_confidence.py | 596 +----------------- tests/domain/test_focus.py | 499 +++++++++++++++ tests/domain/test_generator.py | 21 + tests/domain/test_newest_key.py | 94 +++ tests/domain/test_unlock.py | 364 +++++++++++ .../infrastructure/test_session_repo_jsonl.py | 32 + .../infrastructure/test_settings_repo_toml.py | 15 + .../test_practice_screen_snapshot.svg | 4 +- tests/presentation/test_practice_screen.py | 39 +- 29 files changed, 2126 insertions(+), 911 deletions(-) create mode 100644 src/keystrike/application/session_queries.py create mode 100644 tests/domain/test_focus.py create mode 100644 tests/domain/test_newest_key.py create mode 100644 tests/domain/test_unlock.py diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index c0610c6..b72096d 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -18,6 +18,7 @@ layout, speed, or other UI settings will not change them. | 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 | `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 bigram attempts | `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 | `gating_bigram_limit` | `4` | Directed newest-letter bigrams that must calibrate before the next letter opens. Values are clamped to `2`–`4`. | | Focus char boost | `focus_char_boost` | `3.0` | Multiplier on the focus key's char weight when building lesson sampling weights. | | Focus word boost | `focus_word_boost` | `3.0` | Extra multiplier on dictionary/Markov words that contain the focus character. | | Focus bigram word boost | `focus_bigram_word_boost` | `4.0` | Extra multiplier on words containing the focus letter pair (when transition focus is active). | @@ -29,7 +30,11 @@ layout, speed, or other UI settings will not change them. | Generated word min length | `generated_word_min_len` | `2` | Minimum length for Markov-generated words (dictionary import still filters 3–10). | | Generated word max length | `generated_word_max_len` | `4` | Maximum length for Markov-generated words. | -Valid ranges: window and both attempt floors are **1–100**. Boost multipliers should be **≥ 1.0**. `lesson_word_count` should be **≥ 1**. `focus_word_min_fraction` should be in **(0.0, 1.0]**. `max_word_repeats` should be **≥ 1**. `generated_word_min_len` and `generated_word_max_len` should be **≥ 1** with min ≤ max (invalid pairs are clamped at lesson build time). +Valid ranges: window and both attempt floors are **1–100**. +`gating_bigram_limit` is **2–4** (hand-edited values are clamped). Boost +multipliers should be **≥ 1.0**. `lesson_word_count` should be **≥ 1**. +`focus_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. Confidence uses **min(speed, accuracy)**, not their product: a key must be both fast enough and accurate enough to read as mastered. Speed is `target_ms / @@ -45,6 +50,7 @@ Example (defaults shown): confidence_session_window = 10 min_confidence_attempts = 10 min_transition_confidence_attempts = 4 +gating_bigram_limit = 4 focus_char_boost = 3.0 focus_word_boost = 3.0 focus_bigram_word_boost = 4.0 @@ -87,33 +93,48 @@ skill threshold (default 1.0: min(speed, accuracy) without attempt ramp) **and** has at least `min_confidence_attempts` presses in the session window. Ramped confidence still drives HUD labels (`cal` vs `wk`) and focus weighting. -Beyond solo-key mastery, the most-recently-unlocked key's bigrams with its -peers also gate the next key: it needs at least one measured cross-key pair, -and its single *weakest* measured pair must clear `transition_threshold` -(`domain.unlock.compute_unlocked`'s `transitions` argument, wired from -`build_lesson`/`session_use_cases` via `newest_key_clears_transition_gate`). -This is deliberately bounded to one pair, not every peer combination, so the -bar doesn't grow with alphabet depth — and an untouched peer pair never -counts against it, only pairs that have actually been typed at least once. +Beyond solo-key mastery, a deterministic cohort of directed bigrams involving +the most-recently-practiced key gates the next key. It pairs that key in both +directions with up to its two most-recent practiced peers, bounded by +`gating_bigram_limit`. The cohort is derived from key order, not observed +transition data, so incidental measurements cannot expand or replace it. +Every member must reach confidence 1.0 with +`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 performance -skill (`blocks_transition_focus`), the lesson emphasizes the weakest unlocked -**key** (by ramped confidence, with review urgency). Transition (bigram) focus -activates when every unlocked key meets the skill threshold (speed and accuracy -without the attempt ramp), even if some keys are still calibrating on press -count; then the weakest measured cross-key bigram among unlocked keys drives -focus — unless the most-recently-practiced unlocked key has no measured -transitions of its own yet, in which case its weakest unmeasured pair takes -priority instead (`newest_key_unmeasured_pairs`), so a freshly-mastered key -gets bigram focus before older, merely-weak measured pairs. This unmeasured-pair -fallback is what fires when no transition data exists at all, as long as the -newest practiced key has a practiced peer to pair with — only when it has no -such peer (or nothing's been practiced yet) does focus fall further back to -the weakest key. +**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. ### Transition stats @@ -121,17 +142,10 @@ Same-key / double-letter bigrams are never aggregated into session stats, stored in the stats cache, or used in unlock checks — including the transition gate above. Only cross-key pairs (e.g. `th`, `he`) participate in transition confidence, focus selection, the unlock gate, and bigram-weighted lesson -text. Transition sampling weights apply to **measured** unlocked -cross-key bigrams; other unmeasured pairs keep the generator default (1.0) — -except pairs between the most-recently-*practiced* unlocked key and other -already-practiced unlocked keys, which get an explicit zero-attempt weight -(`transition_practice_weight(0.0, ...) * coverage_deficit_factor(0, ...)`, -12.0 at default settings) instead of 1.0, so a newly-mastered key's bigrams -show up in generated text right away rather than waiting on chance. This -stops as soon as any of that key's pairs gets measured data — see -`domain.focus.newest_key_unmeasured_pairs`, the single source of truth for -this "newest key" candidate set shared by focus selection and lesson -weighting. +text. Transition sampling weights apply to measured unlocked cross-key bigrams. +Unmeasured members of the stable newest-key cohort receive explicit +zero-attempt weighting, and deficient cohort members are distributed across +guaranteed lesson slots instead of waiting to appear by chance. ## Tradeoffs diff --git a/docs/wiki/Focus-States.md b/docs/wiki/Focus-States.md index 5bb4e51..478f3c4 100644 --- a/docs/wiki/Focus-States.md +++ b/docs/wiki/Focus-States.md @@ -42,17 +42,36 @@ Reading left to right: ## Key vs transition focus -**Key focus** applies while any unlocked key with measured stats is below the -performance skill threshold (see `blocks_transition_focus` in the codebase). -Never-typed keys in the unlocked set do not block transition focus. The lesson -emphasizes the weakest unlocked key by ramped confidence, adjusted for review -urgency. - -**Transition focus** applies when every measured unlocked key meets the skill -threshold (performance without attempt ramp). Keys still gathering presses show -`cal` but no longer block bigram focus. The lesson then emphasizes the weakest -measured cross-key bigram among unlocked keys. Same-key repeats (double letters) -never count as transitions. +**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. + +**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. + +## 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. ## Heatmap underline diff --git a/src/keystrike/app.py b/src/keystrike/app.py index 4559ae2..a7c38b8 100644 --- a/src/keystrike/app.py +++ b/src/keystrike/app.py @@ -9,6 +9,7 @@ from keystrike.application.session_use_cases import ( AbortSession, FinishSession, + GetLatestSessionHeader, GetSessionBaseline, RecordKeystroke, StartSession, @@ -97,6 +98,7 @@ def build() -> KeystrikeApp: ) abort = AbortSession() get_session_baseline = GetSessionBaseline(repo=session_repo, settings_repo=settings_repo) + get_latest_session_header = GetLatestSessionHeader(repo=session_repo) rebuild_aggregates = RebuildAggregates( repo=session_repo, cache=aggregates_cache, @@ -137,6 +139,7 @@ def build() -> KeystrikeApp: wordlist_store=wordlist_store, rng=Random(), clock=clock, + session_repo=session_repo, ) prepare_practice = PreparePracticeSession( settings_repo=settings_repo, @@ -159,6 +162,7 @@ def build() -> KeystrikeApp: finish=finish, abort=abort, prepare_practice=prepare_practice, + get_latest_session_header=get_latest_session_header, get_session_baseline=get_session_baseline, rebuild_aggregates=rebuild_aggregates, get_daily_learn_budget=get_daily_learn_budget, diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 6482120..024ab46 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -5,10 +5,15 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from random import Random +from keystrike.application.session_queries import ( + latest_session_header, + session_wpm_below_target, +) from keystrike.domain.confidence import ( + CONFIDENCE_GOOD, accuracy_of, attempts_of, confidence_of, @@ -29,11 +34,10 @@ blocks_transition_focus, coverage_deficit_factor, focus_key_from_transition, - newest_key_unmeasured_pairs, practice_weight, + remedial_focus, select_focus, select_focus_transition, - transition_practice_weight, ) from keystrike.domain.generator import ( AdaptiveGenerator, @@ -52,19 +56,25 @@ Settings, TransitionStats, ) +from keystrike.domain.newest_key import newest_key_gating_cohort +from keystrike.domain.null_adapters import NullSessionRepository from keystrike.domain.protocols import ( AggregatesCache, Clock, LanguageProvider, LayoutRepository, + SessionRepository, SettingsRepository, WordListStore, ) -from keystrike.domain.unlock import compute_unlocked, default_transition_stall_attempts_cap +from keystrike.domain.unlock import ( + compute_unlocked, + default_transition_stall_attempts_cap, + gating_bigram_is_ready, + newest_key_transition_gate_progress, +) from keystrike.domain.wordlist import words_for_alphabet -_CONFIDENCE_GOOD = 1.0 - @dataclass(slots=True, frozen=True) class FocusMetrics: @@ -87,6 +97,8 @@ class LessonProgress: focus: int state: LessonState focus_bigram: Bigram | None + gating_bigrams: tuple[Bigram, ...] + bigram_calibration: tuple[int, int] | None skill_heatmap: dict[int, float] @@ -131,10 +143,10 @@ def _focus_reason_for_confidence( ) -> FocusReason | None: """Shared body for key- and transition-focus reasons — the two only differ in which FocusKind pair applies and whether a Bigram is attached.""" - if urgency > 0 and confidence >= _CONFIDENCE_GOOD: + if urgency > 0 and confidence >= CONFIDENCE_GOOD: return FocusReason(kind=review_kind, pair=pair) - if confidence < _CONFIDENCE_GOOD: - if skill >= _CONFIDENCE_GOOD: + if confidence < CONFIDENCE_GOOD: + if skill >= CONFIDENCE_GOOD: return FocusReason(kind=calibrating_kind, pair=pair) return FocusReason(kind=weak_kind, pair=pair) return None @@ -242,6 +254,7 @@ class Lesson: focus_accuracy: float | None = None focus_attempts: int | None = None focus_min_attempts: int | None = None + bigram_calibration: tuple[int, int] | None = None @property def focus_key(self) -> int: @@ -252,11 +265,20 @@ def heatmap(self) -> dict[int, float]: return {k.codepoint: k.confidence for k in self.state.keys} -def _lesson_progress( - layout_name: str, - ctx: _LessonContext, -) -> LessonProgress: +@dataclass(slots=True, frozen=True) +class _GatingState: + unlocked: tuple[int, ...] + keys_need_focus: bool + transition_blocked: bool + gating_bigrams: tuple[Bigram, ...] + bigram_calibration: tuple[int, int] | None + + +def _gating_state(ctx: _LessonContext) -> _GatingState: order = keyboard_order(ctx.layout) + stall_cap = default_transition_stall_attempts_cap( + ctx.settings.min_transition_confidence_attempts + ) unlocked = compute_unlocked( order, ctx.settings.alphabet_size, @@ -265,38 +287,106 @@ def _lesson_progress( min_attempts=ctx.settings.min_confidence_attempts, transitions=ctx.transitions, transition_min_attempts=ctx.settings.min_transition_confidence_attempts, - transition_stall_attempts_cap=default_transition_stall_attempts_cap( - ctx.settings.min_transition_confidence_attempts - ), + transition_stall_attempts_cap=stall_cap, + gating_bigram_limit=ctx.settings.gating_bigram_limit, ) keys_need_focus = blocks_transition_focus( unlocked, ctx.stats, ctx.target, - threshold=_CONFIDENCE_GOOD, + threshold=CONFIDENCE_GOOD, + min_attempts=ctx.settings.min_confidence_attempts, ) - focus_bigram: Bigram | None = None - if not keys_need_focus: - focus_bigram = select_focus_transition( - unlocked, - ctx.transitions, - ctx.target, - ctx.now, - key_stats=ctx.stats, - min_attempts=ctx.settings.min_transition_confidence_attempts, + cohort = newest_key_gating_cohort( + unlocked, + ctx.stats, + limit=ctx.settings.gating_bigram_limit, + ) + ready, total = newest_key_transition_gate_progress( + unlocked, + ctx.transitions, + ctx.target, + ctx.stats, + min_attempts=ctx.settings.min_transition_confidence_attempts, + stall_attempts_cap=stall_cap, + cohort_limit=ctx.settings.gating_bigram_limit, + ) + transition_blocked = not keys_need_focus and len(unlocked) < len(order) and ready < total + gating_bigrams = ( + tuple( + pair + for pair in cohort + if not gating_bigram_is_ready( + pair, + ctx.transitions, + ctx.target, + min_attempts=ctx.settings.min_transition_confidence_attempts, + stall_attempts_cap=stall_cap, + ) ) + if transition_blocked + else () + ) + return _GatingState( + unlocked=unlocked, + keys_need_focus=keys_need_focus, + transition_blocked=transition_blocked, + gating_bigrams=gating_bigrams, + bigram_calibration=(ready, total) if transition_blocked else None, + ) - if focus_bigram is not None: - focus = focus_key_from_transition(*focus_bigram) - else: - focus = select_focus( - unlocked, + +def _resolve_lesson_focus( + ctx: _LessonContext, + gating: _GatingState, +) -> tuple[int, Bigram | None]: + remedial = None + if ctx.remedial_alphabet is not None: + remedial = remedial_focus( + ctx.remedial_alphabet, + gating.unlocked, ctx.stats, + ctx.transitions, ctx.target, - ctx.now, + now=ctx.now, + threshold=CONFIDENCE_GOOD, min_attempts=ctx.settings.min_confidence_attempts, + min_transition_attempts=ctx.settings.min_transition_confidence_attempts, ) + focus_bigram: Bigram | None = None + if remedial is not None: + focus, focus_bigram = remedial + else: + if not gating.keys_need_focus: + focus_bigram = select_focus_transition( + gating.unlocked, + ctx.transitions, + ctx.target, + ctx.now, + key_stats=ctx.stats, + gating_candidates=gating.gating_bigrams if gating.transition_blocked else None, + min_attempts=ctx.settings.min_transition_confidence_attempts, + ) + + if focus_bigram is not None: + focus = focus_key_from_transition(*focus_bigram) + else: + focus = select_focus( + gating.unlocked, + ctx.stats, + ctx.target, + ctx.now, + min_attempts=ctx.settings.min_confidence_attempts, + ) + return focus, focus_bigram + +def _lesson_progress( + layout_name: str, + ctx: _LessonContext, +) -> LessonProgress: + gating = _gating_state(ctx) + focus, focus_bigram = _resolve_lesson_focus(ctx, gating) keys = tuple( LessonKey( codepoint=cp, @@ -309,7 +399,7 @@ def _lesson_progress( ), is_focus=(cp == focus), ) - for cp in unlocked + for cp in gating.unlocked ) state = LessonState( layout=layout_name, @@ -318,11 +408,13 @@ def _lesson_progress( target_speed_cpm=ctx.settings.target_speed_cpm, ) return LessonProgress( - unlocked=unlocked, + unlocked=gating.unlocked, focus=focus, state=state, focus_bigram=focus_bigram, - skill_heatmap={cp: skill_of(cp, ctx.stats, ctx.target) for cp in unlocked}, + gating_bigrams=gating.gating_bigrams, + bigram_calibration=gating.bigram_calibration, + skill_heatmap={cp: skill_of(cp, ctx.stats, ctx.target) for cp in gating.unlocked}, ) @@ -357,7 +449,7 @@ def _compute_weights( char_weights[chr(focus)] *= ctx.settings.focus_char_boost unlocked_set = frozenset(unlocked) transition_weights = { - key: transition_practice_weight( + key: practice_weight( transition_confidence_of( key.prev_cp, key.next_cp, @@ -376,9 +468,13 @@ def _compute_weights( and key.next_cp in unlocked_set and not is_same_key_transition(key.prev_cp, key.next_cp) } - newest_pairs = newest_key_unmeasured_pairs(unlocked, ctx.transitions, ctx.stats) + newest_pairs = newest_key_gating_cohort( + unlocked, + ctx.stats, + limit=ctx.settings.gating_bigram_limit, + ) if newest_pairs: - default_weight = transition_practice_weight(0.0, urgency=0.0) * coverage_deficit_factor( + default_weight = practice_weight(0.0, urgency=0.0) * coverage_deficit_factor( 0, min_attempts=min_transition_attempts ) for pair in newest_pairs: @@ -386,14 +482,14 @@ def _compute_weights( if focus_bigram is not None: transition_weights.setdefault( focus_bigram, - transition_practice_weight(focus_confidence, urgency=0.0) + practice_weight(focus_confidence, urgency=0.0) * coverage_deficit_factor(0, min_attempts=min_transition_attempts), ) transition_weights[focus_bigram] *= ctx.settings.focus_transition_boost - if focus_confidence < _CONFIDENCE_GOOD: + if focus_confidence < CONFIDENCE_GOOD: transition_weights[focus_bigram] *= ctx.settings.focus_weak_extra_boost char_weights[chr(focus)] *= ctx.settings.focus_weak_extra_boost - elif focus_confidence < _CONFIDENCE_GOOD: + elif focus_confidence < CONFIDENCE_GOOD: char_weights[chr(focus)] *= ctx.settings.focus_weak_extra_boost return char_weights, transition_weights @@ -406,6 +502,9 @@ class _LessonContext: transitions: Mapping[Bigram, TransitionStats] now: float target: float + # The just-finished session's own lesson alphabet, when that session's own + # WPM fell short of its own target -- None otherwise (normal selection). + remedial_alphabet: tuple[int, ...] | None = None @dataclass(slots=True) @@ -417,6 +516,7 @@ class BuildLesson: wordlist_store: WordListStore rng: Random clock: Clock + session_repo: SessionRepository = field(default_factory=NullSessionRepository) def __call__(self, layout_name: str) -> Lesson: ctx = self._load_context(layout_name) @@ -439,6 +539,7 @@ def __call__(self, layout_name: str) -> Lesson: focus_confidence=focus_confidence, char_weights=char_weights, transition_weights=transition_weights, + gating_bigrams=progress.gating_bigrams, ) urgency = _compute_urgency(ctx.stats, progress.unlocked, ctx.now) explanation = _compute_focus_explanation( @@ -455,6 +556,7 @@ def __call__(self, layout_name: str) -> Lesson: focus_accuracy=explanation.accuracy, focus_attempts=explanation.attempts, focus_min_attempts=explanation.min_attempts, + bigram_calibration=progress.bigram_calibration, ) def _load_context(self, layout_name: str) -> _LessonContext: @@ -470,8 +572,19 @@ def _load_context(self, layout_name: str) -> _LessonContext: transitions=transitions, now=self.clock.wall_epoch(), target=target_ms_per_char(settings.target_speed_cpm), + remedial_alphabet=self._remedial_alphabet(layout_name), ) + def _remedial_alphabet(self, layout_name: str) -> tuple[int, ...] | None: + """The last finished session's own lesson alphabet, when that + session's own WPM missed its own target -- otherwise None.""" + last = latest_session_header(self.session_repo, layout_name) + if last is None: + return None + if not session_wpm_below_target(last): + return None + return last.lesson_alphabet + def _generate_text( self, ctx: _LessonContext, @@ -482,6 +595,7 @@ def _generate_text( focus_confidence: float, char_weights: dict[str, float], transition_weights: dict[Bigram, float], + gating_bigrams: tuple[Bigram, ...], ) -> str: table = self.language_provider.transitions(ctx.settings.lang) generator = AdaptiveGenerator(table=table, rng=self.rng) @@ -496,7 +610,7 @@ def _generate_text( word_count = effective_lesson_word_count(ctx.settings.lesson_word_count) quota = ( weak_focus_word_quota(word_count, ctx.settings.focus_word_min_fraction) - if focus_confidence < _CONFIDENCE_GOOD + if focus_confidence < CONFIDENCE_GOOD else 1 ) generated_min_len, generated_max_len = effective_generated_word_bounds( @@ -509,6 +623,9 @@ def _generate_text( word_count=word_count, weighting=weighting, focus_bigram=focus_bigram, + focus_bigrams=tuple(dict.fromkeys((focus_bigram, *gating_bigrams))) + if focus_bigram is not None + else gating_bigrams, min_focus_words=quota, max_word_repeats=ctx.settings.max_word_repeats, generated_min_len=generated_min_len, diff --git a/src/keystrike/application/prepare_practice.py b/src/keystrike/application/prepare_practice.py index 96a0504..76d23d1 100644 --- a/src/keystrike/application/prepare_practice.py +++ b/src/keystrike/application/prepare_practice.py @@ -31,7 +31,7 @@ class SessionPrep: focus_min_attempts: int | None layout_obj: Layout | None lesson_heatmap: dict[int, float] | None - lesson_urgency: dict[int, float] | None + bigram_calibration: tuple[int, int] | None @dataclass(slots=True) @@ -59,5 +59,5 @@ def __call__(self) -> SessionPrep | None: focus_min_attempts=lesson.focus_min_attempts, layout_obj=self.layout_repo.get(settings.layout), lesson_heatmap=lesson.skill_heatmap, - lesson_urgency=lesson.urgency, + bigram_calibration=lesson.bigram_calibration, ) diff --git a/src/keystrike/application/session_queries.py b/src/keystrike/application/session_queries.py new file mode 100644 index 0000000..bdab425 --- /dev/null +++ b/src/keystrike/application/session_queries.py @@ -0,0 +1,73 @@ +"""Shared session header queries and WPM/accuracy metrics for application use cases.""" + +from __future__ import annotations + +from keystrike.domain.generator import typical_chars_per_word, wpm_from_cpm +from keystrike.domain.models import SessionResult +from keystrike.domain.protocols import SessionRepository + + +def _words_for_wpm(result: SessionResult) -> float: + if result.words_completed > 0: + return float(result.words_completed) + if result.correct_keystrokes <= 0: + return 0.0 + # Legacy sessions without words_completed: estimate from char count. + return result.correct_keystrokes / typical_chars_per_word() + + +def compute_wpm(result: SessionResult) -> float: + """Words per minute from completed lesson words, not chars/5.""" + minutes = result.duration_ns / 1e9 / 60.0 + if minutes <= 0: + return 0.0 + return _words_for_wpm(result) / minutes + + +def compute_accuracy(result: SessionResult) -> float: + if result.total_keystrokes == 0: + return 0.0 + return result.correct_keystrokes / result.total_keystrokes + + +def previous_session_header( + repo: SessionRepository, + result: SessionResult, +) -> SessionResult | None: + """Session immediately before ``result`` for the same layout, if any.""" + ordered = sorted(repo.iter_headers(result.layout), key=lambda h: h.started_at) + for i, header in enumerate(ordered): + if header.session_id == result.session_id: + return ordered[i - 1] if i > 0 else None + return None + + +def latest_session_header(repo: SessionRepository, layout: str) -> SessionResult | None: + """Most recently finished session for ``layout``, if any — the header + `BuildLesson` reads to decide whether the next lesson needs the + lesson-WPM remedial-focus gate (`session_wpm_below_target`).""" + headers = list(repo.iter_headers(layout)) + return max(headers, key=lambda h: h.started_at, default=None) + + +def session_wpm_below_target( + result: SessionResult, + *, + generated_min_len: int | None = None, + generated_max_len: int | None = None, +) -> bool: + """Whether a finished session's own WPM fell short of its own target + speed. Drives `BuildLesson`'s remedial lesson-alphabet focus gate; + legacy sessions with no recorded target (``target_speed_cpm == 0``) + never trigger it. Word-length bounds come from the session header + (snapshotted at finish); optional overrides exist for tests.""" + if result.target_speed_cpm <= 0 or result.words_completed <= 0: + return False + min_len = generated_min_len if generated_min_len is not None else result.generated_min_len + max_len = generated_max_len if generated_max_len is not None else result.generated_max_len + target_wpm = wpm_from_cpm( + result.target_speed_cpm, + generated_min_len=min_len, + generated_max_len=max_len, + ) + return compute_wpm(result) < target_wpm diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index a2335ae..9918d07 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -1,13 +1,18 @@ from collections.abc import Iterable from dataclasses import dataclass, field, replace +from keystrike.application.session_queries import ( + compute_accuracy, + compute_wpm, + latest_session_header, +) from keystrike.domain.aggregate import SessionTiming, combine_sessions, session_recency_weights from keystrike.domain.confidence import ( confidence_of, target_ms_per_char, ) from keystrike.domain.enums import Mode, SessionState -from keystrike.domain.generator import typical_chars_per_word +from keystrike.domain.generator import effective_generated_word_bounds from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import Keystroke, SessionResult from keystrike.domain.null_adapters import ( @@ -155,6 +160,7 @@ def _snapshot_unlock_state( transition_stall_attempts_cap=default_transition_stall_attempts_cap( settings.min_transition_confidence_attempts ), + gating_bigram_limit=settings.gating_bigram_limit, ) return unlocked, { cp: confidence_of(cp, combined.keys, target, min_attempts=settings.min_confidence_attempts) @@ -198,8 +204,12 @@ def __call__(self, session: Session) -> SessionResult: ) _sync_alphabet_size(unlocked_keys, self.settings_repo) + generated_min_len, generated_max_len = effective_generated_word_bounds( + settings.generated_word_min_len, + settings.generated_word_max_len, + ) result = SessionResult( - schema_version=3, + schema_version=4, session_id=session.id, started_at=session.started_at_wall, duration_ns=duration_ns, @@ -214,6 +224,8 @@ def __call__(self, session: Session) -> SessionResult: unlocked_keys=unlocked_keys, key_confidence=key_confidence, target_speed_cpm=target_speed_cpm, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, ) self.repo.append_keystrokes(session.id, session.started_at_wall, session.keystrokes) self.repo.save_header(result) @@ -239,41 +251,6 @@ def count_words_completed(text: str, position: int) -> int: return text[:position].count(" ") -def _words_for_wpm(result: SessionResult) -> float: - if result.words_completed > 0: - return float(result.words_completed) - if result.correct_keystrokes <= 0: - return 0.0 - # Legacy sessions without words_completed: estimate from char count. - return result.correct_keystrokes / typical_chars_per_word() - - -def compute_wpm(result: SessionResult) -> float: - """Words per minute from completed lesson words, not chars/5.""" - minutes = result.duration_ns / 1e9 / 60.0 - if minutes <= 0: - return 0.0 - return _words_for_wpm(result) / minutes - - -def compute_accuracy(result: SessionResult) -> float: - if result.total_keystrokes == 0: - return 0.0 - return result.correct_keystrokes / result.total_keystrokes - - -def previous_session_header( - repo: SessionRepository, - result: SessionResult, -) -> SessionResult | None: - """Session immediately before ``result`` for the same layout, if any.""" - ordered = sorted(repo.iter_headers(result.layout), key=lambda h: h.started_at) - for i, header in enumerate(ordered): - if header.session_id == result.session_id: - return ordered[i - 1] if i > 0 else None - return None - - @dataclass(frozen=True, slots=True) class SessionStatsBaseline: wpm: float @@ -323,3 +300,11 @@ class GetSessionBaseline: def __call__(self, result: SessionResult) -> SessionStatsBaseline | None: window = self.settings_repo.load().confidence_session_window return confidence_window_session_baseline(self.repo, result, window=window) + + +@dataclass(slots=True) +class GetLatestSessionHeader: + repo: SessionRepository + + def __call__(self, layout: str) -> SessionResult | None: + return latest_session_header(self.repo, layout) diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index a4b87e1..623b890 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -32,6 +32,8 @@ SESSION_RECENCY_DECAY = 0.7 # Confidence scores are rounded before thresholds, focus, heatmap, and UI. CONFIDENCE_DECIMALS = 2 +# Mastery threshold: skill/confidence at or above this counts as "good". +CONFIDENCE_GOOD = 1.0 def round_confidence(value: float) -> float: @@ -192,6 +194,22 @@ def skill_of(codepoint: int, stats: Mapping[int, KeyStats], target: float) -> fl return skill_from_stats(stats.get(codepoint), target) +def clears_threshold( + stats: HasConfidenceFields | None, + target: float, + *, + threshold: float, + min_attempts: int, +) -> bool: + """Skill (unramped) meets ``threshold`` AND attempts meet ``min_attempts`` + — the shared two-part "no longer needs calibration" gate used by key + unlock, the transition gate, and focus stickiness (`domain.unlock`, + `domain.focus`) so the three can't drift on what counts as cleared.""" + if stats is None: + return False + return skill_from_stats(stats, target) >= threshold and attempts_of(stats) >= min_attempts + + def review_urgency(last_seen: float, now: float) -> float: """How urgently a key needs re-testing before forgetting (0.0-1.0). diff --git a/src/keystrike/domain/focus.py b/src/keystrike/domain/focus.py index 015a01f..f5a1698 100644 --- a/src/keystrike/domain/focus.py +++ b/src/keystrike/domain/focus.py @@ -11,9 +11,11 @@ MIN_CONFIDENCE_ATTEMPTS, MIN_TRANSITION_CONFIDENCE_ATTEMPTS, HasConfidenceFields, + attempts_of, + clears_threshold, confidence_from_stats, review_urgency, - skill_of, + skill_from_stats, ) from .enums import FocusKind from .models import ( @@ -29,12 +31,6 @@ # this module rather than `domain.models` directly. __all__ = ["FOCUS_BIGRAM_WORD_BOOST", "FOCUS_WORD_BOOST"] -_TRANSITION_KINDS = ( - FocusKind.TRANSITION_WEAK, - FocusKind.TRANSITION_CALIBRATING, - FocusKind.TRANSITION_REVIEW, -) - @dataclass(frozen=True, slots=True) class FocusReason: @@ -46,8 +42,16 @@ class FocusReason: kind: FocusKind pair: Bigram | None = None + @property + def is_transition(self) -> bool: + return self.kind in ( + FocusKind.TRANSITION_WEAK, + FocusKind.TRANSITION_CALIBRATING, + FocusKind.TRANSITION_REVIEW, + ) + def __post_init__(self) -> None: - is_transition_kind = self.kind in _TRANSITION_KINDS + is_transition_kind = self.is_transition if is_transition_kind and self.pair is None: raise ValueError(f"{self.kind} requires a pair") if not is_transition_kind and self.pair is not None: @@ -94,12 +98,13 @@ def blocks_transition_focus( target: float, *, threshold: float = 1.0, + min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, ) -> bool: - """True when any unlocked key with measured stats is below performance skill. - - Never-practiced keys (absent from stats) do not block — e.g. a key just - auto-unlocked before its first press. Keys in stats with skill 0 still block.""" - return any(cp in stats and skill_of(cp, stats, target) < threshold for cp in unlocked) + """True when any unlocked key lacks required performance or evidence.""" + return any( + not clears_threshold(stats.get(cp), target, threshold=threshold, min_attempts=min_attempts) + for cp in unlocked + ) def select_focus( @@ -109,13 +114,28 @@ def select_focus( now: float, *, review_penalty: float = 0.5, + threshold: float = 1.0, min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, ) -> int: - """The unlocked key a lesson should emphasize — weakest by confidence, but - stale keys are treated as weaker so high-confidence keys due for review - still surface (SlimStampen-style spacing; see typing-pedagogy.md).""" + """The unlocked key a lesson should emphasize. + + Any key that hasn't yet cleared both the skill threshold and the attempt + floor (`clears_threshold`) wins over every key that has — so focus stays + on an in-progress key across lesson builds instead of a mastered-but- + stale key's review-urgency discount stealing it away mid-calibration. + Only once every unlocked key has cleared does review-urgency-based + staleness compete for focus among the whole set (SlimStampen-style + spacing; see typing-pedagogy.md).""" + candidates = [ + cp + for cp in unlocked + if not clears_threshold( + stats.get(cp), target, threshold=threshold, min_attempts=min_attempts + ) + ] + pool = candidates or unlocked return min( - unlocked, + pool, key=lambda cp: _focus_score( cp, stats, @@ -172,7 +192,9 @@ def select_focus_transition( now: float, *, key_stats: Mapping[int, KeyStats] | None = None, + gating_candidates: Sequence[Bigram] | None = None, review_penalty: float = 0.5, + threshold: float = 1.0, min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, ) -> Bigram | None: """Weakest unlocked bigram by transition confidence. @@ -181,7 +203,15 @@ def select_focus_transition( measured transitions of its own yet (`newest_key_unmeasured_pairs`), fall back to its weakest-scoring unmeasured pair — so a freshly-opened letter gets bigram focus right away instead of waiting for its pairs to appear - by chance.""" + by chance. + + Among measured pairs (outside `gating_candidates` mode), a pair that + hasn't cleared both the skill threshold and attempt floor + (`clears_threshold`) always wins over an already-cleared, merely-stale + pair — the same stickiness `select_focus` applies to keys, so an + in-progress bigram isn't preempted by review urgency before it's done + calibrating. Once every measured pair has cleared, review-urgency-based + staleness competes for focus among all of them, same as before.""" def _score(pair: Bigram) -> float: return _transition_focus_score( @@ -194,12 +224,33 @@ def _score(pair: Bigram) -> float: min_attempts=min_attempts, ) + measured = [pair for pair in unlocked_cross_key_pairs(unlocked) if pair in transitions] + if gating_candidates is not None: + gating_set = frozenset(gating_candidates) + regressions = [ + pair + for pair in measured + if pair not in gating_set + and attempts_of(transitions[pair]) >= min_attempts + and skill_from_stats(transitions[pair], target) < 1.0 + ] + if regressions: + return min(regressions, key=_score) + return min(gating_candidates, key=_score) if gating_candidates else None + candidates = newest_key_unmeasured_pairs(unlocked, transitions, key_stats) if candidates: return min(candidates, key=_score) - - measured = [pair for pair in unlocked_cross_key_pairs(unlocked) if pair in transitions] - return min(measured, key=_score) if measured else None + if not measured: + return None + not_cleared = [ + pair + for pair in measured + if not clears_threshold( + transitions.get(pair), target, threshold=threshold, min_attempts=min_attempts + ) + ] + return min(not_cleared or measured, key=_score) def focus_key_from_transition(_prev_cp: int, next_cp: int) -> int: @@ -207,6 +258,48 @@ def focus_key_from_transition(_prev_cp: int, next_cp: int) -> int: return next_cp +def remedial_focus( + lesson_alphabet: Sequence[int], + unlocked: Sequence[int], + stats: Mapping[int, KeyStats], + transitions: Mapping[Bigram, TransitionStats], + target: float, + *, + now: float, + threshold: float = 1.0, + min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, + min_transition_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, +) -> tuple[int, Bigram | None] | None: + """Weakest key or bigram confined to one already-finished lesson's own + alphabet — pulls focus back onto that lesson's own weak point when its + WPM fell short of target, instead of ordinary weakest-across-window + selection (or the newest-key transition gate) drifting focus elsewhere. + + None once every key in the pool has cleared and no bigram among them is + left wanting either — callers should fall back to ordinary focus + selection at that point (the lesson's weak point is resolved).""" + pool = [cp for cp in unlocked if cp in frozenset(lesson_alphabet)] + if not pool: + return None + if blocks_transition_focus(pool, stats, target, threshold=threshold, min_attempts=min_attempts): + focus = select_focus( + pool, stats, target, now, threshold=threshold, min_attempts=min_attempts + ) + return focus, None + bigram = select_focus_transition( + pool, + transitions, + target, + now, + key_stats=stats, + threshold=threshold, + min_attempts=min_transition_attempts, + ) + if bigram is None: + return None + return focus_key_from_transition(*bigram), bigram + + def coverage_deficit_factor( attempts: int, *, @@ -243,19 +336,3 @@ def practice_weight( keys still appear in generated text.""" base = 1.0 + max_bias * (1.0 - min(confidence, 1.0)) return base * (1.0 + review_bias * urgency) - - -def transition_practice_weight( - confidence: float, - *, - max_bias: float = 3.0, - urgency: float = 0.0, - review_bias: float = 1.0, -) -> float: - """Sampling weight for a prev→next pair — mirrors `practice_weight`.""" - return practice_weight( - confidence, - max_bias=max_bias, - urgency=urgency, - review_bias=review_bias, - ) diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index d086951..8100715 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -5,7 +5,7 @@ import math from collections import Counter -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass from random import Random @@ -86,23 +86,24 @@ def _assert_lesson_focus_quota( matching = sum( 1 for w in words if word_matches_focus(w, focus_char=focus_char, focus_bigram=focus_bigram) ) - assert matching >= min_focus_words, ( - f"focus quota unmet: {matching}/{len(words)} words match (required {min_focus_words})" - ) + if matching < min_focus_words: + raise ValueError( + f"focus quota unmet: {matching}/{len(words)} words match (required {min_focus_words})" + ) def _assert_wordlist_word_len(word: str, *, min_len: int, max_len: int) -> None: """Ensures: imported word length is within dictionary bounds.""" - assert min_len <= len(word) <= max_len, ( - f"wordlist word {word!r} length {len(word)} not in [{min_len}, {max_len}]" - ) + if not (min_len <= len(word) <= max_len): + raise ValueError(f"wordlist word {word!r} length {len(word)} not in [{min_len}, {max_len}]") def _assert_generated_word_len(word: str, *, min_len: int, max_len: int) -> None: """Ensures: Markov-generated word length is within generated bounds.""" - assert min_len <= len(word) <= max_len, ( - f"generated word {word!r} length {len(word)} not in [{min_len}, {max_len}]" - ) + if not (min_len <= len(word) <= max_len): + raise ValueError( + f"generated word {word!r} length {len(word)} not in [{min_len}, {max_len}]" + ) def _wordlist_word_fits( @@ -379,6 +380,7 @@ def generate_lesson( word_count: int = DEFAULT_WORD_COUNT, weighting: LessonWeighting | None = None, focus_bigram: Bigram | None = None, + focus_bigrams: Sequence[Bigram] = (), min_focus_words: int = 1, max_word_repeats: int = MAX_WORD_REPEATS, generated_min_len: int = GENERATED_WORD_MIN_LEN, @@ -392,7 +394,8 @@ def generate_lesson( """ word_count = effective_lesson_word_count(word_count) min_focus_words = min(min_focus_words, word_count) - assert 0 <= min_focus_words <= word_count + if not (0 <= min_focus_words <= word_count): + raise ValueError(f"min_focus_words must be in [0, {word_count}], got {min_focus_words}") max_word_repeats = effective_max_word_repeats(max_word_repeats) generated_min_len, generated_max_len = effective_generated_word_bounds( generated_min_len, generated_max_len @@ -434,8 +437,8 @@ def generate_lesson( if word in pool_set ), ) + lesson_words: list[str] = [] if min_focus_words <= 1: - lesson_words: list[str] = [] while len(lesson_words) < word_count: self._append_general_word_capped( lesson_words, @@ -446,22 +449,9 @@ def generate_lesson( generated_min_len=generated_min_len, generated_max_len=generated_max_len, ) - if focus_bigram is not None: - prev_char, next_char = chr(focus_bigram.prev_cp), chr(focus_bigram.next_cp) - bigram = prev_char + next_char - if not any(bigram in w for w in lesson_words): - idx = self.rng.randrange(len(lesson_words)) - lesson_words[idx] = self._inject_focus_bigram( - lesson_words[idx], - prev_char, - next_char, - ) - elif not any(focus_char in w for w in lesson_words): - idx = self.rng.randrange(len(lesson_words)) - lesson_words[idx] = self._inject_focus(lesson_words[idx], focus_char) + self._ensure_single_focus_match(lesson_words, focus_char, focus_bigram) else: focus_quota = min(min_focus_words, word_count) - lesson_words: list[str] = [] pool_draws = _sample_focus_words_without_replacement( self.rng, focus_pool, @@ -507,8 +497,80 @@ def generate_lesson( focus_char=focus_char, focus_bigram=focus_bigram_str, ) + self._distribute_focus_bigram_coverage( + lesson_words, + focus_bigrams, + alphabet, + weighting, + min_focus_words=min_focus_words, + max_word_repeats=max_word_repeats, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, + ) return " ".join(lesson_words) + def _ensure_single_focus_match( + self, + lesson_words: list[str], + focus_char: str, + focus_bigram: Bigram | None, + ) -> None: + """Guarantee at least one lesson word contains the focus (used when + ``min_focus_words <= 1``, where no quota loop already guarantees it).""" + if focus_bigram is not None: + prev_char, next_char = chr(focus_bigram.prev_cp), chr(focus_bigram.next_cp) + bigram = prev_char + next_char + if not any(bigram in w for w in lesson_words): + idx = self.rng.randrange(len(lesson_words)) + lesson_words[idx] = self._inject_focus_bigram( + lesson_words[idx], prev_char, next_char + ) + elif not any(focus_char in w for w in lesson_words): + idx = self.rng.randrange(len(lesson_words)) + lesson_words[idx] = self._inject_focus(lesson_words[idx], focus_char) + + def _distribute_focus_bigram_coverage( + self, + lesson_words: list[str], + focus_bigrams: Sequence[Bigram], + alphabet: frozenset[str], + weighting: LessonWeighting, + *, + min_focus_words: int, + max_word_repeats: int, + generated_min_len: int, + generated_max_len: int, + ) -> None: + if not focus_bigrams: + return + coverage_slots = min(len(lesson_words), max(min_focus_words, len(focus_bigrams))) + for index in range(coverage_slots): + pair = focus_bigrams[index % len(focus_bigrams)] + candidate = self._inject_focus_bigram( + lesson_words[index], chr(pair.prev_cp), chr(pair.next_cp) + ) + other_words = lesson_words[:index] + lesson_words[index + 1 :] + if Counter(other_words)[candidate] >= max_word_repeats: + for _ in range(MAX_REPEAT_RESAMPLE): + candidate = self._inject_focus_bigram( + self._generate_word_via_markov( + alphabet, weighting, generated_min_len, generated_max_len + ), + chr(pair.prev_cp), + chr(pair.next_cp), + ) + if Counter(other_words)[candidate] < max_word_repeats: + break + lesson_words[index] = candidate + if len(lesson_words) >= len(focus_bigrams): + missing = [ + pair + for pair in focus_bigrams + if not any(pair.chars() in word for word in lesson_words) + ] + if missing: + raise ValueError(f"focus bigrams missing from lesson: {missing!r}") + def _append_focus_word_capped( self, lesson_words: list[str], diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index 5a6e9ea..1b60e95 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -12,6 +12,7 @@ CONFIDENCE_SESSION_WINDOW = 10 MIN_CONFIDENCE_ATTEMPTS = 10 MIN_TRANSITION_CONFIDENCE_ATTEMPTS = 4 +GATING_BIGRAM_LIMIT = 4 FOCUS_CHAR_BOOST = 3.0 FOCUS_WORD_BOOST = 3.0 FOCUS_BIGRAM_WORD_BOOST = 4.0 @@ -53,6 +54,8 @@ class SessionResult: unlocked_keys: tuple[int, ...] = () key_confidence: Mapping[int, float] = field(default_factory=_empty_key_confidence) target_speed_cpm: int = 0 # goal active at finish; 0 = legacy sessions + generated_min_len: int = GENERATED_WORD_MIN_LEN # word bounds at finish; legacy default + generated_max_len: int = GENERATED_WORD_MAX_LEN def __post_init__(self) -> None: # Freezing the dataclass only blocks attribute rebinding — wrap the @@ -162,6 +165,7 @@ class Settings: confidence_session_window: int = CONFIDENCE_SESSION_WINDOW # sessions in rolling stats min_confidence_attempts: int = MIN_CONFIDENCE_ATTEMPTS # presses before full weight min_transition_confidence_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS # bigrams sparser + gating_bigram_limit: int = GATING_BIGRAM_LIMIT # newest-key cohort, clamped to 2-4 focus_char_boost: float = FOCUS_CHAR_BOOST # char weight multiplier for focus key focus_word_boost: float = FOCUS_WORD_BOOST # wordlist/Markov boost when focus char present focus_bigram_word_boost: float = FOCUS_BIGRAM_WORD_BOOST # word boost when focus bigram present diff --git a/src/keystrike/domain/newest_key.py b/src/keystrike/domain/newest_key.py index b2c4593..0b3ec26 100644 --- a/src/keystrike/domain/newest_key.py +++ b/src/keystrike/domain/newest_key.py @@ -12,7 +12,15 @@ from collections.abc import Mapping, Sequence from .confidence import is_same_key_transition -from .models import Bigram, KeyStats, TransitionStats +from .models import GATING_BIGRAM_LIMIT, Bigram, KeyStats, TransitionStats + +_GATING_BIGRAM_MIN = 2 +_GATING_BIGRAM_MAX = 4 + + +def effective_gating_bigram_limit(limit: int) -> int: + """Clamp hand-edited settings to the supported bounded cohort size.""" + return min(_GATING_BIGRAM_MAX, max(_GATING_BIGRAM_MIN, limit)) def unlocked_cross_key_pairs(unlocked: Sequence[int]) -> list[Bigram]: @@ -57,3 +65,25 @@ def newest_practiced_key_pairs( measured = [pair for pair in pairs if pair in transitions] unmeasured = [pair for pair in pairs if pair not in transitions] return unmeasured, measured + + +def newest_key_gating_cohort( + unlocked: Sequence[int], + key_stats: Mapping[int, KeyStats] | None, + *, + limit: int = GATING_BIGRAM_LIMIT, +) -> tuple[Bigram, ...]: + """Stable 2-4 directed bigrams for the newest practiced key. + + Pair it bidirectionally with at most its two most-recent practiced peers. + The cohort depends only on learn/unlock order and which keys have been + practiced, so incidental transition measurements cannot expand or replace + it while the letter is being calibrated. + """ + practiced = [cp for cp in unlocked if key_stats is not None and cp in key_stats] + if len(practiced) < _GATING_BIGRAM_MIN: + return () + newest = practiced[-1] + peers = practiced[-3:-1] + cohort = tuple(pair for peer in peers for pair in (Bigram(peer, newest), Bigram(newest, peer))) + return cohort[-effective_gating_bigram_limit(limit) :] diff --git a/src/keystrike/domain/unlock.py b/src/keystrike/domain/unlock.py index 817be53..ef712a4 100644 --- a/src/keystrike/domain/unlock.py +++ b/src/keystrike/domain/unlock.py @@ -10,11 +10,11 @@ MIN_CONFIDENCE_ATTEMPTS, MIN_TRANSITION_CONFIDENCE_ATTEMPTS, attempts_of, - skill_of, + clears_threshold, transition_confidence_of, ) -from .models import Bigram, KeyStats, TransitionStats -from .newest_key import newest_practiced_key_pairs +from .models import GATING_BIGRAM_LIMIT, Bigram, KeyStats, TransitionStats +from .newest_key import newest_key_gating_cohort # ponytail: fixed multiplier; upgrade to a Settings field if a stuck gating # bigram turns out to need per-user tuning. @@ -39,47 +39,71 @@ def newest_key_clears_transition_gate( threshold: float = 1.0, min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, stall_attempts_cap: int | None = None, + cohort_limit: int = GATING_BIGRAM_LIMIT, ) -> bool: - """Whether the most-recently-*practiced* unlocked key's bigrams with its - peers are solid enough to let the next key open — the unlock half of - `compute_unlocked`'s transition gate. - - Built on `domain.newest_key.newest_practiced_key_pairs`, the same split - `domain.focus.newest_key_unmeasured_pairs` uses for focus selection, so - gating and focus never disagree on what "newest" means. While that key - has *no* measured pair with any peer yet, the gate simply isn't clear - (nothing to release on — lesson weighting is already pushing the user to - type one). Once it has *any* measured pair, only measured pairs are - weighed against each other — an untouched peer pair no longer counts - against it, so this can never regress into requiring every combinatorial - pair, only whichever ones have actually been engaged with (bounded, - "weakest of what's been tried so far", not "weakest of everything - possible"). - - `stall_attempts_cap` is a safety valve: a measured pair stuck below - threshold no matter how much it's drilled would otherwise block - progression forever. Once its attempts pass the cap, the gate clears - anyway. Because the weakest candidate is always drawn from *measured* - pairs, it always has real attempt data to check the cap against.""" - unmeasured, measured = newest_practiced_key_pairs(unlocked, transitions, key_stats) - if not unmeasured and not measured: - return True - if not measured: - return False - weakest = min( - measured, - key=lambda p: transition_confidence_of( - p.prev_cp, p.next_cp, transitions, target, min_attempts=min_attempts - ), + """Whether every member of the newest key's bounded cohort is ready.""" + ready, total = newest_key_transition_gate_progress( + unlocked, + transitions, + target, + key_stats, + threshold=threshold, + min_attempts=min_attempts, + stall_attempts_cap=stall_attempts_cap, + cohort_limit=cohort_limit, ) - weakest_confidence = transition_confidence_of( - weakest.prev_cp, weakest.next_cp, transitions, target, min_attempts=min_attempts + return ready == total + + +def gating_bigram_is_ready( + pair: Bigram, + transitions: Mapping[Bigram, TransitionStats], + target: float, + *, + threshold: float = 1.0, + min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, + stall_attempts_cap: int | None = None, +) -> bool: + stats = transitions.get(pair) + attempts = attempts_of(stats) if stats is not None else 0 + mastered = ( + attempts >= min_attempts + and transition_confidence_of( + pair.prev_cp, + pair.next_cp, + transitions, + target, + min_attempts=min_attempts, + ) + >= threshold + ) + return mastered or (stall_attempts_cap is not None and attempts >= stall_attempts_cap) + + +def newest_key_transition_gate_progress( + unlocked: Sequence[int], + transitions: Mapping[Bigram, TransitionStats], + target: float, + key_stats: Mapping[int, KeyStats] | None, + *, + threshold: float = 1.0, + min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, + stall_attempts_cap: int | None = None, + cohort_limit: int = GATING_BIGRAM_LIMIT, +) -> tuple[int, int]: + cohort = newest_key_gating_cohort(unlocked, key_stats, limit=cohort_limit) + ready = sum( + gating_bigram_is_ready( + pair, + transitions, + target, + threshold=threshold, + min_attempts=min_attempts, + stall_attempts_cap=stall_attempts_cap, + ) + for pair in cohort ) - if weakest_confidence >= threshold: - return True - if stall_attempts_cap is None: - return False - return attempts_of(transitions[weakest]) >= stall_attempts_cap + return ready, len(cohort) def _key_meets_unlock_threshold( @@ -91,11 +115,8 @@ def _key_meets_unlock_threshold( min_attempts: int, ) -> bool: """Performance (skill) and evidence (attempts) — ramp affects display only.""" - key_stats = stats.get(codepoint) - if key_stats is None: - return False - return ( - skill_of(codepoint, stats, target) >= threshold and attempts_of(key_stats) >= min_attempts + return clears_threshold( + stats.get(codepoint), target, threshold=threshold, min_attempts=min_attempts ) @@ -111,16 +132,17 @@ def compute_unlocked( transition_threshold: float = 1.0, transition_min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, transition_stall_attempts_cap: int | None = None, + gating_bigram_limit: int = GATING_BIGRAM_LIMIT, ) -> tuple[int, ...]: """The first `alphabet_size` keys are always unlocked; each further key in `learn_order` unlocks only once every currently-unlocked key meets skill ``threshold`` with at least ``min_attempts`` presses in window. When `transitions` is given, a further key also waits on the current - last-unlocked key's single weakest cross-key bigram + last-unlocked key's bounded cross-key bigram cohort (`newest_key_clears_transition_gate`) — a little bigram practice before - the next letter opens, bounded to one pair so the bar doesn't grow with - alphabet depth. Pass `None` (the default) to skip this and unlock purely + the next letter opens, bounded by `gating_bigram_limit` so the bar doesn't + grow with alphabet depth. Pass `None` (the default) to skip this and unlock purely on solo-key mastery, as before.""" forced_count = min(alphabet_size, len(learn_order)) unlocked = list(learn_order[:forced_count]) @@ -144,6 +166,7 @@ def compute_unlocked( threshold=transition_threshold, min_attempts=transition_min_attempts, stall_attempts_cap=transition_stall_attempts_cap, + cohort_limit=gating_bigram_limit, ): break unlocked.append(codepoint) diff --git a/src/keystrike/infrastructure/session_repo_jsonl.py b/src/keystrike/infrastructure/session_repo_jsonl.py index a4289f0..95bff51 100644 --- a/src/keystrike/infrastructure/session_repo_jsonl.py +++ b/src/keystrike/infrastructure/session_repo_jsonl.py @@ -10,7 +10,12 @@ from typing import cast from keystrike.domain.enums import Mode, migrate_legacy_mode -from keystrike.domain.models import Keystroke, SessionResult +from keystrike.domain.models import ( + GENERATED_WORD_MAX_LEN, + GENERATED_WORD_MIN_LEN, + Keystroke, + SessionResult, +) from keystrike.domain.sync_merge import validate_session_id from .json_coerce import coerce_float, coerce_int, require_float, require_int, require_str @@ -162,6 +167,8 @@ def _header_to_dict(h: SessionResult) -> dict[str, object]: "unlocked_keys": list(h.unlocked_keys), "key_confidence": {str(k): v for k, v in h.key_confidence.items()}, "target_speed_cpm": h.target_speed_cpm, + "generated_min_len": h.generated_min_len, + "generated_max_len": h.generated_max_len, } @@ -210,4 +217,6 @@ def _header_from_dict(d: dict[str, object]) -> SessionResult: unlocked_keys=_require_int_tuple(d, "unlocked_keys"), key_confidence=_parse_key_confidence(d.get("key_confidence", {})), target_speed_cpm=require_int(d, "target_speed_cpm", 0), + generated_min_len=require_int(d, "generated_min_len", GENERATED_WORD_MIN_LEN), + generated_max_len=require_int(d, "generated_max_len", GENERATED_WORD_MAX_LEN), ) diff --git a/src/keystrike/presentation/formatting/trends.py b/src/keystrike/presentation/formatting/trends.py index eb0a8cf..9c6cf68 100644 --- a/src/keystrike/presentation/formatting/trends.py +++ b/src/keystrike/presentation/formatting/trends.py @@ -9,11 +9,8 @@ from collections.abc import Callable, Sequence -from keystrike.application.session_use_cases import ( - SessionStatsBaseline, - compute_accuracy, - compute_wpm, -) +from keystrike.application.session_queries import compute_accuracy, compute_wpm +from keystrike.application.session_use_cases import SessionStatsBaseline from keystrike.domain.confidence import target_ms_per_char from keystrike.domain.models import SessionResult from keystrike.presentation.theme import ( diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index ca0dc7d..dfec8b4 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -24,6 +24,12 @@ from keystrike.presentation.widgets.typing_area import TypingArea +def format_bigram_calibration(progress: tuple[int, int] | None) -> str | None: + if progress is None: + return None + return f"[dim]Bigram calibration: {progress[0]}/{progress[1]} ready[/]" + + class PracticeScreen(Screen[None]): DEFAULT_CSS = """ PracticeScreen > Vertical { @@ -89,18 +95,18 @@ def compose(self) -> ComposeResult: yield Footer() def _focus_note_text(self) -> str: - return ( - format_focus_note( - self._prep.focus_key, - self._prep.focus_reason, - confidence=self._prep.focus_confidence, - speed=self._prep.focus_speed, - accuracy=self._prep.focus_accuracy, - attempts=self._prep.focus_attempts, - min_attempts=self._prep.focus_min_attempts, - ) - or "" + focus_note = format_focus_note( + self._prep.focus_key, + self._prep.focus_reason, + confidence=self._prep.focus_confidence, + speed=self._prep.focus_speed, + accuracy=self._prep.focus_accuracy, + attempts=self._prep.focus_attempts, + min_attempts=self._prep.focus_min_attempts, ) + calibration = self._prep.bigram_calibration + progress_note = format_bigram_calibration(calibration) + return "\n".join(note for note in (focus_note, progress_note) if note) def _current_heatmap_display( self, @@ -118,6 +124,7 @@ def _current_heatmap_display( def on_mount(self) -> None: self._refresh_focus_note() + self._refresh_last_session_stats() self.focus() def on_key(self, event: events.Key) -> None: @@ -155,10 +162,16 @@ def _finish_session(self) -> None: self._begin_session(prep) def _show_last_session_stats(self, result: SessionResult) -> None: - baseline = self._services.get_session_baseline(result) - self.query_one("#last-session-stats", Static).update( - format_session_stats_line(result, baseline=baseline), - ) + self._refresh_last_session_stats(result) + + def _refresh_last_session_stats(self, result: SessionResult | None = None) -> None: + header = result or self._services.get_latest_session_header(self._prep.layout) + widget = self.query_one("#last-session-stats", Static) + if header is None: + widget.update("") + return + baseline = self._services.get_session_baseline(header) + widget.update(format_session_stats_line(header, baseline=baseline)) def _refresh_focus_note(self) -> None: note = self.query_one("#focus-note", Static) diff --git a/src/keystrike/presentation/services.py b/src/keystrike/presentation/services.py index c80fee3..d4dbff8 100644 --- a/src/keystrike/presentation/services.py +++ b/src/keystrike/presentation/services.py @@ -9,6 +9,7 @@ from keystrike.application.session_use_cases import ( AbortSession, FinishSession, + GetLatestSessionHeader, GetSessionBaseline, RecordKeystroke, StartSession, @@ -50,6 +51,7 @@ class PracticeServices: finish: FinishSession abort: AbortSession prepare_practice: PreparePracticeSession + get_latest_session_header: GetLatestSessionHeader get_session_baseline: GetSessionBaseline rebuild_aggregates: StatsRebuilder get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET diff --git a/src/keystrike/presentation/widgets/kb_heatmap.py b/src/keystrike/presentation/widgets/kb_heatmap.py index 805ad47..c9a87df 100644 --- a/src/keystrike/presentation/widgets/kb_heatmap.py +++ b/src/keystrike/presentation/widgets/kb_heatmap.py @@ -5,29 +5,23 @@ from textual.widget import Widget from textual.widgets import Static +from keystrike.domain.confidence import CONFIDENCE_GOOD from keystrike.domain.enums import FocusKind from keystrike.domain.focus import FocusReason from keystrike.domain.models import Bigram, Layout _ROWS = 3 _COLS = 10 -_CONFIDENCE_GOOD = 1.0 _CONFIDENCE_OK = 0.6 _FOCUS_MASTERED_STYLE = "underline cyan" _FOCUS_STYLE = "underline" _REVIEW_STYLE = "underline magenta" -_TRANSITION_KINDS = ( - FocusKind.TRANSITION_WEAK, - FocusKind.TRANSITION_CALIBRATING, - FocusKind.TRANSITION_REVIEW, -) - def focus_transition_pair(focus_reason: FocusReason | None) -> Bigram | None: """The bigram a transition-kind focus reason is about, or None for a key-kind reason (or no reason at all).""" - if focus_reason is None or focus_reason.kind not in _TRANSITION_KINDS: + if focus_reason is None or not focus_reason.is_transition: return None return focus_reason.pair @@ -50,7 +44,7 @@ def focus_reason_label(focus_reason: FocusReason) -> str: """Full display text for a focus reason (wiki/docs). UI uses `focus_reason_label_short` instead.""" word = _FULL_REASON_WORD[focus_reason_label_short(focus_reason)] - if focus_reason.kind in _TRANSITION_KINDS: + if focus_reason.is_transition: assert focus_reason.pair is not None return f"{focus_reason.pair.chars()} {word} transition" return word @@ -59,7 +53,7 @@ def focus_reason_label(focus_reason: FocusReason) -> str: def _confidence_style(confidence: float | None) -> str: if confidence is None: return "grey37" - if confidence >= _CONFIDENCE_GOOD: + if confidence >= CONFIDENCE_GOOD: return "bold green" if confidence >= _CONFIDENCE_OK: return "yellow" @@ -78,7 +72,7 @@ def _key_style( if is_focus: focus_style = ( _FOCUS_MASTERED_STYLE - if confidence is not None and confidence >= _CONFIDENCE_GOOD + if confidence is not None and confidence >= CONFIDENCE_GOOD else _FOCUS_STYLE ) style = f"{style} {focus_style}" diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 850d1af..9030a1b 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -9,11 +9,19 @@ ) from keystrike.domain.aggregate import _combine_transition_maps_weighted, session_recency_weights from keystrike.domain.confidence import target_ms_per_char, transition_confidence_of -from keystrike.domain.enums import FocusKind -from keystrike.domain.focus import FocusReason +from keystrike.domain.enums import FocusKind, Mode +from keystrike.domain.focus import FocusReason, select_focus from keystrike.domain.generator import weak_focus_word_quota, word_matches_focus from keystrike.domain.learn_order import keyboard_order -from keystrike.domain.models import Bigram, KeyStats, LayoutAggregates, Settings, TransitionStats +from keystrike.domain.models import ( + Bigram, + KeyStats, + LayoutAggregates, + SessionResult, + Settings, + TransitionStats, +) +from keystrike.domain.newest_key import newest_key_gating_cohort from keystrike.domain.unlock import compute_unlocked from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from tests.fakes import ( @@ -21,6 +29,7 @@ FakeClock, FakeLanguageProvider, FakeLayoutRepository, + FakeSessionRepository, FakeSettingsRepository, FakeWordListStore, ) @@ -202,7 +211,7 @@ def test_lesson_uses_transition_focus_when_transitions_weak(): slow = 400_000_000.0 keys = { a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), - s: KeyStats(s, 9, at_target, 0, now, attempt_count=9), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), } transitions = { Bigram(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), @@ -248,10 +257,10 @@ def test_lesson_uses_transition_focus_for_colemak_two_keys_before_third_unlocks( now = 1_700_000_000.0 at_target = 200_000_000.0 keys = { - e: KeyStats(e, 8, at_target, 0, now, attempt_count=8), - t: KeyStats(t, 8, at_target, 0, now, attempt_count=8), + e: KeyStats(e, 10, at_target, 0, now, attempt_count=10), + t: KeyStats(t, 10, at_target, 0, now, attempt_count=10), } - assert a not in compute_unlocked(order, alphabet_size=2, stats=keys, target=200.0) + assert a in compute_unlocked(order, alphabet_size=2, stats=keys, target=200.0) cache = FakeAggregatesCache( by_layout={"colemak_dh": LayoutAggregates(keys=keys, transitions={})}, ) @@ -265,9 +274,11 @@ def test_lesson_uses_transition_focus_for_colemak_two_keys_before_third_unlocks( clock=FakeClock(), ) lesson = builder("colemak_dh") - pair = Bigram(e, t) - assert lesson.focus_key == t - assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=pair) + assert lesson.focus_reason is not None + assert lesson.focus_reason.kind == FocusKind.TRANSITION_WEAK + assert lesson.focus_reason.pair in (Bigram(e, t), Bigram(t, e)) + pair = lesson.focus_reason.pair + assert pair is not None assert pair.chars() in lesson.text.replace(" ", "") @@ -318,9 +329,8 @@ def test_lesson_uses_transition_focus_when_newly_unlocked_key_unpracticed(): clock=FakeClock(), ) lesson = builder("qwerty") - pair_bigram = Bigram(a, s) - assert lesson.focus_key == s - assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=pair_bigram) + assert lesson.focus_key == d + assert lesson.focus_reason == FocusReason(kind=FocusKind.KEY_WEAK) def test_lesson_focuses_newest_key_bigram_over_older_weak_measured_pair(): @@ -347,7 +357,7 @@ def test_lesson_focuses_newest_key_bigram_over_older_weak_measured_pair(): assert d in unlocked assert len(unlocked) == 5 transitions = { - Bigram(a, s): TransitionStats(a, s, 10, slow, 0, now, attempt_count=10), + Bigram(a, s): TransitionStats(a, s, 3, slow, 0, now, attempt_count=3), Bigram(s, a): TransitionStats(s, a, 10, fast, 0, now, attempt_count=10), Bigram(a, h): TransitionStats(a, h, 10, fast, 0, now, attempt_count=10), Bigram(h, a): TransitionStats(h, a, 10, fast, 0, now, attempt_count=10), @@ -374,6 +384,10 @@ def test_lesson_focuses_newest_key_bigram_over_older_weak_measured_pair(): focus_pair = lesson.focus_reason.pair assert focus_pair is not None assert d in focus_pair + cohort = newest_key_gating_cohort((a, s, h, d), keys) + assert lesson.bigram_calibration == (0, 4) + assert focus_pair in cohort + assert all(pair.chars() in lesson.text.replace(" ", "") for pair in cohort) # With the transitions gate wired in, the next key does NOT cascade open # until d's weakest bigram clears -- unlike the bare compute_unlocked # call above. @@ -420,11 +434,9 @@ def test_compute_weights_gives_newest_key_pairs_a_default_weight(): unlocked=progress.unlocked, ctx=ctx, ) - # d is unlocked but has no measured transitions -- it should still get a - # default (non-boosted) weight for each pair with an older unlocked key, - # not be entirely absent from the weighting map. - assert Bigram(d, a) in transition_weights - assert Bigram(a, d) in transition_weights + # The bounded cohort uses only the two most-recent practiced peers. + assert Bigram(d, a) not in transition_weights + assert Bigram(a, d) not in transition_weights assert Bigram(d, s) in transition_weights assert Bigram(d, h) in transition_weights @@ -453,38 +465,48 @@ def test_transition_focus_metrics_reports_accuracy_when_speed_measured(): assert metrics.accuracy > 0 -def test_build_lesson_eo_not_zero_confidence_when_counts_zeroed(): +def test_build_lesson_gating_pair_not_zero_confidence_when_counts_zeroed(): """Full BuildLesson path: stale samples=0 must not show 0.00 with speed/acc.""" layout_name = "colemak_dh" layout = BUNDLED_LAYOUTS[layout_name] order = keyboard_order(layout) now = 1_700_000_000.0 at_target = 200_000_000.0 - o = order[3] keys = { cp: KeyStats( cp, - 9 if cp == o else 10, + 10, at_target, 0, now, - attempt_count=9 if cp == o else 10, + attempt_count=10, ) for cp in order[:4] } - eo_key = Bigram(ord("e"), ord("o")) + cohort = newest_key_gating_cohort(order[:4], keys) + eo_key = cohort[0] slow = 566_631_000.0 transitions = { - eo_key: TransitionStats( - ord("e"), - ord("o"), - 0, - slow, + pair: TransitionStats( + pair.prev_cp, + pair.next_cp, + 4, + 100_000_000.0, 0, now, - attempt_count=0, - ), + attempt_count=4, + ) + for pair in cohort } + transitions[eo_key] = TransitionStats( + eo_key.prev_cp, + eo_key.next_cp, + 0, + slow, + 0, + now, + attempt_count=0, + ) cache = FakeAggregatesCache( by_layout={layout_name: LayoutAggregates(keys=keys, transitions=transitions)}, ) @@ -568,7 +590,7 @@ def test_lesson_uses_transition_review_when_stale_and_mastered(): keys = { a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), - h: KeyStats(h, 9, at_target, 0, now, attempt_count=9), + h: KeyStats(h, 10, at_target, 0, now, attempt_count=10), } transitions = { Bigram(a, a): TransitionStats( @@ -666,8 +688,8 @@ def test_lesson_uses_transition_review_when_stale_and_mastered(): clock=FakeClock(), ) lesson = builder("qwerty") - assert lesson.focus_key == s - assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_REVIEW, pair=Bigram(a, s)) + assert lesson.focus_key == h + assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=Bigram(s, h)) def test_lesson_wordlist_biases_weak_transition(): @@ -681,7 +703,7 @@ def test_lesson_wordlist_biases_weak_transition(): slow = 400_000_000.0 keys = { a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), - s: KeyStats(s, 9, at_target, 0, now, attempt_count=9), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), } transitions = { Bigram(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), @@ -745,7 +767,7 @@ def test_weak_transition_focus_over_represented_in_lesson_words(): slow = 400_000_000.0 keys = { a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), - s: KeyStats(s, 9, at_target, 0, now, attempt_count=9), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), } transitions = { Bigram(a, a): TransitionStats(a, a, 10, fast, 0, now, attempt_count=10), @@ -1017,6 +1039,206 @@ def test_lesson_boosts_coverage_starved_key_among_mastered_alphabet(): ) +def test_focus_stays_on_calibrating_key_despite_stale_mastered_peer(): + """Sticky focus: a key still short of skill+attempts keeps lesson focus + across repeated builds even though an already-mastered, stale peer key + exists (that peer's review-urgency discount previously could steal + focus). Once the calibrating key clears both conditions, focus moves on.""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s = order[0], order[1] + now = 1_700_000_000.0 + five_days = 5 * 86_400.0 + at_target = 200_000_000.0 + keys = { + a: KeyStats(a, 8, at_target, 0, now, attempt_count=8), + s: KeyStats(s, 10, at_target, 0, now - five_days, attempt_count=10), + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=2)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + ) + for _ in range(3): + assert builder("qwerty").focus_key == a + + # Once a clears skill + attempts, focus is free to move on. + keys[a] = KeyStats(a, 10, at_target, 0, now, attempt_count=10) + cache.put("qwerty", LayoutAggregates(keys=keys, transitions={})) + assert builder("qwerty").focus_key != a + + +def test_remedial_focus_targets_weak_key_from_low_wpm_lesson_alphabet(): + """A lesson that finished under its own WPM target pulls the next + lesson's focus back onto that lesson's own weak point, even though a + different key is weaker across the whole rolling window.""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s, h, d = order[0], order[1], order[2], order[3] + now = 1_700_000_000.0 + at_target = 200_000_000.0 + keys = { + a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), # mastered + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), # mastered + h: KeyStats(h, 10, at_target / 0.2, 0, now, attempt_count=10), # globally weakest + d: KeyStats(d, 10, at_target / 0.6, 0, now, attempt_count=10), # weak, but not weakest + } + target = target_ms_per_char(300) + # Sanity: plain weakest-across-window selection would land on h, not d. + assert select_focus((a, s, h, d), keys, target, now) == h + + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, + ) + slow_session = SessionResult( + schema_version=3, + session_id="slow", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(a, d), + focus_key=d, + total_keystrokes=1, + correct_keystrokes=1, + words_completed=1, + target_speed_cpm=300, + ) + session_repo = FakeSessionRepository(headers=[slow_session]) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=4, target_speed_cpm=300)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + session_repo=session_repo, + ) + assert builder("qwerty").focus_key == d + + +def test_remedial_focus_uses_session_word_bounds_not_current_settings(): + """Changing word-length bounds after a session finishes must not alter + whether that session triggers remedial focus on the next lesson.""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s, h, d = order[0], order[1], order[2], order[3] + now = 1_700_000_000.0 + at_target = 200_000_000.0 + keys = { + a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + h: KeyStats(h, 10, at_target / 0.2, 0, now, attempt_count=10), + d: KeyStats(d, 10, at_target / 0.6, 0, now, attempt_count=10), + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, + ) + # 50 wpm: below 100 wpm target at snapshotted 2-4 bounds, above 46 at 3-10. + slow_session = SessionResult( + schema_version=4, + session_id="slow", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(a, d), + focus_key=d, + total_keystrokes=1, + correct_keystrokes=1, + words_completed=50, + target_speed_cpm=300, + generated_min_len=2, + generated_max_len=4, + ) + session_repo = FakeSessionRepository(headers=[slow_session]) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository( + Settings( + alphabet_size=4, + target_speed_cpm=300, + generated_word_min_len=3, + generated_word_max_len=10, + ) + ), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + session_repo=session_repo, + ) + assert builder("qwerty").focus_key == d + + +def test_remedial_focus_clears_once_lesson_wpm_meets_target(): + """Once the most recent session's own WPM meets its own target again, + normal weakest-across-window focus selection resumes.""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s, h, d = order[0], order[1], order[2], order[3] + now = 1_700_000_000.0 + at_target = 200_000_000.0 + keys = { + a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + h: KeyStats(h, 10, at_target / 0.2, 0, now, attempt_count=10), + d: KeyStats(d, 10, at_target / 0.6, 0, now, attempt_count=10), + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, + ) + slow_session = SessionResult( + schema_version=3, + session_id="slow", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(a, d), + focus_key=d, + total_keystrokes=1, + correct_keystrokes=1, + words_completed=1, + target_speed_cpm=300, + ) + fast_session = SessionResult( + schema_version=3, + session_id="fast", + started_at=2.0, + duration_ns=1_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(a, d), + focus_key=d, + total_keystrokes=1, + correct_keystrokes=1, + words_completed=10, + target_speed_cpm=300, + ) + session_repo = FakeSessionRepository(headers=[slow_session, fast_session]) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=4, target_speed_cpm=300)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + session_repo=session_repo, + ) + assert builder("qwerty").focus_key == h + + def test_large_alphabet_unlock_advances_when_keys_rotated(): """26+ keys with sparse window stats still unlock next key once all meet floor.""" layout = BUNDLED_LAYOUTS["qwerty"] diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index cacb555..914b10f 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -5,17 +5,21 @@ from typing import TypedDict from keystrike.application.build_lesson import BuildLesson +from keystrike.application.session_queries import ( + compute_accuracy, + compute_wpm, + latest_session_header, + previous_session_header, + session_wpm_below_target, +) from keystrike.application.session_use_cases import ( AbortSession, FinishSession, RecordKeystroke, SessionStatsBaseline, StartSession, - compute_accuracy, - compute_wpm, confidence_window_session_baseline, count_words_completed, - previous_session_header, ) from keystrike.application.stats_use_cases import RebuildAggregates from keystrike.domain.aggregate import combine_sessions @@ -55,7 +59,7 @@ class _SessionStatsCommon(TypedDict): def _session_stats_common(*, duration_ns: int = 60_000_000_000) -> _SessionStatsCommon: return _SessionStatsCommon( - schema_version=3, + schema_version=4, started_at=1.0, duration_ns=duration_ns, layout="qwerty", @@ -417,6 +421,82 @@ def test_previous_session_header_skips_current(clock, id_gen): assert previous_session_header(repo, first) is None +def test_latest_session_header_picks_most_recent_by_started_at(): + older = SessionResult( + session_id="s1", total_keystrokes=1, correct_keystrokes=1, **_session_stats_common() + ) + newer = replace(older, session_id="s2", started_at=2.0) + repo = FakeSessionRepository(headers=[older, newer]) + assert latest_session_header(repo, "qwerty") == newer + + +def test_latest_session_header_none_when_no_sessions(): + assert latest_session_header(FakeSessionRepository(), "qwerty") is None + + +def test_session_wpm_below_target_true_when_slower_than_target(clock, id_gen): + _, result = _drive("hello world", "hello ", clock, id_gen) + result = replace(result, words_completed=1, duration_ns=60_000_000_000, target_speed_cpm=300) + assert session_wpm_below_target(result) is True + + +def test_session_wpm_below_target_false_when_faster_than_target(clock, id_gen): + _, result = _drive("hello world", "hello ", clock, id_gen) + result = replace(result, words_completed=20, duration_ns=1_000_000_000, target_speed_cpm=300) + assert session_wpm_below_target(result) is False + + +def test_session_wpm_below_target_false_for_legacy_session_without_target(): + result = SessionResult( + session_id="s1", + total_keystrokes=1, + correct_keystrokes=1, + **_session_stats_common(), + ) + assert result.target_speed_cpm == 0 + assert session_wpm_below_target(result) is False + + +def test_session_wpm_below_target_uses_snapshotted_word_bounds(): + """WPM target conversion must use bounds recorded at finish, not caller overrides.""" + result = replace( + SessionResult( + session_id="s1", total_keystrokes=1, correct_keystrokes=1, **_session_stats_common() + ), + words_completed=50, + duration_ns=60_000_000_000, + target_speed_cpm=300, + generated_min_len=2, + generated_max_len=4, + ) + # 50 wpm vs target 100 wpm (300 cpm / 3 chars per word at 2-4 bounds) + assert session_wpm_below_target(result) is True + # Wider bounds would lower target to ~46 wpm and incorrectly clear remedial. + assert ( + session_wpm_below_target( + result, + generated_min_len=3, + generated_max_len=10, + ) + is False + ) + + +def test_finish_session_persists_generated_word_bounds(clock, id_gen): + settings_repo = FakeSettingsRepository( + Settings(generated_word_min_len=3, generated_word_max_len=8) + ) + finish = FinishSession(clock=clock, settings_repo=settings_repo) + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + session = start("ab", layout="qwerty", mode=Mode.ADAPTIVE) + clock.advance(100_000_000) + record(session, "a") + result = finish(session) + assert result.generated_min_len == 3 + assert result.generated_max_len == 8 + + def test_finish_session_persists_unlocked_keys(clock, id_gen): settings_repo = FakeSettingsRepository() layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) @@ -464,7 +544,11 @@ def test_finish_session_bumps_alphabet_size_when_unlocked_set_grows(clock, id_ge start = StartSession(clock=clock, id_gen=id_gen) record = RecordKeystroke(clock=clock) - warmup = "".join(chr(cp) for _ in range(10) for cp in order[:5]) + newest = chr(order[4]) + cohort_drill = "".join( + (chr(peer) + newest) * 8 + (newest + chr(peer)) * 8 for peer in order[2:4] + ) + warmup = "".join(chr(cp) for _ in range(15) for cp in order[:5]) + cohort_drill warmup_session = start( warmup, layout="qwerty", @@ -514,7 +598,7 @@ def test_finish_session_persists_key_confidence(clock, id_gen): {}, target, ) - assert result.schema_version == 3 + assert result.schema_version == 4 assert set(result.key_confidence.keys()) == set(expected_unlocked) stats = combine_sessions([(result, session.keystrokes)]).keys for cp in expected_unlocked: diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index 17fb20b..6f5ba44 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -10,17 +10,8 @@ transition_accuracy_of, transition_confidence_of, ) -from keystrike.domain.focus import ( - blocks_transition_focus, - coverage_deficit_factor, - focus_key_from_transition, - newest_key_unmeasured_pairs, - practice_weight, - select_focus, - select_focus_transition, -) +from keystrike.domain.focus import coverage_deficit_factor, practice_weight from keystrike.domain.models import Bigram, KeyStats, TransitionStats -from keystrike.domain.unlock import compute_unlocked, newest_key_clears_transition_gate def test_target_ms_per_char(): @@ -134,53 +125,6 @@ def test_confidence_of_reaches_full_value_at_minimum_attempts(): assert confidence_of(ord("a"), stats, target=200.0) == 1.0 -def test_compute_unlocked_stalls_when_calibrating_on_attempts(): - learn_order = (1, 2, 3) - stats = { - 1: _stats(1, mean_time_ns=100_000_000.0), - 2: KeyStats( - 2, - samples=9, - mean_time_ns=100_000_000.0, - error_count=0, - last_seen=0.0, - attempt_count=9, - ), - } - target = 200.0 - assert skill_of(2, stats, target=target) == 1.0 - assert confidence_of(2, stats, target=target) == 0.9 - unlocked = compute_unlocked(learn_order, alphabet_size=2, stats=stats, target=target) - assert unlocked == (1, 2) - - -def test_compute_unlocked_advances_when_skill_and_attempts_met(): - learn_order = (1, 2, 3) - stats = { - 1: _stats(1, mean_time_ns=100_000_000.0), - 2: _stats(2, mean_time_ns=100_000_000.0), - } - unlocked = compute_unlocked(learn_order, alphabet_size=2, stats=stats, target=200.0) - assert unlocked == (1, 2, 3) - - -def test_compute_unlocked_stalls_when_sparse_key_looks_fast(): - learn_order = (1, 2, 3) - stats = { - 1: _stats(1, mean_time_ns=100_000_000.0), - 2: KeyStats( - 2, - samples=1, - mean_time_ns=100_000_000.0, - error_count=1, - last_seen=0.0, - attempt_count=2, - ), - } - unlocked = compute_unlocked(learn_order, alphabet_size=2, stats=stats, target=200.0) - assert unlocked == (1, 2) - - def test_transition_confidence_scales_down_with_few_attempts(): stats = { Bigram(ord("a"), ord("b")): TransitionStats( @@ -263,16 +207,6 @@ def test_confidence_of_penalizes_frequent_errors(): assert confidence_of(ord("a"), stats, target=200.0) == 0.5 -def test_compute_unlocked_stalls_on_high_error_rate_despite_fast_speed(): - learn_order = (1, 2, 3, 4) - # speed 2.0, but only 50% accuracy -> min(2.0, 0.5) = 0.5 < threshold - stats = {1: _stats(1, mean_time_ns=100_000_000.0, error_count=10)} - unlocked = compute_unlocked( - learn_order, alphabet_size=1, stats=stats, target=200.0, threshold=1.5 - ) - assert unlocked == (1,) - - def test_confidence_of_unseen_key_is_zero(): assert confidence_of(ord("a"), {}, target=200.0) == 0.0 @@ -288,167 +222,6 @@ def test_confidence_of_rounds_near_goal_to_mastery_threshold(): assert confidence_of(ord("i"), stats, target=200.0) == 1.0 -def test_compute_unlocked_force_includes_alphabet_size(): - learn_order = tuple(range(10)) - unlocked = compute_unlocked(learn_order, alphabet_size=3, stats={}, target=200.0) - assert unlocked == learn_order[:3] - - -def test_compute_unlocked_caps_alphabet_size_at_learn_order_length(): - learn_order = tuple(range(4)) - unlocked = compute_unlocked(learn_order, alphabet_size=99, stats={}, target=200.0) - assert unlocked == learn_order - - -def test_compute_unlocked_advances_when_threshold_met(): - learn_order = (1, 2, 3, 4) - stats = {1: _stats(1, mean_time_ns=100_000_000.0)} # confidence 2.0 - unlocked = compute_unlocked(learn_order, alphabet_size=1, stats=stats, target=200.0) - assert unlocked == (1, 2) - - -def test_compute_unlocked_stalls_when_threshold_not_met(): - learn_order = (1, 2, 3, 4) - stats = {1: _stats(1, mean_time_ns=400_000_000.0)} # confidence 0.5 - unlocked = compute_unlocked(learn_order, alphabet_size=1, stats=stats, target=200.0) - assert unlocked == (1,) - - -def test_compute_unlocked_ignores_weak_transitions(): - learn_order = tuple(ord(c) for c in "eabcdfghm") - stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in learn_order[:8]} - unlocked = compute_unlocked( - learn_order, - alphabet_size=8, - stats=stats, - target=200.0, - ) - assert unlocked == learn_order[:9] - assert unlocked[-1] == ord("m") - - -def test_compute_unlocked_gate_blocks_next_key_on_weak_newest_bigram(): - """With a transitions gate, the newest key's weakest bigram must clear - before the next key opens -- unlike the transitions-agnostic default.""" - e, a, b = ord("e"), ord("a"), ord("b") - learn_order = (e, a, b) - now = 1_700_000_000.0 - stats = { - e: _stats(e, mean_time_ns=100_000_000.0, last_seen=now), - a: _stats(a, mean_time_ns=100_000_000.0, last_seen=now), - } - transitions = { - Bigram(e, a): _transition(e, a, 400_000_000.0, last_seen=now, attempt_count=4), - Bigram(a, e): _transition(a, e, 100_000_000.0, last_seen=now, attempt_count=4), - } - unlocked = compute_unlocked( - learn_order, - alphabet_size=2, - stats=stats, - target=200.0, - transitions=transitions, - ) - assert unlocked == (e, a) - - -def test_compute_unlocked_gate_allows_next_key_once_newest_bigram_clears(): - e, a, b = ord("e"), ord("a"), ord("b") - learn_order = (e, a, b) - now = 1_700_000_000.0 - stats = { - e: _stats(e, mean_time_ns=100_000_000.0, last_seen=now), - a: _stats(a, mean_time_ns=100_000_000.0, last_seen=now), - } - transitions = { - Bigram(e, a): _transition(e, a, 100_000_000.0, last_seen=now, attempt_count=4), - Bigram(a, e): _transition(a, e, 100_000_000.0, last_seen=now, attempt_count=4), - } - unlocked = compute_unlocked( - learn_order, - alphabet_size=2, - stats=stats, - target=200.0, - transitions=transitions, - ) - assert unlocked == (e, a, b) - - -def test_compute_unlocked_gate_stall_cap_unblocks_stuck_bigram(): - """A bigram that never clears threshold shouldn't block progression - forever once it's been drilled past the stall cap.""" - e, a, b = ord("e"), ord("a"), ord("b") - learn_order = (e, a, b) - now = 1_700_000_000.0 - stats = { - e: _stats(e, mean_time_ns=100_000_000.0, last_seen=now), - a: _stats(a, mean_time_ns=100_000_000.0, last_seen=now), - } - transitions = { - Bigram(e, a): _transition(e, a, 400_000_000.0, last_seen=now, attempt_count=12), - Bigram(a, e): _transition(a, e, 100_000_000.0, last_seen=now, attempt_count=12), - } - unlocked = compute_unlocked( - learn_order, - alphabet_size=2, - stats=stats, - target=200.0, - transitions=transitions, - transition_stall_attempts_cap=12, - ) - assert unlocked == (e, a, b) - - -def test_select_focus_picks_weakest_unlocked_key(): - stats = { - 1: _stats(1, mean_time_ns=100_000_000.0), # confidence 2.0 - 2: _stats(2, mean_time_ns=400_000_000.0), # confidence 0.5 - } - assert select_focus((1, 2), stats, target=200.0, now=1000.0) == 2 - - -def test_blocks_transition_focus_false_when_key_calibrating(): - stats = { - ord("t"): KeyStats( - ord("t"), - samples=9, - mean_time_ns=125_000_000.0, - error_count=0, - last_seen=0.0, - attempt_count=9, - ), - } - target = target_ms_per_char(300) - assert skill_of(ord("t"), stats, target=target) == 1.0 - assert confidence_of(ord("t"), stats, target=target) == 0.9 - assert blocks_transition_focus((ord("t"),), stats, target=target) is False - - -def test_blocks_transition_focus_true_for_never_practiced(): - stats = {1: _stats(1, mean_time_ns=200_000_000.0)} - assert blocks_transition_focus((1, 2), stats, target=200.0) is False - - -def test_blocks_transition_focus_ignores_never_practiced_key(): - a, s, h = ord("a"), ord("s"), ord("h") - stats = { - a: _stats(a, mean_time_ns=200_000_000.0), - s: _stats(s, mean_time_ns=200_000_000.0), - } - assert blocks_transition_focus((a, s, h), stats, target=200.0) is False - - -def test_blocks_transition_focus_true_for_measured_weak_key(): - h = ord("h") - stats = {h: _stats(h, mean_time_ns=400_000_000.0)} - assert skill_of(h, stats, target=200.0) < 1.0 - assert blocks_transition_focus((h,), stats, target=200.0) is True - - -def test_select_focus_prefers_never_practiced_key(): - stats = {1: _stats(1, mean_time_ns=100_000_000.0)} - assert select_focus((1, 2), stats, target=200.0, now=1000.0) == 2 - - def test_review_urgency_never_seen_or_fresh_is_zero(): assert review_urgency(0.0, 1_000_000.0) == 0.0 assert review_urgency(1000.0, 1000.0) == 0.0 @@ -469,16 +242,6 @@ def test_practice_weight_boosts_stale_mastered_key(): assert practice_weight(1.0, urgency=1.0) == 2.0 -def test_select_focus_picks_stale_over_slightly_weaker_recent(): - now = 1_000_000.0 - five_days = 5 * 86_400.0 - stats = { - 1: _stats(1, mean_time_ns=210_000_000.0, last_seen=now - five_days), # ~0.95 - 2: _stats(2, mean_time_ns=235_000_000.0, last_seen=now), # ~0.85 - } - assert select_focus((1, 2), stats, target=200.0, now=now) == 1 - - def test_practice_weight_unpracticed_key_is_max_biased(): assert practice_weight(0.0, max_bias=3.0) == 4.0 @@ -532,360 +295,3 @@ def _transition( last_seen=last_seen, attempt_count=attempts, ) - - -def test_select_focus_transition_ignores_same_key_pairs(): - now = 1_700_000_000.0 - unlocked = (ord("e"), ord("a")) - transitions = { - Bigram(ord("e"), ord("e")): _transition( - ord("e"), - ord("e"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - Bigram(ord("a"), ord("a")): _transition( - ord("a"), - ord("a"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - } - assert select_focus_transition(unlocked, transitions, 200.0, now) is None - - -def test_select_focus_transition_skips_unmeasured_when_unlocked_key_unpracticed(): - now = 1_700_000_000.0 - unlocked = (ord("e"), ord("a")) - transitions = { - Bigram(ord("e"), ord("e")): _transition( - ord("e"), - ord("e"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - } - assert select_focus_transition(unlocked, transitions, 200.0, now) is None - - -def test_select_focus_transition_never_picks_same_key_even_when_weakest(): - now = 1_700_000_000.0 - unlocked = (ord("a"), ord("b")) - fast = 100_000_000.0 - transitions = { - Bigram(ord("a"), ord("a")): _transition( - ord("a"), - ord("a"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - Bigram(ord("a"), ord("b")): _transition( - ord("a"), - ord("b"), - fast, - last_seen=now, - attempt_count=10, - ), - Bigram(ord("b"), ord("a")): _transition( - ord("b"), - ord("a"), - fast, - last_seen=now, - attempt_count=10, - ), - Bigram(ord("b"), ord("b")): _transition( - ord("b"), - ord("b"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - } - result = select_focus_transition(unlocked, transitions, 200.0, now) - assert result is not None - assert result[0] != result[1] - - -def test_select_focus_transition_picks_stale_over_slightly_weaker_recent(): - now = 1_000_000.0 - five_days = 5 * 86_400.0 - unlocked = (ord("a"), ord("b")) - fast = 100_000_000.0 - transitions = { - Bigram(ord("a"), ord("a")): _transition(ord("a"), ord("a"), fast, last_seen=now), - Bigram(ord("a"), ord("b")): _transition( - ord("a"), - ord("b"), - 210_000_000.0, - last_seen=now - five_days, - ), - Bigram(ord("b"), ord("a")): _transition(ord("b"), ord("a"), 235_000_000.0, last_seen=now), - Bigram(ord("b"), ord("b")): _transition(ord("b"), ord("b"), fast, last_seen=now), - } - assert select_focus_transition(unlocked, transitions, 200.0, now) == (ord("a"), ord("b")) - - -def test_select_focus_transition_returns_none_without_unlocked_pairs(): - assert select_focus_transition((), {}, target=200.0, now=1000.0) is None - assert select_focus_transition((ord("a"),), {}, target=200.0, now=1000.0) is None - - -def test_select_focus_transition_picks_unmeasured_pair_when_all_keys_practiced(): - stats = { - 1: KeyStats(1, 10, 200_000_000.0, 0, 1.0, attempt_count=10), - 2: KeyStats(2, 10, 200_000_000.0, 0, 1.0, attempt_count=10), - } - assert select_focus_transition((1, 2), {}, target=200.0, now=1000.0, key_stats=stats) == ( - 1, - 2, - ) - - -def test_select_focus_transition_skips_unmeasured_pair_on_cold_start(): - assert select_focus_transition((1, 2), {}, target=200.0, now=1000.0) is None - - -def test_select_focus_transition_ignores_unmeasured_pairs(): - unlocked = (ord("a"), ord("b"), ord("c")) - now = 1_700_000_000.0 - fast = 100_000_000.0 - transitions = { - Bigram(ord("a"), ord("b")): _transition( - ord("a"), - ord("b"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - Bigram(ord("b"), ord("c")): _transition( - ord("b"), - ord("c"), - fast, - last_seen=now, - attempt_count=10, - ), - } - assert select_focus_transition(unlocked, transitions, 200.0, now) == (ord("a"), ord("b")) - - -def test_select_focus_transition_prefers_newest_key_over_older_measured_pair(): - """Once a third key is unlocked, its bigrams should win focus over an - already-measured (and merely weak, not unmeasured) older pair, so a - freshly-opened letter gets bigram practice right away.""" - unlocked = (ord("a"), ord("b"), ord("c")) - now = 1_700_000_000.0 - stats = { - ord("a"): KeyStats(ord("a"), 10, 200_000_000.0, 0, now, attempt_count=10), - ord("b"): KeyStats(ord("b"), 10, 200_000_000.0, 0, now, attempt_count=10), - ord("c"): KeyStats(ord("c"), 10, 200_000_000.0, 0, now, attempt_count=10), - } - transitions = { - Bigram(ord("a"), ord("b")): _transition( - ord("a"), - ord("b"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - } - # c is unlocked and practiced solo, but none of its transitions are - # measured yet -- it should still win over the already-measured (a, b). - result = select_focus_transition(unlocked, transitions, 200.0, now, key_stats=stats) - assert result is not None - assert ord("c") in result - - -def test_select_focus_transition_uses_measured_pairs_once_newest_key_has_data(): - """Once the newest key has its own measured transition, ordinary - weakest-pair scoring resumes -- no permanent bias toward the newest key.""" - unlocked = (ord("a"), ord("b"), ord("c")) - now = 1_700_000_000.0 - fast = 100_000_000.0 - stats = { - ord("a"): KeyStats(ord("a"), 10, 200_000_000.0, 0, now, attempt_count=10), - ord("b"): KeyStats(ord("b"), 10, 200_000_000.0, 0, now, attempt_count=10), - ord("c"): KeyStats(ord("c"), 10, 200_000_000.0, 0, now, attempt_count=10), - } - transitions = { - Bigram(ord("a"), ord("b")): _transition( - ord("a"), - ord("b"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - Bigram(ord("b"), ord("c")): _transition( - ord("b"), - ord("c"), - fast, - last_seen=now, - attempt_count=10, - ), - } - assert select_focus_transition(unlocked, transitions, 200.0, now, key_stats=stats) == ( - ord("a"), - ord("b"), - ) - - -def test_newest_key_unmeasured_pairs_excludes_unpracticed_cascade_key(): - """A key unlocked by cascade (no KeyStats yet) shouldn't get bigram - candidacy just for sitting next to the newest practiced key.""" - unlocked = (ord("a"), ord("s"), ord("h"), ord("d"), ord("l")) - now = 1_700_000_000.0 - stats = { - cp: KeyStats(cp, 10, 200_000_000.0, 0, now, attempt_count=10) - for cp in (ord("a"), ord("s"), ord("h"), ord("d")) - } - pairs = newest_key_unmeasured_pairs(unlocked, {}, stats) - assert pairs - assert all(ord("l") not in (p.prev_cp, p.next_cp) for p in pairs) - assert all(ord("d") in (p.prev_cp, p.next_cp) for p in pairs) - - -def test_newest_key_unmeasured_pairs_empty_once_newest_has_any_measured_pair(): - unlocked = (ord("a"), ord("s"), ord("d")) - now = 1_700_000_000.0 - stats = { - cp: KeyStats(cp, 10, 200_000_000.0, 0, now, attempt_count=10) - for cp in (ord("a"), ord("s"), ord("d")) - } - transitions = { - Bigram(ord("s"), ord("d")): _transition( - ord("s"), ord("d"), 200_000_000.0, last_seen=now, attempt_count=10 - ), - } - assert newest_key_unmeasured_pairs(unlocked, transitions, stats) == [] - - -def test_newest_key_clears_transition_gate_vacuous_with_one_key(): - now = 1_700_000_000.0 - stats = {ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now)} - assert newest_key_clears_transition_gate((ord("a"),), {}, 200.0, stats) is True - - -def test_newest_key_clears_transition_gate_false_when_no_pair_measured_yet(): - """Newest key practiced solo but hasn't typed any bigram with a peer - yet -- gate stays closed; lesson weighting is what pushes exposure.""" - unlocked = (ord("a"), ord("b")) - now = 1_700_000_000.0 - stats = { - ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), - ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), - } - assert newest_key_clears_transition_gate(unlocked, {}, 200.0, stats) is False - - -def test_newest_key_clears_transition_gate_false_when_weakest_pair_weak(): - unlocked = (ord("a"), ord("b")) - now = 1_700_000_000.0 - stats = { - ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), - ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), - } - transitions = { - Bigram(ord("a"), ord("b")): _transition( - ord("a"), ord("b"), 400_000_000.0, last_seen=now, attempt_count=4 - ), - Bigram(ord("b"), ord("a")): _transition( - ord("b"), ord("a"), 100_000_000.0, last_seen=now, attempt_count=4 - ), - } - assert newest_key_clears_transition_gate(unlocked, transitions, 200.0, stats) is False - - -def test_newest_key_clears_transition_gate_true_when_weakest_pair_solid(): - unlocked = (ord("a"), ord("b")) - now = 1_700_000_000.0 - stats = { - ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), - ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), - } - transitions = { - Bigram(ord("a"), ord("b")): _transition( - ord("a"), ord("b"), 100_000_000.0, last_seen=now, attempt_count=4 - ), - Bigram(ord("b"), ord("a")): _transition( - ord("b"), ord("a"), 100_000_000.0, last_seen=now, attempt_count=4 - ), - } - assert newest_key_clears_transition_gate(unlocked, transitions, 200.0, stats) is True - - -def test_newest_key_clears_transition_gate_ignores_untouched_peer_pair(): - """Regression: one measured (weak-but-clearable) pair and one entirely - untouched peer pair -- the gate must judge only the measured pair, not - treat the untouched one as an automatic (and un-releasable) failure.""" - unlocked = (ord("a"), ord("b"), ord("c")) - now = 1_700_000_000.0 - stats = { - ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), - ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), - ord("c"): _stats(ord("c"), mean_time_ns=100_000_000.0, last_seen=now), - } - # Only a<->c is measured (and solid); b<->c is never touched at all. - transitions = { - Bigram(ord("a"), ord("c")): _transition( - ord("a"), ord("c"), 100_000_000.0, last_seen=now, attempt_count=4 - ), - Bigram(ord("c"), ord("a")): _transition( - ord("c"), ord("a"), 100_000_000.0, last_seen=now, attempt_count=4 - ), - } - assert newest_key_clears_transition_gate(unlocked, transitions, 200.0, stats) is True - - -def test_newest_key_clears_transition_gate_stall_cap_overrides_weak_pair(): - unlocked = (ord("a"), ord("b")) - now = 1_700_000_000.0 - stats = { - ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), - ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), - } - transitions = { - Bigram(ord("a"), ord("b")): _transition( - ord("a"), ord("b"), 400_000_000.0, last_seen=now, attempt_count=12 - ), - Bigram(ord("b"), ord("a")): _transition( - ord("b"), ord("a"), 100_000_000.0, last_seen=now, attempt_count=12 - ), - } - assert ( - newest_key_clears_transition_gate( - unlocked, transitions, 200.0, stats, stall_attempts_cap=12 - ) - is True - ) - assert ( - newest_key_clears_transition_gate( - unlocked, transitions, 200.0, stats, stall_attempts_cap=13 - ) - is False - ) - - -def test_select_focus_transition_falls_back_to_unmeasured_unlocked_pair(): - unlocked = (ord("a"), ord("b")) - stats = { - ord("a"): KeyStats(ord("a"), 10, 200_000_000.0, 0, 1.0, attempt_count=10), - ord("b"): KeyStats(ord("b"), 10, 200_000_000.0, 0, 1.0, attempt_count=10), - } - transitions = { - Bigram(ord("y"), ord("z")): _transition( - ord("y"), - ord("z"), - 100_000_000.0, - last_seen=1_700_000_000.0, - ), - } - assert select_focus_transition( - unlocked, transitions, 200.0, now=1_700_000_000.0, key_stats=stats - ) == (ord("a"), ord("b")) - - -def test_focus_key_from_transition_uses_next_endpoint(): - assert focus_key_from_transition(ord("t"), ord("h")) == ord("h") diff --git a/tests/domain/test_focus.py b/tests/domain/test_focus.py new file mode 100644 index 0000000..ecbbcae --- /dev/null +++ b/tests/domain/test_focus.py @@ -0,0 +1,499 @@ +import pytest + +from keystrike.domain.confidence import ( + MIN_CONFIDENCE_ATTEMPTS, + attempts_of, + skill_from_stats, + skill_of, + target_ms_per_char, +) +from keystrike.domain.enums import FocusKind +from keystrike.domain.focus import ( + FocusReason, + blocks_transition_focus, + focus_key_from_transition, + remedial_focus, + select_focus, + select_focus_transition, +) +from keystrike.domain.models import Bigram, KeyStats, TransitionStats + + +def _stats( + codepoint: int, + mean_time_ns: float, + error_count: int = 0, + *, + last_seen: float = 0.0, +) -> KeyStats: + return KeyStats( + codepoint=codepoint, + samples=10, + mean_time_ns=mean_time_ns, + error_count=error_count, + last_seen=last_seen, + attempt_count=10 + error_count, + ) + + +def _transition( + prev_cp: int, + next_cp: int, + mean_time_ns: float, + *, + last_seen: float = 0.0, + error_count: int = 0, + attempt_count: int | None = None, +) -> TransitionStats: + attempts = attempt_count if attempt_count is not None else 10 + error_count + return TransitionStats( + prev_cp=prev_cp, + next_cp=next_cp, + samples=10, + mean_time_ns=mean_time_ns, + error_count=error_count, + last_seen=last_seen, + attempt_count=attempts, + ) + + +@pytest.mark.parametrize( + ("kind", "pair", "message"), + [ + (FocusKind.TRANSITION_WEAK, None, "requires a pair"), + (FocusKind.TRANSITION_REVIEW, None, "requires a pair"), + (FocusKind.KEY_WEAK, Bigram(ord("a"), ord("s")), "must not have a pair"), + ], +) +def test_focus_reason_rejects_invalid_kind_pair_combo(kind, pair, message): + with pytest.raises(ValueError, match=message): + FocusReason(kind=kind, pair=pair) + + +def test_focus_reason_accepts_transition_with_pair(): + reason = FocusReason( + kind=FocusKind.TRANSITION_WEAK, + pair=Bigram(ord("a"), ord("s")), + ) + assert reason.pair == Bigram(ord("a"), ord("s")) + assert reason.is_transition is True + + +def test_focus_reason_key_kind_is_not_transition(): + reason = FocusReason(kind=FocusKind.KEY_WEAK) + assert reason.is_transition is False + + +def test_remedial_focus_picks_weakest_key_in_lesson_alphabet(): + """Weakest key within the remedial pool, not the globally weakest key.""" + now = 1_700_000_000.0 + at_target = 200_000_000.0 + a, s, h, d = ord("a"), ord("s"), ord("h"), ord("d") + stats = { + a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), + s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), + h: KeyStats(h, 10, at_target / 0.2, 0, now, attempt_count=10), + d: KeyStats(d, 10, at_target / 0.6, 0, now, attempt_count=10), + } + target = target_ms_per_char(300) + assert select_focus((a, s, h, d), stats, target, now) == h + + result = remedial_focus((a, d), (a, s, h, d), stats, {}, target, now=now) + assert result == (d, None) + + +def test_remedial_focus_none_when_alphabet_disjoint_from_unlocked(): + now = 1_700_000_000.0 + at_target = 200_000_000.0 + a = ord("a") + stats = {a: KeyStats(a, 10, at_target, 0, now, attempt_count=10)} + target = target_ms_per_char(300) + + assert remedial_focus((ord("z"),), (a,), stats, {}, target, now=now) is None + + +def test_select_focus_picks_weakest_unlocked_key(): + stats = { + 1: _stats(1, mean_time_ns=100_000_000.0), # confidence 2.0 + 2: _stats(2, mean_time_ns=400_000_000.0), # confidence 0.5 + } + assert select_focus((1, 2), stats, target=200.0, now=1000.0) == 2 + + +def test_blocks_transition_focus_true_when_key_below_attempt_floor(): + stats = { + ord("t"): KeyStats( + ord("t"), + samples=9, + mean_time_ns=125_000_000.0, + error_count=0, + last_seen=0.0, + attempt_count=9, + ), + } + target = target_ms_per_char(300) + assert skill_of(ord("t"), stats, target=target) == 1.0 + assert blocks_transition_focus((ord("t"),), stats, target=target) is True + + +def test_blocks_transition_focus_true_for_never_practiced(): + stats = {1: _stats(1, mean_time_ns=200_000_000.0)} + assert blocks_transition_focus((1, 2), stats, target=200.0) is True + + +def test_blocks_transition_focus_includes_never_practiced_key(): + a, s, h = ord("a"), ord("s"), ord("h") + stats = { + a: _stats(a, mean_time_ns=200_000_000.0), + s: _stats(s, mean_time_ns=200_000_000.0), + } + assert blocks_transition_focus((a, s, h), stats, target=200.0) is True + + +def test_blocks_transition_focus_true_for_measured_weak_key(): + h = ord("h") + stats = {h: _stats(h, mean_time_ns=400_000_000.0)} + assert skill_of(h, stats, target=200.0) < 1.0 + assert blocks_transition_focus((h,), stats, target=200.0) is True + + +def test_select_focus_prefers_never_practiced_key(): + stats = {1: _stats(1, mean_time_ns=100_000_000.0)} + assert select_focus((1, 2), stats, target=200.0, now=1000.0) == 2 + + +def test_select_focus_stays_on_calibrating_key_over_stale_mastered_peer(): + """A key still short of the attempt floor must not lose focus to an + already-mastered, stale peer -- that peer's review-urgency discount can + make it look "weaker" by raw score, but it has already cleared both + conditions (skill + attempts) so it must not preempt an in-progress key.""" + now = 1_700_000_000.0 + five_days = 5 * 86_400.0 + at_target = 200_000_000.0 + calibrating = KeyStats( + codepoint=1, + samples=8, + mean_time_ns=at_target, + error_count=0, + last_seen=now, + attempt_count=8, + ) + mastered_stale = KeyStats( + codepoint=2, + samples=10, + mean_time_ns=at_target, + error_count=0, + last_seen=now - five_days, + attempt_count=10, + ) + stats = {1: calibrating, 2: mastered_stale} + target = 200.0 + assert skill_of(1, stats, target) == 1.0 + assert attempts_of(calibrating) < MIN_CONFIDENCE_ATTEMPTS + assert skill_of(2, stats, target) == 1.0 + assert attempts_of(mastered_stale) >= MIN_CONFIDENCE_ATTEMPTS + # Without the stickiness fix, key 2's review-urgency discount would drop + # its score below key 1's, stealing focus mid-calibration. + assert select_focus((1, 2), stats, target=target, now=now) == 1 + + +def test_select_focus_picks_stale_over_slightly_weaker_recent(): + now = 1_000_000.0 + five_days = 5 * 86_400.0 + stats = { + 1: _stats(1, mean_time_ns=210_000_000.0, last_seen=now - five_days), # ~0.95 + 2: _stats(2, mean_time_ns=235_000_000.0, last_seen=now), # ~0.85 + } + assert select_focus((1, 2), stats, target=200.0, now=now) == 1 + + +def test_select_focus_transition_ignores_same_key_pairs(): + now = 1_700_000_000.0 + unlocked = (ord("e"), ord("a")) + transitions = { + Bigram(ord("e"), ord("e")): _transition( + ord("e"), + ord("e"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + Bigram(ord("a"), ord("a")): _transition( + ord("a"), + ord("a"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + } + assert select_focus_transition(unlocked, transitions, 200.0, now) is None + + +def test_select_focus_transition_skips_unmeasured_when_unlocked_key_unpracticed(): + now = 1_700_000_000.0 + unlocked = (ord("e"), ord("a")) + transitions = { + Bigram(ord("e"), ord("e")): _transition( + ord("e"), + ord("e"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + } + assert select_focus_transition(unlocked, transitions, 200.0, now) is None + + +def test_select_focus_transition_never_picks_same_key_even_when_weakest(): + now = 1_700_000_000.0 + unlocked = (ord("a"), ord("b")) + fast = 100_000_000.0 + transitions = { + Bigram(ord("a"), ord("a")): _transition( + ord("a"), + ord("a"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + Bigram(ord("a"), ord("b")): _transition( + ord("a"), + ord("b"), + fast, + last_seen=now, + attempt_count=10, + ), + Bigram(ord("b"), ord("a")): _transition( + ord("b"), + ord("a"), + fast, + last_seen=now, + attempt_count=10, + ), + Bigram(ord("b"), ord("b")): _transition( + ord("b"), + ord("b"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + } + result = select_focus_transition(unlocked, transitions, 200.0, now) + assert result is not None + assert result[0] != result[1] + + +def test_select_focus_transition_picks_stale_over_slightly_weaker_recent(): + now = 1_000_000.0 + five_days = 5 * 86_400.0 + unlocked = (ord("a"), ord("b")) + fast = 100_000_000.0 + transitions = { + Bigram(ord("a"), ord("a")): _transition(ord("a"), ord("a"), fast, last_seen=now), + Bigram(ord("a"), ord("b")): _transition( + ord("a"), + ord("b"), + 210_000_000.0, + last_seen=now - five_days, + ), + Bigram(ord("b"), ord("a")): _transition(ord("b"), ord("a"), 235_000_000.0, last_seen=now), + Bigram(ord("b"), ord("b")): _transition(ord("b"), ord("b"), fast, last_seen=now), + } + assert select_focus_transition(unlocked, transitions, 200.0, now) == (ord("a"), ord("b")) + + +def test_select_focus_transition_stays_on_calibrating_pair_over_stale_mastered_peer(): + """Mirrors `test_select_focus_stays_on_calibrating_key_over_stale_mastered_peer` + for bigrams: an in-progress pair (below the attempt floor) must not lose + focus to an already-cleared, merely-stale measured pair.""" + now = 1_700_000_000.0 + five_days = 5 * 86_400.0 + unlocked = (ord("a"), ord("b"), ord("c")) + calibrating = Bigram(ord("a"), ord("b")) + mastered_stale = Bigram(ord("b"), ord("c")) + transitions = { + calibrating: _transition(*calibrating, 200_000_000.0, last_seen=now, attempt_count=2), + mastered_stale: _transition( + *mastered_stale, 200_000_000.0, last_seen=now - five_days, attempt_count=10 + ), + } + target = 200.0 + assert skill_from_stats(transitions[calibrating], target) == 1.0 + assert attempts_of(transitions[calibrating]) < 4 # below MIN_TRANSITION_CONFIDENCE_ATTEMPTS + assert skill_from_stats(transitions[mastered_stale], target) == 1.0 + assert attempts_of(transitions[mastered_stale]) >= 4 + assert select_focus_transition(unlocked, transitions, target, now) == calibrating + + +def test_select_focus_transition_returns_none_without_unlocked_pairs(): + assert select_focus_transition((), {}, target=200.0, now=1000.0) is None + assert select_focus_transition((ord("a"),), {}, target=200.0, now=1000.0) is None + + +def test_select_focus_transition_picks_unmeasured_pair_when_all_keys_practiced(): + stats = { + 1: KeyStats(1, 10, 200_000_000.0, 0, 1.0, attempt_count=10), + 2: KeyStats(2, 10, 200_000_000.0, 0, 1.0, attempt_count=10), + } + assert select_focus_transition((1, 2), {}, target=200.0, now=1000.0, key_stats=stats) == ( + 1, + 2, + ) + + +def test_select_focus_transition_skips_unmeasured_pair_on_cold_start(): + assert select_focus_transition((1, 2), {}, target=200.0, now=1000.0) is None + + +def test_select_focus_transition_ignores_unmeasured_pairs(): + unlocked = (ord("a"), ord("b"), ord("c")) + now = 1_700_000_000.0 + fast = 100_000_000.0 + transitions = { + Bigram(ord("a"), ord("b")): _transition( + ord("a"), + ord("b"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + Bigram(ord("b"), ord("c")): _transition( + ord("b"), + ord("c"), + fast, + last_seen=now, + attempt_count=10, + ), + } + assert select_focus_transition(unlocked, transitions, 200.0, now) == (ord("a"), ord("b")) + + +def test_select_focus_transition_prefers_newest_key_over_older_measured_pair(): + """Once a third key is unlocked, its bigrams should win focus over an + already-measured (and merely weak, not unmeasured) older pair, so a + freshly-opened letter gets bigram practice right away.""" + unlocked = (ord("a"), ord("b"), ord("c")) + now = 1_700_000_000.0 + stats = { + ord("a"): KeyStats(ord("a"), 10, 200_000_000.0, 0, now, attempt_count=10), + ord("b"): KeyStats(ord("b"), 10, 200_000_000.0, 0, now, attempt_count=10), + ord("c"): KeyStats(ord("c"), 10, 200_000_000.0, 0, now, attempt_count=10), + } + transitions = { + Bigram(ord("a"), ord("b")): _transition( + ord("a"), + ord("b"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + } + # c is unlocked and practiced solo, but none of its transitions are + # measured yet -- it should still win over the already-measured (a, b). + result = select_focus_transition(unlocked, transitions, 200.0, now, key_stats=stats) + assert result is not None + assert ord("c") in result + + +def test_select_focus_transition_uses_measured_pairs_once_newest_key_has_data(): + """Once the newest key has its own measured transition, ordinary + weakest-pair scoring resumes -- no permanent bias toward the newest key.""" + unlocked = (ord("a"), ord("b"), ord("c")) + now = 1_700_000_000.0 + fast = 100_000_000.0 + stats = { + ord("a"): KeyStats(ord("a"), 10, 200_000_000.0, 0, now, attempt_count=10), + ord("b"): KeyStats(ord("b"), 10, 200_000_000.0, 0, now, attempt_count=10), + ord("c"): KeyStats(ord("c"), 10, 200_000_000.0, 0, now, attempt_count=10), + } + transitions = { + Bigram(ord("a"), ord("b")): _transition( + ord("a"), + ord("b"), + 400_000_000.0, + last_seen=now, + attempt_count=10, + ), + Bigram(ord("b"), ord("c")): _transition( + ord("b"), + ord("c"), + fast, + last_seen=now, + attempt_count=10, + ), + } + assert select_focus_transition(unlocked, transitions, 200.0, now, key_stats=stats) == ( + ord("a"), + ord("b"), + ) + + +def test_select_focus_transition_falls_back_to_unmeasured_unlocked_pair(): + unlocked = (ord("a"), ord("b")) + stats = { + ord("a"): KeyStats(ord("a"), 10, 200_000_000.0, 0, 1.0, attempt_count=10), + ord("b"): KeyStats(ord("b"), 10, 200_000_000.0, 0, 1.0, attempt_count=10), + } + transitions = { + Bigram(ord("y"), ord("z")): _transition( + ord("y"), + ord("z"), + 100_000_000.0, + last_seen=1_700_000_000.0, + ), + } + assert select_focus_transition( + unlocked, transitions, 200.0, now=1_700_000_000.0, key_stats=stats + ) == (ord("a"), ord("b")) + + +def test_focus_key_from_transition_uses_next_endpoint(): + assert focus_key_from_transition(ord("t"), ord("h")) == ord("h") + + +def test_key_attempt_floor_blocks_transition_focus(): + cp = ord("a") + stats = { + cp: KeyStats(cp, 9, 100_000_000.0, 0, 1.0, attempt_count=9), + } + assert blocks_transition_focus((cp,), stats, 200.0, min_attempts=10) + + +def test_sparse_old_transition_does_not_preempt_gating_cohort(): + a, b, c = map(ord, "abc") + gate = Bigram(b, c) + old = Bigram(a, b) + transitions = { + old: _transition(a, b, 400_000_000.0, attempt_count=3), + } + assert ( + select_focus_transition( + (a, b, c), + transitions, + 200.0, + 1_000.0, + gating_candidates=(gate,), + min_attempts=4, + ) + == gate + ) + + +def test_genuine_old_transition_regression_preempts_gating_cohort(): + a, b, c = map(ord, "abc") + gate = Bigram(b, c) + old = Bigram(a, b) + transitions = { + old: _transition(a, b, 400_000_000.0, attempt_count=4), + } + assert ( + select_focus_transition( + (a, b, c), + transitions, + 200.0, + 1_000.0, + gating_candidates=(gate,), + min_attempts=4, + ) + == old + ) diff --git a/tests/domain/test_generator.py b/tests/domain/test_generator.py index e6e96bb..dfdaa4a 100644 --- a/tests/domain/test_generator.py +++ b/tests/domain/test_generator.py @@ -532,3 +532,24 @@ def test_generate_lesson_weak_guarantees_focus_bigram_fraction(): 1 for w in words if word_matches_focus(w, focus_char="b", focus_bigram=bigram_str) ) assert focus_words >= quota, f"seed={seed}: {focus_words}/{len(words)} bigram words" + + +def test_generate_lesson_distributes_multi_bigram_coverage(): + generator = AdaptiveGenerator(table=_uniform_table("abcd"), rng=Random(0)) + pairs = ( + Bigram(ord("a"), ord("d")), + Bigram(ord("d"), ord("a")), + Bigram(ord("b"), ord("d")), + Bigram(ord("d"), ord("b")), + ) + lesson = generator.generate_lesson( + frozenset("abcd"), + focus_char="d", + word_count=12, + focus_bigram=pairs[0], + focus_bigrams=pairs, + min_focus_words=8, + ) + words = lesson.split() + assert all(any(pair.chars() in word for word in words) for pair in pairs) + assert sum(any(pair.chars() in word for pair in pairs) for word in words) >= 8 diff --git a/tests/domain/test_newest_key.py b/tests/domain/test_newest_key.py new file mode 100644 index 0000000..86e6979 --- /dev/null +++ b/tests/domain/test_newest_key.py @@ -0,0 +1,94 @@ +from keystrike.domain.focus import newest_key_unmeasured_pairs +from keystrike.domain.models import Bigram, KeyStats, TransitionStats +from keystrike.domain.newest_key import effective_gating_bigram_limit, newest_key_gating_cohort + + +def _stats( + codepoint: int, + mean_time_ns: float, + error_count: int = 0, + *, + last_seen: float = 0.0, +) -> KeyStats: + return KeyStats( + codepoint=codepoint, + samples=10, + mean_time_ns=mean_time_ns, + error_count=error_count, + last_seen=last_seen, + attempt_count=10 + error_count, + ) + + +def _transition( + prev_cp: int, + next_cp: int, + mean_time_ns: float, + *, + last_seen: float = 0.0, + error_count: int = 0, + attempt_count: int | None = None, +) -> TransitionStats: + attempts = attempt_count if attempt_count is not None else 10 + error_count + return TransitionStats( + prev_cp=prev_cp, + next_cp=next_cp, + samples=10, + mean_time_ns=mean_time_ns, + error_count=error_count, + last_seen=last_seen, + attempt_count=attempts, + ) + + +def test_newest_key_unmeasured_pairs_excludes_unpracticed_cascade_key(): + """A key unlocked by cascade (no KeyStats yet) shouldn't get bigram + candidacy just for sitting next to the newest practiced key.""" + unlocked = (ord("a"), ord("s"), ord("h"), ord("d"), ord("l")) + now = 1_700_000_000.0 + stats = { + cp: KeyStats(cp, 10, 200_000_000.0, 0, now, attempt_count=10) + for cp in (ord("a"), ord("s"), ord("h"), ord("d")) + } + pairs = newest_key_unmeasured_pairs(unlocked, {}, stats) + assert pairs + assert all(ord("l") not in (p.prev_cp, p.next_cp) for p in pairs) + assert all(ord("d") in (p.prev_cp, p.next_cp) for p in pairs) + + +def test_newest_key_unmeasured_pairs_empty_once_newest_has_any_measured_pair(): + unlocked = (ord("a"), ord("s"), ord("d")) + now = 1_700_000_000.0 + stats = { + cp: KeyStats(cp, 10, 200_000_000.0, 0, now, attempt_count=10) + for cp in (ord("a"), ord("s"), ord("d")) + } + transitions = { + Bigram(ord("s"), ord("d")): _transition( + ord("s"), ord("d"), 200_000_000.0, last_seen=now, attempt_count=10 + ), + } + assert newest_key_unmeasured_pairs(unlocked, transitions, stats) == [] + + +def test_gating_cohort_is_bounded_deterministic_and_measurement_independent(): + unlocked = tuple(map(ord, "abcd")) + stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in unlocked} + expected = ( + Bigram(ord("b"), ord("d")), + Bigram(ord("d"), ord("b")), + Bigram(ord("c"), ord("d")), + Bigram(ord("d"), ord("c")), + ) + assert newest_key_gating_cohort(unlocked, stats) == expected + assert newest_key_gating_cohort(unlocked, stats) == expected + + +def test_gating_cohort_limit_is_configurable_and_clamped(): + unlocked = tuple(map(ord, "abcd")) + stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in unlocked} + assert len(newest_key_gating_cohort(unlocked, stats, limit=2)) == 2 + assert len(newest_key_gating_cohort(unlocked, stats, limit=3)) == 3 + assert len(newest_key_gating_cohort(unlocked, stats, limit=4)) == 4 + assert effective_gating_bigram_limit(0) == 2 + assert effective_gating_bigram_limit(99) == 4 diff --git a/tests/domain/test_unlock.py b/tests/domain/test_unlock.py new file mode 100644 index 0000000..19367bc --- /dev/null +++ b/tests/domain/test_unlock.py @@ -0,0 +1,364 @@ +from keystrike.domain.confidence import confidence_of, skill_of +from keystrike.domain.models import Bigram, KeyStats, TransitionStats +from keystrike.domain.newest_key import newest_key_gating_cohort +from keystrike.domain.unlock import ( + compute_unlocked, + newest_key_clears_transition_gate, + newest_key_transition_gate_progress, +) + + +def _stats( + codepoint: int, + mean_time_ns: float, + error_count: int = 0, + *, + last_seen: float = 0.0, +) -> KeyStats: + return KeyStats( + codepoint=codepoint, + samples=10, + mean_time_ns=mean_time_ns, + error_count=error_count, + last_seen=last_seen, + attempt_count=10 + error_count, + ) + + +def _transition( + prev_cp: int, + next_cp: int, + mean_time_ns: float, + *, + last_seen: float = 0.0, + error_count: int = 0, + attempt_count: int | None = None, +) -> TransitionStats: + attempts = attempt_count if attempt_count is not None else 10 + error_count + return TransitionStats( + prev_cp=prev_cp, + next_cp=next_cp, + samples=10, + mean_time_ns=mean_time_ns, + error_count=error_count, + last_seen=last_seen, + attempt_count=attempts, + ) + + +def test_compute_unlocked_stalls_when_calibrating_on_attempts(): + learn_order = (1, 2, 3) + stats = { + 1: _stats(1, mean_time_ns=100_000_000.0), + 2: KeyStats( + 2, + samples=9, + mean_time_ns=100_000_000.0, + error_count=0, + last_seen=0.0, + attempt_count=9, + ), + } + target = 200.0 + assert skill_of(2, stats, target=target) == 1.0 + assert confidence_of(2, stats, target=target) == 0.9 + unlocked = compute_unlocked(learn_order, alphabet_size=2, stats=stats, target=target) + assert unlocked == (1, 2) + + +def test_compute_unlocked_advances_when_skill_and_attempts_met(): + learn_order = (1, 2, 3) + stats = { + 1: _stats(1, mean_time_ns=100_000_000.0), + 2: _stats(2, mean_time_ns=100_000_000.0), + } + unlocked = compute_unlocked(learn_order, alphabet_size=2, stats=stats, target=200.0) + assert unlocked == (1, 2, 3) + + +def test_compute_unlocked_stalls_when_sparse_key_looks_fast(): + learn_order = (1, 2, 3) + stats = { + 1: _stats(1, mean_time_ns=100_000_000.0), + 2: KeyStats( + 2, + samples=1, + mean_time_ns=100_000_000.0, + error_count=1, + last_seen=0.0, + attempt_count=2, + ), + } + unlocked = compute_unlocked(learn_order, alphabet_size=2, stats=stats, target=200.0) + assert unlocked == (1, 2) + + +def test_compute_unlocked_stalls_on_high_error_rate_despite_fast_speed(): + learn_order = (1, 2, 3, 4) + # speed 2.0, but only 50% accuracy -> min(2.0, 0.5) = 0.5 < threshold + stats = {1: _stats(1, mean_time_ns=100_000_000.0, error_count=10)} + unlocked = compute_unlocked( + learn_order, alphabet_size=1, stats=stats, target=200.0, threshold=1.5 + ) + assert unlocked == (1,) + + +def test_compute_unlocked_force_includes_alphabet_size(): + learn_order = tuple(range(10)) + unlocked = compute_unlocked(learn_order, alphabet_size=3, stats={}, target=200.0) + assert unlocked == learn_order[:3] + + +def test_compute_unlocked_caps_alphabet_size_at_learn_order_length(): + learn_order = tuple(range(4)) + unlocked = compute_unlocked(learn_order, alphabet_size=99, stats={}, target=200.0) + assert unlocked == learn_order + + +def test_compute_unlocked_advances_when_threshold_met(): + learn_order = (1, 2, 3, 4) + stats = {1: _stats(1, mean_time_ns=100_000_000.0)} # confidence 2.0 + unlocked = compute_unlocked(learn_order, alphabet_size=1, stats=stats, target=200.0) + assert unlocked == (1, 2) + + +def test_compute_unlocked_stalls_when_threshold_not_met(): + learn_order = (1, 2, 3, 4) + stats = {1: _stats(1, mean_time_ns=400_000_000.0)} # confidence 0.5 + unlocked = compute_unlocked(learn_order, alphabet_size=1, stats=stats, target=200.0) + assert unlocked == (1,) + + +def test_compute_unlocked_ignores_weak_transitions(): + learn_order = tuple(ord(c) for c in "eabcdfghm") + stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in learn_order[:8]} + unlocked = compute_unlocked( + learn_order, + alphabet_size=8, + stats=stats, + target=200.0, + ) + assert unlocked == learn_order[:9] + assert unlocked[-1] == ord("m") + + +def test_compute_unlocked_gate_blocks_next_key_on_weak_newest_bigram(): + """With a transitions gate, the newest key's weakest bigram must clear + before the next key opens -- unlike the transitions-agnostic default.""" + e, a, b = ord("e"), ord("a"), ord("b") + learn_order = (e, a, b) + now = 1_700_000_000.0 + stats = { + e: _stats(e, mean_time_ns=100_000_000.0, last_seen=now), + a: _stats(a, mean_time_ns=100_000_000.0, last_seen=now), + } + transitions = { + Bigram(e, a): _transition(e, a, 400_000_000.0, last_seen=now, attempt_count=4), + Bigram(a, e): _transition(a, e, 100_000_000.0, last_seen=now, attempt_count=4), + } + unlocked = compute_unlocked( + learn_order, + alphabet_size=2, + stats=stats, + target=200.0, + transitions=transitions, + ) + assert unlocked == (e, a) + + +def test_compute_unlocked_gate_allows_next_key_once_newest_bigram_clears(): + e, a, b = ord("e"), ord("a"), ord("b") + learn_order = (e, a, b) + now = 1_700_000_000.0 + stats = { + e: _stats(e, mean_time_ns=100_000_000.0, last_seen=now), + a: _stats(a, mean_time_ns=100_000_000.0, last_seen=now), + } + transitions = { + Bigram(e, a): _transition(e, a, 100_000_000.0, last_seen=now, attempt_count=4), + Bigram(a, e): _transition(a, e, 100_000_000.0, last_seen=now, attempt_count=4), + } + unlocked = compute_unlocked( + learn_order, + alphabet_size=2, + stats=stats, + target=200.0, + transitions=transitions, + ) + assert unlocked == (e, a, b) + + +def test_compute_unlocked_gate_stall_cap_unblocks_stuck_bigram(): + """A bigram that never clears threshold shouldn't block progression + forever once it's been drilled past the stall cap.""" + e, a, b = ord("e"), ord("a"), ord("b") + learn_order = (e, a, b) + now = 1_700_000_000.0 + stats = { + e: _stats(e, mean_time_ns=100_000_000.0, last_seen=now), + a: _stats(a, mean_time_ns=100_000_000.0, last_seen=now), + } + transitions = { + Bigram(e, a): _transition(e, a, 400_000_000.0, last_seen=now, attempt_count=12), + Bigram(a, e): _transition(a, e, 100_000_000.0, last_seen=now, attempt_count=12), + } + unlocked = compute_unlocked( + learn_order, + alphabet_size=2, + stats=stats, + target=200.0, + transitions=transitions, + transition_stall_attempts_cap=12, + ) + assert unlocked == (e, a, b) + + +def test_configured_cohort_limit_changes_unlock_gate(): + a, b, c, d = map(ord, "abcd") + unlocked = (a, b, c) + stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in unlocked} + transitions = { + pair: _transition(*pair, 100_000_000.0, attempt_count=4) + for pair in newest_key_gating_cohort(unlocked, stats, limit=2) + } + assert compute_unlocked( + (a, b, c, d), + 3, + stats, + 200.0, + transitions=transitions, + gating_bigram_limit=2, + ) == (a, b, c, d) + assert ( + compute_unlocked( + (a, b, c, d), + 3, + stats, + 200.0, + transitions=transitions, + gating_bigram_limit=4, + ) + == unlocked + ) + + +def test_gate_progress_releases_each_stalled_cohort_member(): + a, b = map(ord, "ab") + stats = { + a: _stats(a, mean_time_ns=100_000_000.0), + b: _stats(b, mean_time_ns=100_000_000.0), + } + transitions = { + pair: _transition(*pair, 400_000_000.0, attempt_count=12) + for pair in newest_key_gating_cohort((a, b), stats) + } + assert newest_key_transition_gate_progress( + (a, b), + transitions, + 200.0, + stats, + stall_attempts_cap=12, + ) == (2, 2) + + +def test_newest_key_clears_transition_gate_vacuous_with_one_key(): + now = 1_700_000_000.0 + stats = {ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now)} + assert newest_key_clears_transition_gate((ord("a"),), {}, 200.0, stats) is True + + +def test_newest_key_clears_transition_gate_false_when_no_pair_measured_yet(): + """Newest key practiced solo but hasn't typed any bigram with a peer + yet -- gate stays closed; lesson weighting is what pushes exposure.""" + unlocked = (ord("a"), ord("b")) + now = 1_700_000_000.0 + stats = { + ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), + ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), + } + assert newest_key_clears_transition_gate(unlocked, {}, 200.0, stats) is False + + +def test_newest_key_clears_transition_gate_false_when_weakest_pair_weak(): + unlocked = (ord("a"), ord("b")) + now = 1_700_000_000.0 + stats = { + ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), + ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), + } + transitions = { + Bigram(ord("a"), ord("b")): _transition( + ord("a"), ord("b"), 400_000_000.0, last_seen=now, attempt_count=4 + ), + Bigram(ord("b"), ord("a")): _transition( + ord("b"), ord("a"), 100_000_000.0, last_seen=now, attempt_count=4 + ), + } + assert newest_key_clears_transition_gate(unlocked, transitions, 200.0, stats) is False + + +def test_newest_key_clears_transition_gate_true_when_weakest_pair_solid(): + unlocked = (ord("a"), ord("b")) + now = 1_700_000_000.0 + stats = { + ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), + ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), + } + transitions = { + Bigram(ord("a"), ord("b")): _transition( + ord("a"), ord("b"), 100_000_000.0, last_seen=now, attempt_count=4 + ), + Bigram(ord("b"), ord("a")): _transition( + ord("b"), ord("a"), 100_000_000.0, last_seen=now, attempt_count=4 + ), + } + assert newest_key_clears_transition_gate(unlocked, transitions, 200.0, stats) is True + + +def test_newest_key_transition_gate_requires_entire_stable_cohort(): + unlocked = (ord("a"), ord("b"), ord("c")) + now = 1_700_000_000.0 + stats = { + ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), + ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), + ord("c"): _stats(ord("c"), mean_time_ns=100_000_000.0, last_seen=now), + } + # Only a<->c is measured (and solid); b<->c is never touched at all. + transitions = { + Bigram(ord("a"), ord("c")): _transition( + ord("a"), ord("c"), 100_000_000.0, last_seen=now, attempt_count=4 + ), + Bigram(ord("c"), ord("a")): _transition( + ord("c"), ord("a"), 100_000_000.0, last_seen=now, attempt_count=4 + ), + } + assert newest_key_clears_transition_gate(unlocked, transitions, 200.0, stats) is False + + +def test_newest_key_clears_transition_gate_stall_cap_overrides_weak_pair(): + unlocked = (ord("a"), ord("b")) + now = 1_700_000_000.0 + stats = { + ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, last_seen=now), + ord("b"): _stats(ord("b"), mean_time_ns=100_000_000.0, last_seen=now), + } + transitions = { + Bigram(ord("a"), ord("b")): _transition( + ord("a"), ord("b"), 400_000_000.0, last_seen=now, attempt_count=12 + ), + Bigram(ord("b"), ord("a")): _transition( + ord("b"), ord("a"), 100_000_000.0, last_seen=now, attempt_count=12 + ), + } + assert ( + newest_key_clears_transition_gate( + unlocked, transitions, 200.0, stats, stall_attempts_cap=12 + ) + is True + ) + assert ( + newest_key_clears_transition_gate( + unlocked, transitions, 200.0, stats, stall_attempts_cap=13 + ) + is False + ) diff --git a/tests/infrastructure/test_session_repo_jsonl.py b/tests/infrastructure/test_session_repo_jsonl.py index 917df4c..dda4739 100644 --- a/tests/infrastructure/test_session_repo_jsonl.py +++ b/tests/infrastructure/test_session_repo_jsonl.py @@ -166,6 +166,38 @@ def test_round_trip_target_speed_cpm(paths): assert headers[0].target_speed_cpm == 400 +def test_round_trip_generated_word_bounds(paths): + repo = JsonlSessionRepository(paths) + header = SessionResult( + schema_version=4, + session_id=_VALID_ULID_F, + started_at=1_700_000_000.0, + duration_ns=1_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(ord("a"),), + focus_key=ord("a"), + total_keystrokes=1, + correct_keystrokes=1, + generated_min_len=3, + generated_max_len=8, + ) + repo.save_header(header) + + headers = list(JsonlSessionRepository(paths).iter_headers("qwerty")) + assert headers[0].generated_min_len == 3 + assert headers[0].generated_max_len == 8 + + +def test_legacy_header_without_generated_word_bounds_defaults(paths): + repo = JsonlSessionRepository(paths) + repo.save_header(_header()) + + headers = list(JsonlSessionRepository(paths).iter_headers("qwerty")) + assert headers[0].generated_min_len == 2 + assert headers[0].generated_max_len == 4 + + def test_corrupt_index_line_is_skipped_not_fatal(paths): repo = JsonlSessionRepository(paths) repo.save_header(_header(sid=_VALID_ULID_A)) diff --git a/tests/infrastructure/test_settings_repo_toml.py b/tests/infrastructure/test_settings_repo_toml.py index 20c8866..6e8d425 100644 --- a/tests/infrastructure/test_settings_repo_toml.py +++ b/tests/infrastructure/test_settings_repo_toml.py @@ -77,6 +77,7 @@ def test_round_trip(paths): confidence_session_window=8, min_confidence_attempts=12, min_transition_confidence_attempts=5, + gating_bigram_limit=3, focus_char_boost=2.5, focus_word_boost=5.0, focus_bigram_word_boost=6.0, @@ -95,6 +96,7 @@ def test_round_trip(paths): assert loaded.confidence_session_window == 8 assert loaded.min_confidence_attempts == 12 assert loaded.min_transition_confidence_attempts == 5 + assert loaded.gating_bigram_limit == 3 assert loaded.focus_char_boost == 2.5 assert loaded.focus_word_boost == 5.0 assert loaded.focus_bigram_word_boost == 6.0 @@ -156,6 +158,19 @@ def test_malformed_enum_value_falls_back_to_default(paths): assert s.target_speed_unit == Settings().target_speed_unit +def test_existing_toml_defaults_gating_bigram_limit(paths): + paths.settings_file.write_text('schema_version = 1\nlayout = "qwerty"\n', encoding="utf-8") + assert TomlSettingsRepository(paths).load().gating_bigram_limit == 4 + + +def test_loads_gating_bigram_limit(paths): + paths.settings_file.write_text( + "schema_version = 1\ngating_bigram_limit = 2\n", + encoding="utf-8", + ) + assert TomlSettingsRepository(paths).load().gating_bigram_limit == 2 + + def test_coerce_field_rejects_non_bool_for_bool_default(): # bool("false") is True in Python — a quoted-string bool must not be # silently coerced, or it would flip the setting instead of falling diff --git a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg index 7a49ead..d29b672 100644 --- a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg +++ b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg @@ -130,12 +130,12 @@ - + Acc: 100.0%   Learn: 0.0/10 min   Focus: a · wk -asa·uk·ltg·as·iagr·aoag·skal·uda·fea·ta·ajg·aj +ajg·as·skal·uk·ak·aaaf·aot·iagd·ta·fea·ltg·ai  q  w  e  r  t  y  u  i  o  p  diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index 9b845d1..2ac1bb7 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -10,6 +10,7 @@ from keystrike.application.session_use_cases import ( AbortSession, FinishSession, + GetLatestSessionHeader, GetSessionBaseline, RecordKeystroke, StartSession, @@ -33,7 +34,7 @@ from keystrike.domain.session import LEARN_IDLE_PAUSE_NS, active_typing_duration_ns, is_typing_idle from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from keystrike.presentation.screens.home import HomeScreen -from keystrike.presentation.screens.practice import PracticeScreen +from keystrike.presentation.screens.practice import PracticeScreen, format_bigram_calibration from keystrike.presentation.screens.settings import SettingsScreen from keystrike.presentation.screens.stats import StatsScreen from keystrike.presentation.services import ( @@ -59,6 +60,11 @@ _TZ = dt.timezone(dt.timedelta(hours=3)) +def test_bigram_calibration_progress_text_only_when_relevant(): + assert format_bigram_calibration((2, 4)) == "[dim]Bigram calibration: 2/4 ready[/]" + assert format_bigram_calibration(None) is None + + def _build_app( *, clock: FakeClock | None = None, @@ -125,6 +131,7 @@ def _build_app( ), abort=AbortSession(), prepare_practice=prepare_practice, + get_latest_session_header=GetLatestSessionHeader(repo=session_repo), get_session_baseline=GetSessionBaseline(repo=session_repo, settings_repo=settings_repo), rebuild_aggregates=rebuild_aggregates, get_daily_learn_budget=get_daily_learn_budget, @@ -334,6 +341,36 @@ async def test_adaptive_practice_shows_active_keys_widget(): assert app.screen.query(KbHeatmap) +@pytest.mark.asyncio +async def test_adaptive_shows_last_session_stats_on_startup(): + clock = FakeClock(wall=1_700_000_000.0) + session_repo = FakeSessionRepository() + session_repo.save_header( + SessionResult( + schema_version=3, + session_id="s1", + started_at=clock.wall_epoch(), + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(ord("a"), ord("s")), + focus_key=ord("s"), + total_keystrokes=120, + correct_keystrokes=110, + words_completed=10, + ), + ) + app, _clock, _repo, _settings = _build_app(clock=clock, session_repo=session_repo) + async with app.run_test() as pilot: + await pilot.press("enter") + await pilot.pause() + practice = app.screen + assert isinstance(practice, PracticeScreen) + last_stats = str(practice.query_one("#last-session-stats", Static).content) + assert "Last: WPM" in last_stats + assert "Acc" in last_stats + + @pytest.mark.asyncio async def test_adaptive_shows_last_session_stats_after_completion(): app, clock, session_repo, _ = _build_app() From 92b01f3695476da06c2f9d8c7f94023e494d32b1 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 5 Aug 2026 11:59:59 +0300 Subject: [PATCH 35/47] fix: keep wordlist words intact during focus drill Resample dictionary words for focus bigrams instead of splicing chars into real words. Co-authored-by: Cursor --- src/keystrike/domain/generator.py | 181 ++++++++++++++++++++++++++---- tests/domain/test_generator.py | 46 ++++++++ 2 files changed, 204 insertions(+), 23 deletions(-) diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index 8100715..1e1e00b 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -449,7 +449,18 @@ def generate_lesson( generated_min_len=generated_min_len, generated_max_len=generated_max_len, ) - self._ensure_single_focus_match(lesson_words, focus_char, focus_bigram) + self._ensure_single_focus_match( + lesson_words, + focus_char, + focus_bigram, + alphabet=alphabet, + weighting=weighting, + focus_bigram_str=focus_bigram_str, + focus_pool=focus_pool, + focus_weighted=focus_weighted, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, + ) else: focus_quota = min(min_focus_words, word_count) pool_draws = _sample_focus_words_without_replacement( @@ -502,6 +513,7 @@ def generate_lesson( focus_bigrams, alphabet, weighting, + focus_char=focus_char, min_focus_words=min_focus_words, max_word_repeats=max_word_repeats, generated_min_len=generated_min_len, @@ -514,6 +526,14 @@ def _ensure_single_focus_match( lesson_words: list[str], focus_char: str, focus_bigram: Bigram | None, + *, + alphabet: frozenset[str], + weighting: LessonWeighting, + focus_bigram_str: str | None, + focus_pool: tuple[str, ...], + focus_weighted: WeightedWordlist | None, + generated_min_len: int, + generated_max_len: int, ) -> None: """Guarantee at least one lesson word contains the focus (used when ``min_focus_words <= 1``, where no quota loop already guarantees it).""" @@ -522,12 +542,32 @@ def _ensure_single_focus_match( bigram = prev_char + next_char if not any(bigram in w for w in lesson_words): idx = self.rng.randrange(len(lesson_words)) - lesson_words[idx] = self._inject_focus_bigram( - lesson_words[idx], prev_char, next_char + lesson_words[idx] = self._focus_word_or_inject( + lesson_words[idx], + alphabet=alphabet, + weighting=weighting, + focus_char=focus_char, + focus_bigram=focus_bigram, + focus_bigram_str=focus_bigram_str, + focus_pool=focus_pool, + focus_weighted=focus_weighted, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, ) elif not any(focus_char in w for w in lesson_words): idx = self.rng.randrange(len(lesson_words)) - lesson_words[idx] = self._inject_focus(lesson_words[idx], focus_char) + lesson_words[idx] = self._focus_word_or_inject( + lesson_words[idx], + alphabet=alphabet, + weighting=weighting, + focus_char=focus_char, + focus_bigram=None, + focus_bigram_str=None, + focus_pool=focus_pool, + focus_weighted=focus_weighted, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, + ) def _distribute_focus_bigram_coverage( self, @@ -536,6 +576,7 @@ def _distribute_focus_bigram_coverage( alphabet: frozenset[str], weighting: LessonWeighting, *, + focus_char: str, min_focus_words: int, max_word_repeats: int, generated_min_len: int, @@ -546,21 +587,37 @@ def _distribute_focus_bigram_coverage( coverage_slots = min(len(lesson_words), max(min_focus_words, len(focus_bigrams))) for index in range(coverage_slots): pair = focus_bigrams[index % len(focus_bigrams)] - candidate = self._inject_focus_bigram( - lesson_words[index], chr(pair.prev_cp), chr(pair.next_cp) - ) + prev_char, next_char = chr(pair.prev_cp), chr(pair.next_cp) + bigram_str = pair.chars() other_words = lesson_words[:index] + lesson_words[index + 1 :] - if Counter(other_words)[candidate] >= max_word_repeats: - for _ in range(MAX_REPEAT_RESAMPLE): - candidate = self._inject_focus_bigram( - self._generate_word_via_markov( - alphabet, weighting, generated_min_len, generated_max_len - ), - chr(pair.prev_cp), - chr(pair.next_cp), + candidate = self._sample_wordlist_with_bigram( + alphabet, + weighting, + focus_char=focus_char, + bigram_str=bigram_str, + exclude_counts=Counter(other_words), + max_repeats=max_word_repeats, + ) + if candidate is None: + base = ( + self._generate_word_via_markov( + alphabet, weighting, generated_min_len, generated_max_len ) - if Counter(other_words)[candidate] < max_word_repeats: - break + if weighting.words + else lesson_words[index] + ) + candidate = self._inject_focus_bigram(base, prev_char, next_char) + if Counter(other_words)[candidate] >= max_word_repeats: + for _ in range(MAX_REPEAT_RESAMPLE): + candidate = self._inject_focus_bigram( + self._generate_word_via_markov( + alphabet, weighting, generated_min_len, generated_max_len + ), + prev_char, + next_char, + ) + if Counter(other_words)[candidate] < max_word_repeats: + break lesson_words[index] = candidate if len(lesson_words) >= len(focus_bigrams): missing = [ @@ -617,9 +674,14 @@ def _append_focus_word_capped( lesson_words, word, alphabet, + weighting=weighting, focus_char=focus_char, focus_bigram=focus_bigram, + focus_bigram_str=focus_bigram_str, + focus_pool=focus_pool if use_wordlist else (), + focus_weighted=focus_weighted if use_wordlist else None, max_word_repeats=max_word_repeats, + generated_min_len=generated_min_len, generated_max_len=generated_max_len, ) @@ -666,23 +728,37 @@ def _append_word_mutating_until_under_cap( word: str, alphabet: frozenset[str], *, + weighting: LessonWeighting | None = None, focus_char: str | None = None, focus_bigram: Bigram | None = None, + focus_bigram_str: str | None = None, + focus_pool: tuple[str, ...] = (), + focus_weighted: WeightedWordlist | None = None, max_word_repeats: int = MAX_WORD_REPEATS, + generated_min_len: int = GENERATED_WORD_MIN_LEN, generated_max_len: int = GENERATED_WORD_MAX_LEN, ) -> None: """Ensures: word is appended without exceeding the repeat cap.""" if _append_word_if_under_repeat_cap(lesson_words, word, max_repeats=max_word_repeats): return + weighting = weighting or LessonWeighting() chars = sorted(alphabet) for _ in range(MAX_REPEAT_RESAMPLE): - if len(word) < generated_max_len: + if focus_char is not None or focus_bigram is not None: + word = self._focus_word_or_inject( + word, + alphabet=alphabet, + weighting=weighting, + focus_char=focus_char or "", + focus_bigram=focus_bigram, + focus_bigram_str=focus_bigram_str, + focus_pool=focus_pool, + focus_weighted=focus_weighted, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, + ) + elif len(word) < generated_max_len: word = word + self.rng.choice(chars) - elif focus_bigram is not None: - prev_char, next_char = chr(focus_bigram.prev_cp), chr(focus_bigram.next_cp) - word = self._inject_focus_bigram(word, prev_char, next_char) - elif focus_char is not None: - word = self._inject_focus(word, focus_char) else: pos = self.rng.randrange(len(word)) word = word[:pos] + self.rng.choice(chars) + word[pos + 1 :] @@ -692,6 +768,65 @@ def _append_word_mutating_until_under_cap( # append anyway rather than abort practice. lesson_words.append(word) + def _sample_wordlist_with_bigram( + self, + alphabet: frozenset[str], + weighting: LessonWeighting, + *, + focus_char: str, + bigram_str: str, + exclude_counts: Counter[str], + max_repeats: int, + ) -> str | None: + """Resample a dictionary word containing ``bigram_str``, respecting repeat cap.""" + if not weighting.words: + return None + pool = _focus_pool_from_wordlist( + weighting.words, + alphabet, + focus_char=focus_char, + focus_bigram=bigram_str, + ) + if not pool: + return None + for _ in range(MAX_REPEAT_RESAMPLE): + word = self.rng.choice(pool) + if exclude_counts[word] < max_repeats: + return word + return None + + def _focus_word_or_inject( + self, + word: str, + *, + alphabet: frozenset[str], + weighting: LessonWeighting, + focus_char: str, + focus_bigram: Bigram | None, + focus_bigram_str: str | None, + focus_pool: tuple[str, ...], + focus_weighted: WeightedWordlist | None, + generated_min_len: int, + generated_max_len: int, + ) -> str: + """Focus-matching word without corrupting dictionary entries.""" + if weighting.words: + return self._generate_focus_word( + alphabet, + weighting, + focus_char=focus_char, + focus_bigram=focus_bigram, + focus_bigram_str=focus_bigram_str, + focus_pool=focus_pool, + focus_weighted=focus_weighted, + generated_min_len=generated_min_len, + generated_max_len=generated_max_len, + ) + if focus_bigram is not None: + prev_char, next_char = chr(focus_bigram.prev_cp), chr(focus_bigram.next_cp) + return self._inject_focus_bigram(word, prev_char, next_char) + return self._inject_focus(word, focus_char) + def _generate_focus_word( self, alphabet: frozenset[str], diff --git a/tests/domain/test_generator.py b/tests/domain/test_generator.py index dfdaa4a..714c0e6 100644 --- a/tests/domain/test_generator.py +++ b/tests/domain/test_generator.py @@ -534,6 +534,52 @@ def test_generate_lesson_weak_guarantees_focus_bigram_fraction(): assert focus_words >= quota, f"seed={seed}: {focus_words}/{len(words)} bigram words" +def test_wordlist_words_not_corrupted_by_focus_injection(): + """Dictionary words must stay intact; focus comes from resampling, not splicing.""" + generator = AdaptiveGenerator(table=_uniform_table("abcdefghijklmnopqrstuvwxyz"), rng=Random(0)) + words = ("insertion", "manage", "earning", "learn", "inner", "running", "help", "hello") + alphabet = frozenset("abcdefghijklmnopqrstuvwxyz") + for seed in range(100): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + alphabet, + focus_char="n", + word_count=12, + min_focus_words=min_focus_words(12, FOCUS_WORD_MIN_FRACTION), + focus_bigram=Bigram(ord("r"), ord("n")), + focus_bigrams=( + Bigram(ord("r"), ord("n")), + Bigram(ord("n"), ord("g")), + ), + weighting=LessonWeighting(words=words), + ) + for word in lesson.split(): + if word in words: + continue + assert GENERATED_WORD_MIN_LEN <= len(word) <= GENERATED_WORD_MAX_LEN, ( + f"seed={seed}: corrupted-looking word {word!r}" + ) + + +def test_wordlist_single_focus_resamples_not_injects(): + """Strong focus with wordlist replaces via resample, not char splice.""" + generator = AdaptiveGenerator(table=_uniform_table("ab"), rng=Random(0)) + words = ("bbb", "aba", "bab") + for seed in range(30): + generator.rng = Random(seed) + lesson = generator.generate_lesson( + frozenset("ab"), + focus_char="a", + word_count=6, + min_focus_words=1, + weighting=LessonWeighting(words=words), + ) + for word in lesson.split(): + assert word in words or ( + GENERATED_WORD_MIN_LEN <= len(word) <= GENERATED_WORD_MAX_LEN + ), f"seed={seed}: {word!r}" + + def test_generate_lesson_distributes_multi_bigram_coverage(): generator = AdaptiveGenerator(table=_uniform_table("abcd"), rng=Random(0)) pairs = ( From b1052ce92f5a4c7bc533966dacfe3190456949af Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 5 Aug 2026 12:03:24 +0300 Subject: [PATCH 36/47] Release 1.3.1. Skill/confidence split for unlocks and focus, bigram progression gating, dictionary vs Markov word bounds, and word-list focus drill fixes. Refresh README, wiki, CHANGELOG, and demo GIF. Co-authored-by: Cursor --- README.md | 11 +++++++---- docs/CHANGELOG.md | 19 +++++++++++++++++++ docs/assets/demo.gif | Bin 51676 -> 49039 bytes docs/wiki/Confidence-Tuning.md | 12 +++++------- docs/wiki/Word-Lists.md | 8 ++++++-- pyproject.toml | 2 +- src/keystrike/domain/version.py | 2 +- uv.lock | 2 +- 8 files changed, 40 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 131896a..e3bb81a 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ for sources and how each maps to the code. ## Features -- **Adaptive engine** — row-weighted key unlock order, speed+accuracy confidence - gates, Markov word drills with a guaranteed focus letter. +- **Adaptive engine** — row-weighted key unlock order, skill gates for unlocks, + confidence-weighted focus and heatmap, Markov word drills with a guaranteed + focus letter or bigram. - **Per-layout stats** — heatmap with per-key confidence and urgency; layout-wide and focus-key trend grids (confidence, speed, accuracy); press a heatmap key for per-letter drill-down. @@ -108,12 +109,14 @@ overview. | --- | --- | --- | | Layout | `qwerty` | Any bundled or custom layout | | Target speed | 46 WPM | Or CPM — your unlock threshold | -| Letters unlocked up front | 16 | Force-unlocked before confidence gating | +| Letters unlocked up front | 16 | Force-unlocked before skill gating | | Daily learn goal | 10 min | `0` = no goal | Advanced confidence tuning (session window, min key/bigram attempts) is **config-file only** — edit `settings.toml` directly; see the -[Confidence tuning wiki](https://github.com/egno/keystrike/wiki/Confidence-Tuning). +[Confidence tuning wiki](https://github.com/egno/keystrike/wiki/Confidence-Tuning) +and [Focus states wiki](https://github.com/egno/keystrike/wiki/Focus-States) +for HUD labels (`wk`, `cal`, `rev`). ## Backup sync diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 364abe0..9163089 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,25 @@ 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. +## 1.3.1 + +- **Skill vs confidence split** — unlocks and transition gating use per-key + **skill** (min(speed, accuracy) without attempt ramp) plus attempt floors; + **confidence** (skill scaled by practice volume) drives HUD labels, heatmap + coloring, and focus weighting. Calibrating keys no longer block bigram focus. +- **Bigram progression** — newest-key cohort gates the next letter; deficient + cohort bigrams get guaranteed lesson slots and prioritized practice after solo + keys clear; transition focus when measured keys are ready but bigrams lag. +- **Dictionary vs Markov bounds** — imported words filter on dictionary length + (3–10); Markov fill and WPM math use `generated_word_min_len` / + `generated_word_max_len` (default 2–4). +- **Word-list focus drill** — resample real dictionary words for focus bigrams + instead of splicing characters into imported words. +- **Heatmap and focus UX** — skill-based heatmap without lesson-sampling bias; + calibrating focus display fixes. +- Wiki: new **Focus states** page; **Confidence tuning** updated for skill/confidence + split, sticky focus, transition gate, and lesson WPM gate. + ## 1.3.0 - Weak-focus lessons guarantee a configurable fraction of words match the focus diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif index 2c59cc6f317dd9d9e6a2c272bbb1d25a5f8c5806..0d5df64f54d63871b7eeb291018235c040f24a8d 100644 GIT binary patch delta 31420 zcmV(_K-9n7lLL?c00PICJVmX;5I#oj`*M9ZIyQ z(W6L{DsA|4sne%Wqe`7hwW?K&O|NR*%C)Q4uVBLx%qq65*|TWVs$J`FCEK@f_k zqYobV%y_|p=K>C#Rsj1zVc5VAF#pj0PyFiE|HcoWe&F%H2Cab$G$5XVeEJORpJvWq ztL^>(Cgs^@2@o`2fu?^Is6c`LAb=ip3~+~Ffv$;mzyhqPCLDYV2*BEV)ABu8` zJ^r)caK#y99|BbZ=^l+-4kcrkV9qpP1H}mhpJ<|mCeUdDfcJly0Y)lGkdnov`5u~f zc4wW78Rj@zpHfB_=Ab!!N$8;`{dgym4(|Beb3&Hb69Ta92%K;}(Mg?>je59RaoT|v zU6xmpn!uKbrmCBAiAlt&s{JYdN}{FS!B&_-qCFSr0tnbwm}odYnA&-+UAdctzv37g zXvHmX=87ji3+sQhDH%%bwZHA>U;+jpz@D;qsy0x92Pm6jlyho`-jO_hTV9E47I-9V z4W4&lfv94uZ%5Wn^y)zW?i*lMzOr;szy>??p@9dADDcDlA{>&$5c8)1dlZMX@If0_ z>@ijue=M>?`RPiN01cpua=s(C%uv5B$1L;ATft28%{YJOY?RG9_w4h}G4Txa&_ov< zvC&8;t@P1LH|_M(FNX~E)KtqXbk$gA?eW!GckT7GTz@V0*ja4dOk?5|+emLy1=LJCRex2TW0KT0RfXPk) zApAkX-~LnpxO+tV%LO3+QUC=se^3AjIOe=S)LZ{h_6S^GfC4>%f7$gz{a%0pwR10w4 z1GN)DrdWr9$m0$JR8xTKfJA&m;T>TJ6F-EAaD?&Wo%6EyzJpZndo9dJ2@!I?f7H+- z7s^k50608}1ki>(6rckC3uuw$EyP*kLP-F|6DpILW=G>8%@YL>#fD6=iq4{s0e1Mo z1D1c}g)DRk7+;u>=*>p~1#sWN6o9<%d9fk)yCDxFa)1H^Q6N7|$Q>1uN8%aJAp!8v z3`H_PBGT`T5h*~2JUEa5G{8CxFqtDE2|NSr2qKT{AR_~y9fMd$HJn7!KR&6xPLA?I z?}<lhLcqNaKZaNr1uG>`#y50>+Z9$|lC=o9HVgoIKe%rDs(OaiJ9d&pDcK+>2a zG%^JNFZ|wPPFP7@Ix~&|j3XZNS3>~Qv6}!;KdcF$ndxv>08U@qP7kp)(sI!quQqeZm|dFqfGx zWeP-%`(x(Iz&9B55%ZrW<(M<;hs{4W6^O&@<~O-%0A96gp$WxOIvKFe$7m4%JaV4! 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

Te;@V2F&M=l?3>i+PS`t1*iivWP*Xh&cG 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∓WmEJOC5`SU<94Ll;_32Ap#;RwU<- zmiIdY-f! z@6h>Na?VkB?>wtR(lf4>Am)XFdJ1toyD6jsP_KL~jvys+)lCgnJPU!ZM-mGZ@L0;C z1+i}-FWVaZ;#WQ*qGm}Ia@YLbRhD;UEoc)`TAw~rA|X64YZVeM(1n&k!PX%|J!S`i@M~59@!nwt|kPWghC| z6s!2e|Dfvd7#rgmFF?D}1psw__ahA!&(REt;C+IHu7-H;)O3zeo8LST@5y<2DW&a` zWwqXjF!s%e97vf7yX(nPgubH$)qV0rN^o5%L&HAECyfo@!jhCDfX1*wWKGNuGkDPt z=8Xs;DH%a`+gCcZjI@6V%s%i6>cK<>@op1MVh1H$l;bg{rWri}D+qHfj0hiiqE3PzJ0$3>YFs^a=P#GeYYTyDy z6rpu?CsHQ=&#Xmz4K5<8+*98XkB~Rg%7VmYuDn~`fvu-f5tn}v+xHZ(LvA|FMefv} ztED!e1E5!?KNBj7=_8#FjZ~(u?|F7+^l%^jMid)&-fy(xk?y;3A9dW~xnrxUt!~M( zl1IBM0S^P#dO?Ob^NS!ZU(VTFJ?XjRk8GSZlu;t%gisfXYgT1 zrcUanE7`u9eCB_v93(A@@RyrnrS_o5N@*fp=#T`uEM+Y+X!85piE;fvhWbi=;&w5q zRX3tdsLa%tSgK#|I;6qwzXUD-oW(`FBXx*K#xh{xNCeh(!G(N>RDNVut}|^F<4V!1 zu=Mu)Bmbm)F8F0eoFgQp;4T9e>ZP?GVRlLX`4R$X?D2mD?sQmIp>EHT{nO>Qh*9r6A9RWF~WclC=>cOfdq4a7MOueBY_&&fjh&2JOh9pkw+pS zf_7AaCqaU9VS*{af|mn=AftjPR)Q$_5j6-^bp&Z%cY{2ngLJ`zBLReu1A`qSgF?6v z73hK>A%uS>sDDR@NAIR49gQ@r5TShGtke zL^v@z_*6T1gKqeSdNhOCB!lsGU2f=ubO?qb7>7vMhQjBE6)}7{NL&xdhlnVLZ&-+R zh=XVUm4|w$5O`RKcIb#{=rn8iFf~?(fp|?P=7)a?C~t;XiNR%wl-P$&2!Pr&hMyRR zf+&fMxQN09Ri@|=E2d(y$cc>TiLHo>y10e6Xo|C#O^tYon23ckvx$3%f7eBejrfOj zqMU8l9jjLFVt0;~w8%_xpZsEWRri?-;EDwvJ12#b|Sjrxd#+}Mxkc#jAP5ujFzi^z%T*olu= zh22Pqd#H$QLy!FekqhaOI2ni;sgY~Lk^Ewl&S;O`*pt?%i${OykTS`F0-1>IsFY4gk_qXFL%EbX8H`3r zl#iB_1UZLeD33;olwG-z$oMijX_LL!Llnu6@dS}v*og#rk!*>NJSdkrIhA)fiE!D2 zTbYYODUWD*lW<5}zu1-)S(0J{l-kmbzgU=JX^D>6khRE)!FQN~X_%D>gv`i`N9cb; zJE@ml7>^P8mw=g;cKMah2#S_zj2l>%P1A=1XGa}Dh9xMQk~o{Q>4Y=^n}cYZNQj$b z35N6NnpZ=ch*FT#ft+MHImSsS%K046$u^4Vfz-({(^;L^*)P|bon6tKM!}k$vz?p& zvVLlzQ6EulJCSjhVV)PEo_v`R>`8wS{u7@%d4lLkI_X0ad{k4g^*!`?6aR@f#90w) zXBXho63sDl1eYSek)Rh*pvH(J3Q83R`k-&+puvfl{Kgvcc}Ep`m3Sl}g;qfba()q1 zqMgN{7}OCTN;()C5?EFj;u&daHWZbGZY;{73PDd4frUIupq2rio-}he8k~P2QKT9H zE?-!nLu#a4sXjW&jg1$UDv5-%f=ClJ5?ES2@KS?&ggU)p{Laor?6NMc#2rh19xzWqgv=w3IFhf zXo@m`Dv}XFrx&55nhB0w$XI_Sp{WodU*y>klnN1m+7X^AqXW8e5&!`ZAOXZtF|na` zRs<~&)f~~%N3rT5j|4BUsvWqB5O-3mo}?yGI$6B>SNAj?lTo4HacoT*5jYY+zsgSL z(j1+4tHz2DqskDjnjXpOA(eF+iUlnusYB6nM{hzJ^%JedW33(ZDc^sZAT@cejY>Q0 znl26CMso)Mmb$Dv*st5lqFFS90!kB@3G;cG|H7JFP7LD|y>W z7aX_%t7>l&(5KEcs9_j_nsY_FBS*Vy8s#8<`dOog zP)u7yqv1Ug0$8QNuh^=fN?Sslq&pP@CA%gZMB7VJ+q7l7G+XOasuDdyYZ}plA!P&< za$8MGyJF=!M^~#n`z9#Rg|tJfQGW|59+F(}3IJnORfWrRz!QJBmxe(AARI6zeM-8l zk2bl#BOyTxsX~UiT+6uGceacwxx+?doU3Ut#yfsnM4x*f!{fHEredOtP)w(?)zh^* zf<*WMK~EdDi#xSiOFc3scMJeqY3nOJ!aITrU!3bnkfFN^=CWiPysBHLE&oxs-mARY zi&RlUy>gls9GHIq5Rf9zGja!!rjUdH%fq9i@kq46tV>0|>2hoV%fHQfzX&C$J31kC z2EePKY&rri-K)S0k-rbDJN8S!ZrfG>EVB=6z@Ah@1zG^h5lIJ3R{5(<^mJW`N{53h zz_fV59xNM?bWX9cVu|Ww9=t}48YKWAs0@sGC^o{h1HXU!VY=x!ztr!{u{)|ek?ayjL>eNN$U!lxl^nl+j8_C)$B+CI zhm40M98Q8dM;i1yg`9b@EK9E}JhQA~GwepFEV=ZXK@Y^goXm&8yvSudx50zTye!BG z3(AZN9>6mimE~#lOFPnBa@br#{UKt%)66N`UEhB^#)ijOhXKTnOw9+e%GfH#oVucZ z%r4uU%o8=v&$G;y+{+I9$%uT(q|DEoch7PGf>4yI5bzU*kYu^+37!9ffgsWBQZ@f{iq5vIb~ z=~6`W6xxdH*&3$ z?xhI*77_@j5-@V9>L*w`ACpnMrF^kHA{pr$&(C||rg3V5YT@~T;RV%OWvfxPA>st= z!D_T%6*?b}Bpyt+eoRLme(J#`-a&sOlsh5b;UiwhGY(7>zSV`?PQ#N!`7y-VB;$TM z)F{r>Iez3m{>nvOn2%PgaiQYNg}ay1x;pmDZxq{%wAC0CZooWo%f-u@e}VlqVuaJsBUky{#;Iy8nI3uBo*us!6Kw?w=8R}vd*kM zkm!qrn2m(F6{i#3o zP2u#a>*Z7$ZubNiHu!y|h-e?x}y(o128<$u5b_Nm8_@)>`VwE}oPG*9!w zDVhC)^8-gcsolgk|B_&t^9VJC7YXw^U!~9!oAn7{R5)Zjudj~HrN4HjJOT6%(MMdb z^=HcSZ84(}VdH+=$s%F)P=xkPq4w#~6w#>=ZqN2t>75sGh<7ifIuS`^E>L9uu@l>} zCvmH>LD?li_=2AlabJIDfsZ(gj}drJ7ZoE(Pxki`CH7f{`AG5gYr*+>uM&FCol#-= zqF?%7Li(oPovDu#sK5FK$rO(d`JrF=hOaoY?|^nch>^dNDCw2YxIeegSFOJ`!JiS2 zU;HaMn0m;WyuT5-4}rsvHnV?;ao>+6;g)B3h$MNKNB{5o91(y0XS12xKmMS}l9!p6 zjd_qhAD6o?i)=}Y75V$^XquCWo#1aac3J+y7@2QL{-db>0P*i1000697Bol@p+SZR z6+Udp@Zm&>3oBlP7%|~Qg#t4={HRgnNRlN@otk{qWtw0g9tMy*Ig4mC=VY1ghk&7MV@ zR_$7iG}*p|8&|H}vLKgogo<@xU9NS>M%^3u<59jQvjVMXm8ssuVXKA>`FHVL%9Sl& zj_DTjX3m`}`^9VbFHp%!K_@M(*ehbgStT|Wdv!7Y#KM0rrR7%Su}8{K^f5>wdBpIo z9fw3RNy~s#vPmb2EAmMxr(BUqDzC&cOD(tLG9@d!1T#!A$0Sq1FUv$TO*PkKGp#e* zgfmV#=cMyZI`70YPd%lyb5B421T;|e`V_QKL;rsdMHDVV6J@kfM;}#bQAj7Hv{FPR zwKP*rH;ofhPCo@TR4qLfwNz72?U7VeS7o(T@>F#-R#|5~uqIk>#WmN?VAZu(Uw@6% zR$zxEwpf>VH8xpgWkq&bXP*_dS!k!FmQrb}y@{p(u;sSPYrBPsCSk!Pm(OuU4dC0& zwk?0R-6z%kGu?CDrT12NT`Ir;1M;2sB=h8LNnBOM{YPAOgBAGTl?)J800RORU|WD8 zCio?R`$b8B0`lFrUXOXYmnDa58X4jM(mfeddF92}B$g{)>Ef16%GaQdMUMENiCIc{ zV408Yxa5zAwkhP5Y8Hs$mQXf0=a_Te8Rvh9|I>9T>ZKpn*#L#Tgc??>gO!+6hixu^ zXtTqed99oOQJUw7o8H>rpO4inZj`=eN$Igo*4b-PcNQD#e~>=A@L(@%o9(nkVwq)% z1-97fm+(IN;l!T~dvTH|cNw_A4In-0GFxutKvd+1T;etYj*q94!Lx!k*c|I3SCTJlGy*agr>{rjK) z?w29w?GM2(1K>6;01p*xIzD!WRQa&1YyP+7$(*&uY_wk;qsKnvk1gZDq@6DU{*#TsS%p`Y47ZF&GXpxI){ZO^72o*$;O}uO9YDi6W6!%8uy1 zDGHB>5L=?}npm{zMb2R6``gM+2F5FzL}pc-i4HN>w-Gig00lr}7G1SPnJ|B?hU=SJ z8FBYEv0W^V;mi;g(CUh0g<#>pYFbx))sB`euRK8|pF zHhH5U8+ocn+U|WS{37)Pg2{-$s)mu|TJ1LJMP8M1l&G?$@eGMamDSCao}3}=G+DJ9 zS`ufsJX7%(bmc$2TD&OGgQiiUmrVZya|KRR@{yQ?yc_$_);xU5O_st0=sQn>KA?3|o|~el{?aH)8|v{q z!#ilN5=y{qYEv%dBT+mfN~?)d6r&gIs7>IwQC)?Uq$h=zNm07eWT}5NQ4C$v{#a@) zgU&O0h1%FmX8e6YI&#>wpE%X|g-qP}xZGx;TzBKz@=GNCw_qCsU-y&fL z0}8^wTkuc%|=a+ZI79AqM!tH?*5|J9I@yyPG$naNL9k&~f3Oh?&f1#*CTKyk;8in9Xl?lA7T>=ij)Q&Ua3go$l+O)Gzz)15Z-r$H@fQIGo1r8f1R zQJv~LueyVZV)ZCpEn5Q6*P4tYz;k_L>%xuEn!3I$J3O7-xR#3?|nd6H}-5}?vC z$+f=<5Il{|tJ+|?cE+at>w^LNEyE^2xDO@(XOAS>mC3fWt-Wp7W^9V-E)To0)WdMk zk38jGHnD$4LT=)mo4f=t0KorG5^#bugx~_-(!m?dHh?3X+yQ5&zA3|OjhxWma#8re z8y>xk!%o>U47ax<(($BiT*%M<2fo*J#DM#R;^r1PXD;3rWkZ~FHUBudw`Fr7<2;2r zXZX&`)-jdOCBr0{xrTjy^PT5>=_uhZ5ObY$nm2!)T|nnKmFV3sa)*0du?si83Fq%* z*E@v)4|{`W9`&SQt?McWH^95C?_pb&t;H=#+2t+}j@7$7VISAq$5r-#$DD0g3wcTS zoA|>Egz-E}`@z8Ob%RV^?1DRY*(bls$XgTb_*VJLMY4FR8yxY`jxAOf&bZGv4Dh!l zeC2;14xG3@m+<};Jna2m|M}Mco$};8z2QmE$l>4Z{gjt&*l+~;DzheWX4jqO4Jo)8%guehN1OcTPuIus zpMG-F)%lL6tMWp+09-r*T)m20KZB^gP*Q(Dy}CZk)2fHl7m1UYmTN%kW0$D=zw#>> z0fe~@jIG*}xRzKz5bM9`W0zzzzssw)=sB5->%W3IxBdG#71SA(D?xUlL5nlMlCVDk z{6V$@zVJGUAVj<#yb^i?IDN5|sJof;V+icSrtpJ0gu}J1;WryBx0g%0VN1bUv%-Jw z8NsX(BJyj9my0bfWF-GJWIh1&!Lq3}(bI@%i$i^*!|AIdWrM=MiyJBAyz#RtFPy@- z0Xjh(z?8$oL#)H{FvNg4KAYj4HI%rETQEw5!1_}_NzBASj0iUTz(rKVM7%>eyh0*0 ziBYseI7~#XL%Hg^BeK&sQWU>UEV+LT#HP;+!#O;cVLQBsQMQoV7%R+|u=~Pr+qW@X znD2WkVgtrx3%+1HLRy5mv9Z8uw479=Mr2HhVHCH)(MDPH!yD|w&-+5~s|i_bh+NCY zH(WG(~p2MuLA#$RupV zc_BNj0Y`dVCC%HM|9Trn=AuF{)IK}($Bk)1XUo4Yj6sb+N7$jrS;WLZ!RbF|qe7a|#)q6oX=EF3 zyEvC@zK!w7r*uPaoWqWkH;sQV%Ekjeq8!SjbeTsSIKvxDu>8uHjI)LuIf;A1Z=6J6 zTT0OB#`qhFn4G%ugUF_2BtvAjWb2ohG@#3aM6K+^rj$sLaK@~hNQG%k4)n%otUM5V zL(Yr1_e)G^7%a~zG`m$oJC4D`l2aHBjnaRcQ&PL^QYqa#Fr6y2 z^G+@ezcTf`|DwyiD73}(L(;_RJt{TQD!m!Kb3r;49LpQLHkHBOvzeJ&)7xY?#^O^w z_0rIb&F6}{7$sBO>pYZ{OgB}zx1%sHJr^{!)R{0+xB^OC3%fAH)IL?yO%2r{nYGGW z2_QX-YSf9~Ob>qvO%Oq~3|I{n8-0{heF@Pt3%=S3S49uD%rZU|paZm)Pr=nI@zwK4 zP6HW9%ve@DJJwh`vuKspF{9RM{jzJt)-BW4ZN0K?_0}l^*KmEZaV6I!GuLxHvUF8f zB4gKf1+sUAS00nsdBw4MwO1O$*L;PsedSjc^Vfexv4DRSSP~=Hg5|J-MOX(@*oDQe zhIQE0g4q9v{i}(kSfsMpi`A)&)mWC|*p5Z1j|Ewh64{ZBsFF2Vc|zHgMW&TySy@B5 zmz^}1mDwz!*_z#+OJKftrgof3)`|CnzKb)k5SvTWg@b5TQF#EsX*Wn6l7+{eAw$dz1urQFKx z*UQCRfYsd19oWwGT!RJO&`sFUC0&L!-P3*8)K!07iDli^t=QLvU5u68+1=RMwOx-@;%(XEMP8U)-cwWF=C#@9h2ET%-s$Dp>b2gV#op`{ z+U@1uqV?YIMcVKc-=!tr@^#wtMc=4Z-}R+h_np@Eg?GTLY%DxkX^W8sG&67X^0UM1kN4W-SUPTnk3B3C7^;YTyk{sSfsF zXVKsg-jfj~;XEQGB9>sF&<+w zj$QvVK4UbdT{T`~Hojdqeq%V+T{)g(I__ONzGFNFUOnDpJ|12_{$oHkUO^sYLQY;o zK4e5@UPW%CFJ|N{yW&SK;YgNb6sF`#Ug1l|WEa-tO^#tt4k1SdW#SO!QD!t#{-A$S zM&;g6R|a2LzMxi?<;|ewTCNpbHeX#%pj+l;O#5Z_4Q62;=Jze;-|^*RUW;T_ zW;f~NW@bfb79D1mW}l$uY6h8S#^xru=55Z2ZuVvy3g`bF=Z^vBa^5F&E?{+b7jtcZ z22g;D?Jh44pd|9O$+5NJxn~kgfPQ}_R*_;AcA12(>7_;?n5OB74r>u5Yio+5wO(nr&S|tZA+x?wwbp51 zA(V8{E1kKzefhdKLY*-hBae1G2xx$?-WrXLi30FwUkquqlcNo!23RFuyJwab zfRi>)xW;E3nrF9WX|G;6(Z+vlQ6lZm=IcWGHqWlO?vm?5>S@$=?QZE40cZe^cIy9> zVF2A;fT&J6-A;f8xN3&>=#;5!$CCgEFlq`cfCTsl2tWXRQ|j0m>Zy^u)9x;f(WHNOw*^X_9Hf^<5?EqJBw9apd?(718ZGz5h9i;1lns5S7?FqkZiXQKM-fyC@lK^Od zkX8VXj&Bhs@!Zxo1Muht$nEyVYLC{K^L9IDGXSE#Is};Re`o;e-sl3j?hP062G@ud z&+U-z@a{4I2*5lDC>wtU*ywGMfcEC@Detx=SAe7Xa3VKw2xseq7H|L;bB7Ld|B~i% z*#>PgpY7FNa0%bgQU!agh#y@Maic z8F53eo9zCF1z>LGW&p`6Y(_F^;L>OyKWd6zb3X5Lkr?!UXlj4)zH?tA04KK@CjSR0 zPogQOX!h=&lMZb#uk8pY@VNGGA@}qzuj@TO?ePY7*TM57-*7;u^{p9gM^9^-26ot1 zb`1}9gcfYFzVJ_{^#{LSm$~tf?(Ketqiqjq;uZ)K_jC=H?0ivx3_M*xEmc)lk1 zRoZL=ZFpP%Ba^rIK5y`3XB`iJcC)T<3$OKQ2lP*mc9oC#mqzw2uXku4`LFNtmFIXq zPkFY-dvn$t0qAE1=o#XntMJyD@V36<=5WG3e4d8@%VryQH|b?n>;`}U;4bw&Hwk~{ zaeY^Nc2a-$N1uABr+TA5nXB&`gWq~)&-2emBDQw>wwHa)r+bRGdpUO*|JcX)yqEoC z*L?ogeK((VkyrM<7k0Jxct8LBW6$=J81avO@q$?Z7eDgDj-3|o?fBk($<~+iCiQ-< z3FwZ+p?;Vi7l`WK`4Lp}v#hvkps8Xj=t!nkA00B+~G+2-z z!2*8*{{$%T`jtU}kOa&wP@pyD0tf{WG|>9b00IaS8h~wRAc2H`2*m&XYk=UOfCUl^ zEPbdHr2(%C81!w~&@coD3G500EP--^f(A0n{g*%?fxHkHES>o{s!^7YU;89!GH21& zu4_x>9Z~7a-9T#t7kTog&)7_f(*}Aq=y8A99h36*{ro!i?Ao_;hnO`g!G{CwJ`_-Z zyiLb0cb>n9eyjkYRHLUnub^*Zhy@f#kdU_EcSp&0P(pb<&-Lp@Q9+cp{1^su-Y(RY}<5Q1>kW)o3%qcq5Kb#g=~~ zk2gBkV^bWaBqWeU8hK=kEFyW-k`T!ll>+}YIVFg%QF$e7Bwk6RU4U46`6ZYRDe2{l zNSYTWnlN&iCYoAe=_Z?T%6a6NbJ}?)o_XrIC!c-#iQRjD3OXpEg&KM&qKPWHD5H%! z`Y5E4N;)Z}m0Ef!rj|0+P@tK5`Y8ygp^6GBc>w{lpG~p@f1438LIw{0Az%>Ef#SlL zGi%n&AobVSvuoe3*tS9Cw(nB^{+D3%*5F8Ksu!G5wl|z<1 zK72vS_UX^Rf1En<aWXsg$AF=&G!?8q<(3DgXCfqOR6z>#ZXx04hwf7St=Qzy>QU zMMmu=7L%&v4Xf<3%r@)nv(QE>?X=WZYwfj@|_mz=jV% zZM2YKhorR6NTWTrL2CD`GJ+3Wt&!X}wat*-Y^ObSK~-1(v^Gg-pG7y;90jm-0|XRD z<3Cz|mu*l0T?TMa;|U?~wMBTVEZfw0{{y+?Mei&C0ysDRH%5ck)OkXyyB&JeMDGn$ z>Z;3L677UzM8MVt9E1SZTm$9+*AF-#`R~9luvY*MJh1fxqPcx~-a8MCfb$D5ut3<( z6RyN}oW;cjCu!0^f=5By$QFCe+$%2Pyt_u2Fd9XRztw`{xE(l@QQ>wJ6J{-|fa z>IKk%9+@8krN_CPIqiO-%ijW_=D*iDkbV)&9Y?Si9MGLldkb)Y11`s#0yKbk|M=k6 zMEJGgJ-`43cz}oocrykLO?$<2UffQYfC4O_00{`;KmUfu00U^x0V<1O_PAy_%dBvJ zgp%1>gSG}biO4N;{X1X-zXvbQ+<449=zyby^g0iC_0p+N_ zIx=u?YJ8d;HApP%Z4W|}%bVv;7#WoXayi)}NEI_UL=igDky=d7))D{!)A>+W3kc2v z3;@5q>@IF9!(IV~2f9OAXOsW%VWlE}GR8Oh?}A)>nk*TqkT24)j(BY48^I{Z|G9FO zxRak7Wx2lx3UF|HLL3|v4B$jfMIqPns1Mw$)0~%A%zd@2jC|_VruWP^rZJ0M zO=tt}*3d3;HJB68ELtfTR@-sbhI_PSShadg(EqZvA}&qwKmm;v~=ia zm*!Li+O3#vWNrn0iNV>T?yG6tqIo^bS%fY#kMAvGZRLAgh`1Gd4`=`hh19hG06>Y* zLvGQinLPm*z>iRTrUZe1MgSe+P$14@Kv1)15Ck*EkM4UKg&6?4Z>HF9_0zA6PdnC! zRL!;S#cz(2*-TuX*S=D->H^!lU!#%LterJxkq1oR0z;&)3t8+zNGMJKNVy<=wdzJ} zYRakJ49ZQbp+K%&kP~VbD}PJ#K&?2HgNm8|b+u$=V4ZBDE|0{2h$N27Yw67AhS*so z*?do*2OXf%p@htYPPC#I&FDrux>*58fKr}A#Lt>h660h(=;hV&5C3ILtV3SfY8lLyrW zNOwWb9dFRZx&ZF}47um67jzqh-txA{y4zilQ^UIc2oO**^e`)brTf1Ih^EouVH9IimDG|hWH>w+kN)r`Y=(s!Qel_DSj#=Z-?>7s6#_mG65 zu2~NoqV%g*v4@ggDV2x*9iaRCA=<_=)}QWls8hS>N58n;jZ${3vs_s17InD!g7T)v zeAspejHrEoz4&Rn8!rN(HhG|q@8r<@A)Uv*&LAd?*mJ*zt{~YIR?;fiPI#H6MzQ@L*fK|{^x9amw>bPYjx3i16Y6tm}%d~ z5TS>A^|w;&hk>exev^}Lo`Zl65qrvabsorX9N2*M*Lvocf3`w!ZUuRC7kQ}XeGLM2 z3gK3NBYL;04A0XJr+MuG^jf%+zlb^&j$s1PA%gkrc-z_^R>=8RW(YP)fbcM(0v=uNbL z$b7N}PX-~5=$M1A$R;1Sdsoa1?PAL+~W|SPEZK*SoQTdcZ8BxkaHCf43skK#R^OazEG#B+fV~Lh*QVU-6(majyXSSgo2$wqUjmCfaoFBxa1^_CfdnD=#mk{~%W zkLi{mkv6Mgj1V_()F?TbGfk;v08XL+rDGHj$4Sap04r0P5_e|yv~MSuaYLhWXLd(* zL~ndJ5Uy!ur=|cT^fr`4OpCc;Ms`0Mbz?$FoXFWxYi2=XRXs$eoV2M$zO+lLMOs5P zoI7@8Xth}a22r4eluQLnGXJK3WaJr40ajI`rBKM3K~jZU7?qqk*%r4ac}&Ng$25S8 zMF3y3YNKgA?3MsyXL(|GW^#CkXs2{v0|0&JUAQzfUk3o#7LjfEU3153IHMPKgkxoe zoi#R|-8q~adREf8o$FPWM3zPGDNrN2qOS8rEy|tZ=~;hSp4iz-8G56CIrg0?%Aqkz zqG(iPuOy;&X`e3|O97^%6GWISN}E3Fmt~Q8spoos>193Bct}HRm1GoF7H?KYdR%&X z=T}L9fo;YZZSZgC2W?=QG{nMZ``d@5GLt8|r zj#(D+hmOuiq&*X$S2|RNq@`-NH(%pd+XtY?SAU-|G-f(rd-HTF1EtZ|GFW;Srv|40 zAgAcHoR}(F)%l#k*{F&ts8waB>4ip1%BYRXoJ{Itl}f0g8e=7Ynymf>pEFvcn02i@ zN~75-m)`oQZS$PrDVEs^pV8{1WdUr^SAq(tt*dIGFT_HTtNC7IBp=hkKlXHpFcBYu1cp-|?b1s+z3q<)Lu2x7K-`z{{j5*{Pm-q$moWgd0#m+q;8Xu7#Vt>$$v=8e1(Z zwDwvS5Jzf%#i*rD|0MvYscaGpMC$k%6UU|lL5*yqjGzOsSt>miHV_Oe8Mp>R5K?lx zp_`~Sjzwc?sYE(&Yf(Pivd~MwRF$&O+mmF=uQz*^S8J{5*<|&bUlH72J!-u_hP=>8 zs_hl83rx5~`nLd9UmQ%l3}wM+8z4&6v0Xl=;G9wU!()!+=AxMy#@xImBm4#06EdG=Y;qd^aLIErxqLh?{ht^MpQ;#ajW! z&(gsg8Nz9aK-!3I0QnL~W)*EbEM#29imSyvxW+Rvv`T@;a=gcU+{b?W$6$efK4HfL zNokgU2FSJoIWPZ3$SCMhGBP(LE*?hF_pM_5t3XH zqO20cX2}T=dwwQur<@Y6tbv>S5i2!-6)0>h5z7K2czj{BI|0f)VSc475yNbcoM;lz z*b%H;Y#CUWCgIFic+4X~e$^(;A_2_+!^=^B_%*6|n!V_7|F)SIC(aCUjlAg)j!e1V z+SvLRdm)+4Tq3u*By<25J>GgTFqA#x_gKHb4KlsaL9gG&DcS=efcTK zeofa75!3;y*M`m4^Y(xOD%9f~*0bC#yli3Pr-oo!i5%#;Qc4j~n-Jsn)Gw%bsfNv> zhlBNJjLf`XV#s-h_uEswjuB{nD;0&3^L_e=ZMUa^A~K&|YI->sd%%5v`}lo-)y;*{ z-4L#A-3k9uurb)(pvTYUY={H+bBy=ZIQ)6goz$v5ro$bBVi<+n&Dy+eE}}-x+dMhk zmwe0D%c+Xa@8`?jM2d>o-(yG+z^oAT$J+TC&AtuV18A$=_iGZ4-~%z>ef@dI?N-Ts zIm8No^;gWg9f+(Q*;&Zn6s~c9&HQ~64xkmj-+TGrAV_i=?%k+rh9|g%?ya9-2H-k= z*9P`^4PM@$_Tsl%$L9Ppx*4&zT}pu?B;HNK0WG( zCwr0>x(N`uf(Y1x%yq5{*Ul!^Nf)}-b>5j>)o=ycepb4kj)>mz_b7$38*F)JpIU<7DuMH8N$X5!_iUeP9A#d<~sNR}L?}|u&@C;AnAs_FXSlao> z*IWNB@1CP@mHiN_7;~%p@1REVDbI+mmx}0J>b5TGvdzBVY@G#HReu;}FWp^&fJlRY zC?Fk5r$~2qgEYvwlyrA@cQ;%*q`SMj8}9I*S!>qJtoat_{hhs^U6pdU8?u%%Ju<&s zjp2kb7S>#tWNH&W*?1(k){)LaEH$=Ed)kf|W;-$xj;*^#xzZ8YU@5;IR7RAf4t&)qC+iY?9>{ne>UbVrdme>8kAHXpCIw%nbzf$Im-&pB z#g3O{=-SIF^kx0S>!#rAw(jfh+c}W&df4%Ly!LtueLepGz3h0g;D%mRY~B7eTi<$o z0A8Nf-ss(*>q0&V7~~>}@*6+?Vvq^=z5BH(5==ltNg)KH5`{-bhTZ4-A|wV&2sP$Q z!U2m!Q>)4`NwNa8|KdOiDE|rqllCwSN(jdK^#lUYZKYA_ass?+_Xd3ePI4pqu&OF%+u{4dF!o4D_vtaJo?&}|>C+rjG-uwBzL%v$8W`zs~r*MogC8r0(ReyaZso>kZzz+s+6)3AFV1YdcPSxzmPh& zO6)l#40d@qMGt5c(; znLGZYcZn_?WCDhIaAeu$jA~?2W*qSbxI9v8m9kY7=6fKMvB(l2qR;>iFKHfdH_Y0Ws^npEol=v|s} zk&g_x>osyeEoaI#%@MxMzyCdiJQd<|?f5Y?zE(NYh(P1E!1PR0WsXii?U2Sw5O|6E zqThi)R9DrvX-iom!6e;X!Bjk}0so87T*IP&^P0Kl$0XBNH>Bg+Y3ptTF1@zrDOBw; zWc>2nKUfOrpABcwl3)GFd-6I#7x_-p87kNg{%M`_#3RpVJ# zxlEGoP(L*=YnzyTiPnDp{4Mb;kB2>p*;>9K`jQUyT`Z#u1^GUL8DwsVHYUvVwLZpRjEh+^tpKg(u}|tq=|{ zGE}Wja5|UF$n5wb&|l=$k&$T$x6>6o%cb6FB#3c++H1Yq8KJH~`VBz%#rbdc zGrcE8>6yKWH2E1zyehipFSVp6?)GDi9dnvXoM|1p)`^f!6}ooks$GLM&M1=TVpJ9i`h zZshvYw+()DbYNoxNJe3+OhAtS2ZFqV1KyZklv_$6eloT|#F$aJNlK-DGOohUn8}!1T4Q!HzM;pM z#i>bJ=XNro1KxzyAK;cTAe>6<7cu!7*Cb=|Z7ONZ&xAdXTUN{-YE3_zYx0ddrZ}e- z**4>fic>tE$!bv|wafP(=TgpSc4`g9u~s-Q2KAU9q=xC*Du(~aP0lHPiv9q@Q+8$YHdEO~NriN|ph5wHV)?VC>Dc(1 zLIhb0i5wE8Vp@iruiECy5s%F7#q8N|WTxWYk28_&zj=k@7}S#vH5=L(_O~Vd_0A(AsnvxaU(b+PS-v>6D zXqu8igr&XwXBruYzg)#s>jvtqoOLx7>MvF54|c5FV|W+VZRUh0tSyDlH0Q>2R3Hs2 zXToitW`mv0DwD&QwEa_bZ^G-_lljYB?U!`tqg6ZJpR!mtp=zTA?01sr6aioOR=Z(2 zdl3IH`qBY1OGHR_-AF1H!81=f6=t<1wB)SrVIGE0&9VLNBhV^G?YNDrbl2X2KdfdK z9$KU|355zHRT2l5Bg|K7|A;u0yrqYY&>L#YOmt3@EL(T~rC(=L*H<`MKX0>5Jy5Bd zlndrP8-C?gZ^qZLF^}LIqmt&X`jhXF)Ygh}p_jX_$ugK6Y*5if}%EFAGf>9@+X2~s^#HlEpN!mmkZ ze7mZj-REWNIja3n4t3U&hN=lUT+{eZ$bJ$J1B{hM3s&=bAx?8?WgHF0{HG7uDmq!K zY~Tq1Lr=PUaZV9~qtRY-rBA@6%DlXx#V}a!Ps-a5ffeTxsQsTaqBWSw#Pr-&myhf^%|osMD5k(VK+2336GlCCXjB8(mr%m zl{zHSxo>`Qc?gYIaY(q+S{Hu38g@H5Ne;LJHXNAm9E*GxQ@X?JPA zTR8)eJl7EfUAu@@&jZ9gHwiji`#4uGA_6?OX$0MdR97z(`aO3!I^0HGRJlCagX0i35)_9bUH$ zYcFR3!1InE@Hn^jde;xU-gE%3cWVF?iUa}SLtv;OuLVxV;h2b4Z(u%#K!N$q3*JhFzO_9>?HE*d>7kEoZCrK z+xfnylXSY1Y`c^Ey7L2k7X^OTN9rz0_AV-sE^4JN8lx^+$1Xa*F8bImfFZYwv9{|| zPZ!g47xQ)(%XQah_- zfMsK`I3&S{*u7sCv6`Di09bhtf&}=hJ}Ao$jCdb_Xa{QF?}2X=L2?Bnnu)5F2CA^5 zAguSQ9*YF)fweqAy4%FQdLRX@AKmluS8k%-m7*a+V&Sg6(a54mAX21rQFvEyW}?_P zPf;D}Kzm29{&N8QJ8=h*Kr{S6+gLGIE3jQ|3gW#OBC@zHHCPL{M&AaZ!oTZROYQX+ z>IcC6YNW_U%l$}P65i*cf2{`CVnq?`#G&4a1AraaoEof}+UqS7Xm%Zll!&594brAY zMudn*42U;x^k=V2gvR#LkVq;%BNLXMC@)`Sn1 z8%9Gs7jq^>%>bT}^w_0*g@%ZOP*Rk{{4)F5mS)>ULtZ#C^-MvzxxnBMY2e5kMU6h>Z#K~5DACF; z+h!&F3m-Yc5$s($5OzJ}zb+Q*3Xb#6m4yfQnp+LEs13UG38+LU zsqDY*;ivQ?4TP_h;fQ_zBuT}|h}x9;(@zLo;|T`ch)gH``(!bS5Ep^?2YKIb92sXE zwRik+aGbSi9OGs@k0B@qCY2!O%7z*yzUG~+1_7YBrx=N`^>!lH!IfLaIsEZo`}W%B zOINAvIPzo zUk>s|hw{=71HukVM>on}4?mC}RkLi?>KxH{9W}0QQ_1ADoUS#$9)-6ac8!Mh=o}BY z9uJU*47DGp%pZ>m1dWrQ1aqBC>jchvop=L@CyT2A%d01D_b2N*0h? zPc0iykG=k$zMdMsJG&(Rd#!V(<$8AS<^R}zraXW4$`a5?eqP3RJ|V0+v$a)Eun(^* zyi8sk706q1yo@QI@R%q{RTq!tLT-U}WK!R@SUD`0`d zW5mRH)v-@+7PnPro2K?b?AOIpi*sAk@uksGi~eq<;b!pS41za(w;_1SVGaAkd0RJ8 z%EM*W!5?WyL=1jSITy)km(%sisM`Qmg%_#^Bw7WF@|&azR+_qpu7kV{v!VL8q(DrG zABiu#x>d&%0!Rl_oh76#mboKb^+3T=Q2poA*v&A=OeFXvRjh2bykc9v2w5j7NvyLp z+CVpElf1g}+mj4AEH;dhQtdCAB(R?&83emJJ1x zjxW>^^h4}J_B*So&%eIE1bNwDPn<a?1bk{c zJqMxn*iV}6p@WT;CVmkkUWjBG*JhJ>{)Cyy^VfjIY?<~Q&8K)PsMZuFE*~h8!dpMB zct^)dPY9%wh2cS~w=QHY>y5>NDl&d?-WAq>P~6L1NudiG{#2tCsBYqX)>I?h|Dy5Y zetWVao|b&~;`w+3jK<5>-$(>I<=w4Hz@)(1Nm8DIVTT)#NGnlbx)iKet(knZ;PXg7p32!6`vw0FutQfntnk2-frkO z19np2rUXArh|Q82ydlI38I%#H`JVXQD{|s2= zGlh@dZw9Pkgy^lBzPfDvn*rLeQY~drY)J z#bL_0LEB~R#$MZXFRwwzLpfFo&)y?m1gsTG2=(`B4~k5;*Ld8AJ36x&TO+~g~!gLWmUfz zhgJ2Mn5II_lu3&<_nb4YP1BLDhfT|wbD2%+-EPXSk|#tyyW4BRR=e(p=O?>fqP1YV zz7M*~_5*Z8S`I^>*OnY|kv*+J=uY1+4xY=8>46^6P&F7o3Xz&d5qs5WK({b|niQ zd;p#Ec!SPBEngr2+@XQMHWvg&!#(pDlRW_n&k8Rse2^B0r~ral76WCF1iva!w!(Q! zIul+?BSQDwhQ;1p&$tC<^4%gI#Yg?=&nLfdwTx=j`L21f%+{zG>O{7{p&|g4N z_W{t|5}r%H$N9>ym8llTICi*PUIf)7Glabk#O@ru$+3tt6RDHJcgZ)R3u`M;fNIQo zMAcICWfck`4$0K#@-AGwa4Ui@IiuEv>xp z0=^vW_oPI$qMs3^j-VSm-q`*D+K&bXc@`u;C}7woMK3YVg${YK5!fL#qaxVudE3In zUmJacnm@;V&51wg{aVdaiv8g%`b{FC1>s>1`A>$3VpM%?T|61|A!wC;HZiG!6Q~4S z4|+M^Ne5d$ZbXA;hjbcHvFM1Ipc#@r-L%=m{?y&epEJAYKTr$ie$gM6gQ=7eW>QyY zv7A&u>5iSt){q~-Z_m!px9Vn;ydAQR-;uoZct_+;4AN*leDqn~0+Te+KWSe^EIb<_ zhH!k;oV^Ig+lzaB@JohX4rLV+Xo&-BXA>WPp}w-7>`N;?dclxj=a8iB=P@TwOIB?b z#pH8;%h#9@HM}V?PODKHTbyMrxhZoBtJXSOoTE6psqnY1(!*Ms$NF?z6&F@%^m%Ck z#`3l%k6%IY=h9+p`E6Yt#<7*{(o)aqZ9{L^v0cQ{@-WL?(+tLmW5v?SG@>-%C39=z ze)nndrq8A7G|kq_w`}DF(X|nJOJ?WOZ?S%j2bOu62PF|MZ*cIq{(#`~L)fWd61-gd z)bTEw)QntWHZc1*1RN$sJV8hdn}#BC4)Gzhn}hs=aKR5?{rctI_fVHt8_jk^>VjQP zq~?FWUy~?(ikJ#9+$a6X&y;~OzE3BPOh$16PGzm0MJx6(qDAdDB_hkwp2v1S%+R&V z6Zj`(#%3(0YiVh?7aC0hT897Hkic^DpSoZTE?>0Yy;n~=0|J8AULYo_BQb9wq>rn+jn0#0A>0@d0W*mhn1Q4}B>} z16~AEpzS}hGA?4*ErD+;)x73exIL5M@+8CpBrLPiX?P@QS?6@#18E8d!Dz!QJQG4W ziD(F^U!E?a;LJccxv`kr*`l5NTl&~+Y{5V5b8iJMXM9sl+1AW~5;Z%$ptp}pv&4&Ah!j)S}UCK&A zf`YGt{8cdFJl|!7=3scx@L_+ok##ks-tcwht~-bXT7#yRsk@8_Z*YqUjl9%9Do*Q>d7ot1+2N{6GztTPlYioV4Gh zaUDb#OM&vWbCf>MhA&E3s&Ct_!sM9B6jjVQwa;0LY5_p&K4>KGDa6!TCi7(ql%b|v zxhyZw4nLSLIKwT3Qe7h-&Iz+^PrPD^fDaDkf^@!@FbN*u^a%~riY|Ggf0fK?OD?XT zD%$ucArA<%WU8@H!+d(5ES0w{(N-uHg&#p9$te83EYqE#+YCQi53*)nNgpLv04gj) z4=xRrXpRjjhabuq4^lyIt7St~L&r>4i^TXM5p6XPrWa2C6qI@y-EI+1$SadW8ryq` z!ls2?;=t5RKOA#4q`VyXo)3pox)5pj}5aFc(!HdGGcMZ_#UGu`vgt;edrC92FZ_F!LyW*_GGN* zz=88leVPubM+Ix>V@V|6X6aJUQPz@2GqNdh@q#fWBi34FPIG@w!4&R14&iUh%|L#> zzyM=y*C@HXa+IY#@<}yH4!BYDvV;r5i~FBtsxgX6ve7=F;iP=Fe+-UV$0z2M3`xaA zObo`z`-0IekC2y+#6cEUDN-dvk0^@|R``N1j0O8tfrA!>WzTwVK?WU&a+ATtex|D$ zs)WNBQb3&`{>_RjI*#ryhrfdCZ#Ou$^#xckkHR4d^8QNiuZH)XDv-ki=Y|oLG`pII z4TX7%!WWetx>Ggm981C4i9+|gMwnM|2WJZ3fcz34iy5H?a|M@ei|tcyG!@lUy+qCx zGUnGMWJqM1s}X9RkK(e-^f%>E3Tk%lUIm^Tg^0%PiK$en>e?X)m+WtXpChPdMwI}# zHC#$sdETr|yR^ARURi~PrNWP$mjPAr1zTyQw!M&iP?6==g)}VO@Ga}xdJ-P&1OuZC zm_82&<|W3^7#YPnSe|&OJBJ@4r1buwn+0xCS07|T*2yPR{rW^S<4Z^@LgGlHI&atO zq}>MnJ*V$G=lW9TmN%zeGw0D(=LyWrsc+4B{iy>G<`wbgy=7~CIMgnF%=zx)Hbu`gL_j$dew#|=EM9`!*A63vf>vg)UgL?qD2>Cl^5cS7vi1NV?-D7 ze=j8EEhN`1L|a#;%q*nsETrEoWFRb7fEO}p7PC1Pb3_;4^w&J&#e9Oqb+M zw^(S6YetRsF=w$%RJ|lkqa0zWa%CX~O{1b$qc{z_5&KPlU8;9lYCzC%GgQ|P4gVXt zkR%$0sJK+0x0GBcQf-H{}^+>xiT z@3TCzvpjmUoENtg_a}$n5eST_Q-{oB46v@$k!d$;YfrN-$BIft?^l&!#MaC#%rdW} zA)rGMy+hl=!pE2w5hjYLmllR@)K|(@QZQl(k0}ypG@m6v%LJ=$`s>lo^0@2*tR1ph zsD?~v13(XMp$VZSM*C~2d78F(=8slox0ZBMoFCWV^DaD1S@+Bu2%L9PkHcQpW7O@$ z&^XCkh>2NNY7H}Vo%)J_NweWNk``o53UX@#?rnLcC#n@fPMAU?#RUcbe zg5}b~d3lsV%uEwlxDallNE>b)i|A{wq2Ymss8i$6*weO8niZnV5! z)`@UsX0$OzPKB!ou%o9#`ie@XvZjTLJuIreC%>?shVg}0<>#O+>}z6uy<+=>p%l*7yiVy21^jlcUIe=PjtU;Q#k3seyDjCN>x%4naIdx`4 zv{z;0ci)D8VTQbk!f9oj1`Z#xtOHf@xdog9ktE2)+cyL3(DA4u&HuhF{>b(h>c+vf zNY^jeIUD}~#1=ximL8=-D!?kZrv}nP$bYBSq-ghjzU>$6EF|9t03iKp3MvQ0P3Q+q z>PC7n09pKCYyf&&2ew(bU)kaPUg0lL<>a+P`^)G@yfK|ym#e06PTmqkTV2@3BuwB7 z$v5NV^!-2;bB}cOzo0GNUs@ghc@#wk31<6swQ1!*+`b^=g0V5STm1qpak!z*!sDi) z?>qC#dsD@~W{>>)6^KVt0u~AU`uLbpFEizE_9a*v;Ph0wh64U ziM4hH)c5q#!b7a;Ur|HT!>H>IvPG?m*3I%(H9bcyy#DTsnwT479xe5qjFMSG6P!Y; z`^@9CBP;F$yeU5hjan?uVq^_1J8Fo4W$bpEb3Vr)mEH-szr&G7MEnJH;Hm4_f z$El}BW%(zS^vBC8=LjxKD<C=G7A6wzyiY^Wi`jOSA&fb6ev?Ke$8a<9R{Y1odhqG}rYGE64_%7U=H514KD3i&ld z#`Tq6RlX6Su-)wlPCCRAMDD*c)xb9eRO2_KJ%8KlsPC=IF&^k&aZEiTv^2E5 z$wnj5C;GZ#3|&M|4pbGpL)OqzwF%rf&*6Mk;S9eam0f7gR2ZGTGuzu* z)c9G^ZM|n}gMo&mjy9&#!a{}irQ|}N?jd?k9m#e9a?X{3_HOMW`Nb{9<*@*3BQwA~`{byo;jy0I z9V_9cWbd&&K&R;LF|F*elFkFO-!)Ejty=Y|PTZ{C#k|txsWC!bFX5^Avqx;hQ|sJQ z+ul>Vu2%Ei6NKowlg_iLV!uoLxkvT6x8c$M6{#=ac_86=u)?Tc*Q~Js^?7*id1UXo z%IUcVCUpf4fTBxyLKJFG#*>L&;oBTsx>s6l(V!AM5cBR3<rTgkl?ap{$m{YfbXqbX8=OC-{ z&f7J?T6;B}-^?TL^ejKW;;q1O>+BmUVUmdG`{5d>ZbwMSC1$B$RN`-j)n(h@!bjOqOhA6Or$3!_Lqb}U5WaR zc~$fPb*Kpsf)?m3OEo1^2e;2GW@>@qK+X=++$hbLoVzHZS6iJ%8igs7JWj){@ojpJ zU~e;1>nnD)P<_sHn1g^x?=q%{fs&=>OIYMywzzKAS=awY2=-5%q1-Y6j~Yu==wtbR_V;^cNot^wn2*>NkB z2;{o=78~)rgPy#_Mvz$U-(n*+_Zxw9<@cMRoNuv_NO6|8*odmlTWrL%{9)HkJP~^@ zGl1oBKR3bV@u0Av{PD1~L8oaCxd~u-I-~0u?d!vS0gKn;al_f`(|JdS z*Yow9E%fF7P5^j)zB_|LL9xrPuo4hhl5_COdkR>h0SF?God>FCCOn-)CxE)i;zKx` ziO4zdeOSNJmjXQtS$w|%EBf4zUM34(wy=w^`P~1(@FTjZ1n=?0`QN0NkC-m5JaE_` z7|bOM0=*tgks%0%Jd+a^TL?yC*xgam^V8%h>uoNIatr&u`c@SvYKE~N7hX4s%l3olq(v4~xwlopcn&uE4 zoU2c1cmM&^E+SNiQ?NFC#eYWI$H7BZS>B(EGes)|!&6}6*$2r0u(j1NH{bK<69yzz zalr|LxjX2lMI#7aZ_Lg!4P)N?prG6(%$(mQ6@!~>`$s$&0Z$_u6#3py*}tO`(P>y) z9-u#LE~mgwsix#Gk)Th-$Vp?Vl%U*wF}+oV^poWj{IPe8Z6h`5DONP!F@nieUKRac zpUptT=F+g}Rhfzab>vshLm4}F+=PW5O`0+KU$BnRwnVZ!SY=%Dv6m8gfAEWtg&CEg zU%qB24e!a$1&RA_T7$lc(D7e<>nl18Oa1fjfPJrM)Iz7ake-@R?MhXFesi)k`uZdG zOQR$wB2!WnsTo(rAeE(#;x{*6y00kgBmQFe34D(mYU1>>K<8pb>F+!S8SOaESVT74 ztL+0GO%wTJ@5?IJ7)JW1;K@3c*|5JSdiuiBLWP&IWu9n4@;4?L|LWpvvv&-R^+J9O zK__GD8*kFAyc3pi9>+l?C(P#JpR_>j^^J`*EDj0(mY*0Jn#RLy9@C#D)_LS)jvTG; z$%T1lOzu2qff+5=ycDiOiwlmmkx%$^32*W%{%>S8+x5k#RSq2F+5{yAuQ24Fuz4`~P|`E`ox4R9ygk+GaRxh%D= zMoW}?#;|VAvg?c#aoH2}9r$AffQeH+_kw%km>2#Ca&eNlqg48MDa-D=)8_-6ahR zF@5v3EVHDIPhK2Aq|CmMIn_kcm@zmcWpJbA0$?jrrVCjbWE!lkT|d09Pu@OHc2rc7Tk=_*)g_Yw9TWTx0$A^dCDl~-+M$(p}u zs316H{PwhtUB1h_HFG2f&s$v*t_m_s>UVp69M2+Nb3$!ewH4!APvdedze>CqF}H!( z54cuH|Cv)vFfXxBH!ZCo*=IuHT(~091$F}&?J{*=2B0Yy`u!6lRzz+SK>|-*D-TmG zbKlO4v7G8#99AJ@g>%|aceCPvYZ#kbAI-7UJ009R^|h*N^|9x>SDm|(zE@~|Qo5$n z6k}LA#)EsFoO^o9rY^U#%qCvudp{sBKWev<(;c}Q=J?8wH+0w2tlCrQJs}m zpGX-k^K5}htCM74pEjD9!#yqWw|hzhKbhOyZ+7F zPDZn&=7Cz&T*BDMk~Tf2#A0T;w3g|6z~>udDkO7#Rd07cBdh)Z_gS-2D1tdxqs5Ey zpTK&yxLqS(IPhO_g3Hhtpjxy+MYLTo_VkB7Y?Qhr?35k4^XC~q$EY<89$t~uEP0TIL{)6XA;+DfXPSGkj zCLrQnB;J7`E^&1DZ_theHw;{KnW7}79=9cIG)X}8pDTTzttjhTYig*v4pmZ2h+%XN z9ti0?(mw{I{x%%c6L#hgLK=^bGmPoFjgcDQlq2@!GZ*1ij|(Ms}{Jw6KB{zH~>LKvCJr`%v*67b|B68#LK~0 zmxVY)P$G(HLKBJz#7*<>m8+L?G(V9ND;=}i_HX65hR&)p%2 zn|DjI1UNnZG|(QgXA99;(@NR9`=zju_%Uht8KQErqJmnQL@t}eZmQ%h2^1q^bBg>- zg&1P9=HrC7bGuLyy+~p&-=@bOQe}GLi1u^8*T&YkB?d#Myx%8hZ6tYvrUG^rxynW{S^aO2`&hWvz{ly>ukMxG4c11aqyN=#OHuz7rWr-n5# z@GFu%agrzYq2ag5P?F=mjYguWA(9pEzPP2Uq=i^IJ5m{)YgwvB{!CH2pJWznb?hmO zG|YQvL-Y7de|j5~6#cuZ*2f&UmN@Cbc$uVBsbXnI=wPv=B|`*rKRjb>DG|nmHyD*1%1u&pFeSXJyxB%w@*p8Rpx>o z7z<>#LBjBf?I~^a3yldW`6ckxfcn3LGmdy#f(ox5DjSAsubfn2i(jtqoe?2%fgXw_ zZWU*}#j*JHXqk=nwW%dA#o7G2yFkOT3C2NJ&I(3&QZ2=)f?z8pjIcPPzDJf6wsK#;*vO4@W% zCXaM)fO)T=bk`rYE=v@V@!D=Ls*InxnKkA%dbQTy`BHBFHKl7tupSCOE|^*;*U3xS z0r2rVM;Us~EodC_$G-}ac#56)KocQW@Aw7+ph*>yBpK6#0|8b86bHS2mI@BTzRczW zi1q`zsj4N-{T)wz$H+Mfz_t_Ms2R(V)|YxW*2R=qK^N{Q|z)J8Ry3p z!V@1tqSJN^>R|fkwbd&KFZ-L(JjpXr$o!8_Z$>$3_aa)O@>1KAsGL8Jp(PKT7cxY*PWN;YdxD;vKWTN`4^om*?Vj&E-4r<;9df~cS8MB9Pn+8W) zt$7$n(%8CV-uHI}b2hp6IX{oUrG;*V%KaV|tL!O83;TD;opOFx?A@Ibn4eHo+vU17 zA!V+-yWC5Um>MJU&#-kEq%8_dHUcl}TMYfwx^UbP!{)! zY`PnD9gE+$(ke6q@+BK(?r`O9De4W}*3IfAVH}Bwt}_~LW0Z~Mm=KVmwEXvF$W~o4 zbb0tEf*8b~e_f`XO?#Z3S|atU22=^1wd|;h@eV zW>GD~3UhtY&rrMeg!f)c$DWL*AZ;mut)Zwpt5v%dk!-nptNlb~d9P+^plz8ncX{|_ znP7W)oPPzAdS&|03X;*veA@~rcV!v+vJ%QWx6Z#RvAweWXZ6%@b-!(Okbn93WtAd# z^_+ignQ9HV{*|)!DbF|ZZFYfe5zH+yuWcqEeOoGgQ)YFW`gL0#b5lWJhg4@r zC456IeFwjNM+h+8k~`MERl zIef(qB5%x*t^utwvz!iJN{x{$%Ed7cH>ws_VD(3Y{$oAle;B}G?a4j}= zAdzvM(S9xS`A`meok#xm=j>2P_ol?_M(ylSqvNJ(^+xCCk=}>f27z0niX#)?wyk{A zLh#aR?UojLYnO59Ab7{5d*=+jbj`T?+HvQpd*ubaJaMDeO^er9f%0;?|6ovejZ(08T#<@ zAn-ChcJ?MLLgk(op=U|sYcKa#FY7;>76e~=)LwT5ecpt{#Fp1%K`r3TSo}-#dbuWW ztqYwZfZppW-E}~Br=V}B<^Nw-WY+0P{Iw+(PR8#APy7`!5CwgWA|?Lcu@S{xnPiUt zVk2xHhd{J$hyN#O$sm(1@QcNtMEOvuSh-B6m!#$Z7%Wh4^Fny9a;#Qu{K@9U{4F+8 zZ@tp)zl38w*XVSz+MA+kJK02i_n)qKuGfX}l+To^c45#LRzr@tu6Akk78_~s^Hl#Y zHo^}#@T~qHYI*ec+ncRuG5hOR!(TGZTc}k+h51@vnkJ|4Qp+DG;%c*t-DYo?Cb^f^ z1K_X=#Bc_XYr8M(O_k~PW7fGlpRTkmEx&3%yIwl{;`Xgv_42%w()_%Y-yHsTZh z78{{Va{MYqmK9q)L|jz+Rhpn`y80hh)Ad&w^iKSmVWdHJHd(j{rJ51wyd#_3(|T;p z=6)?A`)fA&BM?FD_%0NggZ)v2_a)p4jc+P%d_2;Fl3sp@0F6ZYyy ztkkOYL>kw#>!kQQ7n|nfsT7%)^wt+z*6a`#TDKq&@flO0HE5yrb8xOe+Laqt$RvN* zs=Ms15NNxfIfZC@-sMT?dO`nu9?%6NsYtw`mMjB$encv+8@}(IUG)N~UR?}=@9OS1 zJ=kVlb;5aV?hGTR2;8<~*6K-sh#5CieUIBF)lA<}C$nrzj>mnq7?oz_{1mwx^FpX} zocp1CNqw_oY0apkW%;L@$0Lbu!j@mv|KzT%YSENEPK1`6TYfffjsCW2KB|2><+_?} zk!^pHyRwD+!}mOAMdNIh?!g;#uNkN@$fF`tLQX_<|B-! z?z;OX9xDaReg~4^^Geg5j%PZe1jP${?dSC8H$77DH40xZ^A(}XMc3=)Zto2oGq(j@ zVrPOD0Om}Xo!@D2*z&E2q+sxiuQn{Ks}M5mPoDu{iVKo^A?OQ7rq|6sJ$SlzKjy6~ zT@We2C%ASXLXu3MhW8M-cX}X1KJcG0whc6=Mq%>T&(3o>nXp{Fqn2(xQ2rg7AnvhmvF~y7%Te0c@7B7C(>*f zQakW>7I_%j2P8PN1s>SSaTur_NRn_JPO_y_se|~SMY8rP z3LbdIS#OW%Q^BXB@#vNGQd~nZB$wMT;;E+mPRZjj;nXm`d~Yw*fnD*7ksBA!EyGGqS=Ub3tIC_Hvr(^dKnGz7AYfA{Zf)pj2IEP0(0k(rmQG zBQUS{c=jF)5}>H1`VJA4`G+^8LFgTlr$Z2?u>ePD=Q}-Bcsf)pi#UJf6sXNC#pj`> zSYKbSpLb&H5v{(42mwXP0$^AfhSB4o-?5lICybUZQ>oGLjT}=7J}wUerCzcWKo2kZ~j#Jw5Bh`5ClG#qB{&Q?bMRC&d@oG=Ly3xW~JzX`&c;D zM6Mf@d%am@zN@UolJJTIbft4v5#g63P$v$?dzq?$zQO-^!yD#+4M#B2`6_CP*-8NJ zt+Vk+y0hjSxa3ir{~h< zHeR+T0?V+J{{F}dLPC#f-Agfnn`l%4r6dM^+a2WoM`T2dl<7syUl?~myt7W61SV%M zInrYqL*Q)~86g0evL8J4#ilsAVbs8iTcjEcIu+HjezcO&X1Iu%Y`xTj6_L6wChD{k z#nfb_F!BSS{}C4i==w1x;!GEX86St(C)0Bf}3pjm9|A`X_ou=Daw*X+7kN0R`l zUUjXV$YoNq*~I~1CJ+nF;a+!-cV6D^1ODN{${v-OE6iZCMP226f40ub;)dOPIgIOf zmXFK}RBM0<8)`lKcG171A5L|-VnHMD(uUSFqW{r-??xAVk&pIk9~E%C<8AnL518Vy zX?|4Pb(!+m6jXHp7uOJ_M^fpMWz?^p^n|PPXSzI0HgcOKD}aeQ4DHU z4;I4nzHhx#KD>N;574ojwqFM=cAYAk%La$L4iTR4?x`=QWHvwp^ltN=_C&Fr-TFiE zJa(R-yRt#X@6^XP)3VC$hT{dOx33V7y=I(+BjgPLp|A^ZNRRf)p+^(`Rjf{`7bV-@`vVYd{oB0P}OU+LNxm zn!hGP003mZlglWAl0W+Mri0S9t`oZQ>z?x)zw&t|2B5zIn!rS(9TXC){7bU^;~+9S zHj-$d45Y3tqrV5xJ_l5;-|?ddz^Fjmv8>al2U4>He*i*zGC@!h!kBZuDtsR3gS1%N zI7G^{iBhdIODS|(u_=NlIP0f>z&#dHL;qIuF0|qa9&4y!4}!IB>MA|NLl%r85}UX^Y$23Fr-pjLW}`!9nm9Q8s6xaY z1t6<#e=>lgAt1z}%2Gpedc;XYDWp=WODsUv3aL~yi9Zaq=V~r}DmtATBxvfb zR*Xd8iL{L3H`lSMhI*-Aq#?IjMOIuWKD5GV)E_Jq9$;cEt~iktlET*M#v;?g*_pxV z$*FG)04XdabA%rvG)F@+JsoSua}38vs;?$1e>cnm9`lMLDx{ru#Kzw-M|6y$Z}LXh z`A2V@$J|+;vlB;eBFHRC$f1i!YNSY(;ju>ZQM`)O$oNq|``XC(82~AQGeu%Z+Nmrg z{K%A)JE-HKj8w^&TplS}Lwp7RLiww YORo$=bZX1El*_rK%c(@Ce+UQwJ1;x6LjV8( diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index b72096d..50a70ab 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -36,13 +36,11 @@ multipliers should be **≥ 1.0**. `lesson_word_count` should be **≥ 1**. `focus_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. -Confidence uses **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. - -Confidence uses **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 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). Example (defaults shown): diff --git a/docs/wiki/Word-Lists.md b/docs/wiki/Word-Lists.md index adf26d2..9b5cf5b 100644 --- a/docs/wiki/Word-Lists.md +++ b/docs/wiki/Word-Lists.md @@ -48,8 +48,12 @@ After **Clear**, the URL field resets to the default pre-fill and status shows ` When a URL is saved **and** the cache file exists, Keystrike: 1. Loads the cached list from disk. -2. Keeps words that are 3–10 letters and use **only** letters in your current unlocked set. -3. Samples lesson words from that pool, overweighting words that contain weak keys. +2. Keeps words that are **3–10 letters** (dictionary bounds, independent of + `generated_word_min_len` / `generated_word_max_len`) and use **only** letters + in your current unlocked set. +3. Samples lesson words from that pool, overweighting words that contain weak keys + or bigrams. Markov fallback fill uses the generated word length settings + (default 2–4). If any step yields no usable words, the lesson falls back to **Markov** generation for that session (or per word when sampling fails). Status `Not cached.` means a URL is saved but the cache file is missing — lessons use Markov until you import again. diff --git a/pyproject.toml b/pyproject.toml index 18520ae..ba3dd8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keystrike" -version = "1.3.0" +version = "1.3.1" description = "Offline terminal typing tutor with keybr-style adaptive drills and per-layout stats" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keystrike/domain/version.py b/src/keystrike/domain/version.py index 67bc602..9c73af2 100644 --- a/src/keystrike/domain/version.py +++ b/src/keystrike/domain/version.py @@ -1 +1 @@ -__version__ = "1.3.0" +__version__ = "1.3.1" diff --git a/uv.lock b/uv.lock index b5e8a4f..0550ed2 100644 --- a/uv.lock +++ b/uv.lock @@ -79,7 +79,7 @@ wheels = [ [[package]] name = "keystrike" -version = "1.3.0" +version = "1.3.1" source = { editable = "." } dependencies = [ { name = "platformdirs" }, From 7d55166f302cc045094393572b8e9313098a4b0a Mon Sep 17 00:00:00 2001 From: Egno Date: Mon, 10 Aug 2026 10:30:39 +0300 Subject: [PATCH 37/47] fix: persist alphabet_size bump before a lesson shows the extra letters BuildLesson could unlock more keys than settings.alphabet_size once compute_unlocked's mastery-driven growth kicked in, without ever writing that growth back to Settings -- only FinishSession did, and only after the fact. Extract the sync into a shared, contract-documented sync_alphabet_size() and call it from BuildLesson before generating lesson text, so the setting always leads the lesson's letter count. Co-Authored-By: Claude Sonnet 5 --- src/keystrike/application/alphabet_sync.py | 42 +++++++++++++++ src/keystrike/application/build_lesson.py | 11 +++- .../application/session_use_cases.py | 15 ++---- src/keystrike/domain/unlock.py | 9 +++- tests/application/test_alphabet_sync.py | 53 +++++++++++++++++++ tests/application/test_build_lesson.py | 40 ++++++++++++++ 6 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 src/keystrike/application/alphabet_sync.py create mode 100644 tests/application/test_alphabet_sync.py diff --git a/src/keystrike/application/alphabet_sync.py b/src/keystrike/application/alphabet_sync.py new file mode 100644 index 0000000..d68a8e4 --- /dev/null +++ b/src/keystrike/application/alphabet_sync.py @@ -0,0 +1,42 @@ +"""Keep `Settings.alphabet_size` in lockstep with the live unlocked-key count. + +`compute_unlocked` (see `domain.unlock`) treats `alphabet_size` as a floor: once +every currently-unlocked key clears its mastery threshold, it force-unlocks +further keys from `learn_order` without touching `Settings`. Left alone, that +means a lesson can show more letters than Settings reports. `sync_alphabet_size` +closes that gap by persisting the higher count as soon as it's discovered -- +before anything downstream builds a lesson or reports progress off the stale +value. +""" + +from __future__ import annotations + +from dataclasses import replace + +from keystrike.domain.models import Settings +from keystrike.domain.protocols import SettingsRepository + + +def sync_alphabet_size( + settings: Settings, + unlocked_keys: tuple[int, ...], + settings_repo: SettingsRepository, +) -> Settings: + """Bump `settings.alphabet_size` up to `len(unlocked_keys)` and persist it. + + Precondition: `unlocked_keys` was computed with `settings.alphabet_size` + as its floor (e.g. via `compute_unlocked`) -- callers must not pass an + unrelated unlocked set. + + Postcondition: the result's `alphabet_size` equals + `max(settings.alphabet_size, len(unlocked_keys))`; `settings_repo.save` + is called exactly when that value is strictly greater than the input's + (the result is `settings` itself, unchanged, otherwise). + """ + unlocked_count = len(unlocked_keys) + if unlocked_count <= settings.alphabet_size: + return settings + updated = replace(settings, alphabet_size=unlocked_count) + settings_repo.save(updated) + assert updated.alphabet_size == max(settings.alphabet_size, unlocked_count) + return updated diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 024ab46..f8f06c4 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -8,6 +8,7 @@ from dataclasses import dataclass, field from random import Random +from keystrike.application.alphabet_sync import sync_alphabet_size from keystrike.application.session_queries import ( latest_session_header, session_wpm_below_target, @@ -404,7 +405,11 @@ def _lesson_progress( state = LessonState( layout=layout_name, keys=keys, - alphabet_size=ctx.settings.alphabet_size, + # Not `ctx.settings.alphabet_size` -- `compute_unlocked` may have + # already grown `gating.unlocked` past that floor (see + # `alphabet_sync.sync_alphabet_size`), and the lesson must always + # show exactly as many letters as it actually unlocked. + alphabet_size=len(gating.unlocked), target_speed_cpm=ctx.settings.target_speed_cpm, ) return LessonProgress( @@ -521,6 +526,10 @@ class BuildLesson: def __call__(self, layout_name: str) -> Lesson: ctx = self._load_context(layout_name) progress = _lesson_progress(layout_name, ctx) + # A new letter opening (via `compute_unlocked`'s mastery-driven + # growth) must bump Settings.alphabet_size before the lesson below + # is generated -- never show more letters than Settings reports. + sync_alphabet_size(ctx.settings, progress.unlocked, self.settings_repo) focus_confidence = _resolve_focus_confidence(progress.focus, progress.focus_bigram, ctx) char_weights, transition_weights = _compute_weights( progress.state, diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 9918d07..c712050 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -1,6 +1,7 @@ from collections.abc import Iterable -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field +from keystrike.application.alphabet_sync import sync_alphabet_size from keystrike.application.session_queries import ( compute_accuracy, compute_wpm, @@ -168,16 +169,6 @@ def _snapshot_unlock_state( } -def _sync_alphabet_size(unlocked_keys: tuple[int, ...], settings_repo: SettingsRepository) -> None: - """Keep settings.alphabet_size at least as large as the current unlock set.""" - if not unlocked_keys: - return - settings = settings_repo.load() - unlocked_count = len(unlocked_keys) - if unlocked_count > settings.alphabet_size: - settings_repo.save(replace(settings, alphabet_size=unlocked_count)) - - @dataclass(slots=True) class FinishSession: clock: Clock @@ -202,7 +193,7 @@ def __call__(self, session: Session) -> SessionResult: settings_repo=self.settings_repo, layout_repo=self.layout_repo, ) - _sync_alphabet_size(unlocked_keys, self.settings_repo) + sync_alphabet_size(settings, unlocked_keys, self.settings_repo) generated_min_len, generated_max_len = effective_generated_word_bounds( settings.generated_word_min_len, diff --git a/src/keystrike/domain/unlock.py b/src/keystrike/domain/unlock.py index ef712a4..19c36b3 100644 --- a/src/keystrike/domain/unlock.py +++ b/src/keystrike/domain/unlock.py @@ -143,7 +143,14 @@ def compute_unlocked( (`newest_key_clears_transition_gate`) — a little bigram practice before the next letter opens, bounded by `gating_bigram_limit` so the bar doesn't grow with alphabet depth. Pass `None` (the default) to skip this and unlock purely - on solo-key mastery, as before.""" + on solo-key mastery, as before. + + The result can exceed `alphabet_size` once mastery conditions are met -- + `alphabet_size` is a floor, not a cap. Callers that persist a Settings + object should feed the result through + `application.alphabet_sync.sync_alphabet_size` so the setting never lags + behind what's actually unlocked (see `build_lesson._gating_state` and + `session_use_cases.FinishSession` for the two call sites).""" forced_count = min(alphabet_size, len(learn_order)) unlocked = list(learn_order[:forced_count]) for codepoint in learn_order[forced_count:]: diff --git a/tests/application/test_alphabet_sync.py b/tests/application/test_alphabet_sync.py new file mode 100644 index 0000000..9c45988 --- /dev/null +++ b/tests/application/test_alphabet_sync.py @@ -0,0 +1,53 @@ +from keystrike.application.alphabet_sync import sync_alphabet_size +from keystrike.domain.models import Settings +from tests.fakes import FakeSettingsRepository + + +def test_sync_alphabet_size_is_noop_when_unlocked_not_larger(): + settings = Settings(alphabet_size=5) + repo = FakeSettingsRepository(settings) + + result = sync_alphabet_size(settings, (1, 2, 3), repo) + + assert result is settings + assert repo.settings is settings + + +def test_sync_alphabet_size_is_noop_when_unlocked_equals_setting(): + settings = Settings(alphabet_size=3) + repo = FakeSettingsRepository(settings) + + result = sync_alphabet_size(settings, (1, 2, 3), repo) + + assert result is settings + assert repo.settings is settings + + +def test_sync_alphabet_size_bumps_and_persists_when_unlocked_grows(): + settings = Settings(alphabet_size=2) + repo = FakeSettingsRepository(settings) + + result = sync_alphabet_size(settings, (1, 2, 3, 4), repo) + + assert result.alphabet_size == 4 + assert repo.settings.alphabet_size == 4 + assert repo.settings is result + + +def test_sync_alphabet_size_preserves_other_fields_on_bump(): + settings = Settings(alphabet_size=2, target_speed_cpm=250) + repo = FakeSettingsRepository(settings) + + result = sync_alphabet_size(settings, (1, 2, 3), repo) + + assert result.target_speed_cpm == 250 + + +def test_sync_alphabet_size_is_noop_on_empty_unlocked(): + settings = Settings(alphabet_size=5) + repo = FakeSettingsRepository(settings) + + result = sync_alphabet_size(settings, (), repo) + + assert result is settings + assert repo.settings is settings diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 9030a1b..8056103 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -86,6 +86,46 @@ def test_alphabet_size_caps_at_learn_order_length(): assert len(lesson.state.keys) == len(layout.learn_order) +def test_lesson_persists_alphabet_growth_when_unlocked_exceeds_setting(): + """Regression: BuildLesson must not show more letters than + Settings.alphabet_size reports -- when mastery lets `compute_unlocked` + grow past the configured floor, that growth has to be persisted back to + Settings *before* the lesson is built, so the setting and the lesson's + letter count never diverge.""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + e, a, b = order[0], order[1], order[2] + now = 1_700_000_000.0 + mastered = 100_000_000.0 + keys = { + e: KeyStats(e, 10, mastered, 0, now, attempt_count=10), + a: KeyStats(a, 10, mastered, 0, now, attempt_count=10), + } + transitions = { + Bigram(e, a): TransitionStats(e, a, 10, mastered, 0, now, attempt_count=4), + Bigram(a, e): TransitionStats(a, e, 10, mastered, 0, now, attempt_count=4), + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, + ) + settings_repo = FakeSettingsRepository(Settings(alphabet_size=2)) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=settings_repo, + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + ) + + lesson = builder("qwerty") + + assert {k.codepoint for k in lesson.state.keys} == {e, a, b} + assert lesson.state.alphabet_size == 3 + assert settings_repo.settings.alphabet_size == 3 + + def test_lesson_heatmap_maps_unlocked_codepoints_to_confidence(): lesson = _build_lesson()("qwerty") assert lesson.heatmap == {k.codepoint: k.confidence for k in lesson.state.keys} From 6035d7794f8d6df8f321ff20aff26d03782343ec Mon Sep 17 00:00:00 2001 From: Egno Date: Fri, 21 Aug 2026 14:04:45 +0300 Subject: [PATCH 38/47] Release 2.0.0. Breaking: settings.toml gains [unlock]/[focus]/[word_gen] tables (old flat keys are dropped, falling back to defaults) and the practice HUD's accuracy/daily-learn segments freeze at lesson start instead of ticking live. Also: alphabet-size sync moves to lesson build to avoid a mid-flow status jump, plus fixes for duplicate-word sampling, settings.toml control- character escaping, bool/int coercion, and aggregates-cache error handling. Co-Authored-By: Claude Sonnet 5 --- README.md | 8 +- docs/CHANGELOG.md | 38 +++++ docs/wiki/Confidence-Tuning.md | 75 +++++---- docs/wiki/Focus-States.md | 2 +- docs/wiki/Word-Lists.md | 2 +- pyproject.toml | 2 +- src/keystrike/app.py | 1 - src/keystrike/application/alphabet_sync.py | 15 +- src/keystrike/application/build_lesson.py | 95 +++++------ .../application/session_use_cases.py | 20 ++- src/keystrike/application/stats_use_cases.py | 17 +- src/keystrike/domain/confidence.py | 5 - src/keystrike/domain/generator.py | 15 +- src/keystrike/domain/models.py | 46 ++++-- src/keystrike/domain/newest_key.py | 16 +- src/keystrike/domain/unlock.py | 61 +++---- src/keystrike/domain/version.py | 2 +- .../infrastructure/aggregates_cache.py | 14 +- src/keystrike/infrastructure/git_client.py | 11 ++ .../infrastructure/session_repo_jsonl.py | 36 ++-- .../infrastructure/settings_repo_toml.py | 56 ++++++- src/keystrike/infrastructure/toml_escape.py | 28 +++- .../presentation/formatting/daily_learn.py | 16 ++ .../presentation/formatting/trends.py | 154 +++++++++--------- src/keystrike/presentation/screens/home.py | 3 +- .../presentation/screens/practice.py | 11 +- .../presentation/screens/settings.py | 42 ++--- src/keystrike/presentation/services.py | 2 - src/keystrike/presentation/theme.py | 1 - src/keystrike/presentation/widgets/hud.py | 42 ++--- .../presentation/widgets/kb_heatmap.py | 7 +- tests/application/test_build_lesson.py | 77 ++++++--- tests/application/test_session_use_cases.py | 74 +++++++-- tests/application/test_settings_use_cases.py | 15 +- tests/application/test_stats_use_cases.py | 7 +- tests/domain/test_unlock.py | 12 +- .../infrastructure/test_settings_repo_toml.py | 110 +++++++++---- tests/infrastructure/test_toml_escape.py | 28 ++++ tests/presentation/test_hud.py | 110 +++---------- tests/presentation/test_kb_heatmap.py | 2 +- tests/presentation/test_practice_screen.py | 55 +------ tests/presentation/test_settings_screen.py | 7 +- uv.lock | 2 +- 43 files changed, 753 insertions(+), 589 deletions(-) create mode 100644 src/keystrike/presentation/formatting/daily_learn.py create mode 100644 tests/infrastructure/test_toml_escape.py diff --git a/README.md b/README.md index e3bb81a..8cea2eb 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,11 @@ On other screens, `Esc` / `q` goes back. Settings saves with `Ctrl+S`. ### Practice Adaptive mode disables backspace — mistakes stay in the record so confidence scores -stay honest. The HUD shows live accuracy, remaining daily learn time, and the current -focus key. When a session ends, the next lesson starts automatically unless you go -back. Hitting your daily goal is shown in the HUD only — practice continues. +stay honest. The HUD shows accuracy, remaining daily learn time, and the current +focus key as a fixed snapshot for the lesson's fresh word set — it does not tick while +you type, only refreshing when the next lesson starts. When a session ends, the next +lesson starts automatically unless you go back. Hitting your daily goal is shown in +the HUD only — practice continues. ### Stats diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9163089..2bcb297 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,44 @@ 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.0.0 + +- **Breaking: `settings.toml` schema** — unlock (`min_confidence_attempts`, + `min_transition_confidence_attempts`, `gating_bigram_limit`), focus-boost + (`focus_char_boost`, `focus_word_boost`, `focus_bigram_word_boost`, + `focus_transition_boost`, `focus_weak_extra_boost`, `focus_word_min_fraction`), + and generated-word-length (`generated_word_min_len`, `generated_word_max_len`) + fields moved off the top-level table into `[unlock]`, `[focus]`, and + `[word_gen]` respectively (see `Settings.unlock` / `.focus` / `.word_gen` in + `domain/models.py`). A settings file written by an older version keeps + loading — unrecognized top-level keys are ignored and missing nested tables + fall back to defaults — but any customized values there need re-entering + under the new tables. New knob: `[unlock].next_letter_unlock_threshold` + (default `1.0`), previously hardcoded. See `Confidence-Tuning` wiki for the + full mapping. +- **Breaking: HUD no longer ticks live** — accuracy and daily-learn-minutes in + the practice HUD are now a fixed snapshot taken when the lesson's fresh word + set is built; they no longer update while typing and no longer dim on + idle. They next refresh when the following lesson starts. (Previously + documented as "live accuracy" — see README's Practice section.) +- **Alphabet-size sync moved to lesson build** — persisting a mastery-driven + `alphabet_size` bump now happens once, right before `BuildLesson` generates + the next lesson's text, instead of immediately when the previous session + finishes; avoids a status jump between finishing a session and the next + fresh word set appearing. +- Fixed a duplicate-word sampling bug in focus-word selection: `pool.index(word)` + could remove the wrong occurrence of a repeated word, silently weakening the + per-lesson repeat cap; sampling now tracks the drawn index directly. +- Fixed `settings.toml` writing to corrupt on values containing raw control + characters (e.g. a stray newline) — `escape_toml_string` now escapes the + full C0/DEL range, not just `\` and `"`. +- Fixed a bug where a boolean in an integer settings field (e.g. a hand-edited + `alphabet_size: true`) silently coerced to `1`/`0` instead of falling back + to the default. +- Fixed the aggregates cache to treat a genuinely corrupt cache file as a + cache miss without also swallowing bugs in the downstream aggregate + computation. + ## 1.3.1 - **Skill vs confidence split** — unlocks and transition gating use per-key diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index 50a70ab..b95361d 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -11,29 +11,36 @@ Edit `{config_dir}/settings.toml` directly (see [Git sync](Git-sync) for the path on your OS). These fields are **not** on the Settings screen — saving layout, speed, or other UI settings will not change them. +**Since 2.0**, the unlock, focus-boost, and generated-word-length knobs moved +off the top-level `Settings` table into three nested tables — `[unlock]`, +`[focus]`, and `[word_gen]` — so a settings file written by an older version +needs those values re-entered under the new tables (unrecognized top-level +keys are ignored, and missing nested tables silently fall back to defaults). + ## Settings | 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 | `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 bigram attempts | `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 | `gating_bigram_limit` | `4` | Directed newest-letter bigrams that must calibrate before the next letter opens. Values are clamped to `2`–`4`. | -| Focus char boost | `focus_char_boost` | `3.0` | Multiplier on the focus key's char weight when building lesson sampling weights. | -| Focus word boost | `focus_word_boost` | `3.0` | Extra multiplier on dictionary/Markov words that contain the focus character. | -| Focus bigram word boost | `focus_bigram_word_boost` | `4.0` | Extra multiplier on words containing the focus letter pair (when transition focus is active). | -| Focus transition boost | `focus_transition_boost` | `4.0` | Multiplier on the focus bigram's transition weight. | -| Focus weak extra boost | `focus_weak_extra_boost` | `1.5` | Additional multiplier when focus confidence is below 1.0 (weak key or weak transition). | +| 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 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. | +| Focus char boost | `[focus].char_boost` | `3.0` | Multiplier on the focus key's char weight when building lesson sampling weights. | +| Focus word boost | `[focus].word_boost` | `3.0` | Extra multiplier on dictionary/Markov words that contain the focus character. | +| Focus bigram word boost | `[focus].bigram_word_boost` | `4.0` | Extra multiplier on words containing the focus letter pair (when transition focus is active). | +| Focus transition boost | `[focus].transition_boost` | `4.0` | Multiplier on the focus bigram's transition weight. | +| Focus weak extra boost | `[focus].weak_extra_boost` | `1.5` | Additional multiplier when focus confidence is below 1.0 (weak key or weak transition). | | Lesson word count | `lesson_word_count` | `12` | Words generated per practice lesson. | -| Focus word min fraction | `focus_word_min_fraction` | `0.6` | When focus is weak, at least this fraction of lesson words must match the focus key or bigram (ceiling). | +| Focus word min fraction | `[focus].word_min_fraction` | `0.6` | When focus is weak, at least this fraction of lesson words must match the focus key or bigram (ceiling). | | Max word repeats | `max_word_repeats` | `2` | Maximum times the same word may appear in one generated lesson. | -| Generated word min length | `generated_word_min_len` | `2` | Minimum length for Markov-generated words (dictionary import still filters 3–10). | -| Generated word max length | `generated_word_max_len` | `4` | Maximum length for Markov-generated words. | +| Generated word min length | `[word_gen].min_len` | `2` | Minimum length for Markov-generated words (dictionary import still filters 3–10). | +| Generated word max length | `[word_gen].max_len` | `4` | Maximum length for Markov-generated words. | Valid ranges: window and both attempt floors are **1–100**. -`gating_bigram_limit` is **2–4** (hand-edited values are clamped). Boost +`[unlock].gating_bigram_limit` is **2–4** (hand-edited values are clamped). Boost multipliers should be **≥ 1.0**. `lesson_word_count` should be **≥ 1**. -`focus_word_min_fraction` should be in **(0.0, 1.0]**. `max_word_repeats` +`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 @@ -46,19 +53,26 @@ Example (defaults shown): ```toml confidence_session_window = 10 +lesson_word_count = 12 +max_word_repeats = 2 + +[unlock] min_confidence_attempts = 10 min_transition_confidence_attempts = 4 gating_bigram_limit = 4 -focus_char_boost = 3.0 -focus_word_boost = 3.0 -focus_bigram_word_boost = 4.0 -focus_transition_boost = 4.0 -focus_weak_extra_boost = 1.5 -lesson_word_count = 12 -focus_word_min_fraction = 0.6 -max_word_repeats = 2 -generated_word_min_len = 2 -generated_word_max_len = 4 +next_letter_unlock_threshold = 1.0 + +[focus] +char_boost = 3.0 +word_boost = 3.0 +bigram_word_boost = 4.0 +transition_boost = 4.0 +weak_extra_boost = 1.5 +word_min_fraction = 0.6 + +[word_gen] +min_len = 2 +max_len = 4 ``` ## What each setting affects @@ -87,17 +101,18 @@ The first **N** keys in layout `learn_order` are always unlocked, where **N** is **Letters unlocked up front** in Settings (`alphabet_size`; see [README — Settings](https://github.com/egno/keystrike#settings)). Each further key in `learn_order` unlocks only when **every** currently unlocked key meets the -skill threshold (default 1.0: min(speed, accuracy) without attempt ramp) **and** -has at least `min_confidence_attempts` presses in the session window. Ramped +skill threshold (`[unlock].next_letter_unlock_threshold`, default 1.0: +min(speed, accuracy) without attempt ramp) **and** +has at least `[unlock].min_confidence_attempts` presses in the session window. Ramped confidence still drives HUD labels (`cal` vs `wk`) and focus weighting. Beyond solo-key mastery, a deterministic cohort of directed bigrams involving the most-recently-practiced key gates the next key. It pairs that key in both directions with up to its two most-recent practiced peers, bounded by -`gating_bigram_limit`. The cohort is derived from key order, not observed +`[unlock].gating_bigram_limit`. The cohort is derived from key order, not observed transition data, so incidental measurements cannot expand or replace it. Every member must reach confidence 1.0 with -`min_transition_confidence_attempts` attempts. +`[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 @@ -161,7 +176,7 @@ guaranteed lesson slots instead of waiting to appear by chance. data; lower it if weak pairs never get targeted. - **Coverage deficit** — Lesson sampling multiplies char (and transition) weights by a session-scale boost when in-window attempts are below - `min_confidence_attempts` (peaking at zero attempts). This is separate + `[unlock].min_confidence_attempts` (peaking at zero attempts). This is separate from performance weakness (`practice_weight`) and day-scale review urgency; it helps large unlocked sets get enough window samples without widening the session window. There is no settings knob — the boost is fixed in code @@ -171,7 +186,7 @@ guaranteed lesson slots instead of waiting to appear by chance. With the default session window (`10`) and lesson length (`12`), each practice session only touches a fraction of a 40–50 key unlocked set. Keys can drop out -of the rolling window or stay below `min_confidence_attempts`, which stalls +of the rolling window or stay below `[unlock].min_confidence_attempts`, which stalls the next unlock and leaves heatmap gaps even when you are typing well. **Coverage-deficit weighting** (above) addresses much of this automatically; defaults may suffice once you have been practicing for a while. @@ -199,7 +214,7 @@ Starting points for 40–50 unlocked keys: above). - **Longer lessons** — More keys sampled per session, but each drill takes longer. -- **`focus_word_min_fraction`** — Lower slightly (e.g. `0.5`) if strict focus +- **`[focus].word_min_fraction`** — Lower slightly (e.g. `0.5`) if strict focus quotas make generated text repetitive at large N; raising it keeps weak-focus keys more prominent at the cost of variety. diff --git a/docs/wiki/Focus-States.md b/docs/wiki/Focus-States.md index 478f3c4..d9aa96a 100644 --- a/docs/wiki/Focus-States.md +++ b/docs/wiki/Focus-States.md @@ -14,7 +14,7 @@ The practice HUD shows `Focus: · `. | Short | Full meaning | | --- | --- | | `wk` | **Weak** — confidence is below the mastery goal (default 1.0). The key or pair needs more deliberate practice. | -| `cal` | **Calibrating** — not enough presses yet for full confidence weight. Confidence ramps linearly until `min_confidence_attempts` (keys) or `min_transition_confidence_attempts` (bigrams) is reached. | +| `cal` | **Calibrating** — not enough presses yet for full confidence weight. Confidence ramps linearly until `[unlock].min_confidence_attempts` (keys) or `[unlock].min_transition_confidence_attempts` (bigrams) is reached. | | `rev` | **Review** — confidence meets the goal, but the key or pair has not been practiced recently. Review urgency pushes it back into focus before it fades. | For **transition focus**, the HUD shows the two-letter pair (e.g. `eo`) instead diff --git a/docs/wiki/Word-Lists.md b/docs/wiki/Word-Lists.md index 9b5cf5b..da3c754 100644 --- a/docs/wiki/Word-Lists.md +++ b/docs/wiki/Word-Lists.md @@ -49,7 +49,7 @@ When a URL is saved **and** the cache file exists, Keystrike: 1. Loads the cached list from disk. 2. Keeps words that are **3–10 letters** (dictionary bounds, independent of - `generated_word_min_len` / `generated_word_max_len`) and use **only** letters + `[word_gen].min_len` / `[word_gen].max_len`) and use **only** letters in your current unlocked set. 3. Samples lesson words from that pool, overweighting words that contain weak keys or bigrams. Markov fallback fill uses the generated word length settings diff --git a/pyproject.toml b/pyproject.toml index ba3dd8d..b6e85d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keystrike" -version = "1.3.1" +version = "2.0.0" description = "Offline terminal typing tutor with keybr-style adaptive drills and per-layout stats" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keystrike/app.py b/src/keystrike/app.py index a7c38b8..dfae21b 100644 --- a/src/keystrike/app.py +++ b/src/keystrike/app.py @@ -156,7 +156,6 @@ def build() -> KeystrikeApp: get_daily_learn_budget=get_daily_learn_budget, ), practice=PracticeServices( - clock=clock, start=start, record=record, finish=finish, diff --git a/src/keystrike/application/alphabet_sync.py b/src/keystrike/application/alphabet_sync.py index d68a8e4..e8f56b1 100644 --- a/src/keystrike/application/alphabet_sync.py +++ b/src/keystrike/application/alphabet_sync.py @@ -22,21 +22,12 @@ def sync_alphabet_size( unlocked_keys: tuple[int, ...], settings_repo: SettingsRepository, ) -> Settings: - """Bump `settings.alphabet_size` up to `len(unlocked_keys)` and persist it. - - Precondition: `unlocked_keys` was computed with `settings.alphabet_size` - as its floor (e.g. via `compute_unlocked`) -- callers must not pass an - unrelated unlocked set. - - Postcondition: the result's `alphabet_size` equals - `max(settings.alphabet_size, len(unlocked_keys))`; `settings_repo.save` - is called exactly when that value is strictly greater than the input's - (the result is `settings` itself, unchanged, otherwise). - """ + """Bump `settings.alphabet_size` up to `len(unlocked_keys)` and persist + it, if that's larger. `unlocked_keys` must have been computed with + `settings.alphabet_size` as its floor (e.g. via `compute_unlocked`).""" unlocked_count = len(unlocked_keys) if unlocked_count <= settings.alphabet_size: return settings updated = replace(settings, alphabet_size=unlocked_count) settings_repo.save(updated) - assert updated.alphabet_size == max(settings.alphabet_size, unlocked_count) return updated diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index f8f06c4..17b83f4 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -166,13 +166,13 @@ def _resolve_focus_confidence( focus_bigram.next_cp, ctx.transitions, ctx.target, - min_attempts=ctx.settings.min_transition_confidence_attempts, + min_attempts=ctx.settings.unlock.min_transition_confidence_attempts, ) return confidence_of( focus, ctx.stats, ctx.target, - min_attempts=ctx.settings.min_confidence_attempts, + min_attempts=ctx.settings.unlock.min_confidence_attempts, ) @@ -199,7 +199,7 @@ def _compute_focus_explanation( pair=focus_bigram, ) attempts = attempts_of(t_stats) if t_stats is not None else 0 - min_attempts = ctx.settings.min_transition_confidence_attempts + min_attempts = ctx.settings.unlock.min_transition_confidence_attempts if reason is None and attempts < min_attempts: reason = FocusReason(kind=FocusKind.TRANSITION_CALIBRATING, pair=focus_bigram) if reason is None: @@ -239,7 +239,7 @@ def _compute_focus_explanation( speed=metrics.speed, accuracy=metrics.accuracy, attempts=attempts, - min_attempts=ctx.settings.min_confidence_attempts, + min_attempts=ctx.settings.unlock.min_confidence_attempts, ) @@ -247,7 +247,6 @@ def _compute_focus_explanation( class Lesson: text: str state: LessonState - urgency: dict[int, float] focus_reason: FocusReason | None skill_heatmap: dict[int, float] focus_confidence: float | None = None @@ -278,39 +277,36 @@ class _GatingState: def _gating_state(ctx: _LessonContext) -> _GatingState: order = keyboard_order(ctx.layout) stall_cap = default_transition_stall_attempts_cap( - ctx.settings.min_transition_confidence_attempts + ctx.settings.unlock.min_transition_confidence_attempts ) unlocked = compute_unlocked( order, ctx.settings.alphabet_size, ctx.stats, ctx.target, - min_attempts=ctx.settings.min_confidence_attempts, + tuning=ctx.settings.unlock, transitions=ctx.transitions, - transition_min_attempts=ctx.settings.min_transition_confidence_attempts, transition_stall_attempts_cap=stall_cap, - gating_bigram_limit=ctx.settings.gating_bigram_limit, ) keys_need_focus = blocks_transition_focus( unlocked, ctx.stats, ctx.target, threshold=CONFIDENCE_GOOD, - min_attempts=ctx.settings.min_confidence_attempts, + min_attempts=ctx.settings.unlock.min_confidence_attempts, ) cohort = newest_key_gating_cohort( unlocked, ctx.stats, - limit=ctx.settings.gating_bigram_limit, + limit=ctx.settings.unlock.gating_bigram_limit, ) ready, total = newest_key_transition_gate_progress( unlocked, ctx.transitions, ctx.target, ctx.stats, - min_attempts=ctx.settings.min_transition_confidence_attempts, + tuning=ctx.settings.unlock, stall_attempts_cap=stall_cap, - cohort_limit=ctx.settings.gating_bigram_limit, ) transition_blocked = not keys_need_focus and len(unlocked) < len(order) and ready < total gating_bigrams = ( @@ -321,7 +317,7 @@ def _gating_state(ctx: _LessonContext) -> _GatingState: pair, ctx.transitions, ctx.target, - min_attempts=ctx.settings.min_transition_confidence_attempts, + tuning=ctx.settings.unlock, stall_attempts_cap=stall_cap, ) ) @@ -351,8 +347,8 @@ def _resolve_lesson_focus( ctx.target, now=ctx.now, threshold=CONFIDENCE_GOOD, - min_attempts=ctx.settings.min_confidence_attempts, - min_transition_attempts=ctx.settings.min_transition_confidence_attempts, + min_attempts=ctx.settings.unlock.min_confidence_attempts, + min_transition_attempts=ctx.settings.unlock.min_transition_confidence_attempts, ) focus_bigram: Bigram | None = None if remedial is not None: @@ -366,7 +362,7 @@ def _resolve_lesson_focus( ctx.now, key_stats=ctx.stats, gating_candidates=gating.gating_bigrams if gating.transition_blocked else None, - min_attempts=ctx.settings.min_transition_confidence_attempts, + min_attempts=ctx.settings.unlock.min_transition_confidence_attempts, ) if focus_bigram is not None: @@ -377,7 +373,7 @@ def _resolve_lesson_focus( ctx.stats, ctx.target, ctx.now, - min_attempts=ctx.settings.min_confidence_attempts, + min_attempts=ctx.settings.unlock.min_confidence_attempts, ) return focus, focus_bigram @@ -396,7 +392,7 @@ def _lesson_progress( cp, ctx.stats, ctx.target, - min_attempts=ctx.settings.min_confidence_attempts, + min_attempts=ctx.settings.unlock.min_confidence_attempts, ), is_focus=(cp == focus), ) @@ -424,19 +420,18 @@ def _lesson_progress( def _compute_weights( - state: LessonState, - focus: int, - focus_bigram: Bigram | None, + progress: LessonProgress, focus_confidence: float, - *, - unlocked: tuple[int, ...], ctx: _LessonContext, ) -> tuple[dict[str, float], dict[Bigram, float]]: """Per-char and per-transition sampling weights for practice-text generation, biased toward weak/stale keys and boosted further for today's focus (see `domain.focus.practice_weight`).""" - min_key_attempts = ctx.settings.min_confidence_attempts - min_transition_attempts = ctx.settings.min_transition_confidence_attempts + focus = progress.focus + focus_bigram = progress.focus_bigram + unlocked = progress.unlocked + min_key_attempts = ctx.settings.unlock.min_confidence_attempts + min_transition_attempts = ctx.settings.unlock.min_transition_confidence_attempts char_weights = { chr(k.codepoint): practice_weight( k.confidence, @@ -449,9 +444,9 @@ def _compute_weights( attempts_of(ctx.stats[k.codepoint]) if k.codepoint in ctx.stats else 0, min_attempts=min_key_attempts, ) - for k in state.keys + for k in progress.state.keys } - char_weights[chr(focus)] *= ctx.settings.focus_char_boost + char_weights[chr(focus)] *= ctx.settings.focus.char_boost unlocked_set = frozenset(unlocked) transition_weights = { key: practice_weight( @@ -476,7 +471,7 @@ def _compute_weights( newest_pairs = newest_key_gating_cohort( unlocked, ctx.stats, - limit=ctx.settings.gating_bigram_limit, + limit=ctx.settings.unlock.gating_bigram_limit, ) if newest_pairs: default_weight = practice_weight(0.0, urgency=0.0) * coverage_deficit_factor( @@ -490,12 +485,11 @@ def _compute_weights( practice_weight(focus_confidence, urgency=0.0) * coverage_deficit_factor(0, min_attempts=min_transition_attempts), ) - transition_weights[focus_bigram] *= ctx.settings.focus_transition_boost + transition_weights[focus_bigram] *= ctx.settings.focus.transition_boost if focus_confidence < CONFIDENCE_GOOD: - transition_weights[focus_bigram] *= ctx.settings.focus_weak_extra_boost - char_weights[chr(focus)] *= ctx.settings.focus_weak_extra_boost - elif focus_confidence < CONFIDENCE_GOOD: - char_weights[chr(focus)] *= ctx.settings.focus_weak_extra_boost + transition_weights[focus_bigram] *= ctx.settings.focus.weak_extra_boost + if focus_confidence < CONFIDENCE_GOOD: + char_weights[chr(focus)] *= ctx.settings.focus.weak_extra_boost return char_weights, transition_weights @@ -529,16 +523,11 @@ def __call__(self, layout_name: str) -> Lesson: # A new letter opening (via `compute_unlocked`'s mastery-driven # growth) must bump Settings.alphabet_size before the lesson below # is generated -- never show more letters than Settings reports. - sync_alphabet_size(ctx.settings, progress.unlocked, self.settings_repo) + # The synced Settings feeds back into ctx so nothing downstream + # reads the now-stale alphabet_size. + ctx.settings = sync_alphabet_size(ctx.settings, progress.unlocked, self.settings_repo) focus_confidence = _resolve_focus_confidence(progress.focus, progress.focus_bigram, ctx) - char_weights, transition_weights = _compute_weights( - progress.state, - progress.focus, - progress.focus_bigram, - focus_confidence, - unlocked=progress.unlocked, - ctx=ctx, - ) + char_weights, transition_weights = _compute_weights(progress, focus_confidence, ctx) alphabet_chars = frozenset(chr(cp) for cp in progress.unlocked) text = self._generate_text( ctx, @@ -550,14 +539,12 @@ def __call__(self, layout_name: str) -> Lesson: transition_weights=transition_weights, gating_bigrams=progress.gating_bigrams, ) - urgency = _compute_urgency(ctx.stats, progress.unlocked, ctx.now) explanation = _compute_focus_explanation( progress.focus, progress.focus_bigram, focus_confidence, ctx ) return Lesson( text=text, state=progress.state, - urgency=urgency, focus_reason=explanation.reason, skill_heatmap=progress.skill_heatmap, focus_confidence=focus_confidence if explanation.reason else None, @@ -613,18 +600,18 @@ def _generate_text( transition_weights=transition_weights, layout=ctx.layout, words=self._resolve_dict_words(ctx.settings, alphabet_chars), - focus_word_boost=ctx.settings.focus_word_boost, - focus_bigram_word_boost=ctx.settings.focus_bigram_word_boost, + focus_word_boost=ctx.settings.focus.word_boost, + focus_bigram_word_boost=ctx.settings.focus.bigram_word_boost, ) word_count = effective_lesson_word_count(ctx.settings.lesson_word_count) quota = ( - weak_focus_word_quota(word_count, ctx.settings.focus_word_min_fraction) + weak_focus_word_quota(word_count, ctx.settings.focus.word_min_fraction) if focus_confidence < CONFIDENCE_GOOD else 1 ) generated_min_len, generated_max_len = effective_generated_word_bounds( - ctx.settings.generated_word_min_len, - ctx.settings.generated_word_max_len, + ctx.settings.word_gen.min_len, + ctx.settings.word_gen.max_len, ) return generator.generate_lesson( alphabet_chars, @@ -653,11 +640,3 @@ def _resolve_dict_words( return None filtered = words_for_alphabet(cached, alphabet_chars) return tuple(filtered) if filtered else None - - -def _compute_urgency( - stats: Mapping[int, KeyStats], - unlocked: tuple[int, ...], - now: float, -) -> dict[int, float]: - return {cp: review_urgency(stats[cp].last_seen if cp in stats else 0.0, now) for cp in unlocked} diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index c712050..6255e4d 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -1,7 +1,6 @@ from collections.abc import Iterable from dataclasses import dataclass, field -from keystrike.application.alphabet_sync import sync_alphabet_size from keystrike.application.session_queries import ( compute_accuracy, compute_wpm, @@ -155,16 +154,16 @@ def _snapshot_unlock_state( settings.alphabet_size, combined.keys, target, - min_attempts=settings.min_confidence_attempts, + tuning=settings.unlock, transitions=combined.transitions, - transition_min_attempts=settings.min_transition_confidence_attempts, transition_stall_attempts_cap=default_transition_stall_attempts_cap( - settings.min_transition_confidence_attempts + settings.unlock.min_transition_confidence_attempts ), - gating_bigram_limit=settings.gating_bigram_limit, ) return unlocked, { - cp: confidence_of(cp, combined.keys, target, min_attempts=settings.min_confidence_attempts) + cp: confidence_of( + cp, combined.keys, target, min_attempts=settings.unlock.min_confidence_attempts + ) for cp in unlocked } @@ -193,11 +192,14 @@ def __call__(self, session: Session) -> SessionResult: settings_repo=self.settings_repo, layout_repo=self.layout_repo, ) - sync_alphabet_size(settings, unlocked_keys, self.settings_repo) + # alphabet_size growth is persisted by BuildLesson right before the + # next lesson is generated (see alphabet_sync.sync_alphabet_size), + # not here -- syncing it immediately on every session finish caused + # a distracting mid-flow status jump before the next word list. generated_min_len, generated_max_len = effective_generated_word_bounds( - settings.generated_word_min_len, - settings.generated_word_max_len, + settings.word_gen.min_len, + settings.word_gen.max_len, ) result = SessionResult( schema_version=4, diff --git a/src/keystrike/application/stats_use_cases.py b/src/keystrike/application/stats_use_cases.py index e7831af..713d6fb 100644 --- a/src/keystrike/application/stats_use_cases.py +++ b/src/keystrike/application/stats_use_cases.py @@ -70,15 +70,14 @@ class GetOrRebuildAggregates: def __call__(self, layout: str) -> Mapping[int, KeyStats]: cached = self.cache.get(layout) + if cached is not None and (cached.transitions or cached.transitions_computed): + return cached.keys has_sessions = any(self.repo.iter_headers(layout)) - if cached is not None: - if cached.transitions or cached.transitions_computed or not has_sessions: - return cached.keys - self.rebuild(layout) - rebuilt = self.cache.get(layout) - return rebuilt.keys if rebuilt is not None else {} if not has_sessions: - return {} + return cached.keys if cached is not None else {} + return self._rebuild_and_fetch(layout) + + def _rebuild_and_fetch(self, layout: str) -> Mapping[int, KeyStats]: self.rebuild(layout) rebuilt = self.cache.get(layout) return rebuilt.keys if rebuilt is not None else {} @@ -112,7 +111,7 @@ def __call__(self, layout: str) -> HeatmapView: cp, stats, target, - min_attempts=settings.min_confidence_attempts, + min_attempts=settings.unlock.min_confidence_attempts, ) for cp in stats }, @@ -223,7 +222,7 @@ def _accumulate_windowed_trends( settings = settings_repo.load() window = settings.confidence_session_window fallback_target = target_ms_per_char(settings.target_speed_cpm) - min_attempts = settings.min_confidence_attempts + min_attempts = settings.unlock.min_confidence_attempts all_headers = sorted( repo.iter_headers(layout), diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index 623b890..d59ad00 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -13,7 +13,6 @@ from typing import Protocol from .models import ( - CONFIDENCE_SESSION_WINDOW, MIN_CONFIDENCE_ATTEMPTS, MIN_TRANSITION_CONFIDENCE_ATTEMPTS, Bigram, @@ -21,10 +20,6 @@ TransitionStats, ) -# Re-exported for call sites/tests that reference the session-window size via -# this module rather than `domain.models` directly. -__all__ = ["CONFIDENCE_SESSION_WINDOW"] - _SECONDS_PER_DAY = 86_400.0 _REVIEW_URGENCY_FULL_DAYS = 3.0 # Exponential decay per session step when merging windowed stats (most recent = 1.0). diff --git a/src/keystrike/domain/generator.py b/src/keystrike/domain/generator.py index 1e1e00b..4ad2e6d 100644 --- a/src/keystrike/domain/generator.py +++ b/src/keystrike/domain/generator.py @@ -153,9 +153,11 @@ def _sample_focus_words_without_replacement( weights = list(focus_weighted.weights) if focus_weighted is not None else [1.0] * len(pool) sampled: list[str] = [] for _ in range(min(count, len(pool))): - word = rng.choices(pool, weights=weights, k=1)[0] - idx = pool.index(word) - sampled.append(word) + # Sample by index, not by value -- `pool.index(word)` would remove + # the *first* occurrence of a duplicate word rather than the one + # actually drawn, silently breaking the no-repeats guarantee below. + idx = rng.choices(range(len(pool)), weights=weights, k=1)[0] + sampled.append(pool[idx]) pool.pop(idx) weights.pop(idx) return sampled @@ -590,12 +592,13 @@ def _distribute_focus_bigram_coverage( prev_char, next_char = chr(pair.prev_cp), chr(pair.next_cp) bigram_str = pair.chars() other_words = lesson_words[:index] + lesson_words[index + 1 :] + other_counts = Counter(other_words) candidate = self._sample_wordlist_with_bigram( alphabet, weighting, focus_char=focus_char, bigram_str=bigram_str, - exclude_counts=Counter(other_words), + exclude_counts=other_counts, max_repeats=max_word_repeats, ) if candidate is None: @@ -607,7 +610,7 @@ def _distribute_focus_bigram_coverage( else lesson_words[index] ) candidate = self._inject_focus_bigram(base, prev_char, next_char) - if Counter(other_words)[candidate] >= max_word_repeats: + if other_counts[candidate] >= max_word_repeats: for _ in range(MAX_REPEAT_RESAMPLE): candidate = self._inject_focus_bigram( self._generate_word_via_markov( @@ -616,7 +619,7 @@ def _distribute_focus_bigram_coverage( prev_char, next_char, ) - if Counter(other_words)[candidate] < max_word_repeats: + if other_counts[candidate] < max_word_repeats: break lesson_words[index] = candidate if len(lesson_words) >= len(focus_bigrams): diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index 1b60e95..5e02861 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -155,6 +155,38 @@ def __post_init__(self) -> None: object.__setattr__(self, "keys", MappingProxyType(dict(self.keys))) +@dataclass(frozen=True, slots=True) +class UnlockTuning: + """Skill-gate knobs for key unlocking and the newest-key bigram cohort + gate, bundled so `domain.unlock.compute_unlocked` takes one config + object instead of a handful of individually-named floats/ints.""" + + min_confidence_attempts: int = MIN_CONFIDENCE_ATTEMPTS # presses before full weight + min_transition_confidence_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS # bigrams sparser + gating_bigram_limit: int = GATING_BIGRAM_LIMIT # newest-key cohort, clamped to 2-4 + next_letter_unlock_threshold: float = 1.0 # confidence threshold for unlocking the next letter + + +@dataclass(frozen=True, slots=True) +class FocusTuning: + """Practice-weight multipliers applied to the lesson's focused key/bigram.""" + + char_boost: float = FOCUS_CHAR_BOOST # char weight multiplier for focus key + word_boost: float = FOCUS_WORD_BOOST # wordlist/Markov boost when focus char present + bigram_word_boost: float = FOCUS_BIGRAM_WORD_BOOST # word boost when focus bigram present + transition_boost: float = FOCUS_TRANSITION_BOOST # transition weight multiplier + weak_extra_boost: float = FOCUS_WEAK_EXTRA_BOOST # extra multiplier when confidence < 1.0 + word_min_fraction: float = FOCUS_WORD_MIN_FRACTION # weak-focus word quota fraction + + +@dataclass(frozen=True, slots=True) +class WordGenBounds: + """Generated-word length bounds for Markov fill and WPM math.""" + + min_len: int = GENERATED_WORD_MIN_LEN # Markov word length floor + max_len: int = GENERATED_WORD_MAX_LEN # Markov word length ceiling + + @dataclass(frozen=True, slots=True) class Settings: schema_version: int = 1 @@ -163,21 +195,13 @@ class Settings: target_speed_unit: TargetSpeedUnit = TargetSpeedUnit.WPM alphabet_size: int = 16 # letters force-unlocked from cold start confidence_session_window: int = CONFIDENCE_SESSION_WINDOW # sessions in rolling stats - min_confidence_attempts: int = MIN_CONFIDENCE_ATTEMPTS # presses before full weight - min_transition_confidence_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS # bigrams sparser - gating_bigram_limit: int = GATING_BIGRAM_LIMIT # newest-key cohort, clamped to 2-4 - focus_char_boost: float = FOCUS_CHAR_BOOST # char weight multiplier for focus key - focus_word_boost: float = FOCUS_WORD_BOOST # wordlist/Markov boost when focus char present - focus_bigram_word_boost: float = FOCUS_BIGRAM_WORD_BOOST # word boost when focus bigram present - focus_transition_boost: float = FOCUS_TRANSITION_BOOST # transition weight multiplier - focus_weak_extra_boost: float = FOCUS_WEAK_EXTRA_BOOST # extra multiplier when confidence < 1.0 + unlock: UnlockTuning = field(default_factory=UnlockTuning) + focus: FocusTuning = field(default_factory=FocusTuning) lang: str = "en" learn_daily_minutes: int = 10 # adaptive mode daily goal (minutes); 0 = no goal lesson_word_count: int = LESSON_WORD_COUNT # words per generated practice lesson - focus_word_min_fraction: float = FOCUS_WORD_MIN_FRACTION # weak-focus word quota fraction max_word_repeats: int = MAX_WORD_REPEATS # per-word repeat cap in generated lessons - generated_word_min_len: int = GENERATED_WORD_MIN_LEN # Markov word length floor - generated_word_max_len: int = GENERATED_WORD_MAX_LEN # Markov word length ceiling + word_gen: WordGenBounds = field(default_factory=WordGenBounds) wordlist_url: str = "" # non-empty + cached file → real words; else Markov updated_at: str | None = None # ISO-8601 UTC; sync LWW diff --git a/src/keystrike/domain/newest_key.py b/src/keystrike/domain/newest_key.py index 0b3ec26..4a09526 100644 --- a/src/keystrike/domain/newest_key.py +++ b/src/keystrike/domain/newest_key.py @@ -16,6 +16,16 @@ _GATING_BIGRAM_MIN = 2 _GATING_BIGRAM_MAX = 4 +_MAX_GATING_PEERS = 2 # newest key pairs with at most this many recent practiced peers + + +def _practiced_unlocked( + unlocked: Sequence[int], key_stats: Mapping[int, KeyStats] | None +) -> list[int]: + """Unlocked keys with at least one recorded stat, in unlock order.""" + if key_stats is None: + return [] + return [cp for cp in unlocked if cp in key_stats] def effective_gating_bigram_limit(limit: int) -> int: @@ -50,7 +60,7 @@ def newest_practiced_key_pairs( (focus selection, lesson weighting) and `domain.unlock.newest_key_clears_transition_gate` (unlock), so the two can't drift on what counts as newest or which pairs are in play.""" - practiced = [cp for cp in unlocked if key_stats is not None and cp in key_stats] + practiced = _practiced_unlocked(unlocked, key_stats) if not practiced: return [], [] newest = practiced[-1] @@ -80,10 +90,10 @@ def newest_key_gating_cohort( practiced, so incidental transition measurements cannot expand or replace it while the letter is being calibrated. """ - practiced = [cp for cp in unlocked if key_stats is not None and cp in key_stats] + practiced = _practiced_unlocked(unlocked, key_stats) if len(practiced) < _GATING_BIGRAM_MIN: return () newest = practiced[-1] - peers = practiced[-3:-1] + peers = practiced[-(_MAX_GATING_PEERS + 1) : -1] cohort = tuple(pair for peer in peers for pair in (Bigram(peer, newest), Bigram(newest, peer))) return cohort[-effective_gating_bigram_limit(limit) :] diff --git a/src/keystrike/domain/unlock.py b/src/keystrike/domain/unlock.py index 19c36b3..0e7260a 100644 --- a/src/keystrike/domain/unlock.py +++ b/src/keystrike/domain/unlock.py @@ -6,20 +6,16 @@ from collections.abc import Mapping, Sequence -from .confidence import ( - MIN_CONFIDENCE_ATTEMPTS, - MIN_TRANSITION_CONFIDENCE_ATTEMPTS, - attempts_of, - clears_threshold, - transition_confidence_of, -) -from .models import GATING_BIGRAM_LIMIT, Bigram, KeyStats, TransitionStats +from .confidence import attempts_of, clears_threshold, transition_confidence_of +from .models import Bigram, KeyStats, TransitionStats, UnlockTuning from .newest_key import newest_key_gating_cohort # ponytail: fixed multiplier; upgrade to a Settings field if a stuck gating # bigram turns out to need per-user tuning. TRANSITION_STALL_ATTEMPTS_MULTIPLIER = 3 +_DEFAULT_UNLOCK_TUNING = UnlockTuning() + def default_transition_stall_attempts_cap(min_attempts: int) -> int: """Default `transition_stall_attempts_cap` for `compute_unlocked`: give a @@ -36,10 +32,8 @@ def newest_key_clears_transition_gate( target: float, key_stats: Mapping[int, KeyStats] | None = None, *, - threshold: float = 1.0, - min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, + tuning: UnlockTuning = _DEFAULT_UNLOCK_TUNING, stall_attempts_cap: int | None = None, - cohort_limit: int = GATING_BIGRAM_LIMIT, ) -> bool: """Whether every member of the newest key's bounded cohort is ready.""" ready, total = newest_key_transition_gate_progress( @@ -47,10 +41,8 @@ def newest_key_clears_transition_gate( transitions, target, key_stats, - threshold=threshold, - min_attempts=min_attempts, + tuning=tuning, stall_attempts_cap=stall_attempts_cap, - cohort_limit=cohort_limit, ) return ready == total @@ -60,22 +52,21 @@ def gating_bigram_is_ready( transitions: Mapping[Bigram, TransitionStats], target: float, *, - threshold: float = 1.0, - min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, + tuning: UnlockTuning = _DEFAULT_UNLOCK_TUNING, stall_attempts_cap: int | None = None, ) -> bool: stats = transitions.get(pair) attempts = attempts_of(stats) if stats is not None else 0 mastered = ( - attempts >= min_attempts + attempts >= tuning.min_transition_confidence_attempts and transition_confidence_of( pair.prev_cp, pair.next_cp, transitions, target, - min_attempts=min_attempts, + min_attempts=tuning.min_transition_confidence_attempts, ) - >= threshold + >= tuning.next_letter_unlock_threshold ) return mastered or (stall_attempts_cap is not None and attempts >= stall_attempts_cap) @@ -86,19 +77,16 @@ def newest_key_transition_gate_progress( target: float, key_stats: Mapping[int, KeyStats] | None, *, - threshold: float = 1.0, - min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, + tuning: UnlockTuning = _DEFAULT_UNLOCK_TUNING, stall_attempts_cap: int | None = None, - cohort_limit: int = GATING_BIGRAM_LIMIT, ) -> tuple[int, int]: - cohort = newest_key_gating_cohort(unlocked, key_stats, limit=cohort_limit) + cohort = newest_key_gating_cohort(unlocked, key_stats, limit=tuning.gating_bigram_limit) ready = sum( gating_bigram_is_ready( pair, transitions, target, - threshold=threshold, - min_attempts=min_attempts, + tuning=tuning, stall_attempts_cap=stall_attempts_cap, ) for pair in cohort @@ -126,24 +114,21 @@ def compute_unlocked( stats: Mapping[int, KeyStats], target: float, *, - threshold: float = 1.0, - min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, + tuning: UnlockTuning = _DEFAULT_UNLOCK_TUNING, transitions: Mapping[Bigram, TransitionStats] | None = None, - transition_threshold: float = 1.0, - transition_min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, transition_stall_attempts_cap: int | None = None, - gating_bigram_limit: int = GATING_BIGRAM_LIMIT, ) -> tuple[int, ...]: """The first `alphabet_size` keys are always unlocked; each further key in `learn_order` unlocks only once every currently-unlocked key meets skill - ``threshold`` with at least ``min_attempts`` presses in window. + ``tuning.next_letter_unlock_threshold`` with at least + ``tuning.min_confidence_attempts`` presses in window. When `transitions` is given, a further key also waits on the current last-unlocked key's bounded cross-key bigram cohort (`newest_key_clears_transition_gate`) — a little bigram practice before - the next letter opens, bounded by `gating_bigram_limit` so the bar doesn't - grow with alphabet depth. Pass `None` (the default) to skip this and unlock purely - on solo-key mastery, as before. + the next letter opens, bounded by `tuning.gating_bigram_limit` so the bar + doesn't grow with alphabet depth. Pass `None` (the default) to skip this + and unlock purely on solo-key mastery, as before. The result can exceed `alphabet_size` once mastery conditions are met -- `alphabet_size` is a floor, not a cap. Callers that persist a Settings @@ -159,8 +144,8 @@ def compute_unlocked( k, stats, target, - threshold=threshold, - min_attempts=min_attempts, + threshold=tuning.next_letter_unlock_threshold, + min_attempts=tuning.min_confidence_attempts, ) for k in unlocked ): @@ -170,10 +155,8 @@ def compute_unlocked( transitions, target, stats, - threshold=transition_threshold, - min_attempts=transition_min_attempts, + tuning=tuning, stall_attempts_cap=transition_stall_attempts_cap, - cohort_limit=gating_bigram_limit, ): break unlocked.append(codepoint) diff --git a/src/keystrike/domain/version.py b/src/keystrike/domain/version.py index 9c73af2..8c0d5d5 100644 --- a/src/keystrike/domain/version.py +++ b/src/keystrike/domain/version.py @@ -1 +1 @@ -__version__ = "1.3.1" +__version__ = "2.0.0" diff --git a/src/keystrike/infrastructure/aggregates_cache.py b/src/keystrike/infrastructure/aggregates_cache.py index f22fa98..3e83de6 100644 --- a/src/keystrike/infrastructure/aggregates_cache.py +++ b/src/keystrike/infrastructure/aggregates_cache.py @@ -78,13 +78,17 @@ def get(self, layout: str) -> LayoutAggregates | None: parsed_transitions = dict( _parse_transition_entry(entry) for entry in transitions.values() ) - return LayoutAggregates( - keys={int(cp): _parse_key_entry(cp, entry) for cp, entry in keys.items()}, - transitions=without_same_key_transitions(parsed_transitions), - transitions_computed="transitions" in data, - ) + parsed_keys = {int(cp): _parse_key_entry(cp, entry) for cp, entry in keys.items()} except (json.JSONDecodeError, KeyError, TypeError, ValueError): + # Corrupt/malformed cache file -- treat as a cache miss. A bug in + # the domain call below is a different problem and must not be + # swallowed the same way, so it's outside this try. return None + return LayoutAggregates( + keys=parsed_keys, + transitions=without_same_key_transitions(parsed_transitions), + transitions_computed="transitions" in data, + ) def put(self, layout: str, aggregates: LayoutAggregates) -> None: transitions = without_same_key_transitions(aggregates.transitions) diff --git a/src/keystrike/infrastructure/git_client.py b/src/keystrike/infrastructure/git_client.py index b0c2492..f54504e 100644 --- a/src/keystrike/infrastructure/git_client.py +++ b/src/keystrike/infrastructure/git_client.py @@ -7,6 +7,7 @@ from __future__ import annotations +import os import subprocess from pathlib import Path from typing import Protocol @@ -16,6 +17,15 @@ _GIT_TIMEOUT_S = 30 +def _git_env() -> dict[str, str]: + # GIT_TERMINAL_PROMPT=0 is the actual fix for the credential-prompt hang + # the timeout above only guards against after the fact -- git fails fast + # instead of blocking on a prompt no one can answer. LC_ALL=C keeps + # stderr in English so callers matching on message substrings (see + # sync_git.py) aren't at the mercy of the user's locale. + return {**os.environ, "GIT_TERMINAL_PROMPT": "0", "LC_ALL": "C"} + + class GitSyncError(RuntimeError): """Raised when a git subprocess used for backup sync fails to run — it hung past the timeout, the `git` binary isn't installed, or the @@ -56,6 +66,7 @@ def run(self, *args: str, cwd: Path | None = None) -> str: capture_output=True, text=True, timeout=self._timeout_s, + env=_git_env(), ) except subprocess.TimeoutExpired as exc: raise GitSyncError( diff --git a/src/keystrike/infrastructure/session_repo_jsonl.py b/src/keystrike/infrastructure/session_repo_jsonl.py index 95bff51..d3a9b3e 100644 --- a/src/keystrike/infrastructure/session_repo_jsonl.py +++ b/src/keystrike/infrastructure/session_repo_jsonl.py @@ -3,6 +3,7 @@ from __future__ import annotations +import dataclasses import datetime as dt import json from collections.abc import Iterable, Iterator @@ -147,29 +148,18 @@ def _read_keystrokes(file: Path) -> Iterator[Keystroke]: def _header_to_dict(h: SessionResult) -> dict[str, object]: - # Built field-by-field rather than via `dataclasses.asdict(h)`: asdict - # falls back to `copy.deepcopy` for non-dataclass/list/tuple/dict values, - # and deepcopy cannot pickle `key_confidence`'s `mappingproxy` wrapper. - # A shallow `dict(...)` here sidesteps that entirely. - return { - "schema_version": h.schema_version, - "session_id": h.session_id, - "started_at": h.started_at, - "duration_ns": h.duration_ns, - "layout": h.layout, - "mode": str(h.mode), # Mode is a StrEnum → str - "lesson_alphabet": list(h.lesson_alphabet), - "focus_key": h.focus_key, - "total_keystrokes": h.total_keystrokes, - "correct_keystrokes": h.correct_keystrokes, - "words_completed": h.words_completed, - "lang": h.lang, - "unlocked_keys": list(h.unlocked_keys), - "key_confidence": {str(k): v for k, v in h.key_confidence.items()}, - "target_speed_cpm": h.target_speed_cpm, - "generated_min_len": h.generated_min_len, - "generated_max_len": h.generated_max_len, - } + # A shallow field-by-field dict rather than `dataclasses.asdict(h)`: + # asdict falls back to `copy.deepcopy` for non-dataclass/list/tuple/dict + # values, and deepcopy cannot pickle `key_confidence`'s `mappingproxy` + # wrapper. Reflection still derives the field *list* from SessionResult + # (so a new plain-scalar field needs no update here); only the four + # fields below need a real JSON-safety conversion. + base: dict[str, object] = {f.name: getattr(h, f.name) for f in dataclasses.fields(h)} + base["mode"] = str(h.mode) # Mode is a StrEnum → str + base["lesson_alphabet"] = list(h.lesson_alphabet) + base["unlocked_keys"] = list(h.unlocked_keys) + base["key_confidence"] = {str(k): v for k, v in h.key_confidence.items()} + return base def _parse_mode(raw: str) -> Mode: diff --git a/src/keystrike/infrastructure/settings_repo_toml.py b/src/keystrike/infrastructure/settings_repo_toml.py index 1a672a5..1fcb1bd 100644 --- a/src/keystrike/infrastructure/settings_repo_toml.py +++ b/src/keystrike/infrastructure/settings_repo_toml.py @@ -1,19 +1,30 @@ """TOML-backed SettingsRepository. Reads via stdlib tomllib; writes hand-rolled -TOML for the small Settings dataclass (all scalar fields, no nesting).""" +TOML for the Settings dataclass. Settings is mostly scalar fields, plus a +handful of nested tuning dataclasses (`UnlockTuning`, `FocusTuning`, +`WordGenBounds`) written as their own `[table]` sections.""" from __future__ import annotations import dataclasses import tomllib from datetime import UTC, datetime +from typing import cast from keystrike.domain.enums import TargetSpeedUnit -from keystrike.domain.models import Settings +from keystrike.domain.models import FocusTuning, Settings, UnlockTuning, WordGenBounds from .atomic_write import atomic_write_text from .paths import Paths from .toml_escape import escape_toml_string +_NestedTuning = UnlockTuning | FocusTuning | WordGenBounds +# Kept as a literal tuple (not derived from `_NestedTuning` via +# `typing.get_args`) so pyright can narrow `isinstance(value, _NESTED_TYPES)` +# -- a runtime-computed tuple loses that. `test_settings_repo_toml.py`'s +# `test_nested_types_matches_nested_tuning_union` guards the two from +# drifting apart instead. +_NESTED_TYPES = (UnlockTuning, FocusTuning, WordGenBounds) + def _fmt_scalar(v: object) -> str: if isinstance(v, bool): @@ -38,6 +49,11 @@ def _coerce_field(default: object, raw_value: object) -> object: raise TypeError(f"expected bool, got {type(raw_value).__name__}") return raw_value if isinstance(default, int): + if isinstance(raw_value, bool): + # bool is an int subclass -- without this, a stray `true`/`false` + # for an int field (e.g. hand-edited alphabet_size) would + # silently coerce to 1/0 instead of falling back to the default. + raise TypeError(f"expected int, got {type(raw_value).__name__}") return int(raw_value) # type: ignore[call-overload] if isinstance(default, float): return float(raw_value) # type: ignore[arg-type] @@ -46,6 +62,30 @@ def _coerce_field(default: object, raw_value: object) -> object: raise AssertionError(f"unsupported settings field type: {type(default)!r}") +def _load_nested(cls: type[_NestedTuning], raw_table: object) -> _NestedTuning: + """Reconstruct a nested tuning dataclass from its TOML sub-table, + falling back per-field (and wholesale, for a missing/malformed table) + to `cls()` defaults -- mirrors `load()`'s top-level fallback behavior.""" + defaults = cls() + if not isinstance(raw_table, dict): + return defaults + table = cast("dict[str, object]", raw_table) + values: dict[str, object] = {} + for f in dataclasses.fields(defaults): + default = getattr(defaults, f.name) + try: + values[f.name] = _coerce_field(default, table.get(f.name, default)) + except (ValueError, TypeError): + values[f.name] = default + return cls(**values) # type: ignore[arg-type] + + +def _write_nested_table(lines: list[str], name: str, value: _NestedTuning) -> None: + lines.append(f"\n[{name}]\n") + for f in dataclasses.fields(value): + lines.append(f"{f.name} = {_fmt_scalar(getattr(value, f.name))}\n") + + class TomlSettingsRepository: def __init__(self, paths: Paths) -> None: self._paths = paths @@ -70,6 +110,9 @@ def load(self) -> Settings: values[f.name] = str(raw_updated) if raw_updated is not None else None continue default = getattr(defaults, f.name) + if isinstance(default, _NESTED_TYPES): + values[f.name] = _load_nested(type(default), raw.get(f.name)) + continue try: values[f.name] = _coerce_field(default, raw.get(f.name, default)) except (ValueError, TypeError): @@ -87,12 +130,21 @@ def save(self, settings: Settings) -> None: # source of truth) rather than hand-maintained here, so it can't # silently drift when a field is added/removed from Settings. lines = ["# keystrike settings — edit with care, or use the Settings screen.\n"] + # Nested tuning tables are buffered separately and appended last -- + # TOML requires every root-level key (including `updated_at` below) + # to appear before any `[table]` header, regardless of the order + # Settings declares its fields in. + table_lines: list[str] = [] for field in dataclasses.fields(settings): if field.name == "updated_at": continue # written fresh below, not round-tripped from the input value = getattr(settings, field.name) + if isinstance(value, _NESTED_TYPES): + _write_nested_table(table_lines, field.name, value) + continue if field.name == "wordlist_url" and not value: continue # omit when unset, matching prior hand-rolled behavior lines.append(f"{field.name} = {_fmt_scalar(value)}\n") lines.append(f"updated_at = {_fmt_scalar(datetime.now(UTC).isoformat())}\n") + lines.extend(table_lines) atomic_write_text(self._paths.settings_file, "".join(lines)) diff --git a/src/keystrike/infrastructure/toml_escape.py b/src/keystrike/infrastructure/toml_escape.py index cf0f714..0202564 100644 --- a/src/keystrike/infrastructure/toml_escape.py +++ b/src/keystrike/infrastructure/toml_escape.py @@ -2,7 +2,33 @@ from __future__ import annotations +# TOML basic strings forbid raw control characters -- an unescaped newline +# (or other C0 control char) breaks the line structure of the file, and a +# hand-rolled writer has no parser downstream to catch that before it's on +# disk. Named escapes per the TOML spec; anything else in that range falls +# back to \uXXXX. +_ASCII_CONTROL_MAX = 0x1F +_ASCII_DEL = 0x7F + +_NAMED_ESCAPES = { + "\\": "\\\\", + '"': '\\"', + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", +} + def escape_toml_string(value: str) -> str: """Escape `value` for embedding in a TOML basic string (`"..."`).""" - return value.replace("\\", "\\\\").replace('"', '\\"') + out: list[str] = [] + for ch in value: + if ch in _NAMED_ESCAPES: + out.append(_NAMED_ESCAPES[ch]) + elif ord(ch) <= _ASCII_CONTROL_MAX or ord(ch) == _ASCII_DEL: + out.append(f"\\u{ord(ch):04x}") + else: + out.append(ch) + return "".join(out) diff --git a/src/keystrike/presentation/formatting/daily_learn.py b/src/keystrike/presentation/formatting/daily_learn.py new file mode 100644 index 0000000..7db721f --- /dev/null +++ b/src/keystrike/presentation/formatting/daily_learn.py @@ -0,0 +1,16 @@ +"""Rich-markup formatting for the daily learn-time budget. + +Pure display logic: no I/O, no use cases — just a `DailyLearnDisplay` in, +a markup string out. Shared by the home screen's hero text and the practice +HUD so they can't drift on how the used/limit fragment reads. +""" + +from __future__ import annotations + +from keystrike.domain.daily_learn import DailyLearnDisplay + + +def format_daily_learn_minutes(display: DailyLearnDisplay) -> str: + """`used/limit min` fragment -- callers own the surrounding label and + coloring.""" + return f"[bold]{display.used_minutes:.1f}[/]/{display.limit_minutes:g} min" diff --git a/src/keystrike/presentation/formatting/trends.py b/src/keystrike/presentation/formatting/trends.py index 9c6cf68..884558c 100644 --- a/src/keystrike/presentation/formatting/trends.py +++ b/src/keystrike/presentation/formatting/trends.py @@ -8,6 +8,7 @@ from __future__ import annotations from collections.abc import Callable, Sequence +from dataclasses import dataclass from keystrike.application.session_queries import compute_accuracy, compute_wpm from keystrike.application.session_use_cases import SessionStatsBaseline @@ -22,6 +23,7 @@ ) _SPARK = "▁▂▃▄▅▆▇█" +_DEFAULT_TREND_LIMIT = 20 # recent-sessions window, and matching sparkline width def _recent(headers: Sequence[SessionResult], limit: int) -> list[SessionResult]: @@ -59,7 +61,7 @@ def key_confidence_values( headers: Sequence[SessionResult], codepoint: int, *, - limit: int = 20, + limit: int = _DEFAULT_TREND_LIMIT, current_target_speed_cpm: int = 0, ) -> list[float]: ordered = _recent(headers, limit) @@ -77,7 +79,7 @@ def key_confidence_sparkline( headers: Sequence[SessionResult], codepoint: int, *, - limit: int = 20, + limit: int = _DEFAULT_TREND_LIMIT, current_target_speed_cpm: int = 0, ) -> str: values = key_confidence_values( @@ -105,7 +107,7 @@ def _focus_char_label(focus_key: int | None) -> str: def format_focus_confidence_trend_line( headers: Sequence[SessionResult], *, - limit: int = 20, + limit: int = _DEFAULT_TREND_LIMIT, current_target_speed_cpm: int = 0, ) -> str: ordered = _recent(headers, limit) @@ -124,8 +126,6 @@ def format_focus_confidence_trend_line( return format_aggregate_metric_trend_block( title=f"Focus '{label}'", confidence_values=values, - speed_values=[], - accuracy_values=[], limit=limit, ) @@ -142,57 +142,49 @@ def _default_metric_value(value: float) -> str: _GRID_VALUE_WIDTH = 8 -def _assemble_metric_line( - label_text: str, - session_part: str, - spark_text: str, - values_part: str, - *, - color: str, - suffix: str, -) -> str: - line = ( - f"[bold {color}]{label_text}[/]{session_part}" - f"{_colored_sparkline(spark_text, color)} " - f"[dim {color}]{values_part}[/]" - ) - if suffix: - line += f" {suffix}" - return line +@dataclass(frozen=True, slots=True) +class MetricLineSpec: + """One metric's inputs to `_format_metric_trend_line_grid` -- bundled so + the three near-identical per-metric callers (confidence/speed/accuracy) + thread one object instead of the same six positional/keyword args. + No `spark` field: the sparkline is always `value_sparkline(values)`, so + it can't be built from a different series than `values` shows.""" -def _format_metric_trend_line_grid( - label: str, - color: str, - values: Sequence[float], - spark: str, - *, - format_value: Callable[[float], str] | None = None, - suffix: str = "", - spark_width: int = 20, -) -> str: + label: str + color: str + values: Sequence[float] + format_value: Callable[[float], str] | None = None + suffix: str = "" + spark_width: int = _DEFAULT_TREND_LIMIT + + +def _format_metric_trend_line_grid(spec: MetricLineSpec) -> str: """Fixed-width columns for side-by-side blocks; session count omitted (the enclosing block header already shows it).""" - if not values: + if not spec.values: return "" - fmt = format_value or _default_metric_value - label_text = label.ljust(_GRID_LABEL_WIDTH) - spark_text = spark.ljust(spark_width) - latest_str = fmt(values[-1]) - peak_str = fmt(max(values)) + fmt = spec.format_value or _default_metric_value + label_text = spec.label.ljust(_GRID_LABEL_WIDTH) + spark_text = value_sparkline(spec.values).ljust(spec.spark_width) + latest_str = fmt(spec.values[-1]) + peak_str = fmt(max(spec.values)) values_part = f"latest {latest_str:>{_GRID_VALUE_WIDTH}} peak {peak_str:>{_GRID_VALUE_WIDTH}}" - return _assemble_metric_line( - label_text, " ", spark_text, values_part, color=color, suffix=suffix + line = ( + f"[bold {spec.color}]{label_text}[/] " + f"{_colored_sparkline(spark_text, spec.color)} " + f"[dim {spec.color}]{values_part}[/]" ) + if spec.suffix: + line += f" {spec.suffix}" + return line -def _format_confidence_trend_line_grid(values: Sequence[float], *, spark_width: int = 20) -> str: +def _format_confidence_trend_line_grid( + values: Sequence[float], *, spark_width: int = _DEFAULT_TREND_LIMIT +) -> str: return _format_metric_trend_line_grid( - "confidence", - STYLE_TREND_CONFIDENCE, - values, - value_sparkline(values), - spark_width=spark_width, + MetricLineSpec("confidence", STYLE_TREND_CONFIDENCE, values, spark_width=spark_width) ) @@ -200,10 +192,10 @@ def _format_key_confidence_trend_line_grid( headers: Sequence[SessionResult], codepoint: int, *, - limit: int = 20, + limit: int = _DEFAULT_TREND_LIMIT, current_target_speed_cpm: int = 0, cumulative: float | None = None, - spark_width: int = 20, + spark_width: int = _DEFAULT_TREND_LIMIT, ) -> str: # The enclosing block's title already names the key, so the line itself # drops the key name to avoid repeating it. @@ -216,44 +208,40 @@ def _format_key_confidence_trend_line_grid( limit=limit, current_target_speed_cpm=current_target_speed_cpm, ) - spark = key_confidence_sparkline( - headers, - codepoint, - limit=limit, - current_target_speed_cpm=current_target_speed_cpm, - ) suffix = "" if cumulative is not None: suffix = f"[dim {STYLE_TREND_CONFIDENCE}]cumulative {cumulative:.2f}[/]" return _format_metric_trend_line_grid( - "confidence", - STYLE_TREND_CONFIDENCE, - values, - spark, - suffix=suffix, - spark_width=spark_width, + MetricLineSpec( + "confidence", + STYLE_TREND_CONFIDENCE, + values, + suffix=suffix, + spark_width=spark_width, + ) ) -def _format_key_speed_trend_line_grid(values: Sequence[float], *, spark_width: int = 20) -> str: +def _format_key_speed_trend_line_grid( + values: Sequence[float], *, spark_width: int = _DEFAULT_TREND_LIMIT +) -> str: return _format_metric_trend_line_grid( - "speed", - STYLE_TREND_SPEED, - values, - value_sparkline(values), - spark_width=spark_width, + MetricLineSpec("speed", STYLE_TREND_SPEED, values, spark_width=spark_width) ) -def _format_key_accuracy_trend_line_grid(values: Sequence[float], *, spark_width: int = 20) -> str: +def _format_key_accuracy_trend_line_grid( + values: Sequence[float], *, spark_width: int = _DEFAULT_TREND_LIMIT +) -> str: pct_values = [v * 100 for v in values] return _format_metric_trend_line_grid( - "accuracy", - STYLE_TREND_ACCURACY, - pct_values, - value_sparkline(pct_values), - format_value=lambda v: f"{v:.1f}%", - spark_width=spark_width, + MetricLineSpec( + "accuracy", + STYLE_TREND_ACCURACY, + pct_values, + format_value=lambda v: f"{v:.1f}%", + spark_width=spark_width, + ) ) @@ -266,12 +254,10 @@ def _assemble_trend_block( *, spark_width: int, ) -> str: - header = f"[bold]{title}[/]" if not session_count: return "" - header += f" ({session_count} sessions)" lines = [ - header, + f"[bold]{title}[/] ({session_count} sessions)", conf_line, _format_key_speed_trend_line_grid(speed_values, spark_width=spark_width), _format_key_accuracy_trend_line_grid(accuracy_values, spark_width=spark_width), @@ -286,7 +272,7 @@ def format_key_metric_trend_block( codepoint: int, speed_values: Sequence[float], accuracy_values: Sequence[float], - limit: int = 20, + limit: int = _DEFAULT_TREND_LIMIT, current_target_speed_cpm: int = 0, cumulative: float | None = None, ) -> str: @@ -316,11 +302,17 @@ def format_aggregate_metric_trend_block( title: str, *, confidence_values: Sequence[float], - speed_values: Sequence[float], - accuracy_values: Sequence[float], - limit: int = 20, + speed_values: Sequence[float] | None = None, + accuracy_values: Sequence[float] | None = None, + limit: int = _DEFAULT_TREND_LIMIT, ) -> str: - """Trend block for layout-wide (or other pre-computed) confidence values.""" + """Trend block for layout-wide (or other pre-computed) confidence values. + + `speed_values`/`accuracy_values` are omittable -- callers with only a + confidence series (e.g. `format_focus_confidence_trend_line`) don't need + to fake up empty sequences just to signal "not applicable".""" + speed_values = speed_values or () + accuracy_values = accuracy_values or () spark_width = limit session_count = max(len(confidence_values), len(speed_values), len(accuracy_values), 0) conf_line = _format_confidence_trend_line_grid( diff --git a/src/keystrike/presentation/screens/home.py b/src/keystrike/presentation/screens/home.py index 89817b5..472d71d 100644 --- a/src/keystrike/presentation/screens/home.py +++ b/src/keystrike/presentation/screens/home.py @@ -8,6 +8,7 @@ from textual.widgets import Footer, Static from keystrike.domain.daily_learn import DailyLearnBudget, daily_learn_display +from keystrike.presentation.formatting.daily_learn import format_daily_learn_minutes from keystrike.presentation.services import HomeServices @@ -15,7 +16,7 @@ def _format_daily_learn_line(budget: DailyLearnBudget) -> str: display = daily_learn_display(budget) if not display.shown: return "" - return f"Learn today: [bold]{display.used_minutes:.1f}[/]/{display.limit_minutes:g} min" + return f"Learn today: {format_daily_learn_minutes(display)}" def _hero_text(layout: str, learn_budget: DailyLearnBudget, *, app_version: str = "") -> str: diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index dfec8b4..aabfe5d 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -8,7 +8,7 @@ from textual.widgets import Footer, Static from keystrike.application.prepare_practice import SessionPrep -from keystrike.domain.models import SessionResult +from keystrike.domain.models import Bigram, SessionResult from keystrike.domain.session import leading_key_char, skip_leading_whitespace from keystrike.presentation.bindings import BACK_BINDINGS from keystrike.presentation.formatting.trends import format_session_stats_line @@ -70,7 +70,6 @@ def __init__( self._typing_area = TypingArea(self._session) self._hud = HUD( self._session, - self._services.clock, get_daily_learn_budget=self._services.get_daily_learn_budget, focus_reason=initial.focus_reason, ) @@ -112,7 +111,7 @@ def _current_heatmap_display( self, *, focus: int | None = None, - focus_transition: tuple[int, int] | None = None, + focus_transition: Bigram | None = None, ) -> HeatmapDisplay | None: """Build heatmap display using current session's layout and heatmap.""" return build_heatmap_display( @@ -147,7 +146,6 @@ def on_key(self, event: events.Key) -> None: event.stop() self._typing_area.refresh_display() - self._hud.refresh_display() if self._session.finished: self._finish_session() @@ -155,15 +153,12 @@ def on_key(self, event: events.Key) -> None: def _finish_session(self) -> None: result = self._services.finish(self._session) self._services.rebuild_aggregates(result.layout) - self._show_last_session_stats(result) + self._refresh_last_session_stats(result) prep = self._services.prepare_practice() if prep is None: return self._begin_session(prep) - def _show_last_session_stats(self, result: SessionResult) -> None: - self._refresh_last_session_stats(result) - def _refresh_last_session_stats(self, result: SessionResult | None = None) -> None: header = result or self._services.get_latest_session_header(self._prep.layout) widget = self.query_one("#last-session-stats", Static) diff --git a/src/keystrike/presentation/screens/settings.py b/src/keystrike/presentation/screens/settings.py index 396db81..1bbde00 100644 --- a/src/keystrike/presentation/screens/settings.py +++ b/src/keystrike/presentation/screens/settings.py @@ -15,6 +15,14 @@ from keystrike.presentation.services import SettingsServices +class FormError(ValueError): + """A form field couldn't be parsed -- distinct from + `SettingsValidationError`, which is the application layer rejecting an + otherwise well-formed value. `action_save` catches both the same way, + but keeping them separate means a form error can't be mistaken for + (or silently absorbed into) a settings-domain rejection.""" + + class SettingsScreen(Screen[None]): DEFAULT_CSS = """ SettingsScreen > Vertical { @@ -42,8 +50,8 @@ def compose(self) -> ComposeResult: settings = self._services.settings_repo.load() layouts = self._layout_select_options() gen_min, gen_max = effective_generated_word_bounds( - settings.generated_word_min_len, - settings.generated_word_max_len, + settings.word_gen.min_len, + settings.word_gen.max_len, ) with Vertical(): yield Static("[bold]Settings[/] [dim](Ctrl+S save, Esc/q back)[/]") @@ -114,12 +122,13 @@ def action_import_wordlist(self) -> None: def action_clear_wordlist(self) -> None: self._do_clear() - def _collect_form_values(self) -> SettingsUpdate | None: + def _collect_form_values(self) -> SettingsUpdate: + """Parse the form into a `SettingsUpdate`, or raise `FormError` -- + `action_save` owns the one place that turns that into a shown + error, so parsing itself has no side effect.""" target_speed_value = self._required_int( "#settings-speed", "Target speed must be an integer." ) - if target_speed_value is None: - return None speed_unit_select = cast( "Select[TargetSpeedUnit]", @@ -127,11 +136,11 @@ def _collect_form_values(self) -> SettingsUpdate | None: ) target_speed_unit = speed_unit_select.value if isinstance(target_speed_unit, NoSelection): - return None + raise FormError("Target speed unit is required.") settings = self._services.settings_repo.load() gen_min, gen_max = effective_generated_word_bounds( - settings.generated_word_min_len, - settings.generated_word_max_len, + settings.word_gen.min_len, + settings.word_gen.max_len, ) target_speed_cpm = ( cpm_from_wpm( @@ -146,20 +155,16 @@ def _collect_form_values(self) -> SettingsUpdate | None: alphabet_size = self._required_int( "#settings-alphabet-size", "Number of letters must be an integer." ) - if alphabet_size is None: - return None learn_daily_minutes = self._required_int( "#settings-learn-daily-minutes", "Daily learn minutes must be an integer." ) - if learn_daily_minutes is None: - return None layout_select = cast("Select[str]", self.query_one("#settings-layout", Select)) layout = layout_select.value if isinstance(layout, NoSelection): # Unreachable with allow_blank=False + an initial value, but keeps typing sound. - return None + raise FormError("Layout is required.") return SettingsUpdate( layout=layout, @@ -170,13 +175,10 @@ def _collect_form_values(self) -> SettingsUpdate | None: ) def action_save(self) -> None: - values = self._collect_form_values() - if values is None: - return - try: + values = self._collect_form_values() self._services.update_settings(values) - except SettingsValidationError as exc: + except (FormError, SettingsValidationError) as exc: self._show_error(str(exc)) return @@ -236,10 +238,10 @@ def _refresh_layout_select(self) -> None: def _show_error(self, message: str) -> None: self.query_one("#settings-error", Static).update(f"[bold red]{message}[/]") - def _required_int(self, widget_id: str, error: str) -> int | None: + def _required_int(self, widget_id: str, error: str) -> int: value = self._parse_int_field(self.query_one(widget_id, Input).value) if value is None: - self._show_error(error) + raise FormError(error) return value @staticmethod diff --git a/src/keystrike/presentation/services.py b/src/keystrike/presentation/services.py index d4dbff8..c3d2bfc 100644 --- a/src/keystrike/presentation/services.py +++ b/src/keystrike/presentation/services.py @@ -28,7 +28,6 @@ ) from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET from keystrike.domain.protocols import ( - Clock, DailyLearnBudgetProvider, LayoutRepository, SettingsRepository, @@ -45,7 +44,6 @@ class HomeServices: @dataclass(frozen=True, slots=True) class PracticeServices: - clock: Clock start: StartSession record: RecordKeystroke finish: FinishSession diff --git a/src/keystrike/presentation/theme.py b/src/keystrike/presentation/theme.py index f0ce47f..ed08d58 100644 --- a/src/keystrike/presentation/theme.py +++ b/src/keystrike/presentation/theme.py @@ -5,7 +5,6 @@ STYLE_CURRENT = "bold underline" STYLE_WRONG_CURRENT = "bold red underline" STYLE_CORRECTED = "dim yellow" -STYLE_IDLE = "dim" STYLE_TREND_CONFIDENCE = "cyan" STYLE_TREND_SPEED = "green" STYLE_TREND_ACCURACY = "yellow" diff --git a/src/keystrike/presentation/widgets/hud.py b/src/keystrike/presentation/widgets/hud.py index 77720fb..422c03f 100644 --- a/src/keystrike/presentation/widgets/hud.py +++ b/src/keystrike/presentation/widgets/hud.py @@ -5,33 +5,21 @@ from keystrike.domain.daily_learn import DailyLearnBudget, daily_learn_display from keystrike.domain.focus import FocusReason from keystrike.domain.null_adapters import NULL_DAILY_LEARN_BUDGET -from keystrike.domain.protocols import Clock, DailyLearnBudgetProvider -from keystrike.domain.session import ( - Session, - active_typing_duration_ns, - is_typing_idle, - session_accuracy, -) -from keystrike.presentation.theme import STYLE_IDLE +from keystrike.domain.protocols import DailyLearnBudgetProvider +from keystrike.domain.session import Session, session_accuracy +from keystrike.presentation.formatting.daily_learn import format_daily_learn_minutes from keystrike.presentation.widgets.kb_heatmap import ( focus_reason_label_short, focus_transition_pair, ) -def learn_timer_dimmed(session: Session, now_ns: int) -> bool: - if session.typing_started_at_ns is None or session.last_keystroke_at_ns is None: - return True - return is_typing_idle(session, now_ns) - - -def _format_daily_learn_segment(budget: DailyLearnBudget, *, dim: bool) -> str: +def _format_daily_learn_segment(budget: DailyLearnBudget) -> str: display = daily_learn_display(budget) if not display.shown: return "" - segment = f" Learn: [bold]{display.used_minutes:.1f}[/]/{display.limit_minutes:g} min" - colored = f"[green]{segment}[/]" if display.limit_reached else segment - return f"[{STYLE_IDLE}]{colored}[/]" if dim else colored + segment = f" Learn: {format_daily_learn_minutes(display)}" + return f"[green]{segment}[/]" if display.limit_reached else segment def _format_focus_segment(focus_key: int | None, focus_reason: FocusReason | None) -> str: @@ -48,17 +36,20 @@ def _format_hud( daily_budget: DailyLearnBudget, *, focus_reason: FocusReason | None = None, - dim_learn: bool = True, ) -> str: accuracy = session_accuracy(session) return ( f"Acc: [bold]{accuracy * 100:5.1f}%[/]" - f"{_format_daily_learn_segment(daily_budget, dim=dim_learn)}" + f"{_format_daily_learn_segment(daily_budget)}" f"{_format_focus_segment(session.focus_key, focus_reason)}" ) class HUD(Widget): + """Shows the current lesson's accuracy, daily learn time, and focus key + as a fixed snapshot taken when the lesson starts -- it does not tick + while typing; call `set_session` to refresh it for the next lesson.""" + DEFAULT_CSS = """ HUD { padding: 0 2; @@ -70,14 +61,12 @@ class HUD(Widget): def __init__( self, session: Session, - clock: Clock, *, get_daily_learn_budget: DailyLearnBudgetProvider = NULL_DAILY_LEARN_BUDGET, focus_reason: FocusReason | None = None, ) -> None: super().__init__() self._session = session - self._clock = clock self._get_daily_learn_budget = get_daily_learn_budget self._focus_reason = focus_reason @@ -91,20 +80,13 @@ def compose(self) -> ComposeResult: id="hud-text", ) - def on_mount(self) -> None: - self.set_interval(0.1, self.refresh_display) - def refresh_display(self) -> None: - now_ns = self._clock.now_ns() - elapsed = active_typing_duration_ns(self._session, now_ns) - daily_budget = self._get_daily_learn_budget(extra_ns=elapsed) static = self.query_one("#hud-text", Static) static.update( _format_hud( self._session, - daily_budget, + self._get_daily_learn_budget(), focus_reason=self._focus_reason, - dim_learn=learn_timer_dimmed(self._session, now_ns), ), ) diff --git a/src/keystrike/presentation/widgets/kb_heatmap.py b/src/keystrike/presentation/widgets/kb_heatmap.py index c9a87df..c43a877 100644 --- a/src/keystrike/presentation/widgets/kb_heatmap.py +++ b/src/keystrike/presentation/widgets/kb_heatmap.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import assert_never from rich.text import Text from textual.app import ComposeResult @@ -38,6 +39,8 @@ def focus_reason_label_short(focus_reason: FocusReason) -> str: return "cal" case FocusKind.KEY_REVIEW | FocusKind.TRANSITION_REVIEW: return "rev" + case _: + assert_never(focus_reason.kind) def focus_reason_label(focus_reason: FocusReason) -> str: @@ -88,7 +91,7 @@ class HeatmapDisplay: heatmap: dict[int, float] focus: int | None = None urgency: dict[int, float] | None = None - focus_transition: tuple[int, int] | None = None + focus_transition: Bigram | None = None def build_heatmap_display( @@ -97,7 +100,7 @@ def build_heatmap_display( *, focus: int | None = None, urgency: dict[int, float] | None = None, - focus_transition: tuple[int, int] | None = None, + focus_transition: Bigram | None = None, ) -> HeatmapDisplay | None: """`HeatmapDisplay(...)`, or None if the layout/heatmap data isn't ready yet.""" if layout is None or heatmap is None: diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 8056103..60586db 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -15,11 +15,14 @@ from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import ( Bigram, + FocusTuning, KeyStats, LayoutAggregates, SessionResult, Settings, TransitionStats, + UnlockTuning, + WordGenBounds, ) from keystrike.domain.newest_key import newest_key_gating_cohort from keystrike.domain.unlock import compute_unlocked @@ -126,6 +129,47 @@ def test_lesson_persists_alphabet_growth_when_unlocked_exceeds_setting(): assert settings_repo.settings.alphabet_size == 3 +def test_lesson_respects_next_letter_unlock_threshold_setting(): + """Same mastery fixture as test_lesson_persists_alphabet_growth_when_unlocked_exceeds_setting, + but with a threshold raised above the achieved confidence -- the third + key must stay locked, proving Settings.next_letter_unlock_threshold is + actually wired into BuildLesson's compute_unlocked call.""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + e, a = order[0], order[1] + now = 1_700_000_000.0 + mastered = 100_000_000.0 + keys = { + e: KeyStats(e, 10, mastered, 0, now, attempt_count=10), + a: KeyStats(a, 10, mastered, 0, now, attempt_count=10), + } + transitions = { + Bigram(e, a): TransitionStats(e, a, 10, mastered, 0, now, attempt_count=4), + Bigram(a, e): TransitionStats(a, e, 10, mastered, 0, now, attempt_count=4), + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, + ) + settings_repo = FakeSettingsRepository( + Settings(alphabet_size=2, unlock=UnlockTuning(next_letter_unlock_threshold=3.0)) + ) + builder = BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=settings_repo, + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + ) + + lesson = builder("qwerty") + + assert {k.codepoint for k in lesson.state.keys} == {e, a} + assert lesson.state.alphabet_size == 2 + assert settings_repo.settings.alphabet_size == 2 + + def test_lesson_heatmap_maps_unlocked_codepoints_to_confidence(): lesson = _build_lesson()("qwerty") assert lesson.heatmap == {k.codepoint: k.confidence for k in lesson.state.keys} @@ -466,14 +510,7 @@ def test_compute_weights_gives_newest_key_pairs_a_default_weight(): ctx = builder._load_context("qwerty") progress = _lesson_progress("qwerty", ctx) focus_confidence = _resolve_focus_confidence(progress.focus, progress.focus_bigram, ctx) - _, transition_weights = _compute_weights( - progress.state, - progress.focus, - progress.focus_bigram, - focus_confidence, - unlocked=progress.unlocked, - ctx=ctx, - ) + _, transition_weights = _compute_weights(progress, focus_confidence, ctx) # The bounded cohort uses only the two most-recent practiced peers. assert Bigram(d, a) not in transition_weights assert Bigram(a, d) not in transition_weights @@ -778,7 +815,7 @@ def test_lesson_wordlist_biases_weak_transition(): pair = chr(a) + chr(s) assert lesson.focus_reason == FocusReason(kind=FocusKind.TRANSITION_WEAK, pair=Bigram(a, s)) words = lesson.text.split() - quota = weak_focus_word_quota(settings.lesson_word_count, settings.focus_word_min_fraction) + quota = weak_focus_word_quota(settings.lesson_word_count, settings.focus.word_min_fraction) bigram_words = sum(1 for word in words if pair in word) assert bigram_words >= quota assert max(words.count(w) for w in set(words)) <= 2 @@ -889,7 +926,7 @@ def test_weak_key_focus_meets_word_quota_in_build_lesson(): clock=FakeClock(), ) settings = Settings() - quota = weak_focus_word_quota(settings.lesson_word_count, settings.focus_word_min_fraction) + quota = weak_focus_word_quota(settings.lesson_word_count, settings.focus.word_min_fraction) focus_char = chr(focus_cp) for seed in range(30): builder.rng = Random(seed) @@ -913,7 +950,7 @@ def test_build_lesson_survives_invalid_focus_word_min_fraction(): cache = FakeAggregatesCache( by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, ) - settings = Settings(alphabet_size=1, focus_word_min_fraction=1.5) + settings = Settings(alphabet_size=1, focus=FocusTuning(word_min_fraction=1.5)) builder = BuildLesson( layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), aggregates_cache=cache, @@ -933,8 +970,7 @@ def test_build_lesson_survives_invalid_focus_word_min_fraction(): def test_build_lesson_markov_respects_generated_word_bounds_from_settings(): """Settings.generated_word_* must reach the generator on the Markov path.""" settings = Settings( - generated_word_min_len=2, - generated_word_max_len=4, + word_gen=WordGenBounds(min_len=2, max_len=4), wordlist_url="", alphabet_size=8, ) @@ -951,8 +987,7 @@ def test_build_lesson_wordlist_uses_dictionary_bounds_not_generated(): url = "https://example.com/words.txt" cached = ["the", "and", "for", "are", "but", "not", "you", "all", "because"] settings = Settings( - generated_word_min_len=2, - generated_word_max_len=4, + word_gen=WordGenBounds(min_len=2, max_len=4), wordlist_url=url, alphabet_size=26, ) @@ -1054,7 +1089,9 @@ def test_lesson_boosts_coverage_starved_key_among_mastered_alphabet(): cache = FakeAggregatesCache( by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, ) - settings = Settings(alphabet_size=alphabet_size, min_confidence_attempts=min_attempts) + settings = Settings( + alphabet_size=alphabet_size, unlock=UnlockTuning(min_confidence_attempts=min_attempts) + ) builder = BuildLesson( layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), aggregates_cache=cache, @@ -1207,8 +1244,7 @@ def test_remedial_focus_uses_session_word_bounds_not_current_settings(): Settings( alphabet_size=4, target_speed_cpm=300, - generated_word_min_len=3, - generated_word_max_len=10, + word_gen=WordGenBounds(min_len=3, max_len=10), ) ), language_provider=FakeLanguageProvider(), @@ -1291,10 +1327,11 @@ def test_large_alphabet_unlock_advances_when_keys_rotated(): stats: dict[int, KeyStats] = { cp: KeyStats(cp, 0, at_target, 0, now, attempt_count=0) for cp in order[:forced] } - assert len(compute_unlocked(order, forced, stats, target, min_attempts=min_attempts)) == forced + tuning = UnlockTuning(min_confidence_attempts=min_attempts) + assert len(compute_unlocked(order, forced, stats, target, tuning=tuning)) == forced for i, cp in enumerate(order[:forced]): stats[cp] = KeyStats(cp, 0, at_target, 0, now, attempt_count=min_attempts) - unlocked = compute_unlocked(order, forced, stats, target, min_attempts=min_attempts) + unlocked = compute_unlocked(order, forced, stats, target, tuning=tuning) if i < forced - 1: assert len(unlocked) == forced else: diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index 914b10f..908984f 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -23,14 +23,16 @@ ) from keystrike.application.stats_use_cases import RebuildAggregates from keystrike.domain.aggregate import combine_sessions -from keystrike.domain.confidence import ( - CONFIDENCE_SESSION_WINDOW, - confidence_of, - target_ms_per_char, -) +from keystrike.domain.confidence import confidence_of, target_ms_per_char from keystrike.domain.enums import Mode, SessionState from keystrike.domain.learn_order import keyboard_order -from keystrike.domain.models import SessionResult, Settings +from keystrike.domain.models import ( + CONFIDENCE_SESSION_WINDOW, + SessionResult, + Settings, + UnlockTuning, + WordGenBounds, +) from keystrike.domain.unlock import compute_unlocked from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from keystrike.presentation.formatting.trends import format_session_stats_line @@ -483,9 +485,7 @@ def test_session_wpm_below_target_uses_snapshotted_word_bounds(): def test_finish_session_persists_generated_word_bounds(clock, id_gen): - settings_repo = FakeSettingsRepository( - Settings(generated_word_min_len=3, generated_word_max_len=8) - ) + settings_repo = FakeSettingsRepository(Settings(word_gen=WordGenBounds(min_len=3, max_len=8))) finish = FinishSession(clock=clock, settings_repo=settings_repo) start = StartSession(clock=clock, id_gen=id_gen) record = RecordKeystroke(clock=clock) @@ -529,7 +529,12 @@ def test_finish_session_persists_unlocked_keys(clock, id_gen): assert repo.headers[0].unlocked_keys == expected -def test_finish_session_bumps_alphabet_size_when_unlocked_set_grows(clock, id_gen): +def test_finish_session_does_not_bump_alphabet_size_immediately(clock, id_gen): + """FinishSession reports the grown unlocked set on the result, but must + not persist it back to Settings.alphabet_size right away -- that eager + write caused a distracting mid-session status jump. Persisting the + growth is BuildLesson's job, done once, right before the next lesson is + generated (see alphabet_sync.sync_alphabet_size).""" layout = BUNDLED_LAYOUTS["qwerty"] order = keyboard_order(layout) settings_repo = FakeSettingsRepository(Settings(alphabet_size=5)) @@ -567,7 +572,54 @@ def test_finish_session_bumps_alphabet_size_when_unlocked_set_grows(clock, id_ge result = finish(session) assert len(result.unlocked_keys) > 5 - assert settings_repo.settings.alphabet_size == len(result.unlocked_keys) + assert settings_repo.settings.alphabet_size == 5 + + +def test_finish_session_respects_next_letter_unlock_threshold_setting(clock, id_gen): + """Same warmup drill as test_finish_session_bumps_alphabet_size_when_unlocked_set_grows, + but with the threshold raised out of reach -- alphabet_size must not + grow, proving Settings.next_letter_unlock_threshold is actually wired + into FinishSession's compute_unlocked call.""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + settings_repo = FakeSettingsRepository( + Settings(alphabet_size=5, unlock=UnlockTuning(next_letter_unlock_threshold=10.0)) + ) + layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) + repo = FakeSessionRepository() + finish = FinishSession( + clock=clock, + repo=repo, + settings_repo=settings_repo, + layout_repo=layout_repo, + ) + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + + newest = chr(order[4]) + cohort_drill = "".join( + (chr(peer) + newest) * 8 + (newest + chr(peer)) * 8 for peer in order[2:4] + ) + warmup = "".join(chr(cp) for _ in range(15) for cp in order[:5]) + cohort_drill + warmup_session = start( + warmup, + layout="qwerty", + mode=Mode.ADAPTIVE, + focus_key=order[0], + ) + for ch in warmup: + clock.advance(50_000_000) + record(warmup_session, ch) + finish(warmup_session) + + session = start("as", layout="qwerty", mode=Mode.ADAPTIVE, focus_key=order[0]) + for ch in "as": + clock.advance(100_000_000) + record(session, ch) + result = finish(session) + + assert len(result.unlocked_keys) == 5 + assert settings_repo.settings.alphabet_size == 5 def test_finish_session_persists_key_confidence(clock, id_gen): diff --git a/tests/application/test_settings_use_cases.py b/tests/application/test_settings_use_cases.py index c1617cd..c3822f0 100644 --- a/tests/application/test_settings_use_cases.py +++ b/tests/application/test_settings_use_cases.py @@ -7,7 +7,7 @@ UpdateSettings, ) from keystrike.domain.enums import TargetSpeedUnit -from keystrike.domain.models import Settings +from keystrike.domain.models import Settings, UnlockTuning from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS, CompositeLayoutRepository from keystrike.infrastructure.paths import Paths from tests.fakes import FakeLayoutRepository, FakeSettingsRepository @@ -42,9 +42,9 @@ def test_update_settings_persists_all_fields(): assert result.alphabet_size == 20 assert result.learn_daily_minutes == 15 assert result.confidence_session_window == Settings().confidence_session_window - assert result.min_confidence_attempts == Settings().min_confidence_attempts - assert result.min_transition_confidence_attempts == ( - Settings().min_transition_confidence_attempts + assert result.unlock.min_confidence_attempts == Settings().unlock.min_confidence_attempts + assert result.unlock.min_transition_confidence_attempts == ( + Settings().unlock.min_transition_confidence_attempts ) assert result.wordlist_url == "" assert repo.settings == result @@ -54,8 +54,7 @@ def test_update_settings_preserves_confidence_fields_from_repo(): repo = FakeSettingsRepository( Settings( confidence_session_window=8, - min_confidence_attempts=12, - min_transition_confidence_attempts=5, + unlock=UnlockTuning(min_confidence_attempts=12, min_transition_confidence_attempts=5), ), ) update = UpdateSettings(repo=repo) @@ -71,8 +70,8 @@ def test_update_settings_preserves_confidence_fields_from_repo(): ) assert result.confidence_session_window == 8 - assert result.min_confidence_attempts == 12 - assert result.min_transition_confidence_attempts == 5 + assert result.unlock.min_confidence_attempts == 12 + assert result.unlock.min_transition_confidence_attempts == 5 def test_update_settings_rejects_non_positive_speed(): diff --git a/tests/application/test_stats_use_cases.py b/tests/application/test_stats_use_cases.py index 2b9669a..ef8e577 100644 --- a/tests/application/test_stats_use_cases.py +++ b/tests/application/test_stats_use_cases.py @@ -7,13 +7,10 @@ RebuildAggregates, ) from keystrike.domain.aggregate import session_recency_weights -from keystrike.domain.confidence import ( - CONFIDENCE_SESSION_WINDOW, - MIN_CONFIDENCE_ATTEMPTS, - SESSION_RECENCY_DECAY, -) +from keystrike.domain.confidence import MIN_CONFIDENCE_ATTEMPTS, SESSION_RECENCY_DECAY from keystrike.domain.enums import Mode from keystrike.domain.models import ( + CONFIDENCE_SESSION_WINDOW, Bigram, KeyStats, Keystroke, diff --git a/tests/domain/test_unlock.py b/tests/domain/test_unlock.py index 19367bc..c1b071a 100644 --- a/tests/domain/test_unlock.py +++ b/tests/domain/test_unlock.py @@ -1,5 +1,5 @@ from keystrike.domain.confidence import confidence_of, skill_of -from keystrike.domain.models import Bigram, KeyStats, TransitionStats +from keystrike.domain.models import Bigram, KeyStats, TransitionStats, UnlockTuning from keystrike.domain.newest_key import newest_key_gating_cohort from keystrike.domain.unlock import ( compute_unlocked, @@ -98,7 +98,11 @@ def test_compute_unlocked_stalls_on_high_error_rate_despite_fast_speed(): # speed 2.0, but only 50% accuracy -> min(2.0, 0.5) = 0.5 < threshold stats = {1: _stats(1, mean_time_ns=100_000_000.0, error_count=10)} unlocked = compute_unlocked( - learn_order, alphabet_size=1, stats=stats, target=200.0, threshold=1.5 + learn_order, + alphabet_size=1, + stats=stats, + target=200.0, + tuning=UnlockTuning(next_letter_unlock_threshold=1.5), ) assert unlocked == (1,) @@ -226,8 +230,8 @@ def test_configured_cohort_limit_changes_unlock_gate(): 3, stats, 200.0, + tuning=UnlockTuning(gating_bigram_limit=2), transitions=transitions, - gating_bigram_limit=2, ) == (a, b, c, d) assert ( compute_unlocked( @@ -235,8 +239,8 @@ def test_configured_cohort_limit_changes_unlock_gate(): 3, stats, 200.0, + tuning=UnlockTuning(gating_bigram_limit=4), transitions=transitions, - gating_bigram_limit=4, ) == unlocked ) diff --git a/tests/infrastructure/test_settings_repo_toml.py b/tests/infrastructure/test_settings_repo_toml.py index 6e8d425..b8015ee 100644 --- a/tests/infrastructure/test_settings_repo_toml.py +++ b/tests/infrastructure/test_settings_repo_toml.py @@ -1,13 +1,19 @@ from random import Random +from typing import get_args import pytest from keystrike.application.build_lesson import BuildLesson from keystrike.domain.enums import TargetSpeedUnit -from keystrike.domain.models import Settings +from keystrike.domain.models import FocusTuning, Settings, UnlockTuning, WordGenBounds from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from keystrike.infrastructure.paths import Paths -from keystrike.infrastructure.settings_repo_toml import TomlSettingsRepository, _coerce_field +from keystrike.infrastructure.settings_repo_toml import ( + _NESTED_TYPES, + TomlSettingsRepository, + _coerce_field, + _NestedTuning, +) from tests.fakes import ( FakeAggregatesCache, FakeClock, @@ -35,21 +41,18 @@ def test_load_defaults_when_no_file(paths): def test_load_generated_word_bounds_from_hand_edited_toml(paths): paths.settings_file.write_text( - "schema_version = 1\ngenerated_word_min_len = 2\ngenerated_word_max_len = 5\n", + "schema_version = 1\n\n[word_gen]\nmin_len = 2\nmax_len = 5\n", encoding="utf-8", ) loaded = TomlSettingsRepository(paths).load() - assert loaded.generated_word_min_len == 2 - assert loaded.generated_word_max_len == 5 + assert loaded.word_gen.min_len == 2 + assert loaded.word_gen.max_len == 5 def test_toml_generated_word_bounds_affect_markov_lesson_words(paths): """Full path: settings.toml → repo.load() → BuildLesson → word lengths.""" paths.settings_file.write_text( - "schema_version = 1\n" - "generated_word_min_len = 2\n" - "generated_word_max_len = 4\n" - 'wordlist_url = ""\n', + 'schema_version = 1\nwordlist_url = ""\n\n[word_gen]\nmin_len = 2\nmax_len = 4\n', encoding="utf-8", ) builder = BuildLesson( @@ -75,38 +78,34 @@ def test_round_trip(paths): target_speed_unit=TargetSpeedUnit.WPM, alphabet_size=20, confidence_session_window=8, - min_confidence_attempts=12, - min_transition_confidence_attempts=5, - gating_bigram_limit=3, - focus_char_boost=2.5, - focus_word_boost=5.0, - focus_bigram_word_boost=6.0, - focus_transition_boost=3.5, - focus_weak_extra_boost=2.0, + unlock=UnlockTuning( + min_confidence_attempts=12, + min_transition_confidence_attempts=5, + gating_bigram_limit=3, + next_letter_unlock_threshold=0.9, + ), + focus=FocusTuning( + char_boost=2.5, + word_boost=5.0, + bigram_word_boost=6.0, + transition_boost=3.5, + weak_extra_boost=2.0, + word_min_fraction=0.75, + ), lesson_word_count=15, - focus_word_min_fraction=0.75, max_word_repeats=3, - generated_word_min_len=2, - generated_word_max_len=5, + word_gen=WordGenBounds(min_len=2, max_len=5), ) repo.save(original) loaded = repo.load() assert loaded.layout == original.layout assert loaded.target_speed_cpm == original.target_speed_cpm assert loaded.confidence_session_window == 8 - assert loaded.min_confidence_attempts == 12 - assert loaded.min_transition_confidence_attempts == 5 - assert loaded.gating_bigram_limit == 3 - assert loaded.focus_char_boost == 2.5 - assert loaded.focus_word_boost == 5.0 - assert loaded.focus_bigram_word_boost == 6.0 - assert loaded.focus_transition_boost == 3.5 - assert loaded.focus_weak_extra_boost == 2.0 + assert loaded.unlock == original.unlock + assert loaded.focus == original.focus assert loaded.lesson_word_count == 15 - assert loaded.focus_word_min_fraction == 0.75 assert loaded.max_word_repeats == 3 - assert loaded.generated_word_min_len == 2 - assert loaded.generated_word_max_len == 5 + assert loaded.word_gen == original.word_gen assert loaded.updated_at is not None @@ -160,15 +159,28 @@ def test_malformed_enum_value_falls_back_to_default(paths): def test_existing_toml_defaults_gating_bigram_limit(paths): paths.settings_file.write_text('schema_version = 1\nlayout = "qwerty"\n', encoding="utf-8") - assert TomlSettingsRepository(paths).load().gating_bigram_limit == 4 + assert TomlSettingsRepository(paths).load().unlock.gating_bigram_limit == 4 def test_loads_gating_bigram_limit(paths): paths.settings_file.write_text( - "schema_version = 1\ngating_bigram_limit = 2\n", + "schema_version = 1\n\n[unlock]\ngating_bigram_limit = 2\n", encoding="utf-8", ) - assert TomlSettingsRepository(paths).load().gating_bigram_limit == 2 + assert TomlSettingsRepository(paths).load().unlock.gating_bigram_limit == 2 + + +def test_existing_toml_defaults_next_letter_unlock_threshold(paths): + paths.settings_file.write_text('schema_version = 1\nlayout = "qwerty"\n', encoding="utf-8") + assert TomlSettingsRepository(paths).load().unlock.next_letter_unlock_threshold == 1.0 + + +def test_loads_next_letter_unlock_threshold(paths): + paths.settings_file.write_text( + "schema_version = 1\n\n[unlock]\nnext_letter_unlock_threshold = 0.85\n", + encoding="utf-8", + ) + assert TomlSettingsRepository(paths).load().unlock.next_letter_unlock_threshold == 0.85 def test_coerce_field_rejects_non_bool_for_bool_default(): @@ -182,3 +194,33 @@ def test_coerce_field_rejects_non_bool_for_bool_default(): def test_coerce_field_accepts_real_bool(): assert _coerce_field(True, False) is False assert _coerce_field(False, True) is True + + +def test_coerce_field_rejects_bool_for_int_default(): + # bool is an int subclass -- int(True) == 1 would otherwise silently + # coerce a stray `alphabet_size = true` instead of falling back to + # the default via load()'s except (ValueError, TypeError). + with pytest.raises(TypeError): + _coerce_field(16, True) + + +def test_settings_file_with_control_chars_round_trips(paths): + """Regression: an unescaped control char in a written string value used + to corrupt the TOML file, which load() then silently replaced with + Settings() -- reverting every setting, not just the offending field.""" + repo = TomlSettingsRepository(paths) + original = Settings( + wordlist_url='https://example.com/a"b\nc', + unlock=UnlockTuning(next_letter_unlock_threshold=0.75), + ) + repo.save(original) + loaded = repo.load() + assert loaded.wordlist_url == original.wordlist_url + assert loaded.unlock.next_letter_unlock_threshold == 0.75 + + +def test_nested_types_matches_nested_tuning_union(): + """`_NESTED_TYPES` is a literal tuple (not derived from `_NestedTuning` + via `typing.get_args`) so pyright can narrow `isinstance` checks on it -- + this guards the two from drifting apart instead.""" + assert set(_NESTED_TYPES) == set(get_args(_NestedTuning)) diff --git a/tests/infrastructure/test_toml_escape.py b/tests/infrastructure/test_toml_escape.py new file mode 100644 index 0000000..3a6ed35 --- /dev/null +++ b/tests/infrastructure/test_toml_escape.py @@ -0,0 +1,28 @@ +import tomllib + +from keystrike.infrastructure.toml_escape import escape_toml_string + + +def test_escapes_backslash_and_quote(): + assert escape_toml_string('a\\b"c') == 'a\\\\b\\"c' + + +def test_escapes_newline_tab_and_carriage_return(): + """Regression: an unescaped control char breaks a hand-rolled TOML + writer's line structure -- verified by round-tripping through tomllib.""" + raw = "line1\nline2\ttabbed\r\n" + escaped = escape_toml_string(raw) + loaded = tomllib.loads(f'value = "{escaped}"\n') + assert loaded["value"] == raw + + +def test_escapes_other_control_characters_as_unicode_escape(): + raw = "a\x01b\x7fc" + escaped = escape_toml_string(raw) + loaded = tomllib.loads(f'value = "{escaped}"\n') + assert loaded["value"] == raw + + +def test_plain_text_round_trips_unchanged(): + raw = "https://example.com/words.txt" + assert escape_toml_string(raw) == raw diff --git a/tests/presentation/test_hud.py b/tests/presentation/test_hud.py index c97baee..3d250e9 100644 --- a/tests/presentation/test_hud.py +++ b/tests/presentation/test_hud.py @@ -6,14 +6,8 @@ from keystrike.domain.enums import FocusKind, Mode from keystrike.domain.focus import FocusReason from keystrike.domain.models import Bigram -from keystrike.domain.session import ( - LEARN_IDLE_PAUSE_NS, - Session, - active_typing_duration_ns, - is_typing_idle, -) -from keystrike.presentation.widgets.hud import HUD, _format_hud, learn_timer_dimmed -from tests.fakes import FakeClock +from keystrike.domain.session import Session +from keystrike.presentation.widgets.hud import HUD, _format_hud _UNLIMITED = compute_daily_learn_budget(completed_ns=0, limit_minutes=0) @@ -22,7 +16,6 @@ def _session( *, correct: int = 0, total: int = 0, - typing_started_at_ns: int | None = None, ) -> Session: session = Session( id="s1", @@ -32,7 +25,6 @@ def _session( lang="en", started_at_wall=0.0, started_at_ns=0, - typing_started_at_ns=typing_started_at_ns, focus_key=ord("e"), ) session.correct_count = correct @@ -40,12 +32,6 @@ def _session( return session -def _active_session(**kwargs) -> Session: - session = _session(typing_started_at_ns=0, **kwargs) - session.last_keystroke_at_ns = 0 - return session - - def test_hud_shows_accuracy_without_wpm(): text = _format_hud(_session(correct=8, total=10), _UNLIMITED) assert text.startswith("Acc: [bold] 80.0%[/]") @@ -123,23 +109,13 @@ def test_hud_shows_daily_learn_goal_reached(): completed_ns=10 * 60 * 1_000_000_000, limit_minutes=10, ) - text = _format_hud(_active_session(), budget, dim_learn=False) + text = _format_hud(_session(), budget) assert "Learn:" in text assert "10.0" in text assert "/10 min" in text assert "[green] Learn: [bold]10.0[/]/10 min[/]" in text -def test_hud_learn_segment_green_when_goal_reached_and_active(): - budget = compute_daily_learn_budget( - completed_ns=10 * 60 * 1_000_000_000, - limit_minutes=10, - ) - text = _format_hud(_active_session(), budget, dim_learn=False) - assert "[green] Learn: [bold]10.0[/]/10 min[/]" in text - assert "[dim] Learn:" not in text - - def test_hud_distinct_labels_for_daily_budget_and_focus(): budget = compute_daily_learn_budget( completed_ns=9 * 60 * 1_000_000_000, @@ -162,71 +138,31 @@ def test_hud_distinct_labels_for_daily_budget_and_focus(): @pytest.mark.asyncio -async def test_hud_accuracy_updates_on_refresh(): - clock = FakeClock() +async def test_hud_reflects_session_passed_at_construction(): session = _session(correct=1, total=2) app = App() - async with app.run_test() as pilot: - await app.mount(HUD(session, clock)) - app.screen.query_one(HUD).refresh_display() - await pilot.pause() + async with app.run_test(): + await app.mount(HUD(session)) text = str(app.screen.query_one("#hud-text", Static).content) assert "50.0%" in text assert "WPM" not in text -def test_hud_learn_timer_excludes_idle_beyond_pause(): - clock = FakeClock() - session = _session(typing_started_at_ns=0) - session.last_keystroke_at_ns = 2_000_000_000 - session.active_duration_ns = 2_000_000_000 - clock.advance(12_000_000_000) # 10s since last key; only 5s grace counts - - assert active_typing_duration_ns(session, clock.now_ns()) == 7_000_000_000 - assert LEARN_IDLE_PAUSE_NS == 5_000_000_000 - - -def test_hud_learn_segment_dimmed_before_first_keystroke(): - budget = compute_daily_learn_budget( - completed_ns=6 * 60 * 1_000_000_000, - limit_minutes=10, - ) - text = _format_hud(_session(), budget) - assert "[dim] Learn: [bold]6.0[/]/10 min[/]" in text - - -def test_hud_learn_segment_undimmed_while_actively_typing(): - budget = compute_daily_learn_budget( - completed_ns=6 * 60 * 1_000_000_000, - limit_minutes=10, - ) - text = _format_hud(_active_session(), budget, dim_learn=False) - assert "[dim] Learn:" not in text - assert " Learn: [bold]6.0[/]/10 min" in text - - -def test_hud_learn_segment_dimmed_while_idle(): - budget = compute_daily_learn_budget( - completed_ns=6 * 60 * 1_000_000_000, - limit_minutes=10, - ) - session = _active_session() - session.last_keystroke_at_ns = 0 - clock = FakeClock() - clock.advance(LEARN_IDLE_PAUSE_NS) - assert is_typing_idle(session, clock.now_ns()) - text = _format_hud(session, budget, dim_learn=learn_timer_dimmed(session, clock.now_ns())) - assert "[dim] Learn: [bold]6.0[/]/10 min[/]" in text - +@pytest.mark.asyncio +async def test_hud_only_updates_via_set_session(): + session = _session(correct=1, total=2) + app = App() + async with app.run_test() as pilot: + hud = HUD(session) + await app.mount(hud) + session.correct_count = 2 + session.total_count = 2 + await pilot.pause() + text = str(app.screen.query_one("#hud-text", Static).content) + assert "50.0%" in text # mutating the session in place doesn't refresh the HUD -def test_hud_learn_segment_dim_green_when_goal_reached_but_idle(): - budget = compute_daily_learn_budget( - completed_ns=10 * 60 * 1_000_000_000, - limit_minutes=10, - ) - session = _active_session() - session.last_keystroke_at_ns = 0 - clock = FakeClock() - clock.advance(LEARN_IDLE_PAUSE_NS) - text = _format_hud(session, budget, dim_learn=learn_timer_dimmed(session, clock.now_ns())) - assert "[dim][green] Learn: [bold]10.0[/]/10 min[/][/]" in text + next_session = _session(correct=2, total=2) + hud.set_session(next_session) + await pilot.pause() + text = str(app.screen.query_one("#hud-text", Static).content) + assert "100.0%" in text diff --git a/tests/presentation/test_kb_heatmap.py b/tests/presentation/test_kb_heatmap.py index b59010f..fe21e2a 100644 --- a/tests/presentation/test_kb_heatmap.py +++ b/tests/presentation/test_kb_heatmap.py @@ -199,7 +199,7 @@ def test_render_heatmap_highlights_both_transition_keys(): QWERTY, {ord("e"): 0.89, ord("o"): 0.89}, focus=ord("o"), - focus_transition=(ord("e"), ord("o")), + focus_transition=Bigram(ord("e"), ord("o")), ) ) for ch in "eo": diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index 2ac1bb7..53fe31f 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -31,7 +31,7 @@ ) from keystrike.domain.enums import Mode, SessionState from keystrike.domain.models import Keystroke, SessionResult, Settings -from keystrike.domain.session import LEARN_IDLE_PAUSE_NS, active_typing_duration_ns, is_typing_idle +from keystrike.domain.session import LEARN_IDLE_PAUSE_NS from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from keystrike.presentation.screens.home import HomeScreen from keystrike.presentation.screens.practice import PracticeScreen, format_bigram_calibration @@ -44,7 +44,6 @@ StatsServices, ) from keystrike.presentation.textual_app import KeystrikeApp -from keystrike.presentation.widgets.hud import HUD from keystrike.presentation.widgets.kb_heatmap import KbHeatmap from tests.fakes import ( FakeAggregatesCache, @@ -120,7 +119,6 @@ def _build_app( get_daily_learn_budget=get_daily_learn_budget, ), practice=PracticeServices( - clock=clock, start=StartSession(clock=clock, id_gen=id_gen), record=RecordKeystroke(clock=clock), finish=FinishSession( @@ -439,7 +437,7 @@ async def test_escape_returns_to_home_and_cancels_session(): @pytest.mark.asyncio -async def test_learn_timer_pauses_while_idle_on_practice_screen(): +async def test_hud_stays_fixed_while_typing(): app, clock, _repo, _settings_repo = _build_app( settings=Settings(learn_daily_minutes=10), ) @@ -450,53 +448,14 @@ async def test_learn_timer_pauses_while_idle_on_practice_screen(): assert isinstance(practice, PracticeScreen) target = practice._session.target_text - clock.advance(100_000_000) - await pilot.press("space" if target[0] == " " else target[0]) - await pilot.pause() - clock.advance(2_000_000_000) - await pilot.press("space" if target[1] == " " else target[1]) - await pilot.pause() - - clock.advance(LEARN_IDLE_PAUSE_NS + 3_000_000_000) - practice.query_one(HUD).refresh_display() - await pilot.pause() - - elapsed = active_typing_duration_ns(practice._session, clock.now_ns()) - assert elapsed == 7_000_000_000 # 2s between keys + 5s idle cap, not 10s wall - - hud_text = str(practice.query_one("#hud-text", Static).content) - assert "Learn:" in hud_text - assert "0.1[/]/10" in hud_text - - -@pytest.mark.asyncio -async def test_hud_learn_dims_while_idle_on_practice_screen(): - app, clock, _repo, _settings = _build_app( - settings=Settings(learn_daily_minutes=10), - ) - async with app.run_test() as pilot: - await pilot.press("enter") - await pilot.pause() - practice = app.screen - assert isinstance(practice, PracticeScreen) - target = practice._session.target_text + hud_text_before = str(practice.query_one("#hud-text", Static).content) clock.advance(100_000_000) await pilot.press("space" if target[0] == " " else target[0]) await pilot.pause() - assert not is_typing_idle(practice._session, clock.now_ns()) - - clock.advance(LEARN_IDLE_PAUSE_NS + 1_000_000_000) - practice.query_one(HUD).refresh_display() + clock.advance(LEARN_IDLE_PAUSE_NS + 3_000_000_000) + await pilot.press("x") await pilot.pause() - assert is_typing_idle(practice._session, clock.now_ns()) - hud_text = str(practice.query_one("#hud-text", Static).content) - assert "[dim] Learn:" in hud_text - clock.advance(100_000_000) - await pilot.press("space" if target[1] == " " else target[1]) - await pilot.pause() - assert not is_typing_idle(practice._session, clock.now_ns()) - hud_text = str(practice.query_one("#hud-text", Static).content) - assert "[dim] Learn:" not in hud_text - assert "Learn:" in hud_text + hud_text_after = str(practice.query_one("#hud-text", Static).content) + assert hud_text_after == hud_text_before diff --git a/tests/presentation/test_settings_screen.py b/tests/presentation/test_settings_screen.py index 827d486..85fbbf5 100644 --- a/tests/presentation/test_settings_screen.py +++ b/tests/presentation/test_settings_screen.py @@ -11,7 +11,7 @@ ) from keystrike.domain.enums import TargetSpeedUnit from keystrike.domain.generator import cpm_from_wpm, wpm_from_cpm -from keystrike.domain.models import Settings +from keystrike.domain.models import Settings, WordGenBounds from keystrike.infrastructure.layout_repo import BUNDLED_LAYOUTS from keystrike.presentation.screens.settings import SettingsScreen from keystrike.presentation.services import SettingsServices @@ -236,8 +236,7 @@ async def test_wpm_display_uses_custom_generated_bounds(): Settings( target_speed_cpm=390, target_speed_unit=TargetSpeedUnit.WPM, - generated_word_min_len=3, - generated_word_max_len=10, + word_gen=WordGenBounds(min_len=3, max_len=10), ), ) layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) @@ -266,7 +265,7 @@ async def test_wpm_display_uses_custom_generated_bounds(): async def test_save_converts_wpm_using_custom_generated_bounds(): app = App() settings_repo = FakeSettingsRepository( - Settings(generated_word_min_len=3, generated_word_max_len=10), + Settings(word_gen=WordGenBounds(min_len=3, max_len=10)), ) layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) store = FakeWordListStore() diff --git a/uv.lock b/uv.lock index 0550ed2..7af7829 100644 --- a/uv.lock +++ b/uv.lock @@ -79,7 +79,7 @@ wheels = [ [[package]] name = "keystrike" -version = "1.3.1" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "platformdirs" }, From d04591c50252bfadd95708699cc8f871c59891d4 Mon Sep 17 00:00:00 2001 From: Egno Date: Fri, 21 Aug 2026 14:11:47 +0300 Subject: [PATCH 39/47] Update practice-screen snapshot for frozen HUD. The HUD no longer renders a dim idle style, dropping one CSS class from the practice screen's terminal snapshot. Co-Authored-By: Claude Sonnet 5 --- .../test_practice_screen_snapshot.svg | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg index d29b672..23ebe73 100644 --- a/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg +++ b/tests/presentation/__snapshots__/test_snapshots/test_practice_screen_snapshot.svg @@ -36,15 +36,14 @@ .terminal-r2 { fill: #b0b0b0 } .terminal-r3 { fill: #b0b0b0;font-weight: bold } .terminal-r4 { fill: #797979 } -.terminal-r5 { fill: #797979;font-weight: bold } -.terminal-r6 { fill: #e0e0e0;font-weight: bold;text-decoration: underline; } -.terminal-r7 { fill: #c4c4c4 } -.terminal-r8 { fill: #5f5f5f } -.terminal-r9 { fill: #3b3b3b;font-weight: bold } -.terminal-r10 { fill: #e0e0e0 } -.terminal-r11 { fill: #3b3b3b;font-weight: bold;text-decoration: underline; } -.terminal-r12 { fill: #6f6f6f } -.terminal-r13 { fill: #a0a0a0 } +.terminal-r5 { fill: #e0e0e0;font-weight: bold;text-decoration: underline; } +.terminal-r6 { fill: #c4c4c4 } +.terminal-r7 { fill: #5f5f5f } +.terminal-r8 { fill: #3b3b3b;font-weight: bold } +.terminal-r9 { fill: #e0e0e0 } +.terminal-r10 { fill: #3b3b3b;font-weight: bold;text-decoration: underline; } +.terminal-r11 { fill: #6f6f6f } +.terminal-r12 { fill: #a0a0a0 } @@ -130,20 +129,20 @@ - + -Acc: 100.0%   Learn: 0.0/10 min   Focus: a · wk +Acc: 100.0%   Learn: 0.0/10 min   Focus: a · wk -ajg·as·skal·uk·ak·aaaf·aot·iagd·ta·fea·ltg·ai +ajg·as·skal·uk·ak·aaaf·aot·iagd·ta·fea·ltg·ai - q  w  e  r  t  y  u  i  o  p  - a  s  d  f  g  h  j  k  l  ;  - z  x  c  v  b  n  m  ,  .  /  + q  w  e  r  t  y  u  i  o  p  + a  s  d  f  g  h  j  k  l  ;  + z  x  c  v  b  n  m  ,  .  /  -a · wk · 0.00 · 0% · 0.00 +a · wk · 0.00 · 0% · 0.00 @@ -155,7 +154,7 @@ - esc Back  ^q Quit  + esc Back  ^q Quit  From af1ba24afccbc799181d667505ab2b0e5d7cd3a0 Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 24 Sep 2026 11:35:15 +0300 Subject: [PATCH 40/47] Store per-session stats instead of keystroke logs; prune outside retention window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A finished session no longer writes sessions//.jsonl. Its keystrokes are reduced at finish time to per-key and per-bigram tallies (SessionResult.stats, [samples, time_ns, errors, attempts]) stored on the index.jsonl row (schema 5). combine_sessions merges those tallies, so heatmap, unlock, focus and trend numbers are unchanged. app.startup() (called by the CLI before build()/build_sync()) folds every existing keystroke log into its index row, deletes the month directories, and drops tallies from sessions older than 2 * confidence_session_window - 1 per layout — the furthest the trend replay reads. Git sync appends index rows only; a legacy remote row is upgraded from the clone's keystroke log on import. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- docs/CHANGELOG.md | 21 ++ docs/wiki/Git-sync.md | 14 +- src/keystrike/app.py | 18 +- .../application/session_use_cases.py | 23 +- src/keystrike/application/stats_use_cases.py | 25 +- src/keystrike/cli.py | 10 +- src/keystrike/domain/aggregate.py | 246 +++++++++------- src/keystrike/domain/models.py | 68 ++++- src/keystrike/domain/null_adapters.py | 14 +- src/keystrike/domain/protocols.py | 12 +- src/keystrike/domain/retention.py | 51 ++++ src/keystrike/domain/sync_merge.py | 20 +- .../infrastructure/session_migration.py | 123 ++++++++ .../infrastructure/session_repo_jsonl.py | 186 ++++++------ src/keystrike/infrastructure/sync_git.py | 6 +- src/keystrike/infrastructure/sync_merge_io.py | 28 +- tests/application/test_prepare_practice.py | 14 +- tests/application/test_prune_session_stats.py | 42 +++ tests/application/test_session_use_cases.py | 14 +- tests/application/test_stats_use_cases.py | 276 ++++++++++-------- tests/domain/test_aggregate.py | 64 ++-- tests/domain/test_null_adapters.py | 7 +- tests/domain/test_retention.py | 76 +++++ tests/domain/test_sync_merge.py | 15 - tests/fakes.py | 27 +- .../infrastructure/test_session_migration.py | 145 +++++++++ .../infrastructure/test_session_repo_jsonl.py | 179 ++++++------ tests/infrastructure/test_sync_git.py | 75 +++-- tests/presentation/test_practice_screen.py | 18 +- tests/presentation/test_stats_screen.py | 76 ++--- 31 files changed, 1240 insertions(+), 655 deletions(-) create mode 100644 src/keystrike/domain/retention.py create mode 100644 src/keystrike/infrastructure/session_migration.py create mode 100644 tests/application/test_prune_session_stats.py create mode 100644 tests/domain/test_retention.py create mode 100644 tests/infrastructure/test_session_migration.py diff --git a/README.md b/README.md index 8cea2eb..2025b98 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ directory. | User settings | `~/.config/keystrike/settings.toml` | `~/Library/Application Support/keystrike/settings.toml` | | Custom layouts | `~/.config/keystrike/layouts/` | `~/Library/Application Support/keystrike/layouts/` | | Sync config (after `sync init`) | `~/.config/keystrike/sync.toml` | `~/Library/Application Support/keystrike/sync.toml` | -| Session JSONL logs | `~/.local/share/keystrike/sessions/` | `~/Library/Application Support/keystrike/sessions/` | +| Session history (`index.jsonl`) | `~/.local/share/keystrike/sessions/` | `~/Library/Application Support/keystrike/sessions/` | | Stats cache | `~/.local/share/keystrike/cache/` | `~/Library/Application Support/keystrike/cache/` | Windows uses `%LOCALAPPDATA%\keystrike\` for the same layout (single directory). diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2bcb297..b2fbe59 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,27 @@ 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. +## Unreleased + +- **Session store: per-session stats instead of keystroke logs** — a finished + session no longer writes `sessions//.jsonl`. Its keystrokes + are reduced at finish time to per-key and per-bigram tallies + (`SessionResult.stats`, `[samples, time_ns, errors, attempts]`) stored on the + `index.jsonl` row (schema 5). `combine_sessions` merges those tallies, so + heatmap, unlock, focus and trend numbers are unchanged. On first start the + app folds every existing keystroke log into its index row and deletes the + month directories (`infrastructure/session_migration.py`). Git sync appends + index rows only; a legacy remote row is upgraded from the clone's keystroke + log on import. +- **Stats retention** — at startup, sessions older than the newest + `2 * confidence_session_window - 1` per layout (the furthest the trend + replay can reach) drop their tallies but keep their history row + (`domain/retention.py`, `PruneSessionStats`). This is irreversible: + raising `confidence_session_window` later only widens the window for + sessions recorded after the change, and trend lines for the older ones + read as empty. Migration and pruning run from `app.startup()`, called by + the CLI before the TUI or a sync command starts — not inside the builders. + ## 2.0.0 - **Breaking: `settings.toml` schema** — unlock (`min_confidence_attempts`, diff --git a/docs/wiki/Git-sync.md b/docs/wiki/Git-sync.md index ea8786f..a331853 100644 --- a/docs/wiki/Git-sync.md +++ b/docs/wiki/Git-sync.md @@ -80,19 +80,19 @@ pull complete: 3 session(s) imported | --- | --- | | `settings.toml` | `cache/` (derived stats — rebuilt locally) | | `layouts/*.toml` (custom layouts) | Bundled layouts shipped with Keystrike | -| `sessions/index.jsonl` | Logs outside the sessions tree | -| `sessions/**/*.jsonl` (session keystroke logs) | | +| `sessions/index.jsonl` (one row per session: header + per-key stats) | Logs outside the sessions tree | +| `sessions/**/*.jsonl` (legacy keystroke logs, if a clone still has them) | | After a successful **pull**, Keystrike replays all sessions in the merged index and rebuilds the stats cache for every layout it finds. You do not need to sync cache files. ## Merge behavior -Keystrike applies its own merge logic before every push and pull. Git is used only for transport; JSONL session files are **not** left to git's native merge. +Keystrike applies its own merge logic before every push and pull. Git is used only for transport; the JSONL session index is **not** left to git's native merge. ### Sessions — union by `session_id` -- Sessions are identified by `session_id` in `sessions/index.jsonl`. -- **Missing sessions are imported** — if the remote has a session ID your machine lacks, the session file and index entry are copied in. +- Sessions are identified by `session_id` in `sessions/index.jsonl`. Each row is self-contained: it carries the session header and its per-key/per-bigram stats. +- **Missing sessions are imported** — if the remote has a session ID your machine lacks, its index row is appended locally. A row written by a pre-2.1 version (no embedded stats) is upgraded on import from the remote's keystroke log when that file still exists. - **Existing IDs are never overwritten** — if both sides have the same `session_id`, the local copy wins and the remote copy is left unchanged on import. This means you can practice on two machines offline and merge later without losing sessions, as long as each session has a unique ID (Keystrike generates UUIDs). @@ -137,7 +137,7 @@ Paths use [platformdirs](https://github.com/tox-dev/platformdirs) (same roots as | `{config_dir}/sync/repo/` | Local git clone used for push/pull | | `{config_dir}/settings.toml` | Synced settings | | `{config_dir}/layouts/` | Synced custom layouts | -| `{data_dir}/sessions/` | Synced session logs | +| `{data_dir}/sessions/` | Synced session index | Typical `{config_dir}` / `{data_dir}`: @@ -204,7 +204,7 @@ Then retry `keystrike sync push`. ### Pull succeeded but stats look stale -Pull rebuilds aggregates from the session index. If something still looks wrong, check `keystrike sync status` session counts and confirm session files exist under `{data_dir}/sessions/`. +Pull rebuilds aggregates from the session index. If something still looks wrong, check `keystrike sync status` session counts and confirm the rows exist in `{data_dir}/sessions/index.jsonl`. ## See also diff --git a/src/keystrike/app.py b/src/keystrike/app.py index dfae21b..c7592e8 100644 --- a/src/keystrike/app.py +++ b/src/keystrike/app.py @@ -21,6 +21,7 @@ GetHistory, GetKeyMetricTrends, GetOrRebuildAggregates, + PruneSessionStats, RebuildAggregates, ) from keystrike.application.sync_use_cases import GetSyncStatus, InitSync, PullSync, PushSync @@ -35,7 +36,8 @@ from keystrike.infrastructure.id_gen import UlidGenerator from keystrike.infrastructure.languages import BundledLanguageProvider from keystrike.infrastructure.layout_repo import CompositeLayoutRepository -from keystrike.infrastructure.paths import default_paths, ensure_dirs +from keystrike.infrastructure.paths import Paths, default_paths, ensure_dirs +from keystrike.infrastructure.session_migration import migrate_keystroke_files from keystrike.infrastructure.session_repo_jsonl import JsonlSessionRepository from keystrike.infrastructure.settings_repo_toml import TomlSettingsRepository from keystrike.infrastructure.sync_git import GitSyncGateway @@ -57,6 +59,20 @@ class SyncServices: status: GetSyncStatus +def startup(paths: Paths | None = None) -> None: + """One-time-per-launch store maintenance, run by the CLI before `build()` + or `build_sync()`: create the data dirs, fold schema ≤4 keystroke logs + into the session index, and drop tallies outside the retention window. + Kept out of the builders so they only assemble the object graph.""" + paths = paths or default_paths() + ensure_dirs(paths) + migrate_keystroke_files(paths) + PruneSessionStats( + repo=JsonlSessionRepository(paths), + settings_repo=TomlSettingsRepository(paths), + )() + + def build_sync() -> SyncServices: paths = default_paths() ensure_dirs(paths) diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 6255e4d..bdb32a8 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -1,4 +1,3 @@ -from collections.abc import Iterable from dataclasses import dataclass, field from keystrike.application.session_queries import ( @@ -6,7 +5,12 @@ compute_wpm, latest_session_header, ) -from keystrike.domain.aggregate import SessionTiming, combine_sessions, session_recency_weights +from keystrike.domain.aggregate import ( + SessionTiming, + combine_sessions, + session_recency_weights, + tally_session, +) from keystrike.domain.confidence import ( confidence_of, target_ms_per_char, @@ -14,7 +18,7 @@ from keystrike.domain.enums import Mode, SessionState from keystrike.domain.generator import effective_generated_word_bounds from keystrike.domain.learn_order import keyboard_order -from keystrike.domain.models import Keystroke, SessionResult +from keystrike.domain.models import Keystroke, SessionResult, SessionStats from keystrike.domain.null_adapters import ( NULL_LAYOUT_REPOSITORY, NULL_SETTINGS_REPOSITORY, @@ -131,6 +135,7 @@ class _DraftTiming: def _snapshot_unlock_state( session: Session, duration_ns: int, + stats: SessionStats, *, repo: SessionRepository, settings_repo: SettingsRepository, @@ -143,10 +148,10 @@ def _snapshot_unlock_state( repo.iter_headers(session.layout), key=lambda h: h.started_at, )[-(settings.confidence_session_window - 1) :] - sessions: list[tuple[SessionTiming, Iterable[Keystroke]]] = [ - (header, repo.load_keystrokes(header.session_id)) for header in prior_headers + sessions: list[tuple[SessionTiming, SessionStats]] = [ + (header, header.stats) for header in prior_headers ] - sessions.append((draft, session.keystrokes)) + sessions.append((draft, stats)) combined = combine_sessions(sessions) target = target_ms_per_char(settings.target_speed_cpm) unlocked = compute_unlocked( @@ -185,9 +190,11 @@ def __call__(self, session: Session) -> SessionResult: settings = self.settings_repo.load() target_speed_cpm = settings.target_speed_cpm + stats = tally_session(session.keystrokes) unlocked_keys, key_confidence = _snapshot_unlock_state( session, duration_ns, + stats, repo=self.repo, settings_repo=self.settings_repo, layout_repo=self.layout_repo, @@ -202,7 +209,7 @@ def __call__(self, session: Session) -> SessionResult: settings.word_gen.max_len, ) result = SessionResult( - schema_version=4, + schema_version=5, session_id=session.id, started_at=session.started_at_wall, duration_ns=duration_ns, @@ -219,8 +226,8 @@ def __call__(self, session: Session) -> SessionResult: target_speed_cpm=target_speed_cpm, generated_min_len=generated_min_len, generated_max_len=generated_max_len, + stats=stats, ) - self.repo.append_keystrokes(session.id, session.started_at_wall, session.keystrokes) self.repo.save_header(result) return result diff --git a/src/keystrike/application/stats_use_cases.py b/src/keystrike/application/stats_use_cases.py index 713d6fb..7b84fbb 100644 --- a/src/keystrike/application/stats_use_cases.py +++ b/src/keystrike/application/stats_use_cases.py @@ -23,6 +23,7 @@ SessionRepository, SettingsRepository, ) +from keystrike.domain.retention import prune_session_stats _NS_PER_MS = 1e6 @@ -49,12 +50,27 @@ def __call__(self, layout: str) -> None: self.repo.iter_headers(layout), key=lambda h: h.started_at, )[-window:] - combined = combine_sessions( - [(header, self.repo.load_keystrokes(header.session_id)) for header in headers], - ) + combined = combine_sessions([(header, header.stats) for header in headers]) self.cache.put(layout, combined) +@dataclass(slots=True) +class PruneSessionStats: + """Command: drop per-session tallies from sessions older than the stats + retention window (see `domain.retention`). History rows stay. Returns + how many rows were changed; the index is rewritten only when non-zero.""" + + repo: SessionRepository + settings_repo: SettingsRepository + + def __call__(self) -> int: + window = self.settings_repo.load().confidence_session_window + pruned, changed = prune_session_stats(self.repo.iter_all_headers(), window=window) + if changed: + self.repo.replace_all_headers(pruned) + return changed + + @dataclass(slots=True) class GetOrRebuildAggregates: """Query: current per-key aggregates for a layout. @@ -196,8 +212,7 @@ def _windowed_session_replays( for rel_i, header in enumerate(ordered): abs_i = start_offset + rel_i window_headers = all_headers[max(0, abs_i - window + 1) : abs_i + 1] - sessions = [(h, repo.load_keystrokes(h.session_id)) for h in window_headers] - combined = combine_sessions(sessions).keys + combined = combine_sessions([(h, h.stats) for h in window_headers]).keys if header.target_speed_cpm > 0: session_target = target_ms_per_char(header.target_speed_cpm) else: diff --git a/src/keystrike/cli.py b/src/keystrike/cli.py index b42ec58..ed987bb 100644 --- a/src/keystrike/cli.py +++ b/src/keystrike/cli.py @@ -5,7 +5,7 @@ import typer from keystrike import __version__ -from keystrike.app import build, build_sync +from keystrike.app import build, build_sync, startup app = typer.Typer( add_completion=False, @@ -27,12 +27,17 @@ def _default( # pyright: ignore[reportUnusedFunction] typer.echo(f"keystrike {__version__}") raise typer.Exit if ctx.invoked_subcommand is None: - build().run() + _run_tui() @app.command() def run() -> None: """Launch the typing tutor TUI.""" + _run_tui() + + +def _run_tui() -> None: + startup() build().run() @@ -44,6 +49,7 @@ def _sync_err(exc: BaseException) -> NoReturn: def _run_sync[T](fn: Callable[[], T]) -> T: """Wrap a sync operation with consistent error handling.""" try: + startup() return fn() except (RuntimeError, subprocess.CalledProcessError, OSError) as exc: _sync_err(exc) diff --git a/src/keystrike/domain/aggregate.py b/src/keystrike/domain/aggregate.py index 8bd6b73..572e6e4 100644 --- a/src/keystrike/domain/aggregate.py +++ b/src/keystrike/domain/aggregate.py @@ -3,6 +3,11 @@ Keybr convention: a key's mean_time_ns is the average time from the previous *correct* keystroke to this one, aggregated per target codepoint. Wrong keystrokes increment error_count for the codepoint they missed. + +The reduction happens in two stages. `tally_session` folds the keystroke +stream into exact integer `KeyTally` counters per key and per bigram — that +`SessionStats` is what gets persisted with the session header. `combine_sessions` +then merges the stored tallies of a window of sessions with recency weights. """ from __future__ import annotations @@ -12,13 +17,21 @@ from typing import Protocol from .confidence import SESSION_RECENCY_DECAY, HasConfidenceFields, is_same_key_transition -from .models import Bigram, KeyStats, Keystroke, LayoutAggregates, TransitionStats +from .models import ( + Bigram, + KeyStats, + Keystroke, + KeyTally, + LayoutAggregates, + SessionStats, + TransitionStats, +) class SessionTiming(Protocol): - """Structural shape `aggregate_session`/`aggregate_transitions`/ - `combine_sessions` need from a session header: only the fields required to - timestamp and weight aggregated stats, not a full persisted `SessionResult`.""" + """Structural shape `session_key_stats`/`session_transition_stats`/ + `combine_sessions` need from a session header: only the fields required + to timestamp aggregated stats, not a full persisted `SessionResult`.""" @property def started_at(self) -> float: ... @@ -33,12 +46,20 @@ class _Partial: error_count: int = 0 attempt_count: int = 0 + def tally(self) -> KeyTally: + return KeyTally( + samples=len(self.time_samples), + time_ns=sum(self.time_samples), + errors=self.error_count, + attempts=self.attempt_count, + ) + def per_key_deltas(keystrokes: Iterable[Keystroke]) -> dict[int, list[int]]: """Chronological inter-keystroke deltas per codepoint: the time from the previous *correct* keystroke to this one, for each correct keystroke. - This is the raw data `aggregate_session` reduces to a mean — also used + This is the raw data `tally_session` reduces to a sum — also used directly by the M4 learning-rate regression, which needs the actual per-attempt sequence rather than a summary statistic. """ @@ -57,35 +78,127 @@ def per_key_deltas(keystrokes: Iterable[Keystroke]) -> dict[int, list[int]]: return deltas -def aggregate_session( - result: SessionTiming, - keystrokes: Iterable[Keystroke], -) -> dict[int, KeyStats]: - partial: dict[int, _Partial] = {} - all_keystrokes = list(keystrokes) +def per_transition_deltas(keystrokes: Iterable[Keystroke]) -> dict[Bigram, list[int]]: + """Inter-keystroke deltas per prev→next target pair for correct keystrokes.""" + deltas: dict[Bigram, list[int]] = {} + last_correct_cp: int | None = None + last_correct_t_ns: int | None = None - for k in all_keystrokes: + for k in keystrokes: + if not k.correct: + continue + if ( + last_correct_cp is not None + and last_correct_t_ns is not None + and not is_same_key_transition(last_correct_cp, k.codepoint) + ): + delta = k.t_ns - last_correct_t_ns + if delta > 0: + deltas.setdefault(Bigram(last_correct_cp, k.codepoint), []).append(delta) + last_correct_cp = k.codepoint + last_correct_t_ns = k.t_ns + + return deltas + + +def _tally_keys(keystrokes: Sequence[Keystroke]) -> dict[int, KeyTally]: + partial: dict[int, _Partial] = {} + for k in keystrokes: entry = partial.setdefault(k.codepoint, _Partial()) entry.attempt_count += 1 if not k.correct: entry.error_count += 1 - - for cp, deltas in per_key_deltas(all_keystrokes).items(): + for cp, deltas in per_key_deltas(keystrokes).items(): partial.setdefault(cp, _Partial()).time_samples.extend(deltas) + return {cp: p.tally() for cp, p in partial.items()} - session_end_wall = result.started_at + result.duration_ns / 1e9 - return { - cp: KeyStats( - codepoint=cp, - samples=len(p.time_samples), - mean_time_ns=(sum(p.time_samples) / len(p.time_samples)) if p.time_samples else 0.0, - error_count=p.error_count, - last_seen=session_end_wall, - attempt_count=p.attempt_count, - ) - for cp, p in partial.items() - } +def _tally_transitions(keystrokes: Sequence[Keystroke]) -> dict[Bigram, KeyTally]: + partial: dict[Bigram, _Partial] = {} + last_correct_cp: int | None = None + + for i, k in enumerate(keystrokes): + if not k.correct: + if i > 0: + prev_cp = keystrokes[i - 1].codepoint + if not is_same_key_transition(prev_cp, k.codepoint): + key = Bigram(prev_cp, k.codepoint) + entry = partial.setdefault(key, _Partial()) + entry.attempt_count += 1 + entry.error_count += 1 + continue + if last_correct_cp is not None and not is_same_key_transition( + last_correct_cp, + k.codepoint, + ): + key = Bigram(last_correct_cp, k.codepoint) + partial.setdefault(key, _Partial()).attempt_count += 1 + last_correct_cp = k.codepoint + + for key, samples in per_transition_deltas(keystrokes).items(): + partial.setdefault(key, _Partial()).time_samples.extend(samples) + + # Same-key pairs never enter `partial`: every branch above skips them. + return {key: p.tally() for key, p in partial.items()} + + +def tally_session(keystrokes: Iterable[Keystroke]) -> SessionStats: + """Reduce a session's keystroke stream to its persisted per-key and + per-bigram tallies. Same-key bigrams (aa, ee) are never tallied.""" + all_keystrokes = list(keystrokes) + return SessionStats( + keys=_tally_keys(all_keystrokes), + transitions=_tally_transitions(all_keystrokes), + ) + + +def _mean_ns(tally: KeyTally) -> float: + return tally.time_ns / tally.samples if tally.samples else 0.0 + + +def _session_end_wall(result: SessionTiming) -> float: + return result.started_at + result.duration_ns / 1e9 + + +def _key_stats_from_tally(codepoint: int, tally: KeyTally, last_seen: float) -> KeyStats: + return KeyStats( + codepoint=codepoint, + samples=tally.samples, + mean_time_ns=_mean_ns(tally), + error_count=tally.errors, + last_seen=last_seen, + attempt_count=tally.attempts, + ) + + +def _transition_stats_from_tally(key: Bigram, tally: KeyTally, last_seen: float) -> TransitionStats: + return TransitionStats( + prev_cp=key.prev_cp, + next_cp=key.next_cp, + samples=tally.samples, + mean_time_ns=_mean_ns(tally), + error_count=tally.errors, + last_seen=last_seen, + attempt_count=tally.attempts, + ) + + +def session_key_stats(result: SessionTiming, stats: SessionStats) -> dict[int, KeyStats]: + """One session's stored key tallies as `KeyStats`, timestamped at session end.""" + end = _session_end_wall(result) + return {cp: _key_stats_from_tally(cp, t, end) for cp, t in stats.keys.items()} + + +def session_transition_stats( + result: SessionTiming, stats: SessionStats +) -> dict[Bigram, TransitionStats]: + """One session's stored bigram tallies as `TransitionStats`, timestamped at + session end. Same-key pairs are filtered here, and only here, because the + tallies may come from stored data rather than from `tally_session`.""" + end = _session_end_wall(result) + return without_same_key_transitions( + {key: _transition_stats_from_tally(key, t, end) for key, t in stats.transitions.items()} + ) def session_recency_weights( @@ -192,7 +305,7 @@ def _combine_transition_maps_weighted( def combine_sessions( - sessions: Sequence[tuple[SessionTiming, Iterable[Keystroke]]], + sessions: Sequence[tuple[SessionTiming, SessionStats]], *, recency_decay: float = SESSION_RECENCY_DECAY, ) -> LayoutAggregates: @@ -204,12 +317,9 @@ def combine_sessions( """ if not sessions: return LayoutAggregates(keys={}, transitions={}) - materialized = [(header, list(keystrokes)) for header, keystrokes in sessions] - weights = session_recency_weights(len(materialized), decay=recency_decay) - key_maps = [aggregate_session(header, keystrokes) for header, keystrokes in materialized] - transition_maps = [ - aggregate_transitions(header, keystrokes) for header, keystrokes in materialized - ] + weights = session_recency_weights(len(sessions), decay=recency_decay) + key_maps = [session_key_stats(header, stats) for header, stats in sessions] + transition_maps = [session_transition_stats(header, stats) for header, stats in sessions] return LayoutAggregates( keys=_combine_key_maps_weighted(key_maps, weights), transitions=_combine_transition_maps_weighted(transition_maps, weights), @@ -242,73 +352,3 @@ def without_same_key_transitions( for key, stats in transitions.items() if not is_same_key_transition(stats.prev_cp, stats.next_cp) } - - -def per_transition_deltas(keystrokes: Iterable[Keystroke]) -> dict[Bigram, list[int]]: - """Inter-keystroke deltas per prev→next target pair for correct keystrokes.""" - deltas: dict[Bigram, list[int]] = {} - last_correct_cp: int | None = None - last_correct_t_ns: int | None = None - - for k in keystrokes: - if not k.correct: - continue - if ( - last_correct_cp is not None - and last_correct_t_ns is not None - and not is_same_key_transition(last_correct_cp, k.codepoint) - ): - delta = k.t_ns - last_correct_t_ns - if delta > 0: - deltas.setdefault(Bigram(last_correct_cp, k.codepoint), []).append(delta) - last_correct_cp = k.codepoint - last_correct_t_ns = k.t_ns - - return deltas - - -def aggregate_transitions( - result: SessionTiming, - keystrokes: Iterable[Keystroke], -) -> dict[Bigram, TransitionStats]: - partial: dict[Bigram, _Partial] = {} - all_keystrokes = list(keystrokes) - last_correct_cp: int | None = None - - for i, k in enumerate(all_keystrokes): - if not k.correct: - if i > 0: - prev_cp = all_keystrokes[i - 1].codepoint - if not is_same_key_transition(prev_cp, k.codepoint): - key = Bigram(prev_cp, k.codepoint) - entry = partial.setdefault(key, _Partial()) - entry.attempt_count += 1 - entry.error_count += 1 - continue - if last_correct_cp is not None and not is_same_key_transition( - last_correct_cp, - k.codepoint, - ): - key = Bigram(last_correct_cp, k.codepoint) - partial.setdefault(key, _Partial()).attempt_count += 1 - last_correct_cp = k.codepoint - - for key, samples in per_transition_deltas(all_keystrokes).items(): - partial.setdefault(key, _Partial()).time_samples.extend(samples) - - session_end_wall = result.started_at + result.duration_ns / 1e9 - - return without_same_key_transitions( - { - key: TransitionStats( - prev_cp=key.prev_cp, - next_cp=key.next_cp, - samples=len(p.time_samples), - mean_time_ns=(sum(p.time_samples) / len(p.time_samples)) if p.time_samples else 0.0, - error_count=p.error_count, - last_seen=session_end_wall, - attempt_count=p.attempt_count, - ) - for key, p in partial.items() - } - ) diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index 5e02861..c143fc8 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -37,6 +37,58 @@ def _empty_key_confidence() -> dict[int, float]: return {} +class Bigram(NamedTuple): + """A directed pair of codepoints: the key pressed before, then the key + pressed after. The value-type key for `TransitionStats` maps, replacing + the old ad-hoc `chr(prev) + chr(next)` string keys.""" + + prev_cp: int + next_cp: int + + def chars(self) -> str: + """Display form, e.g. `Bigram(ord("a"), ord("b")).chars() == "ab"`.""" + return chr(self.prev_cp) + chr(self.next_cp) + + +class KeyTally(NamedTuple): + """Per-session reduction of one key (or one bigram): everything the + confidence math needs from the raw keystroke stream, as exact integers. + + `time_ns` is the *sum* of inter-keystroke deltas over `samples` correct + presses (not the mean) so per-session tallies merge without rounding.""" + + samples: int + time_ns: int + errors: int + attempts: int + + +def _empty_key_tallies() -> dict[int, KeyTally]: + return {} + + +def _empty_transition_tallies() -> dict[Bigram, KeyTally]: + return {} + + +@dataclass(frozen=True, slots=True) +class SessionStats: + """What a finished session contributes to a layout's aggregates. This is + what gets persisted instead of the raw keystroke log — `combine_sessions` + only ever needed these per-key/per-bigram tallies, never the sequence.""" + + keys: Mapping[int, KeyTally] = field(default_factory=_empty_key_tallies) + transitions: Mapping[Bigram, KeyTally] = field(default_factory=_empty_transition_tallies) + + def __post_init__(self) -> None: + object.__setattr__(self, "keys", MappingProxyType(dict(self.keys))) + object.__setattr__(self, "transitions", MappingProxyType(dict(self.transitions))) + + @property + def is_empty(self) -> bool: + return not self.keys and not self.transitions + + @dataclass(frozen=True, slots=True) class SessionResult: schema_version: int @@ -56,6 +108,9 @@ class SessionResult: target_speed_cpm: int = 0 # goal active at finish; 0 = legacy sessions generated_min_len: int = GENERATED_WORD_MIN_LEN # word bounds at finish; legacy default generated_max_len: int = GENERATED_WORD_MAX_LEN + # Per-key/bigram tallies (schema 5+). Empty for sessions older than the + # stats retention window (see domain.retention) — their history row stays. + stats: SessionStats = field(default_factory=SessionStats) def __post_init__(self) -> None: # Freezing the dataclass only blocks attribute rebinding — wrap the @@ -76,19 +131,6 @@ class KeyStats: attempt_count: int = 0 -class Bigram(NamedTuple): - """A directed pair of codepoints: the key pressed before, then the key - pressed after. The value-type key for `TransitionStats` maps, replacing - the old ad-hoc `chr(prev) + chr(next)` string keys.""" - - prev_cp: int - next_cp: int - - def chars(self) -> str: - """Display form, e.g. `Bigram(ord("a"), ord("b")).chars() == "ab"`.""" - return chr(self.prev_cp) + chr(self.next_cp) - - @dataclass(frozen=True, slots=True) class TransitionStats: prev_cp: int diff --git a/src/keystrike/domain/null_adapters.py b/src/keystrike/domain/null_adapters.py index a6de197..4cc9cdd 100644 --- a/src/keystrike/domain/null_adapters.py +++ b/src/keystrike/domain/null_adapters.py @@ -11,18 +11,10 @@ from collections.abc import Iterable, Iterator from .daily_learn import DailyLearnBudget, compute_daily_learn_budget -from .models import KeyStats, Keystroke, Layout, SessionResult, Settings +from .models import KeyStats, Layout, SessionResult, Settings class NullSessionRepository: - def append_keystroke(self, session_id: str, started_at: float, k: Keystroke) -> None: - pass - - def append_keystrokes( - self, session_id: str, started_at: float, keystrokes: Iterable[Keystroke] - ) -> None: - pass - def save_header(self, header: SessionResult) -> None: pass @@ -32,8 +24,8 @@ def iter_headers(self, layout: str) -> Iterator[SessionResult]: def iter_all_headers(self) -> Iterator[SessionResult]: return iter(()) - def load_keystrokes(self, session_id: str) -> Iterator[Keystroke]: - return iter(()) + def replace_all_headers(self, headers: Iterable[SessionResult]) -> None: + pass class NullStatsRebuilder: diff --git a/src/keystrike/domain/protocols.py b/src/keystrike/domain/protocols.py index 3b581cb..65d8e1d 100644 --- a/src/keystrike/domain/protocols.py +++ b/src/keystrike/domain/protocols.py @@ -6,7 +6,6 @@ from .markov import TransitionTable from .models import ( KeyStats, - Keystroke, Layout, LayoutAggregates, SessionResult, @@ -26,14 +25,15 @@ def new_id(self) -> str: ... class SessionRepository(Protocol): - def append_keystroke(self, session_id: str, started_at: float, k: Keystroke) -> None: ... - def append_keystrokes( - self, session_id: str, started_at: float, keystrokes: Iterable[Keystroke] - ) -> None: ... + """Session history: one header row per finished session, carrying its + `SessionStats`. Raw keystrokes are never persisted.""" + def save_header(self, header: SessionResult) -> None: ... def iter_headers(self, layout: str) -> Iterator[SessionResult]: ... def iter_all_headers(self) -> Iterator[SessionResult]: ... - def load_keystrokes(self, session_id: str) -> Iterator[Keystroke]: ... + def replace_all_headers(self, headers: Iterable[SessionResult]) -> None: + """Rewrite the whole history with `headers` (used by stats retention).""" + ... class SettingsRepository(Protocol): diff --git a/src/keystrike/domain/retention.py b/src/keystrike/domain/retention.py new file mode 100644 index 0000000..c7cdd3e --- /dev/null +++ b/src/keystrike/domain/retention.py @@ -0,0 +1,51 @@ +"""Pure retention rule for per-session stats. + +Only a trailing window of sessions per layout is ever replayed: `combine_sessions` +reads the last `confidence_session_window` sessions, and the per-session trend +lines replay that window once for each of the last `window` sessions — so the +oldest session whose tallies can still matter is `2 * window - 1` back. Every +session older than that keeps its history row (WPM, accuracy, snapshots) but +drops its `SessionStats`. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import replace + +from .models import SessionResult, SessionStats + +_EMPTY_STATS = SessionStats() + + +def stats_retention_count(window: int) -> int: + """How many newest sessions per layout must keep their `SessionStats`.""" + return max(1, 2 * window - 1) + + +def prune_session_stats( + headers: Iterable[SessionResult], + *, + window: int, +) -> tuple[list[SessionResult], int]: + """Drop stats from every session outside its layout's retention window. + + Returns the headers in their original order with the affected ones + replaced, plus how many were changed (0 means nothing needs rewriting). + """ + ordered = list(headers) + keep = stats_retention_count(window) + + by_layout: dict[str, list[int]] = {} + for i, header in enumerate(ordered): + by_layout.setdefault(header.layout, []).append(i) + + changed = 0 + for indices in by_layout.values(): + newest_first = sorted(indices, key=lambda i: ordered[i].started_at, reverse=True) + for i in newest_first[keep:]: + if ordered[i].stats.is_empty: + continue + ordered[i] = replace(ordered[i], stats=_EMPTY_STATS) + changed += 1 + return ordered, changed diff --git a/src/keystrike/domain/sync_merge.py b/src/keystrike/domain/sync_merge.py index b097124..c0a8568 100644 --- a/src/keystrike/domain/sync_merge.py +++ b/src/keystrike/domain/sync_merge.py @@ -69,11 +69,10 @@ def index_layouts(entries: Sequence[SessionIndexEntry]) -> set[str]: @dataclass(frozen=True, slots=True) class SessionImportPlan: - """One remote session that should be imported locally.""" + """One remote session that should be imported locally. The index row is + self-contained (header + stats), so importing means appending the line.""" session_id: str - month: str - filename: str index_line: str @@ -86,9 +85,7 @@ def plan_missing_sessions( """Decide which remote sessions are missing locally and need importing. `remote_entries`/`remote_lines` are the parsed and matching raw (stripped) - text of each line in the remote index, in file order. This does not check - whether the session's `.jsonl` file actually exists on disk — the - infrastructure executor skips any plan entry whose source file is missing. + text of each line in the remote index, in file order. """ seen = set(local_session_ids) plans: list[SessionImportPlan] = [] @@ -96,16 +93,7 @@ def plan_missing_sessions( session_id = entry.session_id if session_id in seen: continue - started_at = entry.started_at - month = dt.datetime.fromtimestamp(started_at, tz=dt.UTC).strftime("%Y-%m") - plans.append( - SessionImportPlan( - session_id=session_id, - month=month, - filename=f"{session_id}.jsonl", - index_line=line, - ), - ) + plans.append(SessionImportPlan(session_id=session_id, index_line=line)) seen.add(session_id) return plans diff --git a/src/keystrike/infrastructure/session_migration.py b/src/keystrike/infrastructure/session_migration.py new file mode 100644 index 0000000..ad3ad1f --- /dev/null +++ b/src/keystrike/infrastructure/session_migration.py @@ -0,0 +1,123 @@ +"""One-time migration of session history from schema ≤4 to schema 5. + +Schema ≤4 kept every keystroke in `sessions//.jsonl` next to a +bare header row in `sessions/index.jsonl`. Schema 5 folds each session's +keystrokes into per-key/per-bigram tallies stored on the index row itself +(`SessionResult.stats`), so the keystroke logs are read once here, embedded, +and deleted. Also used by git sync to upgrade legacy rows arriving from a +remote clone that still carries keystroke files. +""" + +from __future__ import annotations + +import datetime as dt +import json +import shutil +from collections.abc import Iterator +from pathlib import Path +from typing import cast + +from keystrike.domain.aggregate import tally_session +from keystrike.domain.models import Keystroke +from keystrike.domain.sync_merge import validate_session_id + +from .atomic_write import atomic_write_text +from .json_coerce import require_float, require_int, require_str +from .paths import Paths +from .session_repo_jsonl import stats_to_row + +STATS_SCHEMA_VERSION = 5 + + +def _month_dir(started_at: float) -> str: + return dt.datetime.fromtimestamp(started_at, tz=dt.UTC).strftime("%Y-%m") + + +def read_legacy_keystrokes(file: Path) -> Iterator[Keystroke]: + with file.open("r", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line: + continue + try: + d = json.loads(line) + keystroke = Keystroke( + codepoint=d["codepoint"], + typed=d["typed"], + t_ns=d["t_ns"], + correct=d["correct"], + ) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + # Skip a corrupt/truncated keystroke row (e.g. a crash mid-append) + # rather than dropping the whole session's contribution. + continue + yield keystroke + + +def is_legacy_row(row: dict[str, object]) -> bool: + return require_int(row, "schema_version", 0) < STATS_SCHEMA_VERSION + + +def embed_legacy_stats(row: dict[str, object], sessions_dir: Path) -> dict[str, object]: + """Schema-5 copy of a legacy index row, with tallies folded in from its + keystroke file under `sessions_dir` (empty stats when the file is gone). + Rows already at schema 5 are returned unchanged.""" + if not is_legacy_row(row): + return row + session_id = require_str(row, "session_id") + validate_session_id(session_id) # Reject path-traversal attempts + file = sessions_dir / _month_dir(require_float(row, "started_at")) / f"{session_id}.jsonl" + upgraded = dict(row) + upgraded["schema_version"] = STATS_SCHEMA_VERSION + upgraded.pop("stats", None) + if file.is_file(): + stats = tally_session(read_legacy_keystrokes(file)) + if not stats.is_empty: + upgraded["stats"] = stats_to_row(stats) + return upgraded + + +def upgrade_index_line(line: str, sessions_dir: Path) -> str: + """`embed_legacy_stats` for one raw index line; unparseable lines pass through.""" + try: + row = json.loads(line) + except json.JSONDecodeError: + return line + if not isinstance(row, dict): + return line + try: + return json.dumps(embed_legacy_stats(cast("dict[str, object]", row), sessions_dir)) + except (KeyError, TypeError, ValueError): + return line + + +def migrate_keystroke_files(paths: Paths) -> int: + """Upgrade every legacy index row and delete the per-session keystroke + logs. Returns the number of rows upgraded; a no-op on an already-migrated + store (no month directories, all rows at schema 5).""" + sessions_dir = paths.sessions_dir + index = paths.sessions_index + if not sessions_dir.is_dir() or not index.is_file(): + # Without an index there is nothing to fold the logs into, so leave + # any keystroke files alone rather than orphaning their data. + return 0 + month_dirs = [p for p in sessions_dir.iterdir() if p.is_dir()] + + upgraded = 0 + out_lines: list[str] = [] + with index.open("r", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line: + continue + new_line = upgrade_index_line(line, sessions_dir) + if new_line != line: + upgraded += 1 + out_lines.append(new_line) + if upgraded: + atomic_write_text(index, "".join(f"{line}\n" for line in out_lines)) + + # Only after the index is safely rewritten: the logs are now redundant. + for month_dir in month_dirs: + shutil.rmtree(month_dir, ignore_errors=True) + return upgraded diff --git a/src/keystrike/infrastructure/session_repo_jsonl.py b/src/keystrike/infrastructure/session_repo_jsonl.py index d3a9b3e..94f88d3 100644 --- a/src/keystrike/infrastructure/session_repo_jsonl.py +++ b/src/keystrike/infrastructure/session_repo_jsonl.py @@ -1,73 +1,45 @@ -"""JSONL-backed SessionRepository. One file per session (rows = keystrokes), -plus a monthly-sharded directory and a top-level index file for headers.""" +"""JSONL-backed SessionRepository: `sessions/index.jsonl`, one row per finished +session — the header fields plus the session's per-key/per-bigram tallies +(`SessionResult.stats`). Raw keystrokes are not stored; schema ≤4 keystroke +logs are folded in once by `session_migration`.""" from __future__ import annotations import dataclasses -import datetime as dt import json from collections.abc import Iterable, Iterator -from pathlib import Path from typing import cast from keystrike.domain.enums import Mode, migrate_legacy_mode from keystrike.domain.models import ( GENERATED_WORD_MAX_LEN, GENERATED_WORD_MIN_LEN, - Keystroke, + Bigram, + KeyTally, SessionResult, + SessionStats, ) from keystrike.domain.sync_merge import validate_session_id +from .atomic_write import atomic_write_text from .json_coerce import coerce_float, coerce_int, require_float, require_int, require_str from .paths import Paths - -def _month_dir(started_at: float) -> str: - return dt.datetime.fromtimestamp(started_at, tz=dt.UTC).strftime("%Y-%m") - - -def _session_file(paths: Paths, header: SessionResult) -> Path: - return paths.sessions_dir / _month_dir(header.started_at) / f"{header.session_id}.jsonl" +# Row layout of `SessionResult.stats` (schema 5): compact positional tallies, +# `[samples, time_ns, errors, attempts]`, keyed by codepoint for keys and by +# "prev,next" codepoints for bigrams. Omitted entirely when the stats are empty. +_STATS_KEY = "stats" +_TALLY_FIELDS = 4 class JsonlSessionRepository: def __init__(self, paths: Paths) -> None: self._paths = paths - # In-memory index maps session_id → header so load_keystrokes can locate - # the file without re-reading index.jsonl. Populated by save_header and - # by _ensure_index (called explicitly, not as an incidental read side effect). - self._session_index: dict[str, SessionResult] = {} - self._indexed = False - - def append_keystroke(self, session_id: str, started_at: float, k: Keystroke) -> None: - self.append_keystrokes(session_id, started_at, (k,)) - - def append_keystrokes( - self, session_id: str, started_at: float, keystrokes: Iterable[Keystroke] - ) -> None: - validate_session_id(session_id) # Reject path-traversal attempts - file = self._paths.sessions_dir / _month_dir(started_at) / f"{session_id}.jsonl" - file.parent.mkdir(parents=True, exist_ok=True) - with file.open("a", encoding="utf-8") as fh: - for k in keystrokes: - fh.write( - json.dumps( - { - "codepoint": k.codepoint, - "typed": k.typed, - "t_ns": k.t_ns, - "correct": k.correct, - } - ) - ) - fh.write("\n") def save_header(self, header: SessionResult) -> None: - self._session_index[header.session_id] = header self._paths.sessions_index.parent.mkdir(parents=True, exist_ok=True) with self._paths.sessions_index.open("a", encoding="utf-8") as fh: - fh.write(json.dumps(_header_to_dict(header))) + fh.write(json.dumps(header_to_row(header))) fh.write("\n") def iter_headers(self, layout: str) -> Iterator[SessionResult]: @@ -76,10 +48,6 @@ def iter_headers(self, layout: str) -> Iterator[SessionResult]: yield header def iter_all_headers(self) -> Iterator[SessionResult]: - yield from self._parsed_headers() - self._indexed = True - - def _parsed_headers(self) -> Iterator[SessionResult]: if not self._paths.sessions_index.exists(): return with self._paths.sessions_index.open("r", encoding="utf-8") as fh: @@ -88,77 +56,88 @@ def _parsed_headers(self) -> Iterator[SessionResult]: if not line: continue try: - header = _header_from_dict(json.loads(line)) + header = header_from_row(json.loads(line)) except (json.JSONDecodeError, KeyError, TypeError, ValueError): # Skip a corrupt/truncated index row rather than aborting # every session recorded after it. continue - self._session_index[header.session_id] = header yield header - def _ensure_index(self) -> None: - """Populate the session-id → header index from disk, once.""" - if self._indexed: - return - for _ in self._parsed_headers(): - pass - self._indexed = True - - def load_keystrokes(self, session_id: str) -> Iterator[Keystroke]: - validate_session_id(session_id) # Reject path-traversal attempts - self._ensure_index() - header = self._session_index.get(session_id) - if header is None: - if not self._paths.sessions_dir.exists(): - return - # Fall back: scan all month directories for the ulid. - for month_dir in self._paths.sessions_dir.iterdir(): - if not month_dir.is_dir(): - continue - candidate = month_dir / f"{session_id}.jsonl" - if candidate.exists(): - yield from _read_keystrokes(candidate) - return - return - file = _session_file(self._paths, header) - if not file.exists(): - return - yield from _read_keystrokes(file) - - -def _read_keystrokes(file: Path) -> Iterator[Keystroke]: - with file.open("r", encoding="utf-8") as fh: - for raw in fh: - line = raw.strip() - if not line: - continue - try: - d = json.loads(line) - keystroke = Keystroke( - codepoint=d["codepoint"], - typed=d["typed"], - t_ns=d["t_ns"], - correct=d["correct"], - ) - except (json.JSONDecodeError, KeyError, TypeError, ValueError): - # Skip a corrupt/truncated keystroke row (e.g. a crash mid-append) - # rather than aborting the whole session's keystroke stream. - continue - yield keystroke - - -def _header_to_dict(h: SessionResult) -> dict[str, object]: + def replace_all_headers(self, headers: Iterable[SessionResult]) -> None: + text = "".join(json.dumps(header_to_row(h)) + "\n" for h in headers) + atomic_write_text(self._paths.sessions_index, text) + + +def _tally_to_row(t: KeyTally) -> list[int]: + return list(t) # NamedTuple field order is the row order + + +def stats_to_row(stats: SessionStats) -> dict[str, object]: + return { + "keys": {str(cp): _tally_to_row(t) for cp, t in stats.keys.items()}, + "pairs": { + f"{key.prev_cp},{key.next_cp}": _tally_to_row(t) for key, t in stats.transitions.items() + }, + } + + +def _tally_from_row(raw: object, *, label: str) -> KeyTally: + if not isinstance(raw, (list, tuple)) or len(cast("list[object]", raw)) != _TALLY_FIELDS: + raise TypeError(f"expected {_TALLY_FIELDS}-item tally for {label}, got {raw!r}") + items = [coerce_int(v, label=label) for v in cast("list[object]", raw)] + return KeyTally(samples=items[0], time_ns=items[1], errors=items[2], attempts=items[3]) + + +def _bigram_from_row(raw: str) -> Bigram: + prev_s, sep, next_s = raw.partition(",") + if not sep: + raise ValueError(f"expected 'prev,next' bigram key, got {raw!r}") + return Bigram(coerce_int(prev_s, label="bigram prev"), coerce_int(next_s, label="bigram next")) + + +def _require_dict(data: dict[str, object], key: str) -> dict[str, object]: + raw = data.get(key, {}) + if not isinstance(raw, dict): + raise TypeError(f"expected object for stats.{key}, got {type(raw).__name__}") + return cast("dict[str, object]", raw) + + +def stats_from_row(raw: object) -> SessionStats: + if raw is None: + return SessionStats() + if not isinstance(raw, dict): + raise TypeError(f"expected object for {_STATS_KEY!r}, got {type(raw).__name__}") + data = cast("dict[str, object]", raw) + keys_raw = _require_dict(data, "keys") + pairs_raw = _require_dict(data, "pairs") + return SessionStats( + keys={ + coerce_int(cp, label="stats key"): _tally_from_row(t, label=f"key {cp}") + for cp, t in keys_raw.items() + }, + transitions={ + _bigram_from_row(pair): _tally_from_row(t, label=f"pair {pair}") + for pair, t in pairs_raw.items() + }, + ) + + +def header_to_row(h: SessionResult) -> dict[str, object]: # A shallow field-by-field dict rather than `dataclasses.asdict(h)`: # asdict falls back to `copy.deepcopy` for non-dataclass/list/tuple/dict # values, and deepcopy cannot pickle `key_confidence`'s `mappingproxy` # wrapper. Reflection still derives the field *list* from SessionResult - # (so a new plain-scalar field needs no update here); only the four - # fields below need a real JSON-safety conversion. + # (so a new plain-scalar field needs no update here); only the fields + # below need a real JSON-safety conversion. base: dict[str, object] = {f.name: getattr(h, f.name) for f in dataclasses.fields(h)} base["mode"] = str(h.mode) # Mode is a StrEnum → str base["lesson_alphabet"] = list(h.lesson_alphabet) base["unlocked_keys"] = list(h.unlocked_keys) base["key_confidence"] = {str(k): v for k, v in h.key_confidence.items()} + if h.stats.is_empty: + del base[_STATS_KEY] + else: + base[_STATS_KEY] = stats_to_row(h.stats) return base @@ -188,7 +167,7 @@ def _parse_key_confidence(raw: object) -> dict[int, float]: return out -def _header_from_dict(d: dict[str, object]) -> SessionResult: +def header_from_row(d: dict[str, object]) -> SessionResult: session_id = require_str(d, "session_id") validate_session_id(session_id) # Reject path-traversal attempts return SessionResult( @@ -209,4 +188,5 @@ def _header_from_dict(d: dict[str, object]) -> SessionResult: target_speed_cpm=require_int(d, "target_speed_cpm", 0), generated_min_len=require_int(d, "generated_min_len", GENERATED_WORD_MIN_LEN), generated_max_len=require_int(d, "generated_max_len", GENERATED_WORD_MAX_LEN), + stats=stats_from_row(d.get(_STATS_KEY)), ) diff --git a/src/keystrike/infrastructure/sync_git.py b/src/keystrike/infrastructure/sync_git.py index a34f5aa..eb3aa90 100644 --- a/src/keystrike/infrastructure/sync_git.py +++ b/src/keystrike/infrastructure/sync_git.py @@ -181,10 +181,9 @@ def _merge_from_clone(self) -> int: remote_path=self.clone_settings, ) imported = import_missing_sessions( - local_sessions_dir=self._paths.sessions_dir, - remote_sessions_dir=self.clone_sessions, local_index=self._paths.sessions_index, remote_index=self.clone_sessions_index, + remote_sessions_dir=self.clone_sessions, ) copy_layouts_missing( local_layouts=self._paths.layouts_dir, @@ -198,10 +197,9 @@ def _merge_to_clone(self) -> None: remote_path=self.clone_settings, ) import_missing_sessions( - local_sessions_dir=self.clone_sessions, - remote_sessions_dir=self._paths.sessions_dir, local_index=self.clone_sessions_index, remote_index=self._paths.sessions_index, + remote_sessions_dir=self._paths.sessions_dir, ) copy_layouts_to_remote( local_layouts=self._paths.layouts_dir, diff --git a/src/keystrike/infrastructure/sync_merge_io.py b/src/keystrike/infrastructure/sync_merge_io.py index 977c8fc..1b908b8 100644 --- a/src/keystrike/infrastructure/sync_merge_io.py +++ b/src/keystrike/infrastructure/sync_merge_io.py @@ -21,6 +21,8 @@ settings_epoch_from_toml, ) +from .session_migration import upgrade_index_line + def _read_index(path: Path) -> tuple[list[SessionIndexEntry], list[str]]: """Load a sessions index file into parsed entries + matching raw lines. @@ -57,12 +59,16 @@ def iter_layouts_from_index(index_path: Path) -> set[str]: def import_missing_sessions( *, - local_sessions_dir: Path, - remote_sessions_dir: Path, local_index: Path, remote_index: Path, + remote_sessions_dir: Path, ) -> list[str]: - """Copy session files and append index entries present remotely but not locally.""" + """Append index rows present remotely but not locally. + + A schema ≤4 remote row is upgraded on the way in: its tallies are folded + from the remote's keystroke file under `remote_sessions_dir` when it still + exists (see `session_migration`). + """ local_entries, _ = _read_index(local_index) remote_entries, remote_lines = _read_index(remote_index) plans = plan_missing_sessions( @@ -72,19 +78,13 @@ def import_missing_sessions( ) if not plans: return [] - local_sessions_dir.mkdir(parents=True, exist_ok=True) + local_index.parent.mkdir(parents=True, exist_ok=True) imported: list[str] = [] - for plan in plans: - remote_file = remote_sessions_dir / plan.month / plan.filename - if not remote_file.is_file(): - continue - dest_dir = local_sessions_dir / plan.month - dest_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(remote_file, dest_dir / plan.filename) - with local_index.open("a", encoding="utf-8") as out: - out.write(plan.index_line) + with local_index.open("a", encoding="utf-8") as out: + for plan in plans: + out.write(upgrade_index_line(plan.index_line, remote_sessions_dir)) out.write("\n") - imported.append(plan.session_id) + imported.append(plan.session_id) return imported diff --git a/tests/application/test_prepare_practice.py b/tests/application/test_prepare_practice.py index abf386c..4f184ec 100644 --- a/tests/application/test_prepare_practice.py +++ b/tests/application/test_prepare_practice.py @@ -72,7 +72,7 @@ def test_prepare_ensures_transitions_before_lesson(): settings_repo = FakeSettingsRepository(Settings(alphabet_size=2)) layout_repo = FakeLayoutRepository(dict(BUNDLED_LAYOUTS)) cache = FakeAggregatesCache() - session_repo.save_header( + session_repo.save_with_keystrokes( SessionResult( schema_version=3, session_id="s1", @@ -85,13 +85,13 @@ def test_prepare_ensures_transitions_before_lesson(): total_keystrokes=4, correct_keystrokes=3, ), + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("s"), typed=ord("s"), t_ns=100_000_000, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=500_000_000, correct=True), + Keystroke(codepoint=ord("s"), typed=ord("x"), t_ns=600_000_000, correct=False), + ], ) - session_repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke(codepoint=ord("s"), typed=ord("s"), t_ns=100_000_000, correct=True), - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=500_000_000, correct=True), - Keystroke(codepoint=ord("s"), typed=ord("x"), t_ns=600_000_000, correct=False), - ] rebuild = RebuildAggregates(repo=session_repo, cache=cache, settings_repo=settings_repo) ensure = GetOrRebuildAggregates(repo=session_repo, cache=cache, rebuild=rebuild) prepare = PreparePracticeSession( diff --git a/tests/application/test_prune_session_stats.py b/tests/application/test_prune_session_stats.py new file mode 100644 index 0000000..6705a12 --- /dev/null +++ b/tests/application/test_prune_session_stats.py @@ -0,0 +1,42 @@ +from dataclasses import replace + +from keystrike.application.stats_use_cases import PruneSessionStats +from keystrike.domain.enums import Mode +from keystrike.domain.models import KeyTally, SessionResult, SessionStats, Settings +from keystrike.domain.retention import stats_retention_count +from tests.fakes import FakeSessionRepository, FakeSettingsRepository + + +def _header(session_id: str, started_at: float) -> SessionResult: + return SessionResult( + schema_version=5, + session_id=session_id, + started_at=started_at, + duration_ns=1_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(ord("a"),), + focus_key=None, + total_keystrokes=1, + correct_keystrokes=1, + stats=SessionStats(keys={ord("a"): KeyTally(1, 100_000_000, 0, 1)}), + ) + + +def test_prune_uses_settings_window_and_rewrites_only_when_needed(): + settings_repo = FakeSettingsRepository(Settings(confidence_session_window=2)) + keep = stats_retention_count(2) + repo = FakeSessionRepository() + for i in range(keep + 2): + repo.save_header(_header(f"s{i}", started_at=float(i))) + original = list(repo.headers) + + prune = PruneSessionStats(repo=repo, settings_repo=settings_repo) + + assert prune() == 2 + assert [h.stats.is_empty for h in repo.headers] == [True, True] + [False] * keep + assert repo.headers[0] == replace(original[0], stats=SessionStats()) + + rewritten = list(repo.headers) + assert prune() == 0 + assert repo.headers == rewritten diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index 908984f..5c946d6 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -138,8 +138,11 @@ def test_record_keystroke_backspace_convenience_method(clock, id_gen): def test_repo_receives_keystrokes(clock, id_gen, session_repo): session, _ = _drive("hi", "hi", clock, id_gen, repo=session_repo) - assert len(session_repo.keystrokes[session.id]) == 2 assert len(session_repo.headers) == 1 + saved = session_repo.headers[0] + assert saved.session_id == session.id + assert saved.stats.keys[ord("h")].attempts == 1 + assert saved.stats.keys[ord("i")].attempts == 1 def test_in_progress_keystrokes_not_persisted(clock, id_gen, session_repo): @@ -148,7 +151,6 @@ def test_in_progress_keystrokes_not_persisted(clock, id_gen, session_repo): session = start("ab", layout="qwerty", mode=Mode.ADAPTIVE) clock.advance(100_000_000) record(session, "a") - assert session_repo.keystrokes == {} assert session_repo.headers == [] @@ -159,7 +161,6 @@ def test_aborted_session_not_persisted(clock, id_gen, session_repo): clock.advance(100_000_000) record(session, "a") AbortSession()(session) - assert session_repo.keystrokes == {} assert session_repo.headers == [] @@ -650,9 +651,9 @@ def test_finish_session_persists_key_confidence(clock, id_gen): {}, target, ) - assert result.schema_version == 4 + assert result.schema_version == 5 assert set(result.key_confidence.keys()) == set(expected_unlocked) - stats = combine_sessions([(result, session.keystrokes)]).keys + stats = combine_sessions([(result, result.stats)]).keys for cp in expected_unlocked: assert result.key_confidence[cp] == confidence_of(cp, stats, target) assert repo.headers[0].key_confidence == result.key_confidence @@ -686,8 +687,7 @@ def test_finish_session_key_confidence_uses_confidence_session_window(clock, id_ target = target_ms_per_char(settings.target_speed_cpm) prior = sorted(repo.headers, key=lambda h: h.started_at)[-(CONFIDENCE_SESSION_WINDOW - 1) :] stats = combine_sessions( - [(header, repo.keystrokes[header.session_id]) for header in prior] - + [(result, session.keystrokes)], + [(header, header.stats) for header in prior] + [(result, result.stats)], ).keys assert result.key_confidence[ord("a")] == confidence_of(ord("a"), stats, target) diff --git a/tests/application/test_stats_use_cases.py b/tests/application/test_stats_use_cases.py index ef8e577..b1d8083 100644 --- a/tests/application/test_stats_use_cases.py +++ b/tests/application/test_stats_use_cases.py @@ -47,23 +47,29 @@ def test_rebuild_aggregates_uses_confidence_session_window(): cache = FakeAggregatesCache() h1 = _header("s1", 1_700_000_000.0) - repo.save_header(h1) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), - ] + repo.save_with_keystrokes( + h1, + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), + ], + ) h2 = _header("s2", 1_700_000_100.0) - repo.save_header(h2) - repo.keystrokes["s2"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True), - ] + repo.save_with_keystrokes( + h2, + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True), + ], + ) # Different layout — must not be mixed in. other = _header("s3", 1_700_000_200.0, layout="dvorak") - repo.save_header(other) - repo.keystrokes["s3"] = [ - Keystroke(codepoint=ord("z"), typed=ord("z"), t_ns=0, correct=True), - ] + repo.save_with_keystrokes( + other, + [ + Keystroke(codepoint=ord("z"), typed=ord("z"), t_ns=0, correct=True), + ], + ) rebuild = RebuildAggregates(repo=repo, cache=cache, settings_repo=FakeSettingsRepository()) assert rebuild("qwerty") is None @@ -78,11 +84,13 @@ def test_rebuild_aggregates_uses_confidence_session_window(): def test_rebuild_aggregates_populates_transitions(): repo = FakeSessionRepository() cache = FakeAggregatesCache() - repo.save_header(_header("s1", 1_700_000_000.0)) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), - ] + repo.save_with_keystrokes( + _header("s1", 1_700_000_000.0), + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), + ], + ) RebuildAggregates( repo=repo, @@ -101,14 +109,16 @@ def test_rebuild_aggregates_populates_transitions(): def test_rebuild_aggregates_excludes_same_key_transitions(): repo = FakeSessionRepository() cache = FakeAggregatesCache() - repo.save_header(_header("s1", 1_700_000_000.0)) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), - Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=200_000_000, correct=True), - Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=300_000_000, correct=True), - Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=400_000_000, correct=True), - ] + repo.save_with_keystrokes( + _header("s1", 1_700_000_000.0), + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), + Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=200_000_000, correct=True), + Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=300_000_000, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=400_000_000, correct=True), + ], + ) RebuildAggregates( repo=repo, @@ -170,11 +180,13 @@ def test_ensure_rebuilds_when_transitions_missing_but_sessions_exist(): ), }, ) - repo.save_header(_header("s1", 1_700_000_000.0)) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), - ] + repo.save_with_keystrokes( + _header("s1", 1_700_000_000.0), + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), + ], + ) rebuild = RebuildAggregates( repo=repo, cache=cache, @@ -200,11 +212,13 @@ def test_ensure_skips_rebuild_when_transitions_computed_empty(): ), }, ) - repo.save_header(_header("s1", 1_700_000_000.0)) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), - ] + repo.save_with_keystrokes( + _header("s1", 1_700_000_000.0), + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), + ], + ) rebuild = RebuildAggregates( repo=repo, cache=cache, @@ -226,8 +240,7 @@ def test_rebuild_aggregates_drops_sessions_outside_window(): for i in range(CONFIDENCE_SESSION_WINDOW + 1): session_id = f"s{i}" - repo.save_header(_header(session_id, started_at=float(i))) - repo.keystrokes[session_id] = [ + keystrokes = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke( codepoint=ord("a"), @@ -236,10 +249,12 @@ def test_rebuild_aggregates_drops_sessions_outside_window(): correct=True, ), ] - # Only session 0 typed "z" — outside the window once s{N} exists. - repo.keystrokes["s0"].append( - Keystroke(codepoint=ord("z"), typed=ord("z"), t_ns=300_000_000, correct=True), - ) + if i == 0: + # Only session 0 typed "z" — outside the window once s{N} exists. + keystrokes.append( + Keystroke(codepoint=ord("z"), typed=ord("z"), t_ns=300_000_000, correct=True), + ) + repo.save_with_keystrokes(_header(session_id, started_at=float(i)), keystrokes) RebuildAggregates( repo=repo, @@ -264,8 +279,7 @@ def test_rebuild_aggregates_respects_settings_window(): for i in range(window + 2): session_id = f"s{i}" - repo.save_header(_header(session_id, started_at=float(i))) - repo.keystrokes[session_id] = [ + keystrokes = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke( codepoint=ord("a"), @@ -274,9 +288,11 @@ def test_rebuild_aggregates_respects_settings_window(): correct=True, ), ] - repo.keystrokes["s0"].append( - Keystroke(codepoint=ord("z"), typed=ord("z"), t_ns=100_000_000, correct=True), - ) + if i == 0: + keystrokes.append( + Keystroke(codepoint=ord("z"), typed=ord("z"), t_ns=100_000_000, correct=True), + ) + repo.save_with_keystrokes(_header(session_id, started_at=float(i)), keystrokes) RebuildAggregates(repo=repo, cache=cache, settings_repo=settings_repo)("qwerty") cached = cache.get("qwerty") @@ -303,19 +319,21 @@ def test_get_heatmap_confidence_ratio(): repo = FakeSessionRepository() now = 1_700_000_000.0 header = _header("s1", now) - repo.save_header(header) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - *[ - Keystroke( - codepoint=ord("a"), - typed=ord("a"), - t_ns=i * 200_000_000, - correct=True, - ) - for i in range(1, MIN_CONFIDENCE_ATTEMPTS) + repo.save_with_keystrokes( + header, + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + *[ + Keystroke( + codepoint=ord("a"), + typed=ord("a"), + t_ns=i * 200_000_000, + correct=True, + ) + for i in range(1, MIN_CONFIDENCE_ATTEMPTS) + ], ], - ] + ) RebuildAggregates( repo=repo, cache=cache, @@ -369,26 +387,30 @@ def test_get_key_metric_trends_tracks_speed_and_accuracy(): repo = FakeSessionRepository() settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=300)) # target 200ms - repo.save_header(_header("s1", 1.0)) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke( - codepoint=ord("a"), - typed=ord("a"), - t_ns=400_000_000, - correct=True, - ), - ] - repo.save_header(_header("s2", 2.0)) - repo.keystrokes["s2"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke( - codepoint=ord("a"), - typed=ord("a"), - t_ns=200_000_000, - correct=True, - ), - ] + repo.save_with_keystrokes( + _header("s1", 1.0), + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), + typed=ord("a"), + t_ns=400_000_000, + correct=True, + ), + ], + ) + repo.save_with_keystrokes( + _header("s2", 2.0), + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), + typed=ord("a"), + t_ns=200_000_000, + correct=True, + ), + ], + ) get_trends = GetKeyMetricTrends(repo=repo, settings_repo=settings_repo) speeds, accuracies = get_trends("qwerty", ord("a")) @@ -403,12 +425,14 @@ def test_get_key_metric_trends_reflects_errors_in_accuracy(): repo = FakeSessionRepository() settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=300)) - repo.save_header(_header("s1", 1.0)) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke(codepoint=ord("a"), typed=ord("x"), t_ns=100_000_000, correct=False), - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True), - ] + repo.save_with_keystrokes( + _header("s1", 1.0), + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("x"), t_ns=100_000_000, correct=False), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True), + ], + ) get_trends = GetKeyMetricTrends(repo=repo, settings_repo=settings_repo) _, accuracies = get_trends("qwerty", ord("a")) @@ -433,16 +457,18 @@ def test_get_key_metric_trends_normalizes_speed_to_current_goal(): correct_keystrokes=2, target_speed_cpm=300, ) - repo.save_header(header) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke( - codepoint=ord("a"), - typed=ord("a"), - t_ns=200_000_000, - correct=True, - ), - ] + repo.save_with_keystrokes( + header, + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), + typed=ord("a"), + t_ns=200_000_000, + correct=True, + ), + ], + ) get_trends = GetKeyMetricTrends(repo=repo, settings_repo=settings_repo) speeds, _ = get_trends("qwerty", ord("a"), current_target_speed_cpm=600) @@ -459,16 +485,18 @@ def test_get_key_metric_trends_limits_to_confidence_session_window(): for i in range(window + 5): session_id = f"s{i}" - repo.save_header(_header(session_id, float(i))) - repo.keystrokes[session_id] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke( - codepoint=ord("a"), - typed=ord("a"), - t_ns=200_000_000, - correct=True, - ), - ] + repo.save_with_keystrokes( + _header(session_id, float(i)), + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), + typed=ord("a"), + t_ns=200_000_000, + correct=True, + ), + ], + ) get_trends = GetKeyMetricTrends(repo=repo, settings_repo=settings_repo) speeds, accuracies = get_trends("qwerty", ord("a")) @@ -481,23 +509,25 @@ def test_get_aggregate_metric_trends_aggregates_all_keys(): repo = FakeSessionRepository() settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=300)) - repo.save_header(_header("s1", 1.0)) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke( - codepoint=ord("a"), - typed=ord("a"), - t_ns=400_000_000, - correct=True, - ), - Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=500_000_000, correct=True), - Keystroke( - codepoint=ord("b"), - typed=ord("x"), - t_ns=600_000_000, - correct=False, - ), - ] + repo.save_with_keystrokes( + _header("s1", 1.0), + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), + typed=ord("a"), + t_ns=400_000_000, + correct=True, + ), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=500_000_000, correct=True), + Keystroke( + codepoint=ord("b"), + typed=ord("x"), + t_ns=600_000_000, + correct=False, + ), + ], + ) get_trends = GetAggregateMetricTrends(repo=repo, settings_repo=settings_repo) confidences, speeds, accuracies = get_trends("qwerty") diff --git a/tests/domain/test_aggregate.py b/tests/domain/test_aggregate.py index 4b912e1..25008c3 100644 --- a/tests/domain/test_aggregate.py +++ b/tests/domain/test_aggregate.py @@ -1,12 +1,13 @@ from keystrike.domain.aggregate import ( _combine_key_maps_weighted, _combine_transition_maps_weighted, - aggregate_session, - aggregate_transitions, combine_sessions, per_key_deltas, per_transition_deltas, + session_key_stats, session_recency_weights, + session_transition_stats, + tally_session, ) from keystrike.domain.confidence import ( SESSION_RECENCY_DECAY, @@ -18,6 +19,14 @@ from keystrike.domain.models import Bigram, Keystroke, SessionResult, TransitionStats +def _key_stats(session: SessionResult, keys: list[Keystroke]): + return session_key_stats(session, tally_session(keys)) + + +def _transition_stats(session: SessionResult, keys: list[Keystroke]): + return session_transition_stats(session, tally_session(keys)) + + def _session( session_id: str = "s1", started_at: float = 1000.0, @@ -44,7 +53,7 @@ def test_aggregate_counts_attempts_per_key(): Keystroke(codepoint=ord("a"), typed=ord("x"), t_ns=50_000_000, correct=False), Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), ] - stats = aggregate_session(_session(), keys) + stats = _key_stats(_session(), keys) assert stats[ord("a")].attempt_count == 3 assert stats[ord("a")].samples == 1 @@ -56,7 +65,7 @@ def test_aggregate_single_session_mean_time(): Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=200_000_000, correct=True), ] - stats = aggregate_session(_session(), keys) + stats = _key_stats(_session(), keys) ks = stats[ord("a")] assert ks.samples == 2 # 3 correct → 2 intervals assert abs(ks.mean_time_ns - 100_000_000) < 1 @@ -70,7 +79,7 @@ def test_aggregate_counts_errors_per_target(): Keystroke(codepoint=ord("b"), typed=ord("x"), t_ns=50_000_000, correct=False), Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), ] - stats = aggregate_session(_session(), keys) + stats = _key_stats(_session(), keys) assert stats[ord("b")].error_count == 2 assert stats[ord("b")].samples == 0 # no interval measured (single correct) @@ -78,7 +87,7 @@ def test_aggregate_counts_errors_per_target(): def test_aggregate_last_seen_matches_session_end(): keys = [Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True)] s = _session(started_at=1000.0, duration_ns=5_000_000_000) - stats = aggregate_session(s, keys) + stats = _key_stats(s, keys) assert stats[ord("a")].last_seen == 1005.0 @@ -136,7 +145,7 @@ def test_combine_sessions_favors_recent_session(): Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=300_000_000, correct=True), ] - out = combine_sessions([(s1, keys1), (s2, keys2)]) + out = combine_sessions([(s1, tally_session(keys1)), (s2, tally_session(keys2))]) w_old, w_new = session_recency_weights(2) expected = (100_000_000 * w_old + 300_000_000 * w_new) / (w_old + w_new) assert abs(out.keys[ord("a")].mean_time_ns - expected) < 1 @@ -153,7 +162,7 @@ def test_combine_sessions_weights_recent_attempts_more(): keys2 = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), ] - out = combine_sessions([(s1, keys1), (s2, keys2)]) + out = combine_sessions([(s1, tally_session(keys1)), (s2, tally_session(keys2))]) w_old, w_new = session_recency_weights(2) expected_attempts = round(2 * w_old + 1 * w_new) assert out.keys[ord("a")].attempt_count == expected_attempts @@ -174,11 +183,11 @@ def test_combine_sessions_recent_errors_weigh_more_on_confidence(): Keystroke(codepoint=ord("a"), typed=ord("x"), t_ns=50_000_000, correct=False), Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=150_000_000, correct=True), ] - stats = combine_sessions([(s1, keys1), (s2, keys2)]).keys + stats = combine_sessions([(s1, tally_session(keys1)), (s2, tally_session(keys2))]).keys equal_weight = _combine_key_maps_weighted( [ - {ord("a"): aggregate_session(s1, keys1)[ord("a")]}, - {ord("a"): aggregate_session(s2, keys2)[ord("a")]}, + {ord("a"): _key_stats(s1, keys1)[ord("a")]}, + {ord("a"): _key_stats(s2, keys2)[ord("a")]}, ], [1.0, 1.0], ) @@ -196,11 +205,26 @@ def test_combine_sessions_includes_transitions_from_iterators(): Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), ] ) - out = combine_sessions([(_session(), keys)]) + out = combine_sessions([(_session(), tally_session(keys))]) assert out.keys[ord("b")].samples == 1 assert out.transitions[Bigram(ord("a"), ord("b"))].samples == 1 +def test_tally_session_stores_exact_integer_sums(): + keys = [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("b"), typed=ord("x"), t_ns=50_000_000, correct=False), + Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=250_000_000, correct=True), + ] + stats = tally_session(keys) + assert stats.keys[ord("a")] == (1, 150_000_000, 0, 2) # samples, time_ns, errors, attempts + assert stats.keys[ord("b")] == (1, 100_000_000, 1, 2) + assert stats.transitions[Bigram(ord("a"), ord("b"))] == (1, 100_000_000, 1, 2) + assert stats.transitions[Bigram(ord("b"), ord("a"))] == (1, 150_000_000, 0, 1) + assert Bigram(ord("a"), ord("a")) not in stats.transitions + + def test_per_transition_deltas_tracks_prev_to_next_pair(): keys = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), @@ -212,13 +236,13 @@ def test_per_transition_deltas_tracks_prev_to_next_pair(): assert deltas[Bigram(ord("b"), ord("c"))] == [150_000_000] -def test_aggregate_transitions_skips_same_key_pairs(): +def test_tally_transitions_skips_same_key_pairs(): keys = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=100_000_000, correct=True), Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=200_000_000, correct=True), ] - transitions = aggregate_transitions(_session(), keys) + transitions = _transition_stats(_session(), keys) assert Bigram(ord("a"), ord("a")) not in transitions assert Bigram(ord("a"), ord("b")) in transitions @@ -234,25 +258,25 @@ def test_per_transition_deltas_skips_same_key_pairs(): assert deltas[Bigram(ord("a"), ord("b"))] == [150_000_000] -def test_aggregate_transitions_attributes_errors_to_pair(): +def test_tally_transitions_attributes_errors_to_pair(): keys = [ Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), Keystroke(codepoint=ord("b"), typed=ord("x"), t_ns=50_000_000, correct=False), Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100_000_000, correct=True), ] - transitions = aggregate_transitions(_session(), keys) + transitions = _transition_stats(_session(), keys) ab = transitions[Bigram(ord("a"), ord("b"))] assert ab.error_count == 1 assert ab.samples == 1 assert abs(ab.mean_time_ns - 100_000_000) < 1 -def test_aggregate_transitions_skips_error_on_first_keystroke(): +def test_tally_transitions_skips_error_on_first_keystroke(): keys = [ Keystroke(codepoint=ord("a"), typed=ord("x"), t_ns=0, correct=False), Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=50_000_000, correct=True), ] - transitions = aggregate_transitions(_session(), keys) + transitions = _transition_stats(_session(), keys) assert Bigram(ord("a"), ord("a")) not in transitions assert transitions == {} @@ -279,7 +303,9 @@ def test_combine_sessions_keeps_transition_samples_when_weight_rounds_down(): keys3 = [ Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=0, correct=True), ] - out = combine_sessions([(s1, keys1), (s2, keys2), (s3, keys3)]) + out = combine_sessions( + [(s1, tally_session(keys1)), (s2, tally_session(keys2)), (s3, tally_session(keys3))] + ) eo = out.transitions[Bigram(ord("e"), ord("o"))] assert eo.samples > 0 assert eo.mean_time_ns > 0 diff --git a/tests/domain/test_null_adapters.py b/tests/domain/test_null_adapters.py index f848bc0..a4d9e4b 100644 --- a/tests/domain/test_null_adapters.py +++ b/tests/domain/test_null_adapters.py @@ -1,5 +1,5 @@ from keystrike.domain.enums import Mode -from keystrike.domain.models import Keystroke, SessionResult +from keystrike.domain.models import SessionResult from keystrike.domain.null_adapters import ( NULL_AGGREGATES_ENSURER, NULL_DAILY_LEARN_BUDGET, @@ -10,8 +10,6 @@ def test_null_session_repository_is_inert(): repo = NullSessionRepository() - k = Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True) - repo.append_keystroke("s1", 0.0, k) # must not raise header = SessionResult( schema_version=1, @@ -26,9 +24,10 @@ def test_null_session_repository_is_inert(): correct_keystrokes=0, ) repo.save_header(header) # must not raise + repo.replace_all_headers([header]) # must not raise assert list(repo.iter_headers("qwerty")) == [] - assert list(repo.load_keystrokes("s1")) == [] + assert list(repo.iter_all_headers()) == [] def test_null_stats_rebuilder_returns_none(): diff --git a/tests/domain/test_retention.py b/tests/domain/test_retention.py new file mode 100644 index 0000000..42d1c9e --- /dev/null +++ b/tests/domain/test_retention.py @@ -0,0 +1,76 @@ +from dataclasses import replace + +from keystrike.domain.enums import Mode +from keystrike.domain.models import KeyTally, SessionResult, SessionStats +from keystrike.domain.retention import prune_session_stats, stats_retention_count + + +def _stats() -> SessionStats: + return SessionStats(keys={ord("a"): KeyTally(1, 100_000_000, 0, 1)}) + + +def _header(session_id: str, started_at: float, layout: str = "qwerty") -> SessionResult: + return SessionResult( + schema_version=5, + session_id=session_id, + started_at=started_at, + duration_ns=1_000_000_000, + layout=layout, + mode=Mode.ADAPTIVE, + lesson_alphabet=(ord("a"),), + focus_key=None, + total_keystrokes=1, + correct_keystrokes=1, + stats=_stats(), + ) + + +def test_retention_covers_every_session_the_trend_replay_can_reach(): + # Trends replay a `window`-long window once for each of the last `window` + # sessions, so the oldest session they read is 2*window-1 back. + assert stats_retention_count(10) == 19 + assert stats_retention_count(1) == 1 + assert stats_retention_count(0) == 1 + + +def test_prune_drops_stats_only_outside_per_layout_window(): + headers = [_header(f"q{i}", started_at=float(i)) for i in range(5)] + headers.append(_header("d0", started_at=100.0, layout="dvorak")) + + pruned, changed = prune_session_stats(headers, window=2) # keep 3 per layout + + assert changed == 2 + by_id = {h.session_id: h for h in pruned} + assert by_id["q0"].stats.is_empty + assert by_id["q1"].stats.is_empty + assert not by_id["q2"].stats.is_empty + assert not by_id["q4"].stats.is_empty + assert not by_id["d0"].stats.is_empty + + +def test_prune_keeps_order_and_every_other_field(): + headers = [_header(f"q{i}", started_at=float(i)) for i in range(3)] + pruned, changed = prune_session_stats(headers, window=1) + + assert changed == 2 + assert [h.session_id for h in pruned] == ["q0", "q1", "q2"] + assert pruned[0] == replace(headers[0], stats=SessionStats()) + + +def test_prune_uses_started_at_not_list_order(): + newest_first = [_header("new", started_at=9.0), _header("old", started_at=1.0)] + pruned, changed = prune_session_stats(newest_first, window=1) + + assert changed == 1 + assert not pruned[0].stats.is_empty + assert pruned[1].stats.is_empty + + +def test_prune_reports_zero_when_already_pruned(): + headers = [_header("q0", started_at=0.0), _header("q1", started_at=1.0)] + once, changed_once = prune_session_stats(headers, window=1) + again, changed_again = prune_session_stats(once, window=1) + + assert changed_once == 1 + assert changed_again == 0 + assert again == once diff --git a/tests/domain/test_sync_merge.py b/tests/domain/test_sync_merge.py index 7bb85c6..3d52af7 100644 --- a/tests/domain/test_sync_merge.py +++ b/tests/domain/test_sync_merge.py @@ -48,21 +48,6 @@ def test_plan_missing_sessions_skips_ids_already_local(): assert [p.session_id for p in plans] == [_VALID_ULID_B] assert plans[0].index_line == f'{{"session_id": "{_VALID_ULID_B}"}}' - assert plans[0].filename == f"{_VALID_ULID_B}.jsonl" - - -def test_plan_missing_sessions_derives_month_from_started_at(): - started_at = 1_700_000_000.0 # 2023-11-14 ~ - remote_entries = [_entry(_VALID_ULID_B, started_at=started_at)] - remote_lines = ["line"] - - plans = plan_missing_sessions( - local_session_ids=set(), - remote_entries=remote_entries, - remote_lines=remote_lines, - ) - - assert plans[0].month == "2023-11" def test_plan_missing_sessions_ignores_duplicate_remote_entries(): diff --git a/tests/fakes.py b/tests/fakes.py index 77ad861..f8f8050 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -2,9 +2,10 @@ import datetime as dt from collections.abc import Iterable, Iterator -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from itertools import count +from keystrike.domain.aggregate import tally_session from keystrike.domain.markov import TransitionTable from keystrike.domain.models import ( Keystroke, @@ -50,40 +51,30 @@ def new_id(self) -> str: return f"fake-{next(self._counter):06d}" -def _empty_keystrokes() -> dict[str, list[Keystroke]]: - return {} - - def _empty_headers() -> list[SessionResult]: return [] @dataclass(slots=True) class FakeSessionRepository: - keystrokes: dict[str, list[Keystroke]] = field(default_factory=_empty_keystrokes) headers: list[SessionResult] = field(default_factory=_empty_headers) - def append_keystroke(self, session_id: str, started_at: float, k: Keystroke) -> None: - _ = started_at # not used by the fake, but keeps protocol shape - self.keystrokes.setdefault(session_id, []).append(k) - - def append_keystrokes( - self, session_id: str, started_at: float, keystrokes: Iterable[Keystroke] - ) -> None: - _ = started_at # not used by the fake, but keeps protocol shape - self.keystrokes.setdefault(session_id, []).extend(keystrokes) - def save_header(self, header: SessionResult) -> None: self.headers.append(header) + def save_with_keystrokes(self, header: SessionResult, keystrokes: Iterable[Keystroke]) -> None: + """Test convenience: store `header` with the stats `FinishSession` + would have tallied from `keystrokes`.""" + self.headers.append(replace(header, stats=tally_session(keystrokes))) + def iter_headers(self, layout: str) -> Iterator[SessionResult]: return iter(h for h in self.headers if h.layout == layout) def iter_all_headers(self) -> Iterator[SessionResult]: return iter(self.headers) - def load_keystrokes(self, session_id: str) -> Iterator[Keystroke]: - return iter(self.keystrokes.get(session_id, [])) + def replace_all_headers(self, headers: Iterable[SessionResult]) -> None: + self.headers = list(headers) def _empty_aggregates() -> dict[str, LayoutAggregates]: diff --git a/tests/infrastructure/test_session_migration.py b/tests/infrastructure/test_session_migration.py new file mode 100644 index 0000000..d45f997 --- /dev/null +++ b/tests/infrastructure/test_session_migration.py @@ -0,0 +1,145 @@ +import json +from pathlib import Path + +import pytest + +from keystrike.domain.models import Bigram, KeyTally +from keystrike.infrastructure.paths import Paths +from keystrike.infrastructure.session_migration import ( + embed_legacy_stats, + migrate_keystroke_files, + upgrade_index_line, +) +from keystrike.infrastructure.session_repo_jsonl import JsonlSessionRepository + +_VALID_ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAV" +_VALID_ULID_B = "01ARZ3NDEKTSV4RRFFQ69G5FBV" +_STARTED_AT = 1_700_000_000.0 # 2023-11 +_MONTH = "2023-11" + + +@pytest.fixture +def paths(tmp_path: Path) -> Paths: + p = Paths( + config_dir=tmp_path / "config", + data_dir=tmp_path / "data", + log_dir=tmp_path / "logs", + ) + p.sessions_dir.mkdir(parents=True) + return p + + +def _legacy_row(sid: str, schema_version: int = 4) -> dict[str, object]: + return { + "schema_version": schema_version, + "session_id": sid, + "started_at": _STARTED_AT, + "duration_ns": 1_000_000_000, + "layout": "qwerty", + "mode": "adaptive", + "lesson_alphabet": [97, 98], + "focus_key": None, + "total_keystrokes": 3, + "correct_keystrokes": 2, + } + + +def _write_keystroke_log(paths: Paths, sid: str) -> Path: + month_dir = paths.sessions_dir / _MONTH + month_dir.mkdir(exist_ok=True) + file = month_dir / f"{sid}.jsonl" + file.write_text( + '{"codepoint": 97, "typed": 97, "t_ns": 0, "correct": true}\n' + '{"codepoint": 98, "typed": 120, "t_ns": 50000000, "correct": false}\n' + "{not valid json\n" + '{"codepoint": 98, "typed": 98, "t_ns": 100000000, "correct": true}\n', + encoding="utf-8", + ) + return file + + +def _write_index(paths: Paths, *rows: dict[str, object] | str) -> None: + lines = [r if isinstance(r, str) else json.dumps(r) for r in rows] + paths.sessions_index.write_text("".join(f"{line}\n" for line in lines), encoding="utf-8") + + +def test_embed_legacy_stats_folds_keystroke_file_into_row(paths: Paths) -> None: + _write_keystroke_log(paths, _VALID_ULID_A) + + row = embed_legacy_stats(_legacy_row(_VALID_ULID_A), paths.sessions_dir) + + assert row["schema_version"] == 5 + stats = row["stats"] + assert isinstance(stats, dict) + assert stats["keys"] == {"97": [0, 0, 0, 1], "98": [1, 100_000_000, 1, 2]} + assert stats["pairs"] == {"97,98": [1, 100_000_000, 1, 2]} + + +def test_embed_legacy_stats_without_file_yields_no_stats(paths: Paths) -> None: + row = embed_legacy_stats(_legacy_row(_VALID_ULID_A), paths.sessions_dir) + assert row["schema_version"] == 5 + assert "stats" not in row + + +def test_embed_legacy_stats_leaves_schema_5_rows_alone(paths: Paths) -> None: + row = _legacy_row(_VALID_ULID_A, schema_version=5) + row["stats"] = {"keys": {"97": [1, 1, 0, 1]}, "pairs": {}} + assert embed_legacy_stats(row, paths.sessions_dir) is row + + +def test_upgrade_index_line_passes_unparseable_lines_through(paths: Paths) -> None: + assert upgrade_index_line("{not json", paths.sessions_dir) == "{not json" + assert upgrade_index_line('{"schema_version": 1}', paths.sessions_dir) == ( + '{"schema_version": 1}' + ) + assert upgrade_index_line("[1, 2]", paths.sessions_dir) == "[1, 2]" + + +def test_migrate_rewrites_rows_and_deletes_keystroke_logs(paths: Paths) -> None: + _write_keystroke_log(paths, _VALID_ULID_A) + _write_index(paths, _legacy_row(_VALID_ULID_A), _legacy_row(_VALID_ULID_B)) + + upgraded = migrate_keystroke_files(paths) + + assert upgraded == 2 + assert not (paths.sessions_dir / _MONTH).exists() + headers = {h.session_id: h for h in JsonlSessionRepository(paths).iter_all_headers()} + a, b = headers[_VALID_ULID_A], headers[_VALID_ULID_B] + assert a.schema_version == 5 + assert a.stats.keys[ord("b")] == KeyTally(1, 100_000_000, 1, 2) + assert a.stats.transitions[Bigram(ord("a"), ord("b"))] == KeyTally(1, 100_000_000, 1, 2) + assert b.stats.is_empty # log was already gone: history row survives + # Every other header field is untouched. + assert a.total_keystrokes == 3 + assert a.lesson_alphabet == (ord("a"), ord("b")) + + +def test_migrate_keeps_corrupt_index_lines_verbatim(paths: Paths) -> None: + _write_index(paths, "{not valid json", _legacy_row(_VALID_ULID_A)) + + assert migrate_keystroke_files(paths) == 1 + lines = paths.sessions_index.read_text(encoding="utf-8").splitlines() + assert lines[0] == "{not valid json" + assert json.loads(lines[1])["schema_version"] == 5 + + +def test_migrate_is_a_noop_on_migrated_store(paths: Paths) -> None: + _write_keystroke_log(paths, _VALID_ULID_A) + _write_index(paths, _legacy_row(_VALID_ULID_A)) + migrate_keystroke_files(paths) + before = paths.sessions_index.read_text(encoding="utf-8") + + assert migrate_keystroke_files(paths) == 0 + assert paths.sessions_index.read_text(encoding="utf-8") == before + + +def test_migrate_without_index_leaves_logs_untouched(paths: Paths) -> None: + """No index means nothing to fold the keystrokes into — never delete them.""" + file = _write_keystroke_log(paths, _VALID_ULID_A) + assert migrate_keystroke_files(paths) == 0 + assert file.is_file() + + +def test_migrate_without_sessions_dir_is_noop(tmp_path: Path) -> None: + p = Paths(config_dir=tmp_path / "c", data_dir=tmp_path / "d", log_dir=tmp_path / "l") + assert migrate_keystroke_files(p) == 0 diff --git a/tests/infrastructure/test_session_repo_jsonl.py b/tests/infrastructure/test_session_repo_jsonl.py index dda4739..9c65287 100644 --- a/tests/infrastructure/test_session_repo_jsonl.py +++ b/tests/infrastructure/test_session_repo_jsonl.py @@ -1,9 +1,12 @@ +import json +from dataclasses import replace + import pytest from keystrike.domain.enums import Mode -from keystrike.domain.models import Keystroke, SessionResult +from keystrike.domain.models import Bigram, KeyTally, SessionResult, SessionStats from keystrike.infrastructure.paths import Paths -from keystrike.infrastructure.session_repo_jsonl import JsonlSessionRepository, _session_file +from keystrike.infrastructure.session_repo_jsonl import JsonlSessionRepository # Valid 26-character ULIDs for testing (Crockford base32 alphabet) _VALID_ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAV" @@ -41,31 +44,26 @@ def _header(sid: str = _VALID_ULID_A, layout: str = "qwerty", started_at: float ) +def _stats() -> SessionStats: + return SessionStats( + keys={ord("a"): KeyTally(0, 0, 0, 1), ord("b"): KeyTally(1, 100, 0, 1)}, + transitions={Bigram(ord("a"), ord("b")): KeyTally(1, 100, 0, 1)}, + ) + + def test_round_trip_single_session(paths): repo = JsonlSessionRepository(paths) - header = _header() + header = replace(_header(), stats=_stats()) repo.save_header(header) - repo.append_keystroke( - header.session_id, - header.started_at, - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - ) - repo.append_keystroke( - header.session_id, - header.started_at, - Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100, correct=True), - ) # Fresh repo instance — read must survive across process restart. repo2 = JsonlSessionRepository(paths) headers = list(repo2.iter_headers("qwerty")) assert len(headers) == 1 assert headers[0].session_id == _VALID_ULID_A - - keystrokes = list(repo2.load_keystrokes(_VALID_ULID_A)) - assert len(keystrokes) == 2 - assert keystrokes[0].codepoint == ord("a") - assert keystrokes[1].t_ns == 100 + assert headers[0] == header + assert headers[0].stats.keys[ord("b")] == KeyTally(1, 100, 0, 1) + assert headers[0].stats.transitions[Bigram(ord("a"), ord("b"))] == KeyTally(1, 100, 0, 1) def test_iter_headers_filters_by_layout(paths): @@ -210,69 +208,80 @@ def test_corrupt_index_line_is_skipped_not_fatal(paths): assert headers == [_VALID_ULID_A, _VALID_ULID_B] -def test_keystrokes_persisted_with_header_at_finish(paths): +def test_stats_row_is_compact_positional_tallies(paths): repo = JsonlSessionRepository(paths) - header = _header(sid=_VALID_ULID_B) - k = Keystroke(codepoint=ord("x"), typed=ord("x"), t_ns=0, correct=True) - repo.append_keystroke(header.session_id, header.started_at, k) - repo.save_header(header) + repo.save_header(replace(_header(sid=_VALID_ULID_B), stats=_stats())) - repo2 = JsonlSessionRepository(paths) - list(repo2.iter_headers("qwerty")) - ks = list(repo2.load_keystrokes(_VALID_ULID_B)) - assert len(ks) == 1 + row = json.loads(paths.sessions_index.read_text(encoding="utf-8").splitlines()[0]) + assert row["stats"] == { + "keys": {"97": [0, 0, 0, 1], "98": [1, 100, 0, 1]}, + "pairs": {"97,98": [1, 100, 0, 1]}, + } -def test_append_keystrokes_bulk_writes_all_in_one_open(paths): +def test_empty_stats_are_omitted_from_row_and_read_back_empty(paths): repo = JsonlSessionRepository(paths) - header = _header(sid=_VALID_ULID_C) - keystrokes = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100, correct=True), - Keystroke(codepoint=ord("c"), typed=ord("c"), t_ns=200, correct=False), - ] - repo.append_keystrokes(header.session_id, header.started_at, keystrokes) - repo.save_header(header) + repo.save_header(_header(sid=_VALID_ULID_C)) - repo2 = JsonlSessionRepository(paths) - list(repo2.iter_headers("qwerty")) - ks = list(repo2.load_keystrokes(_VALID_ULID_C)) - assert [k.codepoint for k in ks] == [ord("a"), ord("b"), ord("c")] - assert ks[2].correct is False + row = json.loads(paths.sessions_index.read_text(encoding="utf-8").splitlines()[0]) + assert "stats" not in row + headers = list(JsonlSessionRepository(paths).iter_headers("qwerty")) + assert headers[0].stats.is_empty -def test_corrupt_keystroke_line_is_skipped_not_fatal(paths): +def test_legacy_row_without_stats_reads_as_empty_stats(paths): + with paths.sessions_index.open("a", encoding="utf-8") as fh: + fh.write( + f'{{"schema_version": 4, "session_id": "{_VALID_ULID_D}", "layout": "qwerty", ' + '"started_at": 1700000000.0, "duration_ns": 1000000000, "mode": "adaptive", ' + '"lesson_alphabet": [], "focus_key": null, "total_keystrokes": 0, ' + '"correct_keystrokes": 0}\n' + ) + headers = list(JsonlSessionRepository(paths).iter_headers("qwerty")) + assert headers[0].session_id == _VALID_ULID_D + assert headers[0].stats.is_empty + + +@pytest.mark.parametrize( + "stats_json", + [ + '{"keys": {"97": [1, 2]}}', # tally with the wrong arity + '{"keys": 5}', # keys is not an object + '{"pairs": [1, 2]}', # pairs is not an object + '{"pairs": {"97": [1, 1, 0, 1]}}', # bigram key without a comma + '"nope"', # stats is not an object + ], +) +def test_malformed_stats_skip_the_row_not_the_index(paths, stats_json): repo = JsonlSessionRepository(paths) - header = _header(sid=_VALID_ULID_F) - repo.append_keystroke( - header.session_id, - header.started_at, - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - ) - with _session_file(paths, header).open("a", encoding="utf-8") as fh: - fh.write("{not valid json\n") - fh.write('{"codepoint": 1}\n') # valid JSON, missing required fields - repo.append_keystroke( - header.session_id, - header.started_at, - Keystroke(codepoint=ord("b"), typed=ord("b"), t_ns=100, correct=True), - ) - repo.save_header(header) + repo.save_header(_header(sid=_VALID_ULID_A)) + with paths.sessions_index.open("a", encoding="utf-8") as fh: + fh.write( + f'{{"schema_version": 5, "session_id": "{_VALID_ULID_E}", "layout": "qwerty", ' + '"started_at": 1700000000.0, "duration_ns": 1000000000, "mode": "adaptive", ' + '"lesson_alphabet": [], "focus_key": null, "total_keystrokes": 0, ' + f'"correct_keystrokes": 0, "stats": {stats_json}}}\n' + ) + repo.save_header(_header(sid=_VALID_ULID_B)) - repo2 = JsonlSessionRepository(paths) - ks = list(repo2.load_keystrokes(_VALID_ULID_F)) - assert [k.codepoint for k in ks] == [ord("a"), ord("b")] + headers = [h.session_id for h in JsonlSessionRepository(paths).iter_headers("qwerty")] + assert headers == [_VALID_ULID_A, _VALID_ULID_B] -def test_load_keystrokes_scan_fallback_when_sessions_dir_missing(tmp_path): - empty_paths = Paths( - config_dir=tmp_path / "config2", - data_dir=tmp_path / "data2", - log_dir=tmp_path / "logs2", - ) - repo = JsonlSessionRepository(empty_paths) - # Use a valid ULID that doesn't exist in the repo - assert list(repo.load_keystrokes(_VALID_ULID_A)) == [] +def test_replace_all_headers_rewrites_index_in_place(paths): + repo = JsonlSessionRepository(paths) + a = replace(_header(sid=_VALID_ULID_A, started_at=1.0), stats=_stats()) + b = replace(_header(sid=_VALID_ULID_B, started_at=2.0), stats=_stats()) + repo.save_header(a) + repo.save_header(b) + + repo.replace_all_headers([replace(a, stats=SessionStats()), b]) + + headers = list(JsonlSessionRepository(paths).iter_all_headers()) + assert [h.session_id for h in headers] == [_VALID_ULID_A, _VALID_ULID_B] + assert headers[0].stats.is_empty + assert headers[1] == b + assert len(paths.sessions_index.read_text(encoding="utf-8").splitlines()) == 2 def test_path_traversal_session_id_rejected_in_header_parse(paths): @@ -292,30 +301,14 @@ def test_path_traversal_session_id_rejected_in_header_parse(paths): assert len(headers) == 0 -def test_path_traversal_session_id_rejected_in_append_keystrokes(paths): - """Regression: path-traversal session_id should be rejected in append_keystrokes.""" - repo = JsonlSessionRepository(paths) - with pytest.raises(ValueError, match=r"session_id.*26 characters"): - repo.append_keystrokes( - session_id="../../evil", - started_at=1_700_000_000.0, - keystrokes=[], - ) - - -def test_path_traversal_session_id_rejected_in_load_keystrokes(paths): - """Regression: path-traversal session_id should be rejected in load_keystrokes.""" - repo = JsonlSessionRepository(paths) - with pytest.raises(ValueError, match=r"session_id.*26 characters"): - list(repo.load_keystrokes("../../evil")) - - -def test_invalid_chars_session_id_rejected_in_append_keystrokes(paths): +def test_invalid_chars_session_id_rejected_in_header_parse(paths): """Regression: session_id with invalid characters should be rejected.""" repo = JsonlSessionRepository(paths) - with pytest.raises(ValueError, match="invalid characters"): - repo.append_keystrokes( - session_id="01ARZ3NDEKTSV4RRFFQ69G5F!V", # '!' is not Crockford base32 - started_at=1_700_000_000.0, - keystrokes=[], + with paths.sessions_index.open("a", encoding="utf-8") as fh: + fh.write( + '{"schema_version": 1, "session_id": "01ARZ3NDEKTSV4RRFFQ69G5F!V", ' + '"layout": "qwerty", "started_at": 1700000000.0, "duration_ns": 1000000000, ' + '"mode": "adaptive", "lesson_alphabet": [], "focus_key": null, ' + '"total_keystrokes": 0, "correct_keystrokes": 0}\n' ) + assert list(repo.iter_all_headers()) == [] diff --git a/tests/infrastructure/test_sync_git.py b/tests/infrastructure/test_sync_git.py index 5050d3b..411fc68 100644 --- a/tests/infrastructure/test_sync_git.py +++ b/tests/infrastructure/test_sync_git.py @@ -85,69 +85,93 @@ def test_iter_layouts_from_index(tree: dict[str, Path]) -> None: assert iter_layouts_from_index(tree["local_index"]) == {"qwerty", "dvorak"} +def _local_rows(tree: dict[str, Path]) -> dict[str, dict[str, object]]: + rows = [json.loads(line) for line in tree["local_index"].read_text().splitlines() if line] + return {str(r["session_id"]): r for r in rows} + + def test_session_union_imports_missing_remote_only(tree: dict[str, Path]) -> None: _write_index(tree["local_index"], _header(_VALID_ULID_A)) _write_index(tree["remote_index"], _header(_VALID_ULID_A), _header(_VALID_ULID_B)) + + imported = import_missing_sessions( + local_index=tree["local_index"], + remote_index=tree["remote_index"], + remote_sessions_dir=tree["remote_sessions"], + ) + + assert imported == [_VALID_ULID_B] + assert read_index_session_ids(tree["local_index"]) == {_VALID_ULID_A, _VALID_ULID_B} + + +def test_session_union_folds_legacy_keystroke_file_into_imported_row( + tree: dict[str, Path], +) -> None: + """A schema ≤4 remote row still backed by a keystroke log arrives as a + schema-5 row with the tallies embedded; nothing is copied into local sessions/.""" + _write_index(tree["remote_index"], _header(_VALID_ULID_B)) month = "2023-11" (tree["remote_sessions"] / month).mkdir() (tree["remote_sessions"] / month / f"{_VALID_ULID_B}.jsonl").write_text( - '{"codepoint": 97, "typed": 97, "t_ns": 0, "correct": true}\n', + '{"codepoint": 97, "typed": 97, "t_ns": 0, "correct": true}\n' + '{"codepoint": 98, "typed": 98, "t_ns": 100000000, "correct": true}\n', encoding="utf-8", ) imported = import_missing_sessions( - local_sessions_dir=tree["local_sessions"], - remote_sessions_dir=tree["remote_sessions"], local_index=tree["local_index"], remote_index=tree["remote_index"], + remote_sessions_dir=tree["remote_sessions"], ) assert imported == [_VALID_ULID_B] - assert read_index_session_ids(tree["local_index"]) == {_VALID_ULID_A, _VALID_ULID_B} - assert (tree["local_sessions"] / month / f"{_VALID_ULID_B}.jsonl").exists() + row = _local_rows(tree)[_VALID_ULID_B] + assert row["schema_version"] == 5 + stats = row["stats"] + assert isinstance(stats, dict) + assert stats["keys"]["98"] == [1, 100_000_000, 0, 1] + assert stats["pairs"]["97,98"] == [1, 100_000_000, 0, 1] + assert not (tree["local_sessions"] / month).exists() def test_session_union_skips_duplicate_local_ids(tree: dict[str, Path]) -> None: _write_index(tree["local_index"], _header(_VALID_ULID_A)) _write_index(tree["remote_index"], _header(_VALID_ULID_A), _header(_VALID_ULID_B)) - month = "2023-11" - (tree["remote_sessions"] / month).mkdir() - (tree["remote_sessions"] / month / f"{_VALID_ULID_B}.jsonl").write_text( - "{}\n", encoding="utf-8" - ) import_missing_sessions( - local_sessions_dir=tree["local_sessions"], - remote_sessions_dir=tree["remote_sessions"], local_index=tree["local_index"], remote_index=tree["remote_index"], + remote_sessions_dir=tree["remote_sessions"], ) lines = tree["local_index"].read_text().splitlines() assert lines.count(json.dumps(_header(_VALID_ULID_A))) == 1 - assert json.dumps(_header(_VALID_ULID_B)) in lines + assert _VALID_ULID_B in _local_rows(tree) -def test_session_union_skips_entries_whose_file_is_missing(tree: dict[str, Path]) -> None: +def test_session_union_imports_legacy_row_without_file_as_empty_stats( + tree: dict[str, Path], +) -> None: _write_index(tree["remote_index"], _header(_VALID_ULID_B)) - # No B.jsonl on disk anywhere. + # No B.jsonl on disk anywhere: the history row still comes across. imported = import_missing_sessions( - local_sessions_dir=tree["local_sessions"], - remote_sessions_dir=tree["remote_sessions"], local_index=tree["local_index"], remote_index=tree["remote_index"], + remote_sessions_dir=tree["remote_sessions"], ) - assert imported == [] + assert imported == [_VALID_ULID_B] + row = _local_rows(tree)[_VALID_ULID_B] + assert row["schema_version"] == 5 + assert "stats" not in row def test_session_union_no_remote_index_is_noop(tree: dict[str, Path]) -> None: imported = import_missing_sessions( - local_sessions_dir=tree["local_sessions"], - remote_sessions_dir=tree["remote_sessions"], local_index=tree["local_index"], remote_index=tree["remote_index"], + remote_sessions_dir=tree["remote_sessions"], ) assert imported == [] @@ -172,20 +196,11 @@ def test_session_union_skips_path_traversal_session_ids(tree: dict[str, Path]) - # Write remote index with mix of valid and invalid entries _write_index(tree["remote_index"], valid_entry, *evil_entries) - # Create the session file for the valid entry - month = "2023-11" - (tree["remote_sessions"] / month).mkdir() - (tree["remote_sessions"] / month / f"{_VALID_ULID_A}.jsonl").write_text( - '{"codepoint": 97, "typed": 97, "t_ns": 0, "correct": true}\n', - encoding="utf-8", - ) - # Import should succeed, but only with the valid entry imported = import_missing_sessions( - local_sessions_dir=tree["local_sessions"], - remote_sessions_dir=tree["remote_sessions"], local_index=tree["local_index"], remote_index=tree["remote_index"], + remote_sessions_dir=tree["remote_sessions"], ) # Only the valid ULID is imported; malicious entries are silently skipped diff --git a/tests/presentation/test_practice_screen.py b/tests/presentation/test_practice_screen.py index 53fe31f..051803e 100644 --- a/tests/presentation/test_practice_screen.py +++ b/tests/presentation/test_practice_screen.py @@ -172,8 +172,7 @@ async def test_app_launches_types_and_persists_session(): clock.advance(100_000_000) await pilot.press("space" if target[0] == " " else target[0]) await pilot.pause() - assert len(session_repo.headers) == 0 # session not finished yet - assert session_repo.keystrokes == {} + assert session_repo.headers == [] # session not finished yet assert isinstance(app.screen, PracticeScreen) assert app.screen._session.position >= 1 @@ -271,7 +270,7 @@ async def test_adaptive_allowed_when_daily_learn_goal_reached(): async def test_adaptive_practice_shows_weak_key_focus_note(): clock = FakeClock(wall=1_700_000_000.0) session_repo = FakeSessionRepository() - session_repo.save_header( + session_repo.save_with_keystrokes( SessionResult( schema_version=3, session_id="s1", @@ -284,13 +283,13 @@ async def test_adaptive_practice_shows_weak_key_focus_note(): total_keystrokes=4, correct_keystrokes=3, ), + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke(codepoint=ord("s"), typed=ord("s"), t_ns=100_000_000, correct=True), + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=500_000_000, correct=True), + Keystroke(codepoint=ord("s"), typed=ord("x"), t_ns=600_000_000, correct=False), + ], ) - session_repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke(codepoint=ord("s"), typed=ord("s"), t_ns=100_000_000, correct=True), - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=500_000_000, correct=True), - Keystroke(codepoint=ord("s"), typed=ord("x"), t_ns=600_000_000, correct=False), - ] app, _clock, _repo, _settings = _build_app( clock=clock, settings=Settings(alphabet_size=2), @@ -432,7 +431,6 @@ async def test_escape_returns_to_home_and_cancels_session(): assert practice._session.state is SessionState.CANCELLED assert session_repo.headers == [] - assert session_repo.keystrokes == {} assert isinstance(app.screen, HomeScreen) diff --git a/tests/presentation/test_stats_screen.py b/tests/presentation/test_stats_screen.py index c4cbd57..a383a63 100644 --- a/tests/presentation/test_stats_screen.py +++ b/tests/presentation/test_stats_screen.py @@ -101,16 +101,18 @@ async def test_stats_overview_focus_shows_confidence_only(): correct_keystrokes=50, key_confidence={ord("e"): 0.82}, ) - repo.save_header(header) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=0, correct=True), - Keystroke( - codepoint=ord("e"), - typed=ord("e"), - t_ns=400_000_000, - correct=True, - ), - ] + repo.save_with_keystrokes( + header, + [ + Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("e"), + typed=ord("e"), + t_ns=400_000_000, + correct=True, + ), + ], + ) await app.push_screen(_build_screen(repo)) await pilot.pause() @@ -155,10 +157,12 @@ async def test_stats_screen_with_sessions_renders_trends_and_heatmap(): total_keystrokes=1, correct_keystrokes=1, ) - repo.save_header(header) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - ] + repo.save_with_keystrokes( + header, + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + ], + ) await app.push_screen(_build_screen(repo)) await pilot.pause() @@ -431,22 +435,22 @@ async def test_stats_key_detail_shows_speed_and_accuracy_trends(): app = App() async with app.run_test() as pilot: repo = FakeSessionRepository() - repo.save_header( + repo.save_with_keystrokes( _session_with_key_confidence( session_id="s1", started_at=1.0, key_confidence={ord("e"): 0.55}, - ) - ) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=0, correct=True), - Keystroke( - codepoint=ord("e"), - typed=ord("e"), - t_ns=400_000_000, - correct=True, ), - ] + [ + Keystroke(codepoint=ord("e"), typed=ord("e"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("e"), + typed=ord("e"), + t_ns=400_000_000, + correct=True, + ), + ], + ) await app.push_screen(_build_screen(repo)) await pilot.pause() @@ -479,16 +483,18 @@ async def test_stats_screen_renders_speed_and_accuracy_trends(): correct_keystrokes=50, key_confidence={ord("a"): 0.82}, ) - repo.save_header(header) - repo.keystrokes["s1"] = [ - Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), - Keystroke( - codepoint=ord("a"), - typed=ord("a"), - t_ns=400_000_000, - correct=True, - ), - ] + repo.save_with_keystrokes( + header, + [ + Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), + Keystroke( + codepoint=ord("a"), + typed=ord("a"), + t_ns=400_000_000, + correct=True, + ), + ], + ) await app.push_screen(_build_screen(repo)) await pilot.pause() From 3e949abca0336e4503cb8b59af23c2674f5ed138 Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 24 Sep 2026 11:38:06 +0300 Subject: [PATCH 41/47] Release 2.1.0. Session history now stores per-key/per-bigram tallies on each index.jsonl row instead of raw keystroke logs (schema 5); existing logs are folded in and removed on first start, and tallies outside the stats retention window (2 * confidence_session_window - 1 sessions per layout) are pruned at startup. Git sync exchanges index rows only. Docs, wiki pages and the demo GIF refreshed. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 ++-- docs/CHANGELOG.md | 2 +- docs/PLAN.md | 14 +++++++++----- docs/assets/demo.gif | Bin 49039 -> 49042 bytes docs/wiki/Confidence-Tuning.md | 8 ++++++++ pyproject.toml | 2 +- scripts/generate_demo_gif.py | 8 +++++--- src/keystrike/domain/version.py | 2 +- uv.lock | 2 +- 9 files changed, 28 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2025b98..62f32c9 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ for sources and how each maps to the code. [Custom layouts wiki](https://github.com/egno/keystrike/wiki/Custom-Layouts). - **Git backup sync** — optional CLI to push/pull settings and sessions to a private remote (union-merge sessions, last-write-wins settings). -- **Offline by default** — JSONL session logs and a local stats cache under - platformdirs paths; sync is opt-in. +- **Offline by default** — a compact JSONL session history (per-key stats, no + keystroke logs) and a local stats cache under platformdirs paths; sync is opt-in. ## Install diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b2fbe59..8c6749f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,7 +5,7 @@ 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. -## Unreleased +## 2.1.0 - **Session store: per-session stats instead of keystroke logs** — a finished session no longer writes `sessions//.jsonl`. Its keystrokes diff --git a/docs/PLAN.md b/docs/PLAN.md index 14b3f91..fe9a0c7 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -212,6 +212,8 @@ Ruff, pyright, and pytest must be green before commit (Ruff on desktop only). Home without crashes. - [x] Session appears under `/sessions/YYYY-MM/{ulid}.jsonl` and its header appears in `/sessions/index.jsonl` after finishing a session. + (Since 2.1.0 the keystroke file is gone: the index row carries the + session's per-key tallies — see `SessionResult.stats`.) - [x] Switching layout on Home changes which per-layout heatmap/history shows on Stats (confirmed empty-history isolation on the unused layout). - [x] Dropping a hand-written layout TOML into `/keystrike/layouts/` @@ -265,9 +267,11 @@ returns how many more attempts until it crosses the target, `0` if already there, or `None` if flat/worsening/no data. `domain/aggregate.py` gained `per_key_deltas()` — the chronological -inter-keystroke timing sequence per codepoint that `aggregate_session` already -computed internally but only reduced to a mean; regression needs the raw -sequence, so this was factored out and reused rather than duplicated. +inter-keystroke timing sequence per codepoint that the per-session tally (now +`tally_session`) already computed internally but only reduced to a mean; +regression needs the raw sequence, so this was factored out and reused rather +than duplicated. (Since 2.1.0 raw keystrokes are not persisted, so this +regression can only run on the in-memory session being typed.) `application/stats_use_cases.py` gained `GetLearningRate(layout, codepoint)`, which reads the last 10 sessions' raw deltas for that key and feeds them to `estimate_sessions_to_goal`. Wired into `presentation/widgets/hud.py`: the HUD @@ -340,8 +344,8 @@ Commands: `keystrike sync init `, `pull`, `push`, `status`. - `settings.toml` - `layouts/*.toml` -- `sessions/index.jsonl` -- `sessions/**/*.jsonl` +- `sessions/index.jsonl` (one self-contained row per session since 2.1.0) +- `sessions/**/*.jsonl` (legacy keystroke logs, read once on import) **Not synced:** `cache/` — after a successful pull, `RebuildAggregates` replays all layouts found in the merged session index. diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif index 0d5df64f54d63871b7eeb291018235c040f24a8d..2400e3dfedd6e5ceb37154b3dac599a689bdbc12 100644 GIT binary patch delta 16237 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 XKP=Dp{#n2ue)3lVI|uF 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 C+z9yq diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index b95361d..1d08444 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -85,6 +85,14 @@ average of all window sessions. 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. +The window also sets how much history is kept on disk. Since 2.1.0 a session +stores per-key tallies instead of its keystrokes, and on each start Keystrike +drops those tallies from sessions older than the newest `2 × window − 1` per +layout (19 with the default window) — the furthest back the Stats trend lines +read. The sessions themselves stay in History with their speed and accuracy. +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 diff --git a/pyproject.toml b/pyproject.toml index b6e85d4..a04dd9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keystrike" -version = "2.0.0" +version = "2.1.0" description = "Offline terminal typing tutor with keybr-style adaptive drills and per-layout stats" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/generate_demo_gif.py b/scripts/generate_demo_gif.py index 2f137af..e7774d1 100644 --- a/scripts/generate_demo_gif.py +++ b/scripts/generate_demo_gif.py @@ -39,9 +39,11 @@ def _svg_to_image(path: Path) -> Image.Image: def main() -> None: if sys.platform == "darwin" and "DYLD_LIBRARY_PATH" not in os.environ: - brew_lib = Path("/opt/homebrew/lib") - if brew_lib.is_dir(): - os.environ["DYLD_LIBRARY_PATH"] = str(brew_lib) + # `brew install cairo` does not always link libcairo into /opt/homebrew/lib. + for brew_lib in (Path("/opt/homebrew/opt/cairo/lib"), Path("/opt/homebrew/lib")): + if (brew_lib / "libcairo.2.dylib").is_file(): + os.environ["DYLD_LIBRARY_PATH"] = str(brew_lib) + break frames: list[Image.Image] = [] for name in _FRAMES: diff --git a/src/keystrike/domain/version.py b/src/keystrike/domain/version.py index 8c0d5d5..9aa3f90 100644 --- a/src/keystrike/domain/version.py +++ b/src/keystrike/domain/version.py @@ -1 +1 @@ -__version__ = "2.0.0" +__version__ = "2.1.0" diff --git a/uv.lock b/uv.lock index 7af7829..7f54163 100644 --- a/uv.lock +++ b/uv.lock @@ -79,7 +79,7 @@ wheels = [ [[package]] name = "keystrike" -version = "2.0.0" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "platformdirs" }, From 37de9280e03ba2953dd4c0e35a69b1e1302be9bf Mon Sep 17 00:00:00 2001 From: Egno Date: Thu, 24 Sep 2026 11:59:52 +0300 Subject: [PATCH 42/47] Rebuild the README demo GIF from a seeded, simulated run The GIF was rendered from the regression snapshots, which start from an empty store: no sessions, a blank heatmap, 100% accuracy. The generator now drives the real app with the test fakes through ~16 simulated lessons (a typist who is quick on the home row, slow and error-prone on a few keys, and improves over time), then captures Home, a lesson in progress, Stats with trends and a coloured heatmap, one letter's detail, and Settings. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +- docs/assets/demo.gif | Bin 49042 -> 72569 bytes scripts/generate_demo_gif.py | 172 ++++++++++++++++++++++++++++------- 3 files changed, 143 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 62f32c9..a698c64 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif index 2400e3dfedd6e5ceb37154b3dac599a689bdbc12..5f16a09543463b15cf66d890ce5837f9eaf626d9 100644 GIT binary patch literal 72569 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

S`{Z&zLTq>b1nobABXn?eH1Attq|tOY~P6 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 literal 49042 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�#;)(aa9J?TgDdMF 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 D^5$g* diff --git a/scripts/generate_demo_gif.py b/scripts/generate_demo_gif.py index e7774d1..473b500 100644 --- a/scripts/generate_demo_gif.py +++ b/scripts/generate_demo_gif.py @@ -1,56 +1,164 @@ -"""Build docs/assets/demo.gif from presentation snapshot SVGs. +"""Build docs/assets/demo.gif from a seeded, simulated Keystrike run. -Run after `uv sync --group snapshot` and -`pytest tests/presentation/test_snapshots.py --snapshot-update`. +Drives the real app (with the test fakes for clock/storage) through a +deterministic typist who is fast on the home row, slow and error-prone on a +few keys, and improves over ~16 sessions. Then screenshots Home, a lesson in +progress, Stats (trends + heatmap), one letter's detail, and Settings. Requires pillow + cairosvg (one-off: `uv pip install pillow cairosvg`). -On macOS with Homebrew cairo: `DYLD_LIBRARY_PATH=/opt/homebrew/lib`. +On macOS `brew install cairo`; the script finds the Homebrew library itself. """ from __future__ import annotations +import asyncio import io import os +import random import sys from pathlib import Path - -import cairosvg -from PIL import Image +from typing import cast _ROOT = Path(__file__).resolve().parent.parent -_SNAPSHOTS = _ROOT / "tests/presentation/__snapshots__/test_snapshots" -_OUT = _ROOT / "docs/assets/demo.gif" +sys.path.insert(0, str(_ROOT)) # `tests.*` fakes and the fully-wired test app -_FRAME_MS = 10_000 # ~10 s per frame in the README demo GIF - -_FRAMES = ( - "test_home_screen_snapshot.svg", - "test_practice_screen_snapshot.svg", - "test_stats_screen_snapshot.svg", - "test_settings_screen_snapshot.svg", - "test_home_screen_snapshot.svg", +from tests.fakes import FakeClock # noqa: E402 +from tests.presentation.test_practice_screen import ( # noqa: E402 + _build_app, # pyright: ignore[reportPrivateUsage] ) +from textual.pilot import Pilot # noqa: E402 +from keystrike.presentation.screens.practice import PracticeScreen # noqa: E402 -def _svg_to_image(path: Path) -> Image.Image: - png = cairosvg.svg2png(url=str(path), output_width=720) - return Image.open(io.BytesIO(png)).convert("P", palette=Image.ADAPTIVE) +_OUT = _ROOT / "docs/assets/demo.gif" +_FRAME_MS = 5_000 +_TERMINAL = (80, 24) +_SEED_SESSIONS = 16 +_MID_LESSON_FRACTION = 0.45 # how much of the lesson is typed in the practice frame + +# Textual key names for the non-letter characters a lesson can contain. +_KEY_NAMES = {" ": "space", ",": "comma", ".": "full_stop", ";": "semicolon", "/": "slash"} + +_HOME_ROW = set("asdfghjkl") +_WEAK_KEYS = {"r": 2.0, "u": 1.8, "w": 1.6} # slower and error-prone throughout +# Per-keystroke seconds before jitter/improvement. Tuned against the default +# 100 WPM goal (120 ms/char) so the final heatmap mixes green, yellow and red. +_BASE_S = {"home": 0.16, "other": 0.26} +_SPEEDUP_PER_SESSION = 0.97 +_SLIP_P = {"weak": 0.25, "other": 0.04} +_SLIP_DECAY_PER_SESSION = 0.95 + + +def _setup_cairo_path() -> None: + if sys.platform != "darwin" or "DYLD_LIBRARY_PATH" in os.environ: + return + # `brew install cairo` does not always link libcairo into /opt/homebrew/lib. + for brew_lib in (Path("/opt/homebrew/opt/cairo/lib"), Path("/opt/homebrew/lib")): + if (brew_lib / "libcairo.2.dylib").is_file(): + os.environ["DYLD_LIBRARY_PATH"] = str(brew_lib) + return + + +class _Typist: + """Deterministic keystroke timing/error model that improves per session.""" + + def __init__(self, rng: random.Random) -> None: + self._rng = rng + self.session = 0 + + def delay_ns(self, char: str) -> int: + base = _BASE_S["home"] if char in _HOME_ROW or char == " " else _BASE_S["other"] + base *= _WEAK_KEYS.get(char, 1.0) + base *= _SPEEDUP_PER_SESSION**self.session # gets faster over time + jitter = self._rng.uniform(0.8, 1.25) + return int(base * jitter * 1e9) + + def slips(self, char: str) -> bool: + p = _SLIP_P["weak"] if char in _WEAK_KEYS else _SLIP_P["other"] + return self._rng.random() < p * _SLIP_DECAY_PER_SESSION**self.session + + def wrong_key(self, char: str) -> str: + pool = [c for c in "asdfghjklqwertyuiopzxcvbnm" if c != char] + return self._rng.choice(pool) + + +def _key(char: str) -> str: + return _KEY_NAMES.get(char, char) + + +def _lesson_text(screen: object) -> str: + assert isinstance(screen, PracticeScreen) + return screen._session.target_text # pyright: ignore[reportPrivateUsage] + + +async def _type(pilot: Pilot[None], clock: FakeClock, typist: _Typist, text: str) -> None: + for char in text: + if typist.slips(char) and char != " ": + clock.advance(typist.delay_ns(char)) + await pilot.press(_key(typist.wrong_key(char))) + clock.advance(typist.delay_ns(char)) + await pilot.press(_key(char)) + + +async def _seed_and_capture() -> list[str]: + app, clock, repo, _settings = _build_app() + rng = random.Random(7) + typist = _Typist(rng) + svgs: list[str] = [] + + async with app.run_test(size=_TERMINAL) as pilot: + await pilot.press("enter") # Home → Practice + await pilot.pause() + for i in range(_SEED_SESSIONS): + typist.session = i + clock.wall += 6 * 60 # sessions a few minutes apart, all "today" + await _type(pilot, clock, typist, _lesson_text(app.screen)) + await pilot.pause() + assert len(repo.headers) == i + 1, "lesson did not finish" + + # Frame: a lesson in progress with a slip already made. + typist.session = _SEED_SESSIONS + text = _lesson_text(app.screen) + cut = int(len(text) * _MID_LESSON_FRACTION) + await _type(pilot, clock, typist, text[:cut]) + await pilot.pause() + practice_svg = app.export_screenshot() + + await pilot.press("escape") # abort the in-progress lesson → Home + await pilot.pause() + svgs.append(app.export_screenshot()) # Home + svgs.append(practice_svg) + + await pilot.press("s") + await pilot.pause() + svgs.append(app.export_screenshot()) # Stats overview + await pilot.press("r") + await pilot.pause() + svgs.append(app.export_screenshot()) # Letter detail for a weak key + await pilot.press("escape") + await pilot.press("escape") + await pilot.pause() + + await pilot.press("o") + await pilot.pause() + svgs.append(app.export_screenshot()) # Settings + await pilot.press("escape") + await pilot.pause() + + svgs.append(svgs[0]) # loop back to Home + return svgs def main() -> None: - if sys.platform == "darwin" and "DYLD_LIBRARY_PATH" not in os.environ: - # `brew install cairo` does not always link libcairo into /opt/homebrew/lib. - for brew_lib in (Path("/opt/homebrew/opt/cairo/lib"), Path("/opt/homebrew/lib")): - if (brew_lib / "libcairo.2.dylib").is_file(): - os.environ["DYLD_LIBRARY_PATH"] = str(brew_lib) - break + _setup_cairo_path() + import cairosvg # noqa: PLC0415 (needs DYLD_LIBRARY_PATH set first) + from PIL import Image # noqa: PLC0415 + svgs = asyncio.run(_seed_and_capture()) frames: list[Image.Image] = [] - for name in _FRAMES: - svg = _SNAPSHOTS / name - if not svg.is_file(): - raise SystemExit(f"missing snapshot: {svg}") - frames.append(_svg_to_image(svg)) + for svg in svgs: + png = cast("bytes", cairosvg.svg2png(bytestring=svg.encode("utf-8"), output_width=720)) + frames.append(Image.open(io.BytesIO(png)).convert("P", palette=Image.Palette.ADAPTIVE)) _OUT.parent.mkdir(parents=True, exist_ok=True) frames[0].save( @@ -61,7 +169,7 @@ def main() -> None: loop=0, optimize=True, ) - print(f"wrote {_OUT} ({_OUT.stat().st_size // 1024} KiB)") + print(f"wrote {_OUT} ({_OUT.stat().st_size // 1024} KiB, {len(frames)} frames)") if __name__ == "__main__": From a6fec61702a21468fd8e2e8f9246400882cc4939 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 7 Oct 2026 10:31:05 +0300 Subject: [PATCH 43/47] Save the weakest pair on the session result FinishSession now stores SessionResult.weakest_pair: the slowest cross-key pair typed in the session (enough attempts, skill < 1.0). The next lesson's remedial focus starts from it instead of a calibrating pair with only a few presses. The optional field keeps schema 5; rows without it read back as None. Co-Authored-By: Claude Sonnet 5.5 --- docs/CHANGELOG.md | 12 ++++ docs/wiki/Focus-States.md | 4 +- src/keystrike/application/build_lesson.py | 27 +++++--- .../application/session_use_cases.py | 20 ++++-- src/keystrike/domain/focus.py | 52 +++++++++++++- src/keystrike/domain/models.py | 4 ++ .../infrastructure/session_repo_jsonl.py | 16 +++++ tests/application/test_build_lesson.py | 52 ++++++++++++++ tests/application/test_session_use_cases.py | 42 ++++++++++++ tests/domain/test_focus.py | 68 +++++++++++++++++++ .../infrastructure/test_session_repo_jsonl.py | 14 ++++ 11 files changed, 291 insertions(+), 20 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8c6749f..f9607fa 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,18 @@ 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. +## Unreleased + +- **Weakest pair saved on the session result** — `FinishSession` now stores + `SessionResult.weakest_pair`: the slowest cross-key pair typed in the + session (enough attempts, skill < 1.0, lowest raw skill; + `domain.focus.weakest_session_pair`). The optional `weakest_pair` field in + the `index.jsonl` row stays schema 5, and rows without it read back as + `None`. After a lesson that missed its WPM target, `remedial_focus` starts + from that pair instead of a calibrating pair with only a few presses. The + newest key's gating cohort still takes priority while the unlock gate is + open. + ## 2.1.0 - **Session store: per-session stats instead of keystroke logs** — a finished diff --git a/docs/wiki/Focus-States.md b/docs/wiki/Focus-States.md index d9aa96a..176af8c 100644 --- a/docs/wiki/Focus-States.md +++ b/docs/wiki/Focus-States.md @@ -69,7 +69,9 @@ 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 +whatever else is weakest across the full practice history. The pair comes from the session's saved +weakest pair (the slowest pair it typed with enough attempts) when there is +one, so a barely-sampled calibrating pair doesn't take the slot. This doesn't add a new HUD label; the chosen key/pair still shows the ordinary `wk`, `cal`, or `rev` reason. diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 17b83f4..a95734a 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -54,6 +54,7 @@ Layout, LessonKey, LessonState, + SessionResult, Settings, TransitionStats, ) @@ -346,6 +347,7 @@ def _resolve_lesson_focus( ctx.transitions, ctx.target, now=ctx.now, + preferred=ctx.saved_pair, threshold=CONFIDENCE_GOOD, min_attempts=ctx.settings.unlock.min_confidence_attempts, min_transition_attempts=ctx.settings.unlock.min_transition_confidence_attempts, @@ -362,6 +364,7 @@ def _resolve_lesson_focus( ctx.now, key_stats=ctx.stats, gating_candidates=gating.gating_bigrams if gating.transition_blocked else None, + preferred=ctx.saved_pair, min_attempts=ctx.settings.unlock.min_transition_confidence_attempts, ) @@ -504,6 +507,16 @@ class _LessonContext: # The just-finished session's own lesson alphabet, when that session's own # WPM fell short of its own target -- None otherwise (normal selection). remedial_alphabet: tuple[int, ...] | None = None + # The just-finished session's saved weakest pair (`SessionResult.weakest_pair`). + saved_pair: Bigram | None = None + + +def _remedial_alphabet(last: SessionResult | None) -> tuple[int, ...] | None: + """The last finished session's own lesson alphabet, when that session's + own WPM missed its own target -- otherwise None.""" + if last is None or not session_wpm_below_target(last): + return None + return last.lesson_alphabet @dataclass(slots=True) @@ -559,6 +572,7 @@ def _load_context(self, layout_name: str) -> _LessonContext: settings = self.settings_repo.load() layout = self.layout_repo.get(layout_name) aggregates = self.aggregates_cache.get(layout_name) + last = latest_session_header(self.session_repo, layout_name) stats: Mapping[int, KeyStats] = aggregates.keys if aggregates else {} transitions: Mapping[Bigram, TransitionStats] = aggregates.transitions if aggregates else {} return _LessonContext( @@ -568,19 +582,10 @@ def _load_context(self, layout_name: str) -> _LessonContext: transitions=transitions, now=self.clock.wall_epoch(), target=target_ms_per_char(settings.target_speed_cpm), - remedial_alphabet=self._remedial_alphabet(layout_name), + remedial_alphabet=_remedial_alphabet(last), + saved_pair=last.weakest_pair if last is not None else None, ) - def _remedial_alphabet(self, layout_name: str) -> tuple[int, ...] | None: - """The last finished session's own lesson alphabet, when that - session's own WPM missed its own target -- otherwise None.""" - last = latest_session_header(self.session_repo, layout_name) - if last is None: - return None - if not session_wpm_below_target(last): - return None - return last.lesson_alphabet - def _generate_text( self, ctx: _LessonContext, diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index bdb32a8..0fc2036 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -16,9 +16,10 @@ target_ms_per_char, ) from keystrike.domain.enums import Mode, SessionState +from keystrike.domain.focus import weakest_session_pair from keystrike.domain.generator import effective_generated_word_bounds from keystrike.domain.learn_order import keyboard_order -from keystrike.domain.models import Keystroke, SessionResult, SessionStats +from keystrike.domain.models import Bigram, Keystroke, SessionResult, SessionStats from keystrike.domain.null_adapters import ( NULL_LAYOUT_REPOSITORY, NULL_SETTINGS_REPOSITORY, @@ -132,7 +133,7 @@ class _DraftTiming: duration_ns: int -def _snapshot_unlock_state( +def _snapshot_finish_state( session: Session, duration_ns: int, stats: SessionStats, @@ -140,7 +141,7 @@ def _snapshot_unlock_state( repo: SessionRepository, settings_repo: SettingsRepository, layout_repo: LayoutRepository, -) -> tuple[tuple[int, ...], dict[int, float]]: +) -> tuple[tuple[int, ...], dict[int, float], Bigram | None]: settings = settings_repo.load() layout = layout_repo.get(session.layout) draft = _DraftTiming(started_at=session.started_at_wall, duration_ns=duration_ns) @@ -165,12 +166,20 @@ def _snapshot_unlock_state( settings.unlock.min_transition_confidence_attempts ), ) - return unlocked, { + key_confidence = { cp: confidence_of( cp, combined.keys, target, min_attempts=settings.unlock.min_confidence_attempts ) for cp in unlocked } + weakest_pair = weakest_session_pair( + stats.transitions, + unlocked, + combined.transitions, + target, + min_attempts=settings.unlock.min_transition_confidence_attempts, + ) + return unlocked, key_confidence, weakest_pair @dataclass(slots=True) @@ -191,7 +200,7 @@ def __call__(self, session: Session) -> SessionResult: settings = self.settings_repo.load() target_speed_cpm = settings.target_speed_cpm stats = tally_session(session.keystrokes) - unlocked_keys, key_confidence = _snapshot_unlock_state( + unlocked_keys, key_confidence, weakest_pair = _snapshot_finish_state( session, duration_ns, stats, @@ -227,6 +236,7 @@ def __call__(self, session: Session) -> SessionResult: generated_min_len=generated_min_len, generated_max_len=generated_max_len, stats=stats, + weakest_pair=weakest_pair, ) self.repo.save_header(result) return result diff --git a/src/keystrike/domain/focus.py b/src/keystrike/domain/focus.py index f5a1698..80888fb 100644 --- a/src/keystrike/domain/focus.py +++ b/src/keystrike/domain/focus.py @@ -4,7 +4,7 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass from .confidence import ( @@ -185,6 +185,39 @@ def newest_key_unmeasured_pairs( return [] if measured else unmeasured +def _is_slow_measured(stats: TransitionStats | None, target: float, *, min_attempts: int) -> bool: + """Enough attempts to trust the timing, and still slower than the goal.""" + return ( + stats is not None + and attempts_of(stats) >= min_attempts + and skill_from_stats(stats, target) < 1.0 + ) + + +def weakest_session_pair( + session_pairs: Iterable[Bigram], + unlocked: Sequence[int], + transitions: Mapping[Bigram, TransitionStats], + target: float, + *, + min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, +) -> Bigram | None: + """Slowest cross-key pair typed in a finished session, judged on the + combined window stats: enough attempts (`min_attempts`) and raw skill + still under 1.0, lowest skill first. None when no such pair exists. + + Saved on the session result so the next lesson can pick up the weak pair + right away instead of starting a fresh calibration.""" + eligible = frozenset(unlocked_cross_key_pairs(unlocked)) + slow = [ + pair + for pair in session_pairs + if pair in eligible + and _is_slow_measured(transitions.get(pair), target, min_attempts=min_attempts) + ] + return min(slow, key=lambda pair: skill_from_stats(transitions[pair], target), default=None) + + def select_focus_transition( unlocked: Sequence[int], transitions: Mapping[Bigram, TransitionStats], @@ -193,12 +226,18 @@ def select_focus_transition( *, key_stats: Mapping[int, KeyStats] | None = None, gating_candidates: Sequence[Bigram] | None = None, + preferred: Bigram | None = None, review_penalty: float = 0.5, threshold: float = 1.0, min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, ) -> Bigram | None: """Weakest unlocked bigram by transition confidence. + `preferred` (the last session's saved `weakest_session_pair`) wins over + ordinary weakest-pair scoring when it is still measured and slow, but + only after the newest key's unmeasured pairs and never in + `gating_candidates` mode, where the unlock cohort comes first. + Measured pairs are preferred. But when the newest practiced key has no measured transitions of its own yet (`newest_key_unmeasured_pairs`), fall back to its weakest-scoring unmeasured pair — so a freshly-opened letter @@ -231,8 +270,7 @@ def _score(pair: Bigram) -> float: pair for pair in measured if pair not in gating_set - and attempts_of(transitions[pair]) >= min_attempts - and skill_from_stats(transitions[pair], target) < 1.0 + and _is_slow_measured(transitions[pair], target, min_attempts=min_attempts) ] if regressions: return min(regressions, key=_score) @@ -243,6 +281,12 @@ def _score(pair: Bigram) -> float: return min(candidates, key=_score) if not measured: return None + if ( + preferred is not None + and preferred in measured + and _is_slow_measured(transitions.get(preferred), target, min_attempts=min_attempts) + ): + return preferred not_cleared = [ pair for pair in measured @@ -266,6 +310,7 @@ def remedial_focus( target: float, *, now: float, + preferred: Bigram | None = None, threshold: float = 1.0, min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, min_transition_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, @@ -292,6 +337,7 @@ def remedial_focus( target, now, key_stats=stats, + preferred=preferred, threshold=threshold, min_attempts=min_transition_attempts, ) diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index c143fc8..3024cff 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -111,6 +111,10 @@ class SessionResult: # Per-key/bigram tallies (schema 5+). Empty for sessions older than the # stats retention window (see domain.retention) — their history row stays. stats: SessionStats = field(default_factory=SessionStats) + # Slowest pair typed this session (see `domain.focus.weakest_session_pair`); + # the next lesson's transition focus starts there. Optional, so schema 5 + # rows without it still read back unchanged. + weakest_pair: Bigram | None = None def __post_init__(self) -> None: # Freezing the dataclass only blocks attribute rebinding — wrap the diff --git a/src/keystrike/infrastructure/session_repo_jsonl.py b/src/keystrike/infrastructure/session_repo_jsonl.py index 94f88d3..219fe6c 100644 --- a/src/keystrike/infrastructure/session_repo_jsonl.py +++ b/src/keystrike/infrastructure/session_repo_jsonl.py @@ -29,7 +29,9 @@ # `[samples, time_ns, errors, attempts]`, keyed by codepoint for keys and by # "prev,next" codepoints for bigrams. Omitted entirely when the stats are empty. _STATS_KEY = "stats" +_WEAKEST_PAIR_KEY = "weakest_pair" _TALLY_FIELDS = 4 +_PAIR_FIELDS = 2 class JsonlSessionRepository: @@ -138,6 +140,10 @@ def header_to_row(h: SessionResult) -> dict[str, object]: del base[_STATS_KEY] else: base[_STATS_KEY] = stats_to_row(h.stats) + if h.weakest_pair is None: + del base[_WEAKEST_PAIR_KEY] + else: + base[_WEAKEST_PAIR_KEY] = [h.weakest_pair.prev_cp, h.weakest_pair.next_cp] return base @@ -167,6 +173,15 @@ def _parse_key_confidence(raw: object) -> dict[int, float]: return out +def _parse_weakest_pair(raw: object) -> Bigram | None: + if raw is None: + return None + if not isinstance(raw, (list, tuple)) or len(cast("list[object]", raw)) != _PAIR_FIELDS: + raise TypeError(f"expected [prev, next] for {_WEAKEST_PAIR_KEY!r}, got {raw!r}") + prev, nxt = (coerce_int(v, label=_WEAKEST_PAIR_KEY) for v in cast("list[object]", raw)) + return Bigram(prev, nxt) + + def header_from_row(d: dict[str, object]) -> SessionResult: session_id = require_str(d, "session_id") validate_session_id(session_id) # Reject path-traversal attempts @@ -189,4 +204,5 @@ def header_from_row(d: dict[str, object]) -> SessionResult: generated_min_len=require_int(d, "generated_min_len", GENERATED_WORD_MIN_LEN), generated_max_len=require_int(d, "generated_max_len", GENERATED_WORD_MAX_LEN), stats=stats_from_row(d.get(_STATS_KEY)), + weakest_pair=_parse_weakest_pair(d.get(_WEAKEST_PAIR_KEY)), ) diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 60586db..19d3d08 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -1,3 +1,4 @@ +from dataclasses import replace from random import Random from keystrike.application.build_lesson import ( @@ -1337,3 +1338,54 @@ def test_large_alphabet_unlock_advances_when_keys_rotated(): else: assert len(unlocked) == forced + 1 assert unlocked[-1] == order[forced] + + +def test_saved_weakest_pair_wins_remedial_focus_over_calibrating_pair(): + """After a lesson that missed its WPM target, the saved weakest pair wins + over a calibrating pair that ordinary scoring would pick (fewer attempts, + so lower ramped confidence).""" + layout = BUNDLED_LAYOUTS["qwerty"] + order = keyboard_order(layout) + a, s, h = order[0], order[1], order[2] + now = 1_700_000_000.0 + at_target = 200_000_000.0 + keys = {cp: KeyStats(cp, 10, at_target, 0, now, attempt_count=10) for cp in (a, s, h)} + saved = Bigram(a, s) + calibrating = Bigram(s, h) + transitions = { + saved: TransitionStats(*saved, 10, at_target / 0.5, 0, now, attempt_count=10), + calibrating: TransitionStats(*calibrating, 1, at_target, 0, now, attempt_count=1), + } + cache = FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, + ) + + def build(headers: list[SessionResult]): + return BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=cache, + settings_repo=FakeSettingsRepository(Settings(alphabet_size=3, target_speed_cpm=300)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + session_repo=FakeSessionRepository(headers=headers), + )("qwerty") + + last = SessionResult( + schema_version=5, + session_id="last", + started_at=1.0, + duration_ns=60_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(a, s, h), + focus_key=a, + total_keystrokes=1, + correct_keystrokes=1, + words_completed=1, + target_speed_cpm=300, # 60s for one word: far under target, so remedial + weakest_pair=saved, + ) + assert build([last]).focus_key == saved.next_cp + assert build([replace(last, weakest_pair=None)]).focus_key == calibrating.next_cp diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index 5c946d6..579e44a 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -766,3 +766,45 @@ def test_finish_session_alphabet_bump_respects_transition_gate(clock, id_gen): unlocked_cps = {k.codepoint for k in lesson.state.keys} assert unlocked_cps == set(order[:4]) assert order[4] not in unlocked_cps + + +def test_finish_session_saves_weakest_pair_typed_in_session(clock, id_gen): + settings_repo = FakeSettingsRepository( + Settings( + target_speed_cpm=300, + unlock=UnlockTuning(min_transition_confidence_attempts=2), + ) + ) + repo = FakeSessionRepository() + finish = FinishSession( + clock=clock, + repo=repo, + settings_repo=settings_repo, + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + ) + start = StartSession(clock=clock, id_gen=id_gen) + record = RecordKeystroke(clock=clock) + order = keyboard_order(BUNDLED_LAYOUTS["qwerty"]) + a, b = chr(order[0]), chr(order[1]) + session = start(f"{a}{b}{a}{b}", layout="qwerty", mode=Mode.ADAPTIVE, focus_key=order[0]) + for ch in f"{a}{b}{a}{b}": + clock.advance(2_000_000_000) # far slower than 300 cpm + record(session, ch) + result = finish(session) + assert result.weakest_pair is not None + assert {result.weakest_pair.prev_cp, result.weakest_pair.next_cp} == {order[0], order[1]} + assert repo.headers[0].weakest_pair == result.weakest_pair + + +def test_finish_session_without_slow_pair_saves_none(clock, id_gen): + repo = FakeSessionRepository() + finish = FinishSession( + clock=clock, + repo=repo, + settings_repo=FakeSettingsRepository(), + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + ) + start = StartSession(clock=clock, id_gen=id_gen) + session = start("ab", layout="qwerty", mode=Mode.ADAPTIVE, focus_key=ord("a")) + result = finish(session) + assert result.weakest_pair is None diff --git a/tests/domain/test_focus.py b/tests/domain/test_focus.py index ecbbcae..5a5efe3 100644 --- a/tests/domain/test_focus.py +++ b/tests/domain/test_focus.py @@ -15,6 +15,7 @@ remedial_focus, select_focus, select_focus_transition, + weakest_session_pair, ) from keystrike.domain.models import Bigram, KeyStats, TransitionStats @@ -497,3 +498,70 @@ def test_genuine_old_transition_regression_preempts_gating_cohort(): ) == old ) + + +def test_weakest_session_pair_picks_slowest_pair_with_enough_attempts(): + unlocked = (ord("a"), ord("b"), ord("c")) + slow = Bigram(ord("a"), ord("b")) + slower = Bigram(ord("b"), ord("c")) + calibrating = Bigram(ord("c"), ord("a")) # slowest, but too few attempts + transitions = { + slow: _transition(*slow, 400_000_000.0), + slower: _transition(*slower, 800_000_000.0), + calibrating: _transition(*calibrating, 2_000_000_000.0, attempt_count=2), + } + assert weakest_session_pair(transitions, unlocked, transitions, 200.0) == slower + + +def test_weakest_session_pair_none_when_all_pairs_meet_target(): + unlocked = (ord("a"), ord("b")) + fast = Bigram(ord("a"), ord("b")) + transitions = {fast: _transition(*fast, 100_000_000.0)} + assert weakest_session_pair(transitions, unlocked, transitions, 200.0) is None + + +def test_weakest_session_pair_only_considers_pairs_typed_in_the_session(): + unlocked = (ord("a"), ord("b"), ord("c")) + typed = Bigram(ord("a"), ord("b")) + untouched = Bigram(ord("b"), ord("c")) + transitions = { + typed: _transition(*typed, 400_000_000.0), + untouched: _transition(*untouched, 800_000_000.0), + } + assert weakest_session_pair([typed], unlocked, transitions, 200.0) == typed + + +def test_weakest_session_pair_skips_same_key_and_locked_pairs(): + unlocked = (ord("a"), ord("b")) + same_key = Bigram(ord("a"), ord("a")) + locked = Bigram(ord("a"), ord("z")) + transitions = { + same_key: _transition(*same_key, 800_000_000.0), + locked: _transition(*locked, 800_000_000.0), + } + assert weakest_session_pair(transitions, unlocked, transitions, 200.0) is None + + +def test_select_focus_transition_prefers_saved_slow_pair_over_calibrating_pair(): + now = 1_700_000_000.0 + unlocked = (ord("a"), ord("b"), ord("c")) + saved = Bigram(ord("a"), ord("b")) + calibrating = Bigram(ord("b"), ord("c")) + transitions = { + saved: _transition(*saved, 400_000_000.0, last_seen=now), + calibrating: _transition(*calibrating, 200_000_000.0, last_seen=now, attempt_count=1), + } + assert select_focus_transition(unlocked, transitions, 200.0, now) == calibrating + assert select_focus_transition(unlocked, transitions, 200.0, now, preferred=saved) == saved + + +def test_select_focus_transition_ignores_saved_pair_that_is_no_longer_slow(): + now = 1_700_000_000.0 + unlocked = (ord("a"), ord("b"), ord("c")) + saved = Bigram(ord("a"), ord("b")) + other = Bigram(ord("b"), ord("c")) + transitions = { + saved: _transition(*saved, 100_000_000.0, last_seen=now), # now fast + other: _transition(*other, 400_000_000.0, last_seen=now), + } + assert select_focus_transition(unlocked, transitions, 200.0, now, preferred=saved) == other diff --git a/tests/infrastructure/test_session_repo_jsonl.py b/tests/infrastructure/test_session_repo_jsonl.py index 9c65287..2f6bca7 100644 --- a/tests/infrastructure/test_session_repo_jsonl.py +++ b/tests/infrastructure/test_session_repo_jsonl.py @@ -66,6 +66,20 @@ def test_round_trip_single_session(paths): assert headers[0].stats.transitions[Bigram(ord("a"), ord("b"))] == KeyTally(1, 100, 0, 1) +def test_round_trip_weakest_pair(paths): + repo = JsonlSessionRepository(paths) + header = replace(_header(), weakest_pair=Bigram(ord("s"), ord("a"))) + repo.save_header(header) + assert list(JsonlSessionRepository(paths).iter_headers("qwerty")) == [header] + + +def test_row_without_weakest_pair_reads_back_as_none(paths): + repo = JsonlSessionRepository(paths) + repo.save_header(_header()) + assert "weakest_pair" not in paths.sessions_index.read_text(encoding="utf-8") + assert next(iter(repo.iter_headers("qwerty"))).weakest_pair is None + + def test_iter_headers_filters_by_layout(paths): repo = JsonlSessionRepository(paths) repo.save_header(_header(sid=_VALID_ULID_A, layout="qwerty")) From 4ed2d60b7c0b8b09b24a78e2431b16034c919673 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 7 Oct 2026 10:40:24 +0300 Subject: [PATCH 44/47] Address review of the weakest-pair change Co-Authored-By: Claude Opus 5.5 --- src/keystrike/domain/focus.py | 13 ++--- tests/application/test_session_use_cases.py | 53 ++++++++++++++++--- tests/domain/test_focus.py | 49 +++++++++++++++++ .../infrastructure/test_session_repo_jsonl.py | 26 +++++++++ 4 files changed, 127 insertions(+), 14 deletions(-) diff --git a/src/keystrike/domain/focus.py b/src/keystrike/domain/focus.py index 80888fb..576be01 100644 --- a/src/keystrike/domain/focus.py +++ b/src/keystrike/domain/focus.py @@ -185,13 +185,9 @@ def newest_key_unmeasured_pairs( return [] if measured else unmeasured -def _is_slow_measured(stats: TransitionStats | None, target: float, *, min_attempts: int) -> bool: +def _is_slow_measured(stats: TransitionStats, target: float, *, min_attempts: int) -> bool: """Enough attempts to trust the timing, and still slower than the goal.""" - return ( - stats is not None - and attempts_of(stats) >= min_attempts - and skill_from_stats(stats, target) < 1.0 - ) + return attempts_of(stats) >= min_attempts and skill_from_stats(stats, target) < 1.0 def weakest_session_pair( @@ -213,7 +209,8 @@ def weakest_session_pair( pair for pair in session_pairs if pair in eligible - and _is_slow_measured(transitions.get(pair), target, min_attempts=min_attempts) + and pair in transitions + and _is_slow_measured(transitions[pair], target, min_attempts=min_attempts) ] return min(slow, key=lambda pair: skill_from_stats(transitions[pair], target), default=None) @@ -284,7 +281,7 @@ def _score(pair: Bigram) -> float: if ( preferred is not None and preferred in measured - and _is_slow_measured(transitions.get(preferred), target, min_attempts=min_attempts) + and _is_slow_measured(transitions[preferred], target, min_attempts=min_attempts) ): return preferred not_cleared = [ diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index 579e44a..1ca7aa2 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -796,15 +796,56 @@ def test_finish_session_saves_weakest_pair_typed_in_session(clock, id_gen): assert repo.headers[0].weakest_pair == result.weakest_pair -def test_finish_session_without_slow_pair_saves_none(clock, id_gen): - repo = FakeSessionRepository() +def _finish_typed(clock, id_gen, repo, text: str, step_ns: int, *, min_pair_attempts: int): + """Type `text` at a fixed pace through Start/Record/FinishSession.""" finish = FinishSession( clock=clock, repo=repo, - settings_repo=FakeSettingsRepository(), + settings_repo=FakeSettingsRepository( + Settings( + target_speed_cpm=300, + unlock=UnlockTuning(min_transition_confidence_attempts=min_pair_attempts), + ) + ), layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), ) - start = StartSession(clock=clock, id_gen=id_gen) - session = start("ab", layout="qwerty", mode=Mode.ADAPTIVE, focus_key=ord("a")) - result = finish(session) + session = StartSession(clock=clock, id_gen=id_gen)( + text, layout="qwerty", mode=Mode.ADAPTIVE, focus_key=ord(text[0]) + ) + record = RecordKeystroke(clock=clock) + for ch in text: + clock.advance(step_ns) + record(session, ch) + return finish(session) + + +def test_finish_session_without_slow_pair_saves_none(clock, id_gen): + order = keyboard_order(BUNDLED_LAYOUTS["qwerty"]) + a, b = chr(order[0]), chr(order[1]) + fast = 50_000_000 # well over 300 cpm + result = _finish_typed( + clock, id_gen, FakeSessionRepository(), f"{a}{b}{a}{b}", fast, min_pair_attempts=2 + ) assert result.weakest_pair is None + + +def test_finish_session_skips_slow_pair_below_attempt_floor(clock, id_gen): + order = keyboard_order(BUNDLED_LAYOUTS["qwerty"]) + a, b = chr(order[0]), chr(order[1]) + slow = 2_000_000_000 + result = _finish_typed( + clock, id_gen, FakeSessionRepository(), f"{a}{b}", slow, min_pair_attempts=4 + ) + assert result.weakest_pair is None + + +def test_finish_session_skips_slow_pair_only_in_history(clock, id_gen): + order = keyboard_order(BUNDLED_LAYOUTS["qwerty"]) + a, b = chr(order[0]), chr(order[1]) + slow = 2_000_000_000 + repo = FakeSessionRepository() + first = _finish_typed(clock, id_gen, repo, f"{a}{b}{a}{b}", slow, min_pair_attempts=2) + assert first.weakest_pair is not None + # Same-key presses only: the slow pair stays in the window but is not typed. + second = _finish_typed(clock, id_gen, repo, f"{a}{a}{a}", slow, min_pair_attempts=2) + assert second.weakest_pair is None diff --git a/tests/domain/test_focus.py b/tests/domain/test_focus.py index 5a5efe3..fdab52f 100644 --- a/tests/domain/test_focus.py +++ b/tests/domain/test_focus.py @@ -565,3 +565,52 @@ def test_select_focus_transition_ignores_saved_pair_that_is_no_longer_slow(): other: _transition(*other, 400_000_000.0, last_seen=now), } assert select_focus_transition(unlocked, transitions, 200.0, now, preferred=saved) == other + + +def test_select_focus_transition_saved_pair_loses_to_newest_key_unmeasured_pairs(): + unlocked = (ord("a"), ord("b"), ord("c")) + now = 1_700_000_000.0 + stats = {cp: KeyStats(cp, 10, 200_000_000.0, 0, now, attempt_count=10) for cp in unlocked} + saved = Bigram(ord("a"), ord("b")) + transitions = {saved: _transition(*saved, 400_000_000.0, last_seen=now)} + result = select_focus_transition( + unlocked, transitions, 200.0, now, key_stats=stats, preferred=saved + ) + assert result is not None + assert ord("c") in result + + +def test_select_focus_transition_ignores_saved_pair_in_gating_mode(): + a, b, c = map(ord, "abc") + gate = Bigram(b, c) + weaker = Bigram(a, b) + saved = Bigram(c, a) + transitions = { + weaker: _transition(*weaker, 800_000_000.0), + saved: _transition(*saved, 400_000_000.0), + } + assert ( + select_focus_transition( + (a, b, c), transitions, 200.0, 1_000.0, gating_candidates=(gate,), preferred=saved + ) + == weaker + ) + + +@pytest.mark.parametrize( + "saved", + [ + Bigram(ord("c"), ord("a")), # no transition data + Bigram(ord("a"), ord("b")), # slow, but too few attempts + ], +) +def test_select_focus_transition_ignores_unmeasured_or_calibrating_saved_pair(saved): + now = 1_700_000_000.0 + unlocked = (ord("a"), ord("b"), ord("c")) + calibrating = Bigram(ord("a"), ord("b")) + weakest = Bigram(ord("b"), ord("c")) + transitions = { + calibrating: _transition(*calibrating, 400_000_000.0, last_seen=now, attempt_count=2), + weakest: _transition(*weakest, 800_000_000.0, last_seen=now, attempt_count=1), + } + assert select_focus_transition(unlocked, transitions, 200.0, now, preferred=saved) == weakest diff --git a/tests/infrastructure/test_session_repo_jsonl.py b/tests/infrastructure/test_session_repo_jsonl.py index 2f6bca7..cc5cb91 100644 --- a/tests/infrastructure/test_session_repo_jsonl.py +++ b/tests/infrastructure/test_session_repo_jsonl.py @@ -254,6 +254,7 @@ def test_legacy_row_without_stats_reads_as_empty_stats(paths): headers = list(JsonlSessionRepository(paths).iter_headers("qwerty")) assert headers[0].session_id == _VALID_ULID_D assert headers[0].stats.is_empty + assert headers[0].weakest_pair is None @pytest.mark.parametrize( @@ -282,6 +283,31 @@ def test_malformed_stats_skip_the_row_not_the_index(paths, stats_json): assert headers == [_VALID_ULID_A, _VALID_ULID_B] +@pytest.mark.parametrize( + "pair_json", + [ + "[97]", # wrong arity + "[97, 98, 99]", # wrong arity + '"97,98"', # not a list + '[97, "x"]', # not a codepoint + ], +) +def test_malformed_weakest_pair_skips_the_row_not_the_index(paths, pair_json): + repo = JsonlSessionRepository(paths) + repo.save_header(_header(sid=_VALID_ULID_A)) + with paths.sessions_index.open("a", encoding="utf-8") as fh: + fh.write( + f'{{"schema_version": 5, "session_id": "{_VALID_ULID_E}", "layout": "qwerty", ' + '"started_at": 1700000000.0, "duration_ns": 1000000000, "mode": "adaptive", ' + '"lesson_alphabet": [], "focus_key": null, "total_keystrokes": 0, ' + f'"correct_keystrokes": 0, "weakest_pair": {pair_json}}}\n' + ) + repo.save_header(_header(sid=_VALID_ULID_B)) + + headers = [h.session_id for h in JsonlSessionRepository(paths).iter_headers("qwerty")] + assert headers == [_VALID_ULID_A, _VALID_ULID_B] + + def test_replace_all_headers_rewrites_index_in_place(paths): repo = JsonlSessionRepository(paths) a = replace(_header(sid=_VALID_ULID_A, started_at=1.0), stats=_stats()) From 4a61da30d853c0d94f91c53e342f1edf810fa734 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 7 Oct 2026 11:21:21 +0300 Subject: [PATCH 45/47] Fix the attempt floors, accuracy target and WPM gate - Count attempt floors and the stall cap on real presses in the window, not recency-weighted ones (aggregates cache schema 2). - Score accuracy against a 95% target and keep weighted errors fractional, so one old typo no longer blocks a well-practiced key. - Compare lesson speed in keystrokes per minute, spaces included, so the remedial WPM gate stops firing at exactly target speed. Co-Authored-By: Claude Sonnet 5.5 --- docs/CHANGELOG.md | 22 +++++ docs/research/typing-pedagogy.md | 2 +- docs/wiki/Confidence-Tuning.md | 51 ++++++---- docs/wiki/Focus-States.md | 11 ++- src/keystrike/application/build_lesson.py | 4 +- src/keystrike/application/session_queries.py | 39 ++++---- src/keystrike/application/stats_use_cases.py | 2 +- src/keystrike/domain/aggregate.py | 26 +++-- src/keystrike/domain/confidence.py | 30 ++++-- src/keystrike/domain/models.py | 8 +- src/keystrike/domain/unlock.py | 24 ++--- .../infrastructure/aggregates_cache.py | 17 +++- tests/application/test_build_lesson.py | 15 +-- tests/application/test_session_use_cases.py | 61 +++++++----- tests/application/test_stats_use_cases.py | 10 +- tests/domain/test_aggregate.py | 64 ++++++++++++- tests/domain/test_confidence.py | 20 +++- tests/domain/test_unlock.py | 94 ++++++++++++++++++- tests/infrastructure/test_aggregates_cache.py | 30 +++++- 19 files changed, 397 insertions(+), 133 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f9607fa..1025e31 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,28 @@ design) stays in `PLAN.md` §5/§6. ## Unreleased +- **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. +- **Lesson WPM gate compares like with like** — `session_wpm_below_target` + compares correct keystrokes per minute (spaces included) with + `target_speed_cpm`. It used WPM against `cpm / mean word length`, which + leaves out the space, so a lesson typed at exactly target speed on every + key read as about 25% too slow and kept the remedial focus on. Sessions + without a target (`target_speed_cpm == 0`) still never trigger it. - **Weakest pair saved on the session result** — `FinishSession` now stores `SessionResult.weakest_pair`: the slowest cross-key pair typed in the session (enough attempts, skill < 1.0, lowest raw skill; diff --git a/docs/research/typing-pedagogy.md b/docs/research/typing-pedagogy.md index 701acef..009cf2e 100644 --- a/docs/research/typing-pedagogy.md +++ b/docs/research/typing-pedagogy.md @@ -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_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. diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index 1d08444..5736055 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -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. | @@ -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): @@ -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. @@ -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 @@ -121,10 +130,14 @@ 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. +Every member must pass the same `domain.confidence.clears_threshold` rule as +key unlock and focus. 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. +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** is letter-first: while any unlocked key is below the skill threshold or key-attempt floor, the lesson emphasizes the weakest unlocked @@ -144,13 +157,17 @@ 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 +A finished session's own typing speed is compared against its own target +speed in the same unit the keys are timed in: correct keystrokes per minute, +spaces included, against `target_speed_cpm`. (Words per minute converted with +`SettingsScreen`'s CPM→WPM rule would leave out the space after each word, so +a lesson typed at exactly target speed on every key would miss it by about a +quarter; word-list lessons with longer words would also be judged by a wrong +word length.) If that session's speed 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 +effect lesson-over-lesson until a lesson's speed 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 diff --git a/docs/wiki/Focus-States.md b/docs/wiki/Focus-States.md index 176af8c..446006d 100644 --- a/docs/wiki/Focus-States.md +++ b/docs/wiki/Focus-States.md @@ -38,7 +38,7 @@ 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 @@ -65,11 +65,14 @@ is due for review (`rev`) again. ## Lesson WPM gate -If a finished lesson's overall words-per-minute came in under its own target, +If a finished lesson's overall typing speed 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's own text* until a lesson's speed 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. The pair comes from the session's saved +whatever else is weakest across the full practice history. Speed here is +correct keystrokes per minute, spaces included, against the target CPM — the +same unit every key is timed against — so a lesson typed at exactly target +speed on every key meets it. The pair comes from the session's saved weakest pair (the slowest pair it typed with enough attempts) when there is one, so a barely-sampled calibrating pair doesn't take the slot. This doesn't add a new HUD label; the chosen key/pair still shows the ordinary `wk`, `cal`, or diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index a95734a..9a81a01 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -505,7 +505,7 @@ class _LessonContext: now: float target: float # The just-finished session's own lesson alphabet, when that session's own - # WPM fell short of its own target -- None otherwise (normal selection). + # typing speed fell short of its own target -- None otherwise (normal selection). remedial_alphabet: tuple[int, ...] | None = None # The just-finished session's saved weakest pair (`SessionResult.weakest_pair`). saved_pair: Bigram | None = None @@ -513,7 +513,7 @@ class _LessonContext: def _remedial_alphabet(last: SessionResult | None) -> tuple[int, ...] | None: """The last finished session's own lesson alphabet, when that session's - own WPM missed its own target -- otherwise None.""" + own typing speed missed its own target -- otherwise None.""" if last is None or not session_wpm_below_target(last): return None return last.lesson_alphabet diff --git a/src/keystrike/application/session_queries.py b/src/keystrike/application/session_queries.py index bdab425..5280f09 100644 --- a/src/keystrike/application/session_queries.py +++ b/src/keystrike/application/session_queries.py @@ -2,7 +2,7 @@ from __future__ import annotations -from keystrike.domain.generator import typical_chars_per_word, wpm_from_cpm +from keystrike.domain.generator import typical_chars_per_word from keystrike.domain.models import SessionResult from keystrike.domain.protocols import SessionRepository @@ -24,6 +24,15 @@ def compute_wpm(result: SessionResult) -> float: return _words_for_wpm(result) / minutes +def compute_cpm(result: SessionResult) -> float: + """Correct keystrokes per minute, spaces included — the same unit as + `target_speed_cpm`, which every key (space included) is timed against.""" + minutes = result.duration_ns / 1e9 / 60.0 + if minutes <= 0: + return 0.0 + return result.correct_keystrokes / minutes + + def compute_accuracy(result: SessionResult) -> float: if result.total_keystrokes == 0: return 0.0 @@ -50,24 +59,16 @@ def latest_session_header(repo: SessionRepository, layout: str) -> SessionResult return max(headers, key=lambda h: h.started_at, default=None) -def session_wpm_below_target( - result: SessionResult, - *, - generated_min_len: int | None = None, - generated_max_len: int | None = None, -) -> bool: - """Whether a finished session's own WPM fell short of its own target - speed. Drives `BuildLesson`'s remedial lesson-alphabet focus gate; +def session_wpm_below_target(result: SessionResult) -> bool: + """Whether a finished session's own typing speed fell short of its own + target speed. Drives `BuildLesson`'s remedial lesson-alphabet focus gate; legacy sessions with no recorded target (``target_speed_cpm == 0``) - never trigger it. Word-length bounds come from the session header - (snapshotted at finish); optional overrides exist for tests.""" + never trigger it. + + Compares keystrokes per minute (spaces included) with `target_speed_cpm`, + not WPM with `wpm_from_cpm`: that conversion leaves out the space after + each word, so a learner at exactly target speed on every key would miss + it by about a quarter.""" if result.target_speed_cpm <= 0 or result.words_completed <= 0: return False - min_len = generated_min_len if generated_min_len is not None else result.generated_min_len - max_len = generated_max_len if generated_max_len is not None else result.generated_max_len - target_wpm = wpm_from_cpm( - result.target_speed_cpm, - generated_min_len=min_len, - generated_max_len=max_len, - ) - return compute_wpm(result) < target_wpm + return compute_cpm(result) < result.target_speed_cpm diff --git a/src/keystrike/application/stats_use_cases.py b/src/keystrike/application/stats_use_cases.py index 7b84fbb..764e9e1 100644 --- a/src/keystrike/application/stats_use_cases.py +++ b/src/keystrike/application/stats_use_cases.py @@ -161,7 +161,7 @@ def _aggregate_speed_accuracy( target_ms: float, ) -> tuple[float, float]: total_samples = 0 - total_errors = 0 + total_errors = 0.0 weighted_time = 0.0 for key_stats in stats.values(): total_samples += key_stats.samples diff --git a/src/keystrike/domain/aggregate.py b/src/keystrike/domain/aggregate.py index 572e6e4..8988073 100644 --- a/src/keystrike/domain/aggregate.py +++ b/src/keystrike/domain/aggregate.py @@ -7,7 +7,8 @@ The reduction happens in two stages. `tally_session` folds the keystroke stream into exact integer `KeyTally` counters per key and per bigram — that `SessionStats` is what gets persisted with the session header. `combine_sessions` -then merges the stored tallies of a window of sessions with recency weights. +then merges the stored tallies of a window of sessions: recency weights on +speed and accuracy, plain counts for attempts. """ from __future__ import annotations @@ -228,7 +229,7 @@ class MergedFields: samples: int mean_time_ns: float - error_count: int + error_count: float attempt_count: int last_seen: float @@ -237,8 +238,9 @@ def _weighted_merge_fields( entries: Sequence[tuple[HasConfidenceFields, float]], ) -> MergedFields: weighted_samples = sum(weight * stats.samples for stats, weight in entries) + # Errors stay fractional: rounding would turn one older typo (weight 0.7) + # into a full error and fail the accuracy target on its own. weighted_errors = sum(weight * stats.error_count for stats, weight in entries) - weighted_attempts = sum(weight * stats.attempt_count for stats, weight in entries) if weighted_samples > 0: mean = ( sum(stats.mean_time_ns * weight * stats.samples for stats, weight in entries) @@ -250,8 +252,10 @@ def _weighted_merge_fields( return MergedFields( samples=_rounded_weighted_count(weighted_samples), mean_time_ns=mean, - error_count=round(weighted_errors), - attempt_count=_rounded_weighted_count(weighted_attempts), + error_count=weighted_errors, + # Unweighted: the attempt floors and the transition stall cap count + # real presses in the window, so decay cannot hold them below the bar. + attempt_count=sum(stats.attempt_count for stats, _ in entries), last_seen=last_seen, ) @@ -260,7 +264,7 @@ def _combine_key_maps_weighted( maps: Sequence[dict[int, KeyStats]], weights: Sequence[float], ) -> dict[int, KeyStats]: - """Merge key stats with recency weights on speed, accuracy, and attempts.""" + """Merge key stats with recency weights on speed and accuracy.""" by_cp: dict[int, list[tuple[KeyStats, float]]] = {} for m, weight in zip(maps, weights, strict=True): for cp, stats in m.items(): @@ -312,8 +316,10 @@ def combine_sessions( """Merge per-session stats into one layout aggregate. Sessions must be in chronological order. Recent sessions weigh more on - mean time, accuracy, and attempt counts so the sample ramp tracks recent - practice, not stale volume alone. + mean time and accuracy so stale practice cannot mask recent form. Attempt + counts are plain sums over the window: they are evidence (the attempt + floors and the stall cap), and decay would keep a key or pair that gets a + few presses per lesson below the floor forever. """ if not sessions: return LayoutAggregates(keys={}, transitions={}) @@ -334,12 +340,12 @@ def infer_key_stat_samples(samples: int, mean_time_ns: float) -> int: return samples -def infer_key_stat_attempt_count(samples: int, error_count: int, attempt_count: int) -> int: +def infer_key_stat_attempt_count(samples: int, error_count: float, attempt_count: int) -> int: """Legacy caches predate a stored `attempt_count`; treat samples + errors as the inferred total whenever the stored value is non-positive.""" inferred = samples + error_count if attempt_count <= 0 and inferred > 0: - return inferred + return max(1, round(inferred)) return attempt_count diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index d59ad00..a17aaae 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -29,6 +29,11 @@ CONFIDENCE_DECIMALS = 2 # Mastery threshold: skill/confidence at or above this counts as "good". CONFIDENCE_GOOD = 1.0 +# Accuracy that counts as fully accurate for skill/confidence. The methodology +# (docs/research/typing-pedagogy.md: "accuracy first", structured training with +# accuracy above 95%) asks for a low error rate, not zero errors — so accuracy +# is scored against this target instead of against 100%. +ACCURACY_TARGET = 0.95 def round_confidence(value: float) -> float: @@ -60,7 +65,7 @@ class HasConfidenceFields(Protocol): @property def samples(self) -> int: ... @property - def error_count(self) -> int: ... + def error_count(self) -> float: ... @property def attempt_count(self) -> int: ... @property @@ -90,9 +95,15 @@ def accuracy_of(key_stats: KeyStats) -> float: return _accuracy(key_stats) +def _accuracy_score(stats: HasConfidenceFields) -> float: + """Accuracy against `ACCURACY_TARGET`, capped at 1.0: the accuracy term of + skill/confidence. Raw accuracy (`accuracy_of`) stays the display value.""" + return min(1.0, _accuracy(stats) / ACCURACY_TARGET) + + def _effective_attempt_count( samples: int, - error_count: int, + error_count: float, attempt_count: int, *, mean_time_ns: float = 0.0, @@ -106,7 +117,7 @@ def _effective_attempt_count( return attempt_count inferred = samples + error_count if inferred > 0: - return inferred + return max(1, round(inferred)) return _inferred_attempt(mean_time_ns) @@ -153,21 +164,22 @@ def confidence_from_stats( min_attempts: int, ) -> float: """Shared body for `confidence_of`/`transition_confidence_of`: min(speed, - accuracy) so fast-but-sloppy or slow-but-accurate cannot read as mastered, - scaled down until `min_attempts` so a lucky first session can't read as - mastered (see docs/research/typing-pedagogy.md). 0.0 when never practiced. + accuracy score) so fast-but-sloppy or slow-but-accurate cannot read as + mastered, scaled down until `min_attempts` so a lucky first session can't + read as mastered (see docs/research/typing-pedagogy.md). 0.0 when never + practiced. """ if stats is None: return 0.0 - raw = min(key_confidence(target, stats.mean_time_ns), _accuracy(stats)) + raw = min(key_confidence(target, stats.mean_time_ns), _accuracy_score(stats)) return round_confidence(raw * confidence_sample_factor(_attempts(stats), minimum=min_attempts)) def skill_from_stats(stats: HasConfidenceFields | None, target: float) -> float: - """Performance skill without attempt ramp — for display only.""" + """Performance skill without attempt ramp: min(speed, accuracy score).""" if stats is None: return 0.0 - raw = min(key_confidence(target, stats.mean_time_ns), _accuracy(stats)) + raw = min(key_confidence(target, stats.mean_time_ns), _accuracy_score(stats)) return round_confidence(raw) diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index 3024cff..f9284fc 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -130,8 +130,10 @@ class KeyStats: codepoint: int samples: int mean_time_ns: float - error_count: int + # Recency-weighted in windowed aggregates, so it can be fractional. + error_count: float last_seen: float + # Real presses in the window (not recency-weighted): the attempt floor. attempt_count: int = 0 @@ -141,9 +143,9 @@ class TransitionStats: next_cp: int samples: int mean_time_ns: float - error_count: int + error_count: float # see KeyStats last_seen: float - attempt_count: int = 0 + attempt_count: int = 0 # see KeyStats def _empty_transitions() -> dict[Bigram, TransitionStats]: diff --git a/src/keystrike/domain/unlock.py b/src/keystrike/domain/unlock.py index 0e7260a..27266eb 100644 --- a/src/keystrike/domain/unlock.py +++ b/src/keystrike/domain/unlock.py @@ -6,7 +6,7 @@ from collections.abc import Mapping, Sequence -from .confidence import attempts_of, clears_threshold, transition_confidence_of +from .confidence import attempts_of, clears_threshold from .models import Bigram, KeyStats, TransitionStats, UnlockTuning from .newest_key import newest_key_gating_cohort @@ -19,8 +19,9 @@ def default_transition_stall_attempts_cap(min_attempts: int) -> int: """Default `transition_stall_attempts_cap` for `compute_unlocked`: give a - stuck gating bigram this many times the normal calibration floor before - releasing it anyway. Single source of truth so callers that need the + stuck gating bigram this many times the normal calibration floor (real, + unweighted attempts in the window) before releasing it anyway. Single + source of truth so callers that need the gate (`build_lesson`, `session_use_cases`) can't compute this differently and drift apart.""" return min_attempts * TRANSITION_STALL_ATTEMPTS_MULTIPLIER @@ -56,18 +57,13 @@ def gating_bigram_is_ready( stall_attempts_cap: int | None = None, ) -> bool: stats = transitions.get(pair) - attempts = attempts_of(stats) if stats is not None else 0 - mastered = ( - attempts >= tuning.min_transition_confidence_attempts - and transition_confidence_of( - pair.prev_cp, - pair.next_cp, - transitions, - target, - min_attempts=tuning.min_transition_confidence_attempts, - ) - >= tuning.next_letter_unlock_threshold + mastered = clears_threshold( + stats, + target, + threshold=tuning.next_letter_unlock_threshold, + min_attempts=tuning.min_transition_confidence_attempts, ) + attempts = attempts_of(stats) if stats is not None else 0 return mastered or (stall_attempts_cap is not None and attempts >= stall_attempts_cap) diff --git a/src/keystrike/infrastructure/aggregates_cache.py b/src/keystrike/infrastructure/aggregates_cache.py index 3e83de6..91fbb37 100644 --- a/src/keystrike/infrastructure/aggregates_cache.py +++ b/src/keystrike/infrastructure/aggregates_cache.py @@ -18,6 +18,11 @@ from .json_coerce import require_float, require_int from .paths import Paths, sanitize_layout_name +# 2: attempt_count is a plain (unweighted) window count and error_count is +# fractional. Older files hold recency-weighted attempts, so `get` treats them +# as a miss and the aggregates rebuild from the session rows. +_SCHEMA_VERSION = 2 + def _coerce_samples(entry: dict[str, object]) -> int: samples = require_int(entry, "samples") @@ -26,8 +31,8 @@ def _coerce_samples(entry: dict[str, object]) -> int: def _coerce_attempt_count(entry: dict[str, object], samples: int) -> int: - errors = require_int(entry, "error_count") - stored = require_int(entry, "attempt_count", samples + errors) + errors = require_float(entry, "error_count") + stored = require_int(entry, "attempt_count", 0) return infer_key_stat_attempt_count(samples, errors, stored) @@ -41,7 +46,7 @@ def _parse_transition_entry(entry: dict[str, object]) -> tuple[Bigram, Transitio next_cp=require_int(entry, "next_cp"), samples=samples, mean_time_ns=require_float(entry, "mean_time_ns"), - error_count=require_int(entry, "error_count"), + error_count=require_float(entry, "error_count"), last_seen=require_float(entry, "last_seen"), attempt_count=_coerce_attempt_count(entry, samples), ) @@ -53,7 +58,7 @@ def _parse_key_entry(cp: str, entry: dict[str, object]) -> KeyStats: codepoint=int(cp), samples=samples, mean_time_ns=require_float(entry, "mean_time_ns"), - error_count=require_int(entry, "error_count"), + error_count=require_float(entry, "error_count"), last_seen=require_float(entry, "last_seen"), attempt_count=_coerce_attempt_count(entry, samples), ) @@ -73,6 +78,8 @@ def get(self, layout: str) -> LayoutAggregates | None: return None try: data = json.loads(file.read_text(encoding="utf-8")) + if data.get("schema_version") != _SCHEMA_VERSION: + return None keys = data.get("keys", {}) transitions = data.get("transitions", {}) parsed_transitions = dict( @@ -93,7 +100,7 @@ def get(self, layout: str) -> LayoutAggregates | None: def put(self, layout: str, aggregates: LayoutAggregates) -> None: transitions = without_same_key_transitions(aggregates.transitions) payload = { - "schema_version": 1, + "schema_version": _SCHEMA_VERSION, "layout": layout, "keys": {str(cp): asdict(k) for cp, k in aggregates.keys.items()}, "transitions": {key.chars(): asdict(t) for key, t in transitions.items()}, diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index 19d3d08..aa01638 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -1204,8 +1204,8 @@ def test_remedial_focus_targets_weak_key_from_low_wpm_lesson_alphabet(): def test_remedial_focus_uses_session_word_bounds_not_current_settings(): - """Changing word-length bounds after a session finishes must not alter - whether that session triggers remedial focus on the next lesson.""" + """Word-length bounds (at finish or in current settings) must not alter + whether a session triggers remedial focus on the next lesson.""" layout = BUNDLED_LAYOUTS["qwerty"] order = keyboard_order(layout) a, s, h, d = order[0], order[1], order[2], order[3] @@ -1220,7 +1220,7 @@ def test_remedial_focus_uses_session_word_bounds_not_current_settings(): cache = FakeAggregatesCache( by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, ) - # 50 wpm: below 100 wpm target at snapshotted 2-4 bounds, above 46 at 3-10. + # 1 keystroke in 60 s: far below 300 CPM whatever the word length. slow_session = SessionResult( schema_version=4, session_id="slow", @@ -1288,18 +1288,19 @@ def test_remedial_focus_clears_once_lesson_wpm_meets_target(): words_completed=1, target_speed_cpm=300, ) + # 12 words x 3 letters + 11 spaces at exactly 200 ms (300 CPM) per keystroke. fast_session = SessionResult( schema_version=3, session_id="fast", started_at=2.0, - duration_ns=1_000_000_000, + duration_ns=46 * 200_000_000, layout="qwerty", mode=Mode.ADAPTIVE, lesson_alphabet=(a, d), focus_key=d, - total_keystrokes=1, - correct_keystrokes=1, - words_completed=10, + total_keystrokes=47, + correct_keystrokes=47, + words_completed=12, target_speed_cpm=300, ) session_repo = FakeSessionRepository(headers=[slow_session, fast_session]) diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index 1ca7aa2..6e80257 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -25,6 +25,7 @@ from keystrike.domain.aggregate import combine_sessions from keystrike.domain.confidence import confidence_of, target_ms_per_char from keystrike.domain.enums import Mode, SessionState +from keystrike.domain.generator import wpm_from_cpm from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import ( CONFIDENCE_SESSION_WINDOW, @@ -460,29 +461,47 @@ def test_session_wpm_below_target_false_for_legacy_session_without_target(): assert session_wpm_below_target(result) is False -def test_session_wpm_below_target_uses_snapshotted_word_bounds(): - """WPM target conversion must use bounds recorded at finish, not caller overrides.""" - result = replace( - SessionResult( - session_id="s1", total_keystrokes=1, correct_keystrokes=1, **_session_stats_common() - ), - words_completed=50, - duration_ns=60_000_000_000, - target_speed_cpm=300, - generated_min_len=2, - generated_max_len=4, +def _drive_at_pace(text: str, ms_per_keystroke: int, target_speed_cpm: int) -> SessionResult: + clock = FakeClock() + settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=target_speed_cpm)) + session = StartSession(clock=clock, id_gen=FakeIdGenerator())( + text, layout="qwerty", mode=Mode.ADAPTIVE ) - # 50 wpm vs target 100 wpm (300 cpm / 3 chars per word at 2-4 bounds) + record = RecordKeystroke(clock=clock) + for ch in text: + clock.advance(ms_per_keystroke * 1_000_000) + record(session, ch) + return FinishSession(clock=clock, settings_repo=settings_repo)(session) + + +# 12 words x 3 letters + 11 spaces = 47 keystrokes (default lesson, 2-4 bounds). +_TWELVE_WORD_LESSON = " ".join(["abc"] * 12) + + +def test_session_wpm_below_target_false_when_every_key_is_at_target_speed(): + """40 WPM in Settings is 120 CPM (500 ms per key). Typing every keystroke, + spaces included, at exactly 500 ms meets the target: the gate counts the + spaces, so it must not fire. WPM against cpm / mean word length (40) would.""" + result = _drive_at_pace(_TWELVE_WORD_LESSON, 500, target_speed_cpm=120) + assert result.correct_keystrokes == 47 + assert result.words_completed == 12 + a = result.stats.keys[ord("a")] + assert a.time_ns / a.samples == 500_000_000 # key speed exactly 1.0 + assert compute_wpm(result) < wpm_from_cpm(120) # the old comparison fired here + assert session_wpm_below_target(result) is False + + +def test_session_wpm_below_target_true_when_keys_slower_than_target(): + result = _drive_at_pace(_TWELVE_WORD_LESSON, 550, target_speed_cpm=120) assert session_wpm_below_target(result) is True - # Wider bounds would lower target to ~46 wpm and incorrectly clear remedial. - assert ( - session_wpm_below_target( - result, - generated_min_len=3, - generated_max_len=10, - ) - is False - ) + + +def test_session_wpm_below_target_ignores_word_length_bounds(): + """Keystrokes per minute need no word-length guess, so word-list lessons + (3-10 letters) are judged the same way as generated ones.""" + result = _drive_at_pace(_TWELVE_WORD_LESSON, 500, target_speed_cpm=120) + wide = replace(result, generated_min_len=3, generated_max_len=10) + assert session_wpm_below_target(wide) is False def test_finish_session_persists_generated_word_bounds(clock, id_gen): diff --git a/tests/application/test_stats_use_cases.py b/tests/application/test_stats_use_cases.py index b1d8083..2a8cd3f 100644 --- a/tests/application/test_stats_use_cases.py +++ b/tests/application/test_stats_use_cases.py @@ -6,8 +6,7 @@ GetOrRebuildAggregates, RebuildAggregates, ) -from keystrike.domain.aggregate import session_recency_weights -from keystrike.domain.confidence import MIN_CONFIDENCE_ATTEMPTS, SESSION_RECENCY_DECAY +from keystrike.domain.confidence import MIN_CONFIDENCE_ATTEMPTS from keystrike.domain.enums import Mode from keystrike.domain.models import ( CONFIDENCE_SESSION_WINDOW, @@ -267,8 +266,8 @@ def test_rebuild_aggregates_drops_sessions_outside_window(): assert ord("a") in result assert ord("z") not in result - weights = session_recency_weights(CONFIDENCE_SESSION_WINDOW, decay=SESSION_RECENCY_DECAY) - assert result[ord("a")].attempt_count == round(2 * sum(weights)) + # Attempts are real presses in the window, not recency-weighted. + assert result[ord("a")].attempt_count == 2 * CONFIDENCE_SESSION_WINDOW def test_rebuild_aggregates_respects_settings_window(): @@ -300,8 +299,7 @@ def test_rebuild_aggregates_respects_settings_window(): result = cached.keys assert ord("z") not in result - weights = session_recency_weights(window, decay=SESSION_RECENCY_DECAY) - assert result[ord("a")].attempt_count == round(2 * sum(weights)) + assert result[ord("a")].attempt_count == 2 * window def test_get_heatmap_empty_cache_returns_empty_view(): diff --git a/tests/domain/test_aggregate.py b/tests/domain/test_aggregate.py index 25008c3..737adaf 100644 --- a/tests/domain/test_aggregate.py +++ b/tests/domain/test_aggregate.py @@ -1,3 +1,7 @@ +from dataclasses import replace + +import pytest + from keystrike.domain.aggregate import ( _combine_key_maps_weighted, _combine_transition_maps_weighted, @@ -11,6 +15,7 @@ ) from keystrike.domain.confidence import ( SESSION_RECENCY_DECAY, + clears_threshold, confidence_of, transition_accuracy_of, transition_confidence_of, @@ -152,7 +157,9 @@ def test_combine_sessions_favors_recent_session(): assert out.keys[ord("a")].mean_time_ns > 200_000_000 -def test_combine_sessions_weights_recent_attempts_more(): +def test_combine_sessions_counts_attempts_unweighted(): + """Attempts are evidence for the floors, so they are real presses in the + window — only speed and accuracy carry recency weights.""" s1 = _session("s1", started_at=1.0) s2 = _session("s2", started_at=2.0) keys1 = [ @@ -163,10 +170,57 @@ def test_combine_sessions_weights_recent_attempts_more(): Keystroke(codepoint=ord("a"), typed=ord("a"), t_ns=0, correct=True), ] out = combine_sessions([(s1, tally_session(keys1)), (s2, tally_session(keys2))]) - w_old, w_new = session_recency_weights(2) - expected_attempts = round(2 * w_old + 1 * w_new) - assert out.keys[ord("a")].attempt_count == expected_attempts - assert out.keys[ord("a")].attempt_count < 3 + assert out.keys[ord("a")].attempt_count == 3 + + +def _lesson_of_pairs(keys: str, ms: int = 200) -> list[Keystroke]: + return [ + Keystroke(codepoint=ord(ch), typed=ord(ch), t_ns=i * ms * 1_000_000, correct=True) + for i, ch in enumerate(keys) + ] + + +def test_combine_sessions_attempts_reach_floor_at_two_presses_per_lesson(): + """Default window (10) and decay (0.7): a pair typed twice per lesson has + 20 real attempts, not the ~6.5 a recency-weighted count settles at.""" + sessions = [ + (_session(f"s{i}", started_at=float(i)), tally_session(_lesson_of_pairs("abab"))) + for i in range(10) + ] + ab = combine_sessions(sessions).transitions[Bigram(ord("a"), ord("b"))] + assert ab.attempt_count == 20 + assert 2 * sum(session_recency_weights(10)) < 7 + + +def test_weighted_merge_keeps_one_old_typo_fractional(): + """One typo in the previous lesson (weight 0.7) with about 7 weighted + samples counts as 0.7 of an error, not a full one.""" + ab = Bigram(ord("a"), ord("b")) + old = TransitionStats(ord("a"), ord("b"), 4, 200_000_000.0, 1, 1.0, attempt_count=5) + recent = TransitionStats(ord("a"), ord("b"), 4, 200_000_000.0, 0, 2.0, attempt_count=4) + merged = _combine_transition_maps_weighted( + [{ab: old}, {ab: recent}], session_recency_weights(2) + )[ab] + assert merged.samples == 7 # 0.7 * 4 + 4 = 6.8 + assert merged.error_count == pytest.approx(0.7) + assert merged.attempt_count == 9 + assert transition_accuracy_of(merged) == pytest.approx(7 / 7.7) + + +def test_one_old_typo_does_not_block_an_otherwise_clean_pair(): + """14 weighted samples and one typo last-but-one lesson: 14 / 14.7 is + above the 95% accuracy target. Rounded to a full error (14 / 15) it was not.""" + ab = Bigram(ord("a"), ord("b")) + old = TransitionStats(ord("a"), ord("b"), 10, 200_000_000.0, 1, 1.0, attempt_count=11) + recent = TransitionStats(ord("a"), ord("b"), 7, 200_000_000.0, 0, 2.0, attempt_count=7) + merged = _combine_transition_maps_weighted( + [{ab: old}, {ab: recent}], session_recency_weights(2) + )[ab] + assert merged.samples == 14 + target = 200.0 # 300 CPM; the pair is at exactly target speed + assert clears_threshold(merged, target, threshold=1.0, min_attempts=4) + rounded = replace(merged, error_count=1) + assert not clears_threshold(rounded, target, threshold=1.0, min_attempts=4) def test_combine_sessions_recent_errors_weigh_more_on_confidence(): diff --git a/tests/domain/test_confidence.py b/tests/domain/test_confidence.py index 6f5ba44..c9b375d 100644 --- a/tests/domain/test_confidence.py +++ b/tests/domain/test_confidence.py @@ -1,4 +1,5 @@ from keystrike.domain.confidence import ( + ACCURACY_TARGET, MIN_CONFIDENCE_ATTEMPTS, accuracy_of, confidence_of, @@ -89,8 +90,8 @@ def test_confidence_of_scales_down_with_few_attempts(): attempt_count=2, ), } - # raw min(speed, accuracy) = min(2.0, 0.5) = 0.5; only 2 attempts -> x0.2 - assert confidence_of(ord("a"), stats, target=200.0) == 0.1 + # raw min(speed, accuracy score) = min(2.0, 0.5 / 0.95); only 2 attempts -> x0.2 + assert confidence_of(ord("a"), stats, target=200.0) == 0.11 def test_skill_of_ignores_attempt_ramp(): @@ -202,9 +203,9 @@ def test_transition_confidence_infers_attempts_from_mean_when_all_counts_zeroed( def test_confidence_of_penalizes_frequent_errors(): - # Fast (2.0 speed) but wrong half the time -> min(2.0, 0.5) = 0.5, not mastered. + # Fast (2.0 speed) but wrong half the time -> min(2.0, 0.5 / 0.95), not mastered. stats = {ord("a"): _stats(ord("a"), mean_time_ns=100_000_000.0, error_count=10)} - assert confidence_of(ord("a"), stats, target=200.0) == 0.5 + assert confidence_of(ord("a"), stats, target=200.0) == 0.53 def test_confidence_of_unseen_key_is_zero(): @@ -295,3 +296,14 @@ def _transition( last_seen=last_seen, attempt_count=attempts, ) + + +def test_skill_scores_accuracy_against_target(): + """Accuracy at `ACCURACY_TARGET` (95%) is full marks; below it scales down. + Raw accuracy stays the display value.""" + assert ACCURACY_TARGET == 0.95 + at_target = KeyStats(ord("a"), 19, 200_000_000.0, 1, 0.0, attempt_count=20) + below = KeyStats(ord("a"), 15, 200_000_000.0, 1, 0.0, attempt_count=16) + assert accuracy_of(at_target) == 0.95 + assert skill_of(ord("a"), {ord("a"): at_target}, 200.0) == 1.0 + assert skill_of(ord("a"), {ord("a"): below}, 200.0) == 0.99 # 0.9375 / 0.95 diff --git a/tests/domain/test_unlock.py b/tests/domain/test_unlock.py index c1b071a..7c4a302 100644 --- a/tests/domain/test_unlock.py +++ b/tests/domain/test_unlock.py @@ -1,8 +1,19 @@ +from keystrike.domain.aggregate import combine_sessions, tally_session from keystrike.domain.confidence import confidence_of, skill_of -from keystrike.domain.models import Bigram, KeyStats, TransitionStats, UnlockTuning +from keystrike.domain.enums import Mode +from keystrike.domain.models import ( + Bigram, + KeyStats, + Keystroke, + SessionResult, + TransitionStats, + UnlockTuning, +) from keystrike.domain.newest_key import newest_key_gating_cohort from keystrike.domain.unlock import ( compute_unlocked, + default_transition_stall_attempts_cap, + gating_bigram_is_ready, newest_key_clears_transition_gate, newest_key_transition_gate_progress, ) @@ -366,3 +377,84 @@ def test_newest_key_clears_transition_gate_stall_cap_overrides_weak_pair(): ) is False ) + + +def _header(i: int) -> SessionResult: + return SessionResult( + schema_version=5, + session_id=f"s{i}", + started_at=float(i), + duration_ns=1_000_000_000, + layout="qwerty", + mode=Mode.ADAPTIVE, + lesson_alphabet=(), + focus_key=None, + total_keystrokes=0, + correct_keystrokes=0, + ) + + +def _lesson(text: str, ms: int) -> list[Keystroke]: + return [ + Keystroke(codepoint=ord(ch), typed=ord(ch), t_ns=i * ms * 1_000_000, correct=True) + for i, ch in enumerate(text) + ] + + +def test_compute_unlocked_sixteen_keys_reach_attempt_floor_over_window(): + """16 unlocked keys and 12-word lessons (about 2 presses per key per + lesson): over the default 10-session window every key has 20 real + presses, so the default floor of 10 is met. Recency-weighted attempts + settle near 6.5 and held the next letter back forever.""" + learn_order = tuple(ord(ch) for ch in "abcdefghijklmnopq") + sixteen = "abcdefghijklmnop" + sessions = [(_header(i), tally_session(_lesson(sixteen * 2, 200))) for i in range(10)] + stats = combine_sessions(sessions).keys + assert all(stats[cp].attempt_count == 20 for cp in learn_order[:16]) + tuning = UnlockTuning() + assert tuning.min_confidence_attempts == 10 + unlocked = compute_unlocked(learn_order, 16, stats, target=200.0, tuning=tuning) + assert unlocked == learn_order + + +def test_transition_stall_cap_reachable_at_two_attempts_per_lesson(): + """A stuck cohort pair that gets 2 of the weak-focus coverage slots per + lesson reaches the default stall cap (3 x 4 = 12 real attempts) on the + 6th lesson in the window, instead of settling near 6.5 weighted ones.""" + ea = Bigram(ord("e"), ord("a")) + tuning = UnlockTuning() + cap = default_transition_stall_attempts_cap(tuning.min_transition_confidence_attempts) + assert cap == 12 + + def ready_after(lessons: int) -> bool: + # "eaea" types e->a twice, slowly (400 ms vs a 200 ms target). + sessions = [(_header(i), tally_session(_lesson("eaea", 400))) for i in range(lessons)] + transitions = combine_sessions(sessions).transitions + return gating_bigram_is_ready(ea, transitions, 200.0, tuning=tuning, stall_attempts_cap=cap) + + assert not ready_after(5) + assert ready_after(6) + assert ready_after(10) + + +def test_gate_pair_clears_at_accuracy_target(): + """The gate uses the same `clears_threshold` rule as key unlock: a pair + at target speed with 95% accuracy is ready — no near-perfect bar.""" + ea = Bigram(ord("e"), ord("a")) + pair = TransitionStats(ord("e"), ord("a"), 19, 200_000_000.0, 1, 0.0, attempt_count=20) + assert gating_bigram_is_ready(ea, {ea: pair}, 200.0) + sloppy = TransitionStats(ord("e"), ord("a"), 15, 200_000_000.0, 1, 0.0, attempt_count=16) + assert not gating_bigram_is_ready(ea, {ea: sloppy}, 200.0) + + +def test_compute_unlocked_allows_one_recent_typo_on_a_well_practiced_key(): + """Key s: 60 weighted samples, speed 1.3, one typo last lesson. Accuracy + 60 / 61 = 0.984 is above the 95% target, so s no longer blocks the unlock + (it read as skill 0.98 against a 100% accuracy bar).""" + a, s, d = ord("a"), ord("s"), ord("d") + stats = { + a: _stats(a, mean_time_ns=200_000_000.0 / 1.3), + s: KeyStats(s, 60, 200_000_000.0 / 1.3, 1, 0.0, attempt_count=61), + } + assert skill_of(s, stats, 200.0) == 1.0 + assert compute_unlocked((a, s, d), 2, stats, target=200.0) == (a, s, d) diff --git a/tests/infrastructure/test_aggregates_cache.py b/tests/infrastructure/test_aggregates_cache.py index 71bf993..99d272d 100644 --- a/tests/infrastructure/test_aggregates_cache.py +++ b/tests/infrastructure/test_aggregates_cache.py @@ -60,7 +60,7 @@ def test_get_pre_transition_cache_sets_transitions_computed_false(paths): cache._file("qwerty").write_text( json.dumps( { - "schema_version": 1, + "schema_version": 2, "layout": "qwerty", "keys": { "97": { @@ -118,7 +118,7 @@ def test_get_strips_same_key_transitions_from_stale_cache(paths): cache._file("qwerty").write_text( json.dumps( { - "schema_version": 1, + "schema_version": 2, "layout": "qwerty", "keys": {}, "transitions": { @@ -157,7 +157,7 @@ def test_get_repairs_zero_samples_when_mean_time_present(paths): cache._file("qwerty").write_text( json.dumps( { - "schema_version": 1, + "schema_version": 2, "layout": "qwerty", "keys": {}, "transitions": { @@ -197,7 +197,7 @@ def test_get_repairs_zero_attempt_count_when_samples_present(paths): cache._file("qwerty").write_text( json.dumps( { - "schema_version": 1, + "schema_version": 2, "layout": "qwerty", "keys": {}, "transitions": { @@ -229,3 +229,25 @@ def test_get_repairs_zero_attempt_count_when_samples_present(paths): ) > 0.0 ) + + +def test_get_treats_schema_1_cache_as_miss(paths): + """Schema 1 stored recency-weighted attempt counts; it must rebuild.""" + cache = FileAggregatesCache(paths) + cache._file("qwerty").write_text( + json.dumps({"schema_version": 1, "layout": "qwerty", "keys": {}, "transitions": {}}), + encoding="utf-8", + ) + assert cache.get("qwerty") is None + + +def test_put_then_get_keeps_fractional_errors(paths): + cache = FileAggregatesCache(paths) + aggregates = LayoutAggregates( + keys={ord("a"): KeyStats(ord("a"), 7, 200_000_000.0, 0.7, 1.0, attempt_count=9)}, + ) + cache.put("qwerty", aggregates) + loaded = cache.get("qwerty") + assert loaded is not None + assert loaded.keys[ord("a")].error_count == 0.7 + assert json.loads(cache._file("qwerty").read_text(encoding="utf-8"))["schema_version"] == 2 From d6b75ecfd2b0e4847b14ddd83d88721b8b89d1b5 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 7 Oct 2026 15:31:16 +0300 Subject: [PATCH 46/47] Replace focus selection with one ordered ladder - select_lesson_focus picks the first rule with candidates: keys not cleared, the newest key's unlock-gate pairs, weak pairs ranked by (1 - confidence) x language frequency, then review of the most overdue key or pair. Each rule keeps the last lesson's focus until it clears or stalls at 3x its attempt floor. - Save focus_pair on the session result (only for pair focus) so the next lesson can keep it; drop weakest_pair. - Remove the remedial WPM gate, the saved weakest pair, the newest-key unmeasured-pair fallback and the review penalty in focus scoring. - Move the stall cap to domain.confidence so focus and unlock share it. Co-Authored-By: Claude Opus 5.5 --- docs/CHANGELOG.md | 11 + docs/PLAN.md | 6 +- docs/research/typing-pedagogy.md | 2 +- docs/wiki/Confidence-Tuning.md | 41 +- docs/wiki/Focus-States.md | 58 +- src/keystrike/application/build_lesson.py | 115 ++-- src/keystrike/application/prepare_practice.py | 4 +- src/keystrike/application/session_queries.py | 27 +- .../application/session_use_cases.py | 23 +- src/keystrike/domain/confidence.py | 12 + src/keystrike/domain/focus.py | 343 +++------- src/keystrike/domain/markov.py | 7 + src/keystrike/domain/models.py | 8 +- src/keystrike/domain/newest_key.py | 35 +- src/keystrike/domain/session.py | 3 +- src/keystrike/domain/unlock.py | 14 - .../infrastructure/session_repo_jsonl.py | 16 +- .../presentation/screens/practice.py | 2 + tests/application/test_build_lesson.py | 236 +------ tests/application/test_session_use_cases.py | 155 +---- tests/domain/test_focus.py | 609 +++--------------- tests/domain/test_markov.py | 7 + tests/domain/test_newest_key.py | 54 +- tests/domain/test_unlock.py | 5 +- .../infrastructure/test_session_repo_jsonl.py | 16 +- 25 files changed, 360 insertions(+), 1449 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1025e31..70739cc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,17 @@ design) stays in `PLAN.md` §5/§6. ## Unreleased +- **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 (`session_wpm_below_target`), the review + penalty in focus scoring, and the saved `weakest_pair`. Sessions now save + `focus_pair` when the focus was a pair (written only when set; 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, diff --git a/docs/PLAN.md b/docs/PLAN.md index fe9a0c7..eef0d4a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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] @@ -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 @@ -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. diff --git a/docs/research/typing-pedagogy.md b/docs/research/typing-pedagogy.md index 009cf2e..2f46a91 100644 --- a/docs/research/typing-pedagogy.md +++ b/docs/research/typing-pedagogy.md @@ -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 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_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. diff --git a/docs/wiki/Confidence-Tuning.md b/docs/wiki/Confidence-Tuning.md index 5736055..7dc44d5 100644 --- a/docs/wiki/Confidence-Tuning.md +++ b/docs/wiki/Confidence-Tuning.md @@ -132,47 +132,18 @@ Every member must reach confidence 1.0 with `[unlock].min_transition_confidence_attempts` attempts. Every member must pass the same `domain.confidence.clears_threshold` rule as key unlock and focus. -A `transition_stall_attempts_cap` (`domain.unlock.default_transition_stall_attempts_cap`, +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** 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 typing speed is compared against its own target -speed in the same unit the keys are timed in: correct keystrokes per minute, -spaces included, against `target_speed_cpm`. (Words per minute converted with -`SettingsScreen`'s CPM→WPM rule would leave out the space after each word, so -a lesson typed at exactly target speed on every key would miss it by about a -quarter; word-list lessons with longer words would also be judged by a wrong -word length.) If that session's speed 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 speed 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. +**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 diff --git a/docs/wiki/Focus-States.md b/docs/wiki/Focus-States.md index 446006d..d58c99f 100644 --- a/docs/wiki/Focus-States.md +++ b/docs/wiki/Focus-States.md @@ -40,43 +40,35 @@ Reading left to right: 4. **Accuracy** — correct attempts ÷ total attempts (percent). 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 + +Focus follows one ordered list of rules. The first rule that has candidates +picks the focus (`select_lesson_focus` in `domain/focus.py`): + +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. -**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 stays put until it clears -**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. +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. -## Focus stays put until it clears +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. -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 typing speed 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 speed 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. Speed here is -correct keystrokes per minute, spaces included, against the target CPM — the -same unit every key is timed against — so a lesson typed at exactly target -speed on every key meets it. The pair comes from the session's saved -weakest pair (the slowest pair it typed with enough attempts) when there is -one, so a barely-sampled calibrating pair doesn't take the slot. This doesn't add a -new HUD label; the chosen key/pair still shows the ordinary `wk`, `cal`, or -`rev` reason. +The last focus comes from the last saved session: `focus_key`, plus +`focus_pair` when the focus was a pair. ## Heatmap underline diff --git a/src/keystrike/application/build_lesson.py b/src/keystrike/application/build_lesson.py index 9a81a01..e905800 100644 --- a/src/keystrike/application/build_lesson.py +++ b/src/keystrike/application/build_lesson.py @@ -9,14 +9,12 @@ from random import Random from keystrike.application.alphabet_sync import sync_alphabet_size -from keystrike.application.session_queries import ( - latest_session_header, - session_wpm_below_target, -) +from keystrike.application.session_queries import latest_session_header from keystrike.domain.confidence import ( CONFIDENCE_GOOD, accuracy_of, attempts_of, + clears_threshold, confidence_of, is_same_key_transition, key_confidence, @@ -24,6 +22,7 @@ round_confidence, skill_from_stats, skill_of, + stall_attempts_cap, target_ms_per_char, transition_accuracy_of, transition_confidence, @@ -32,13 +31,9 @@ from keystrike.domain.enums import FocusKind from keystrike.domain.focus import ( FocusReason, - blocks_transition_focus, coverage_deficit_factor, - focus_key_from_transition, practice_weight, - remedial_focus, - select_focus, - select_focus_transition, + select_lesson_focus, ) from keystrike.domain.generator import ( AdaptiveGenerator, @@ -48,13 +43,13 @@ weak_focus_word_quota, ) from keystrike.domain.learn_order import keyboard_order +from keystrike.domain.markov import TransitionTable from keystrike.domain.models import ( Bigram, KeyStats, Layout, LessonKey, LessonState, - SessionResult, Settings, TransitionStats, ) @@ -71,7 +66,6 @@ ) from keystrike.domain.unlock import ( compute_unlocked, - default_transition_stall_attempts_cap, gating_bigram_is_ready, newest_key_transition_gate_progress, ) @@ -250,6 +244,7 @@ class Lesson: state: LessonState focus_reason: FocusReason | None skill_heatmap: dict[int, float] + focus_pair: Bigram | None = None focus_confidence: float | None = None focus_speed: float | None = None focus_accuracy: float | None = None @@ -277,9 +272,7 @@ class _GatingState: def _gating_state(ctx: _LessonContext) -> _GatingState: order = keyboard_order(ctx.layout) - stall_cap = default_transition_stall_attempts_cap( - ctx.settings.unlock.min_transition_confidence_attempts - ) + stall_cap = stall_attempts_cap(ctx.settings.unlock.min_transition_confidence_attempts) unlocked = compute_unlocked( order, ctx.settings.alphabet_size, @@ -289,12 +282,14 @@ def _gating_state(ctx: _LessonContext) -> _GatingState: transitions=ctx.transitions, transition_stall_attempts_cap=stall_cap, ) - keys_need_focus = blocks_transition_focus( - unlocked, - ctx.stats, - ctx.target, - threshold=CONFIDENCE_GOOD, - min_attempts=ctx.settings.unlock.min_confidence_attempts, + keys_need_focus = not all( + clears_threshold( + ctx.stats.get(cp), + ctx.target, + threshold=CONFIDENCE_GOOD, + min_attempts=ctx.settings.unlock.min_confidence_attempts, + ) + for cp in unlocked ) cohort = newest_key_gating_cohort( unlocked, @@ -338,47 +333,19 @@ def _resolve_lesson_focus( ctx: _LessonContext, gating: _GatingState, ) -> tuple[int, Bigram | None]: - remedial = None - if ctx.remedial_alphabet is not None: - remedial = remedial_focus( - ctx.remedial_alphabet, - gating.unlocked, - ctx.stats, - ctx.transitions, - ctx.target, - now=ctx.now, - preferred=ctx.saved_pair, - threshold=CONFIDENCE_GOOD, - min_attempts=ctx.settings.unlock.min_confidence_attempts, - min_transition_attempts=ctx.settings.unlock.min_transition_confidence_attempts, - ) - focus_bigram: Bigram | None = None - if remedial is not None: - focus, focus_bigram = remedial - else: - if not gating.keys_need_focus: - focus_bigram = select_focus_transition( - gating.unlocked, - ctx.transitions, - ctx.target, - ctx.now, - key_stats=ctx.stats, - gating_candidates=gating.gating_bigrams if gating.transition_blocked else None, - preferred=ctx.saved_pair, - min_attempts=ctx.settings.unlock.min_transition_confidence_attempts, - ) - - if focus_bigram is not None: - focus = focus_key_from_transition(*focus_bigram) - else: - focus = select_focus( - gating.unlocked, - ctx.stats, - ctx.target, - ctx.now, - min_attempts=ctx.settings.unlock.min_confidence_attempts, - ) - return focus, focus_bigram + return select_lesson_focus( + gating.unlocked, + ctx.stats, + ctx.transitions, + ctx.target, + ctx.now, + pair_frequency=ctx.table.pair_weight, + gate=gating.gating_bigrams, + last_key=ctx.last_key, + last_pair=ctx.last_pair, + min_attempts=ctx.settings.unlock.min_confidence_attempts, + min_transition_attempts=ctx.settings.unlock.min_transition_confidence_attempts, + ) def _lesson_progress( @@ -504,19 +471,10 @@ class _LessonContext: transitions: Mapping[Bigram, TransitionStats] now: float target: float - # The just-finished session's own lesson alphabet, when that session's own - # typing speed fell short of its own target -- None otherwise (normal selection). - remedial_alphabet: tuple[int, ...] | None = None - # The just-finished session's saved weakest pair (`SessionResult.weakest_pair`). - saved_pair: Bigram | None = None - - -def _remedial_alphabet(last: SessionResult | None) -> tuple[int, ...] | None: - """The last finished session's own lesson alphabet, when that session's - own typing speed missed its own target -- otherwise None.""" - if last is None or not session_wpm_below_target(last): - return None - return last.lesson_alphabet + table: TransitionTable + # The last finished session's focus, kept while it still needs work. + last_key: int | None = None + last_pair: Bigram | None = None @dataclass(slots=True) @@ -560,6 +518,7 @@ def __call__(self, layout_name: str) -> Lesson: state=progress.state, focus_reason=explanation.reason, skill_heatmap=progress.skill_heatmap, + focus_pair=progress.focus_bigram, focus_confidence=focus_confidence if explanation.reason else None, focus_speed=explanation.speed, focus_accuracy=explanation.accuracy, @@ -582,8 +541,9 @@ def _load_context(self, layout_name: str) -> _LessonContext: transitions=transitions, now=self.clock.wall_epoch(), target=target_ms_per_char(settings.target_speed_cpm), - remedial_alphabet=_remedial_alphabet(last), - saved_pair=last.weakest_pair if last is not None else None, + table=self.language_provider.transitions(settings.lang), + last_key=last.focus_key if last is not None else None, + last_pair=last.focus_pair if last is not None else None, ) def _generate_text( @@ -598,8 +558,7 @@ def _generate_text( transition_weights: dict[Bigram, float], gating_bigrams: tuple[Bigram, ...], ) -> str: - table = self.language_provider.transitions(ctx.settings.lang) - generator = AdaptiveGenerator(table=table, rng=self.rng) + generator = AdaptiveGenerator(table=ctx.table, rng=self.rng) weighting = LessonWeighting( char_weights=char_weights, transition_weights=transition_weights, diff --git a/src/keystrike/application/prepare_practice.py b/src/keystrike/application/prepare_practice.py index 76d23d1..945edf6 100644 --- a/src/keystrike/application/prepare_practice.py +++ b/src/keystrike/application/prepare_practice.py @@ -7,7 +7,7 @@ from keystrike.application.build_lesson import BuildLesson from keystrike.domain.enums import Mode from keystrike.domain.focus import FocusReason -from keystrike.domain.models import Layout +from keystrike.domain.models import Bigram, Layout from keystrike.domain.null_adapters import NULL_AGGREGATES_ENSURER from keystrike.domain.protocols import ( AggregatesEnsurer, @@ -23,6 +23,7 @@ class SessionPrep: layout: str mode: Mode focus_key: int | None + focus_pair: Bigram | None focus_reason: FocusReason | None focus_confidence: float | None focus_speed: float | None @@ -51,6 +52,7 @@ def __call__(self) -> SessionPrep | None: layout=settings.layout, mode=Mode.ADAPTIVE, focus_key=lesson.focus_key, + focus_pair=lesson.focus_pair, focus_reason=lesson.focus_reason, focus_confidence=lesson.focus_confidence, focus_speed=lesson.focus_speed, diff --git a/src/keystrike/application/session_queries.py b/src/keystrike/application/session_queries.py index 5280f09..3bee747 100644 --- a/src/keystrike/application/session_queries.py +++ b/src/keystrike/application/session_queries.py @@ -24,15 +24,6 @@ def compute_wpm(result: SessionResult) -> float: return _words_for_wpm(result) / minutes -def compute_cpm(result: SessionResult) -> float: - """Correct keystrokes per minute, spaces included — the same unit as - `target_speed_cpm`, which every key (space included) is timed against.""" - minutes = result.duration_ns / 1e9 / 60.0 - if minutes <= 0: - return 0.0 - return result.correct_keystrokes / minutes - - def compute_accuracy(result: SessionResult) -> float: if result.total_keystrokes == 0: return 0.0 @@ -53,22 +44,6 @@ def previous_session_header( def latest_session_header(repo: SessionRepository, layout: str) -> SessionResult | None: """Most recently finished session for ``layout``, if any — the header - `BuildLesson` reads to decide whether the next lesson needs the - lesson-WPM remedial-focus gate (`session_wpm_below_target`).""" + `BuildLesson` reads to keep the last lesson's focus.""" headers = list(repo.iter_headers(layout)) return max(headers, key=lambda h: h.started_at, default=None) - - -def session_wpm_below_target(result: SessionResult) -> bool: - """Whether a finished session's own typing speed fell short of its own - target speed. Drives `BuildLesson`'s remedial lesson-alphabet focus gate; - legacy sessions with no recorded target (``target_speed_cpm == 0``) - never trigger it. - - Compares keystrokes per minute (spaces included) with `target_speed_cpm`, - not WPM with `wpm_from_cpm`: that conversion leaves out the space after - each word, so a learner at exactly target speed on every key would miss - it by about a quarter.""" - if result.target_speed_cpm <= 0 or result.words_completed <= 0: - return False - return compute_cpm(result) < result.target_speed_cpm diff --git a/src/keystrike/application/session_use_cases.py b/src/keystrike/application/session_use_cases.py index 0fc2036..0aff852 100644 --- a/src/keystrike/application/session_use_cases.py +++ b/src/keystrike/application/session_use_cases.py @@ -13,10 +13,10 @@ ) from keystrike.domain.confidence import ( confidence_of, + stall_attempts_cap, target_ms_per_char, ) from keystrike.domain.enums import Mode, SessionState -from keystrike.domain.focus import weakest_session_pair from keystrike.domain.generator import effective_generated_word_bounds from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import Bigram, Keystroke, SessionResult, SessionStats @@ -40,7 +40,7 @@ note_keystroke_for_timer, skip_leading_whitespace, ) -from keystrike.domain.unlock import compute_unlocked, default_transition_stall_attempts_cap +from keystrike.domain.unlock import compute_unlocked @dataclass(slots=True) @@ -56,6 +56,7 @@ def __call__( mode: Mode = Mode.ADAPTIVE, lang: str = "en", focus_key: int | None = None, + focus_pair: Bigram | None = None, ) -> Session: return Session( id=self.id_gen.new_id(), @@ -66,6 +67,7 @@ def __call__( started_at_wall=self.clock.wall_epoch(), started_at_ns=self.clock.now_ns(), focus_key=focus_key, + focus_pair=focus_pair, ) @@ -141,7 +143,7 @@ def _snapshot_finish_state( repo: SessionRepository, settings_repo: SettingsRepository, layout_repo: LayoutRepository, -) -> tuple[tuple[int, ...], dict[int, float], Bigram | None]: +) -> tuple[tuple[int, ...], dict[int, float]]: settings = settings_repo.load() layout = layout_repo.get(session.layout) draft = _DraftTiming(started_at=session.started_at_wall, duration_ns=duration_ns) @@ -162,7 +164,7 @@ def _snapshot_finish_state( target, tuning=settings.unlock, transitions=combined.transitions, - transition_stall_attempts_cap=default_transition_stall_attempts_cap( + transition_stall_attempts_cap=stall_attempts_cap( settings.unlock.min_transition_confidence_attempts ), ) @@ -172,14 +174,7 @@ def _snapshot_finish_state( ) for cp in unlocked } - weakest_pair = weakest_session_pair( - stats.transitions, - unlocked, - combined.transitions, - target, - min_attempts=settings.unlock.min_transition_confidence_attempts, - ) - return unlocked, key_confidence, weakest_pair + return unlocked, key_confidence @dataclass(slots=True) @@ -200,7 +195,7 @@ def __call__(self, session: Session) -> SessionResult: settings = self.settings_repo.load() target_speed_cpm = settings.target_speed_cpm stats = tally_session(session.keystrokes) - unlocked_keys, key_confidence, weakest_pair = _snapshot_finish_state( + unlocked_keys, key_confidence = _snapshot_finish_state( session, duration_ns, stats, @@ -226,6 +221,7 @@ def __call__(self, session: Session) -> SessionResult: mode=session.mode, lesson_alphabet=tuple(sorted({ord(c) for c in session.target_text})), focus_key=session.focus_key, + focus_pair=session.focus_pair, total_keystrokes=session.total_count, correct_keystrokes=session.correct_count, words_completed=count_words_completed(session.target_text, session.position), @@ -236,7 +232,6 @@ def __call__(self, session: Session) -> SessionResult: generated_min_len=generated_min_len, generated_max_len=generated_max_len, stats=stats, - weakest_pair=weakest_pair, ) self.repo.save_header(result) return result diff --git a/src/keystrike/domain/confidence.py b/src/keystrike/domain/confidence.py index a17aaae..fa01174 100644 --- a/src/keystrike/domain/confidence.py +++ b/src/keystrike/domain/confidence.py @@ -201,6 +201,18 @@ def skill_of(codepoint: int, stats: Mapping[int, KeyStats], target: float) -> fl return skill_from_stats(stats.get(codepoint), target) +# ponytail: fixed multiplier; upgrade to a Settings field if a stuck key or +# pair turns out to need per-user tuning. +STALL_ATTEMPTS_MULTIPLIER = 3 + + +def stall_attempts_cap(min_attempts: int) -> int: + """Real (unweighted) window attempts after which a key or pair that still + has not cleared counts as stalled: the transition gate releases it + (`domain.unlock`) and focus stops holding on to it (`domain.focus`).""" + return min_attempts * STALL_ATTEMPTS_MULTIPLIER + + def clears_threshold( stats: HasConfidenceFields | None, target: float, diff --git a/src/keystrike/domain/focus.py b/src/keystrike/domain/focus.py index 576be01..0b9fcaf 100644 --- a/src/keystrike/domain/focus.py +++ b/src/keystrike/domain/focus.py @@ -4,10 +4,11 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from .confidence import ( + CONFIDENCE_GOOD, MIN_CONFIDENCE_ATTEMPTS, MIN_TRANSITION_CONFIDENCE_ATTEMPTS, HasConfidenceFields, @@ -15,7 +16,7 @@ clears_threshold, confidence_from_stats, review_urgency, - skill_from_stats, + stall_attempts_cap, ) from .enums import FocusKind from .models import ( @@ -25,7 +26,7 @@ KeyStats, TransitionStats, ) -from .newest_key import newest_practiced_key_pairs, unlocked_cross_key_pairs +from .newest_key import unlocked_cross_key_pairs # Re-exported for domain.generator, which imports these boost defaults from # this module rather than `domain.models` directly. @@ -58,289 +59,101 @@ def __post_init__(self) -> None: raise ValueError(f"{self.kind} must not have a pair") -def _focus_score_from_entry( - stats: HasConfidenceFields | None, - target: float, - now: float, - *, - review_penalty: float, - min_attempts: int, -) -> float: - """Shared body for `_focus_score`/`_transition_focus_score`: confidence, - discounted further the longer it's been since last practiced, so a stale - mastered key/pair can still outrank a merely-weak recent one.""" - urgency = review_urgency(stats.last_seen if stats else 0.0, now) - confidence = confidence_from_stats(stats, target, min_attempts=min_attempts) - return confidence * (1.0 - review_penalty * urgency) +def _cleared(stats: HasConfidenceFields | None, target: float, min_attempts: int) -> bool: + return clears_threshold(stats, target, threshold=CONFIDENCE_GOOD, min_attempts=min_attempts) -def _focus_score( - codepoint: int, - stats: Mapping[int, KeyStats], - target: float, - now: float, - *, - review_penalty: float, - min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, -) -> float: - return _focus_score_from_entry( - stats.get(codepoint), - target, - now, - review_penalty=review_penalty, - min_attempts=min_attempts, - ) +def _stalled(stats: HasConfidenceFields | None, min_attempts: int) -> bool: + """Enough presses that holding focus longer is not helping (plateau): + the same cap the transition gate uses to stop waiting on a pair.""" + return stats is not None and attempts_of(stats) >= stall_attempts_cap(min_attempts) -def blocks_transition_focus( - unlocked: Sequence[int], - stats: Mapping[int, KeyStats], - target: float, - *, - threshold: float = 1.0, - min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, -) -> bool: - """True when any unlocked key lacks required performance or evidence.""" - return any( - not clears_threshold(stats.get(cp), target, threshold=threshold, min_attempts=min_attempts) - for cp in unlocked - ) +def _pick[T]( + candidates: Sequence[T], + last: T | None, + stats_of: Callable[[T], HasConfidenceFields | None], + min_attempts: int, + rank: Callable[[T], float], +) -> T: + """The last lesson's focus while it is still a candidate and not stalled, + else the lowest-ranked candidate.""" + if last is not None and last in candidates and not _stalled(stats_of(last), min_attempts): + return last + return min(candidates, key=rank) -def select_focus( +def select_lesson_focus( unlocked: Sequence[int], - stats: Mapping[int, KeyStats], + key_stats: Mapping[int, KeyStats], + transitions: Mapping[Bigram, TransitionStats], target: float, now: float, *, - review_penalty: float = 0.5, - threshold: float = 1.0, + pair_frequency: Callable[[Bigram], float], + gate: Sequence[Bigram] = (), + last_key: int | None = None, + last_pair: Bigram | None = None, min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, -) -> int: - """The unlocked key a lesson should emphasize. - - Any key that hasn't yet cleared both the skill threshold and the attempt - floor (`clears_threshold`) wins over every key that has — so focus stays - on an in-progress key across lesson builds instead of a mastered-but- - stale key's review-urgency discount stealing it away mid-calibration. - Only once every unlocked key has cleared does review-urgency-based - staleness compete for focus among the whole set (SlimStampen-style - spacing; see typing-pedagogy.md).""" - candidates = [ - cp - for cp in unlocked - if not clears_threshold( - stats.get(cp), target, threshold=threshold, min_attempts=min_attempts + min_transition_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, +) -> tuple[int, Bigram | None]: + """Today's focus as (key, pair); pair is None for key focus, else the key + is the pair's second letter. The first rule with candidates wins, and each + rule keeps the last lesson's focus while it is still in that rule's list + and not stalled (see docs/wiki/Focus-States.md): + + 1. Keys not cleared (skill and attempt floor) -- weakest key. + 2. The newest key's `gate` pairs that block the next unlock -- weakest pair. + 3. Pairs not cleared that occur in the language -- highest + (1 - confidence) x `pair_frequency`. + 4. Cleared keys and pairs due for review -- most overdue. + 5. Otherwise the weakest key. + """ + + def key_conf(cp: int) -> float: + return confidence_from_stats(key_stats.get(cp), target, min_attempts=min_attempts) + + def pair_conf(pair: Bigram) -> float: + return confidence_from_stats( + transitions.get(pair), target, min_attempts=min_transition_attempts ) - ] - pool = candidates or unlocked - return min( - pool, - key=lambda cp: _focus_score( - cp, - stats, - target, - now, - review_penalty=review_penalty, - min_attempts=min_attempts, - ), - ) - - -def _transition_focus_score( - prev_cp: int, - next_cp: int, - stats: Mapping[Bigram, TransitionStats], - target: float, - now: float, - *, - review_penalty: float, - min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, -) -> float: - return _focus_score_from_entry( - stats.get(Bigram(prev_cp, next_cp)), - target, - now, - review_penalty=review_penalty, - min_attempts=min_attempts, - ) - - -def newest_key_unmeasured_pairs( - unlocked: Sequence[int], - transitions: Mapping[Bigram, TransitionStats], - key_stats: Mapping[int, KeyStats] | None, -) -> list[Bigram]: - """Cross-key pairs between the most-recently-*practiced* unlocked key and - its other already-practiced unlocked peers, when none of those pairs - have measured transition data yet. - - Built on `domain.newest_key.newest_practiced_key_pairs`, the single - source of truth shared with `domain.unlock.newest_key_clears_transition_gate`. - Returns empty once the newest key has *any* measured transition - (ordinary weakest-pair scoring takes over from there for focus - selection), when no unlocked key has been practiced yet, or when it has - no other practiced key to pair with.""" - unmeasured, measured = newest_practiced_key_pairs(unlocked, transitions, key_stats) - return [] if measured else unmeasured + weak_keys = [cp for cp in unlocked if not _cleared(key_stats.get(cp), target, min_attempts)] + if weak_keys: + return _pick(weak_keys, last_key, key_stats.get, min_attempts, key_conf), None -def _is_slow_measured(stats: TransitionStats, target: float, *, min_attempts: int) -> bool: - """Enough attempts to trust the timing, and still slower than the goal.""" - return attempts_of(stats) >= min_attempts and skill_from_stats(stats, target) < 1.0 + if gate: + pair = _pick(gate, last_pair, transitions.get, min_transition_attempts, pair_conf) + return pair.next_cp, pair - -def weakest_session_pair( - session_pairs: Iterable[Bigram], - unlocked: Sequence[int], - transitions: Mapping[Bigram, TransitionStats], - target: float, - *, - min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, -) -> Bigram | None: - """Slowest cross-key pair typed in a finished session, judged on the - combined window stats: enough attempts (`min_attempts`) and raw skill - still under 1.0, lowest skill first. None when no such pair exists. - - Saved on the session result so the next lesson can pick up the weak pair - right away instead of starting a fresh calibration.""" - eligible = frozenset(unlocked_cross_key_pairs(unlocked)) - slow = [ + weak_pairs = [ pair - for pair in session_pairs - if pair in eligible - and pair in transitions - and _is_slow_measured(transitions[pair], target, min_attempts=min_attempts) + for pair in unlocked_cross_key_pairs(unlocked) + if pair_frequency(pair) > 0 + and not _cleared(transitions.get(pair), target, min_transition_attempts) ] - return min(slow, key=lambda pair: skill_from_stats(transitions[pair], target), default=None) - - -def select_focus_transition( - unlocked: Sequence[int], - transitions: Mapping[Bigram, TransitionStats], - target: float, - now: float, - *, - key_stats: Mapping[int, KeyStats] | None = None, - gating_candidates: Sequence[Bigram] | None = None, - preferred: Bigram | None = None, - review_penalty: float = 0.5, - threshold: float = 1.0, - min_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, -) -> Bigram | None: - """Weakest unlocked bigram by transition confidence. - - `preferred` (the last session's saved `weakest_session_pair`) wins over - ordinary weakest-pair scoring when it is still measured and slow, but - only after the newest key's unmeasured pairs and never in - `gating_candidates` mode, where the unlock cohort comes first. - - Measured pairs are preferred. But when the newest practiced key has no - measured transitions of its own yet (`newest_key_unmeasured_pairs`), fall - back to its weakest-scoring unmeasured pair — so a freshly-opened letter - gets bigram focus right away instead of waiting for its pairs to appear - by chance. - - Among measured pairs (outside `gating_candidates` mode), a pair that - hasn't cleared both the skill threshold and attempt floor - (`clears_threshold`) always wins over an already-cleared, merely-stale - pair — the same stickiness `select_focus` applies to keys, so an - in-progress bigram isn't preempted by review urgency before it's done - calibrating. Once every measured pair has cleared, review-urgency-based - staleness competes for focus among all of them, same as before.""" - - def _score(pair: Bigram) -> float: - return _transition_focus_score( - pair.prev_cp, - pair.next_cp, - transitions, - target, - now, - review_penalty=review_penalty, - min_attempts=min_attempts, - ) - - measured = [pair for pair in unlocked_cross_key_pairs(unlocked) if pair in transitions] - if gating_candidates is not None: - gating_set = frozenset(gating_candidates) - regressions = [ - pair - for pair in measured - if pair not in gating_set - and _is_slow_measured(transitions[pair], target, min_attempts=min_attempts) - ] - if regressions: - return min(regressions, key=_score) - return min(gating_candidates, key=_score) if gating_candidates else None - - candidates = newest_key_unmeasured_pairs(unlocked, transitions, key_stats) - if candidates: - return min(candidates, key=_score) - if not measured: - return None - if ( - preferred is not None - and preferred in measured - and _is_slow_measured(transitions[preferred], target, min_attempts=min_attempts) - ): - return preferred - not_cleared = [ - pair - for pair in measured - if not clears_threshold( - transitions.get(pair), target, threshold=threshold, min_attempts=min_attempts + if weak_pairs: + pair = _pick( + weak_pairs, + last_pair, + transitions.get, + min_transition_attempts, + lambda p: -(1.0 - pair_conf(p)) * pair_frequency(p), ) - ] - return min(not_cleared or measured, key=_score) - + return pair.next_cp, pair -def focus_key_from_transition(_prev_cp: int, next_cp: int) -> int: - """Endpoint key to mark as lesson focus for a transition-driven bigram.""" - return next_cp + def urgency(stats: HasConfidenceFields | None) -> float: + return review_urgency(stats.last_seen if stats else 0.0, now) + due = [(urgency(key_stats.get(cp)), cp, None) for cp in unlocked] + [ + (urgency(transitions.get(pair)), pair.next_cp, pair) + for pair in unlocked_cross_key_pairs(unlocked) + ] + most_due, key, pair = max(due, key=lambda entry: entry[0]) + if most_due > 0: + return key, pair -def remedial_focus( - lesson_alphabet: Sequence[int], - unlocked: Sequence[int], - stats: Mapping[int, KeyStats], - transitions: Mapping[Bigram, TransitionStats], - target: float, - *, - now: float, - preferred: Bigram | None = None, - threshold: float = 1.0, - min_attempts: int = MIN_CONFIDENCE_ATTEMPTS, - min_transition_attempts: int = MIN_TRANSITION_CONFIDENCE_ATTEMPTS, -) -> tuple[int, Bigram | None] | None: - """Weakest key or bigram confined to one already-finished lesson's own - alphabet — pulls focus back onto that lesson's own weak point when its - WPM fell short of target, instead of ordinary weakest-across-window - selection (or the newest-key transition gate) drifting focus elsewhere. - - None once every key in the pool has cleared and no bigram among them is - left wanting either — callers should fall back to ordinary focus - selection at that point (the lesson's weak point is resolved).""" - pool = [cp for cp in unlocked if cp in frozenset(lesson_alphabet)] - if not pool: - return None - if blocks_transition_focus(pool, stats, target, threshold=threshold, min_attempts=min_attempts): - focus = select_focus( - pool, stats, target, now, threshold=threshold, min_attempts=min_attempts - ) - return focus, None - bigram = select_focus_transition( - pool, - transitions, - target, - now, - key_stats=stats, - preferred=preferred, - threshold=threshold, - min_attempts=min_transition_attempts, - ) - if bigram is None: - return None - return focus_key_from_transition(*bigram), bigram + return min(unlocked, key=key_conf), None def coverage_deficit_factor( diff --git a/src/keystrike/domain/markov.py b/src/keystrike/domain/markov.py index 73f1395..fd2d10a 100644 --- a/src/keystrike/domain/markov.py +++ b/src/keystrike/domain/markov.py @@ -37,6 +37,13 @@ def __post_init__(self) -> None: # dict field too so in-place mutation of its contents also raises. object.__setattr__(self, "transitions", MappingProxyType(dict(self.transitions))) + def pair_weight(self, pair: Bigram) -> int: + """Language weight of `pair` -- the order-1 row for its first letter, + else the global "" row (same back-off as `sample`). 0 when the pair + never occurs.""" + row = self.transitions.get(chr(pair.prev_cp)) or self.transitions.get("", {}) + return row.get(chr(pair.next_cp), 0) + def sample( self, context: str, diff --git a/src/keystrike/domain/models.py b/src/keystrike/domain/models.py index f9284fc..29b27f9 100644 --- a/src/keystrike/domain/models.py +++ b/src/keystrike/domain/models.py @@ -111,10 +111,10 @@ class SessionResult: # Per-key/bigram tallies (schema 5+). Empty for sessions older than the # stats retention window (see domain.retention) — their history row stays. stats: SessionStats = field(default_factory=SessionStats) - # Slowest pair typed this session (see `domain.focus.weakest_session_pair`); - # the next lesson's transition focus starts there. Optional, so schema 5 - # rows without it still read back unchanged. - weakest_pair: Bigram | None = None + # The pair this lesson emphasized, for pair focus (`focus_key` is then its + # second letter); the next lesson keeps it while it still needs work. + # Written only when set, so schema 5 rows without it read back unchanged. + focus_pair: Bigram | None = None def __post_init__(self) -> None: # Freezing the dataclass only blocks attribute rebinding — wrap the diff --git a/src/keystrike/domain/newest_key.py b/src/keystrike/domain/newest_key.py index 4a09526..b9d2493 100644 --- a/src/keystrike/domain/newest_key.py +++ b/src/keystrike/domain/newest_key.py @@ -12,7 +12,7 @@ from collections.abc import Mapping, Sequence from .confidence import is_same_key_transition -from .models import GATING_BIGRAM_LIMIT, Bigram, KeyStats, TransitionStats +from .models import GATING_BIGRAM_LIMIT, Bigram, KeyStats _GATING_BIGRAM_MIN = 2 _GATING_BIGRAM_MAX = 4 @@ -44,39 +44,6 @@ def unlocked_cross_key_pairs(unlocked: Sequence[int]) -> list[Bigram]: ] -def newest_practiced_key_pairs( - unlocked: Sequence[int], - transitions: Mapping[Bigram, TransitionStats], - key_stats: Mapping[int, KeyStats] | None, -) -> tuple[list[Bigram], list[Bigram]]: - """(unmeasured, measured) cross-key pairs between the most-recently- - *practiced* unlocked key (skipping any further, not-yet-touched key - added by an unlock-threshold cascade) and its other already-practiced - unlocked peers. Both empty when no unlocked key has been practiced yet, - or the newest practiced key has no practiced peer to pair with. - - Single source of truth for "what does the newest key still need bigram - attention on" — shared by `domain.focus.newest_key_unmeasured_pairs` - (focus selection, lesson weighting) and - `domain.unlock.newest_key_clears_transition_gate` (unlock), so the two - can't drift on what counts as newest or which pairs are in play.""" - practiced = _practiced_unlocked(unlocked, key_stats) - if not practiced: - return [], [] - newest = practiced[-1] - practiced_set = frozenset(practiced) - pairs = [ - pair - for pair in unlocked_cross_key_pairs(unlocked) - if newest in (pair.prev_cp, pair.next_cp) - and pair.prev_cp in practiced_set - and pair.next_cp in practiced_set - ] - measured = [pair for pair in pairs if pair in transitions] - unmeasured = [pair for pair in pairs if pair not in transitions] - return unmeasured, measured - - def newest_key_gating_cohort( unlocked: Sequence[int], key_stats: Mapping[int, KeyStats] | None, diff --git a/src/keystrike/domain/session.py b/src/keystrike/domain/session.py index aa99d93..744f35b 100644 --- a/src/keystrike/domain/session.py +++ b/src/keystrike/domain/session.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from .enums import Mode, SessionState -from .models import Keystroke +from .models import Bigram, Keystroke BACKSPACE = "\x7f" # normalized backspace codepoint marker used at edges LEADING_SKIP_KEYS = frozenset(" \t\n\r") # accidental space/enter/tab before session/word start @@ -111,6 +111,7 @@ class Session: error_positions: set[int] = field(default_factory=set[int]) # positions that needed correction state: SessionState = SessionState.RUNNING focus_key: int | None = None # adaptive mode: the key this lesson emphasized + focus_pair: Bigram | None = None # adaptive mode: the pair, for pair focus @property def finished(self) -> bool: diff --git a/src/keystrike/domain/unlock.py b/src/keystrike/domain/unlock.py index 27266eb..95356bf 100644 --- a/src/keystrike/domain/unlock.py +++ b/src/keystrike/domain/unlock.py @@ -10,23 +10,9 @@ from .models import Bigram, KeyStats, TransitionStats, UnlockTuning from .newest_key import newest_key_gating_cohort -# ponytail: fixed multiplier; upgrade to a Settings field if a stuck gating -# bigram turns out to need per-user tuning. -TRANSITION_STALL_ATTEMPTS_MULTIPLIER = 3 - _DEFAULT_UNLOCK_TUNING = UnlockTuning() -def default_transition_stall_attempts_cap(min_attempts: int) -> int: - """Default `transition_stall_attempts_cap` for `compute_unlocked`: give a - stuck gating bigram this many times the normal calibration floor (real, - unweighted attempts in the window) before releasing it anyway. Single - source of truth so callers that need the - gate (`build_lesson`, `session_use_cases`) can't compute this - differently and drift apart.""" - return min_attempts * TRANSITION_STALL_ATTEMPTS_MULTIPLIER - - def newest_key_clears_transition_gate( unlocked: Sequence[int], transitions: Mapping[Bigram, TransitionStats], diff --git a/src/keystrike/infrastructure/session_repo_jsonl.py b/src/keystrike/infrastructure/session_repo_jsonl.py index 219fe6c..e2c8af7 100644 --- a/src/keystrike/infrastructure/session_repo_jsonl.py +++ b/src/keystrike/infrastructure/session_repo_jsonl.py @@ -29,7 +29,7 @@ # `[samples, time_ns, errors, attempts]`, keyed by codepoint for keys and by # "prev,next" codepoints for bigrams. Omitted entirely when the stats are empty. _STATS_KEY = "stats" -_WEAKEST_PAIR_KEY = "weakest_pair" +_FOCUS_PAIR_KEY = "focus_pair" _TALLY_FIELDS = 4 _PAIR_FIELDS = 2 @@ -140,10 +140,10 @@ def header_to_row(h: SessionResult) -> dict[str, object]: del base[_STATS_KEY] else: base[_STATS_KEY] = stats_to_row(h.stats) - if h.weakest_pair is None: - del base[_WEAKEST_PAIR_KEY] + if h.focus_pair is None: + del base[_FOCUS_PAIR_KEY] else: - base[_WEAKEST_PAIR_KEY] = [h.weakest_pair.prev_cp, h.weakest_pair.next_cp] + base[_FOCUS_PAIR_KEY] = [h.focus_pair.prev_cp, h.focus_pair.next_cp] return base @@ -173,12 +173,12 @@ def _parse_key_confidence(raw: object) -> dict[int, float]: return out -def _parse_weakest_pair(raw: object) -> Bigram | None: +def _parse_focus_pair(raw: object) -> Bigram | None: if raw is None: return None if not isinstance(raw, (list, tuple)) or len(cast("list[object]", raw)) != _PAIR_FIELDS: - raise TypeError(f"expected [prev, next] for {_WEAKEST_PAIR_KEY!r}, got {raw!r}") - prev, nxt = (coerce_int(v, label=_WEAKEST_PAIR_KEY) for v in cast("list[object]", raw)) + raise TypeError(f"expected [prev, next] for {_FOCUS_PAIR_KEY!r}, got {raw!r}") + prev, nxt = (coerce_int(v, label=_FOCUS_PAIR_KEY) for v in cast("list[object]", raw)) return Bigram(prev, nxt) @@ -204,5 +204,5 @@ def header_from_row(d: dict[str, object]) -> SessionResult: generated_min_len=require_int(d, "generated_min_len", GENERATED_WORD_MIN_LEN), generated_max_len=require_int(d, "generated_max_len", GENERATED_WORD_MAX_LEN), stats=stats_from_row(d.get(_STATS_KEY)), - weakest_pair=_parse_weakest_pair(d.get(_WEAKEST_PAIR_KEY)), + focus_pair=_parse_focus_pair(d.get(_FOCUS_PAIR_KEY)), ) diff --git a/src/keystrike/presentation/screens/practice.py b/src/keystrike/presentation/screens/practice.py index aabfe5d..401021d 100644 --- a/src/keystrike/presentation/screens/practice.py +++ b/src/keystrike/presentation/screens/practice.py @@ -66,6 +66,7 @@ def __init__( layout=initial.layout, mode=initial.mode, focus_key=initial.focus_key, + focus_pair=initial.focus_pair, ) self._typing_area = TypingArea(self._session) self._hud = HUD( @@ -181,6 +182,7 @@ def _begin_session(self, prep: SessionPrep) -> None: layout=prep.layout, mode=prep.mode, focus_key=prep.focus_key, + focus_pair=prep.focus_pair, ) self._typing_area.set_session(self._session) self._hud.set_session( diff --git a/tests/application/test_build_lesson.py b/tests/application/test_build_lesson.py index aa01638..6c618c2 100644 --- a/tests/application/test_build_lesson.py +++ b/tests/application/test_build_lesson.py @@ -1,4 +1,3 @@ -from dataclasses import replace from random import Random from keystrike.application.build_lesson import ( @@ -11,7 +10,7 @@ from keystrike.domain.aggregate import _combine_transition_maps_weighted, session_recency_weights from keystrike.domain.confidence import target_ms_per_char, transition_confidence_of from keystrike.domain.enums import FocusKind, Mode -from keystrike.domain.focus import FocusReason, select_focus +from keystrike.domain.focus import FocusReason from keystrike.domain.generator import weak_focus_word_quota, word_matches_focus from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import ( @@ -663,7 +662,7 @@ def test_lesson_uses_transition_review_when_stale_and_mastered(): five_days = 5 * 86_400.0 at_target = 200_000_000.0 # s→h stays weak; h still calibrating so unlock stops at a,s,h. Stale a→s - # wins transition focus via review scoring (see test_select_focus_transition_*). + # wins transition focus via review (see tests/domain/test_focus.py). slow = 380_000_000.0 keys = { a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), @@ -1153,168 +1152,44 @@ def test_focus_stays_on_calibrating_key_despite_stale_mastered_peer(): assert builder("qwerty").focus_key != a -def test_remedial_focus_targets_weak_key_from_low_wpm_lesson_alphabet(): - """A lesson that finished under its own WPM target pulls the next - lesson's focus back onto that lesson's own weak point, even though a - different key is weaker across the whole rolling window.""" - layout = BUNDLED_LAYOUTS["qwerty"] - order = keyboard_order(layout) - a, s, h, d = order[0], order[1], order[2], order[3] - now = 1_700_000_000.0 - at_target = 200_000_000.0 - keys = { - a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), # mastered - s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), # mastered - h: KeyStats(h, 10, at_target / 0.2, 0, now, attempt_count=10), # globally weakest - d: KeyStats(d, 10, at_target / 0.6, 0, now, attempt_count=10), # weak, but not weakest - } - target = target_ms_per_char(300) - # Sanity: plain weakest-across-window selection would land on h, not d. - assert select_focus((a, s, h, d), keys, target, now) == h - - cache = FakeAggregatesCache( - by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, - ) - slow_session = SessionResult( - schema_version=3, - session_id="slow", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(a, d), - focus_key=d, - total_keystrokes=1, - correct_keystrokes=1, - words_completed=1, - target_speed_cpm=300, - ) - session_repo = FakeSessionRepository(headers=[slow_session]) - builder = BuildLesson( - layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), - aggregates_cache=cache, - settings_repo=FakeSettingsRepository(Settings(alphabet_size=4, target_speed_cpm=300)), - language_provider=FakeLanguageProvider(), - wordlist_store=FakeWordListStore(), - rng=Random(0), - clock=FakeClock(), - session_repo=session_repo, - ) - assert builder("qwerty").focus_key == d - - -def test_remedial_focus_uses_session_word_bounds_not_current_settings(): - """Word-length bounds (at finish or in current settings) must not alter - whether a session triggers remedial focus on the next lesson.""" - layout = BUNDLED_LAYOUTS["qwerty"] - order = keyboard_order(layout) - a, s, h, d = order[0], order[1], order[2], order[3] - now = 1_700_000_000.0 - at_target = 200_000_000.0 +def test_focus_keeps_the_last_session_focus_while_it_needs_work(): + order = keyboard_order(BUNDLED_LAYOUTS["qwerty"]) + a, s = order[0], order[1] keys = { - a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), - s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), - h: KeyStats(h, 10, at_target / 0.2, 0, now, attempt_count=10), - d: KeyStats(d, 10, at_target / 0.6, 0, now, attempt_count=10), + a: KeyStats(a, 10, 400_000_000.0, 0, 0.0, attempt_count=10), # weakest + s: KeyStats(s, 10, 300_000_000.0, 0, 0.0, attempt_count=10), } - cache = FakeAggregatesCache( - by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, - ) - # 1 keystroke in 60 s: far below 300 CPM whatever the word length. - slow_session = SessionResult( - schema_version=4, - session_id="slow", + last = SessionResult( + schema_version=5, + session_id="s1", started_at=1.0, - duration_ns=60_000_000_000, + duration_ns=1, layout="qwerty", mode=Mode.ADAPTIVE, - lesson_alphabet=(a, d), - focus_key=d, + lesson_alphabet=(a, s), + focus_key=s, total_keystrokes=1, correct_keystrokes=1, - words_completed=50, - target_speed_cpm=300, - generated_min_len=2, - generated_max_len=4, - ) - session_repo = FakeSessionRepository(headers=[slow_session]) - builder = BuildLesson( - layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), - aggregates_cache=cache, - settings_repo=FakeSettingsRepository( - Settings( - alphabet_size=4, - target_speed_cpm=300, - word_gen=WordGenBounds(min_len=3, max_len=10), - ) - ), - language_provider=FakeLanguageProvider(), - wordlist_store=FakeWordListStore(), - rng=Random(0), - clock=FakeClock(), - session_repo=session_repo, ) - assert builder("qwerty").focus_key == d + def build(headers: list[SessionResult]): + return BuildLesson( + layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), + aggregates_cache=FakeAggregatesCache( + by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, + ), + settings_repo=FakeSettingsRepository(Settings(alphabet_size=2)), + language_provider=FakeLanguageProvider(), + wordlist_store=FakeWordListStore(), + rng=Random(0), + clock=FakeClock(), + session_repo=FakeSessionRepository(headers=headers), + )("qwerty") -def test_remedial_focus_clears_once_lesson_wpm_meets_target(): - """Once the most recent session's own WPM meets its own target again, - normal weakest-across-window focus selection resumes.""" - layout = BUNDLED_LAYOUTS["qwerty"] - order = keyboard_order(layout) - a, s, h, d = order[0], order[1], order[2], order[3] - now = 1_700_000_000.0 - at_target = 200_000_000.0 - keys = { - a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), - s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), - h: KeyStats(h, 10, at_target / 0.2, 0, now, attempt_count=10), - d: KeyStats(d, 10, at_target / 0.6, 0, now, attempt_count=10), - } - cache = FakeAggregatesCache( - by_layout={"qwerty": LayoutAggregates(keys=keys, transitions={})}, - ) - slow_session = SessionResult( - schema_version=3, - session_id="slow", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(a, d), - focus_key=d, - total_keystrokes=1, - correct_keystrokes=1, - words_completed=1, - target_speed_cpm=300, - ) - # 12 words x 3 letters + 11 spaces at exactly 200 ms (300 CPM) per keystroke. - fast_session = SessionResult( - schema_version=3, - session_id="fast", - started_at=2.0, - duration_ns=46 * 200_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(a, d), - focus_key=d, - total_keystrokes=47, - correct_keystrokes=47, - words_completed=12, - target_speed_cpm=300, - ) - session_repo = FakeSessionRepository(headers=[slow_session, fast_session]) - builder = BuildLesson( - layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), - aggregates_cache=cache, - settings_repo=FakeSettingsRepository(Settings(alphabet_size=4, target_speed_cpm=300)), - language_provider=FakeLanguageProvider(), - wordlist_store=FakeWordListStore(), - rng=Random(0), - clock=FakeClock(), - session_repo=session_repo, - ) - assert builder("qwerty").focus_key == h + assert build([]).focus_key == a + lesson = build([last]) + assert lesson.focus_key == s + assert lesson.focus_pair is None def test_large_alphabet_unlock_advances_when_keys_rotated(): @@ -1339,54 +1214,3 @@ def test_large_alphabet_unlock_advances_when_keys_rotated(): else: assert len(unlocked) == forced + 1 assert unlocked[-1] == order[forced] - - -def test_saved_weakest_pair_wins_remedial_focus_over_calibrating_pair(): - """After a lesson that missed its WPM target, the saved weakest pair wins - over a calibrating pair that ordinary scoring would pick (fewer attempts, - so lower ramped confidence).""" - layout = BUNDLED_LAYOUTS["qwerty"] - order = keyboard_order(layout) - a, s, h = order[0], order[1], order[2] - now = 1_700_000_000.0 - at_target = 200_000_000.0 - keys = {cp: KeyStats(cp, 10, at_target, 0, now, attempt_count=10) for cp in (a, s, h)} - saved = Bigram(a, s) - calibrating = Bigram(s, h) - transitions = { - saved: TransitionStats(*saved, 10, at_target / 0.5, 0, now, attempt_count=10), - calibrating: TransitionStats(*calibrating, 1, at_target, 0, now, attempt_count=1), - } - cache = FakeAggregatesCache( - by_layout={"qwerty": LayoutAggregates(keys=keys, transitions=transitions)}, - ) - - def build(headers: list[SessionResult]): - return BuildLesson( - layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), - aggregates_cache=cache, - settings_repo=FakeSettingsRepository(Settings(alphabet_size=3, target_speed_cpm=300)), - language_provider=FakeLanguageProvider(), - wordlist_store=FakeWordListStore(), - rng=Random(0), - clock=FakeClock(), - session_repo=FakeSessionRepository(headers=headers), - )("qwerty") - - last = SessionResult( - schema_version=5, - session_id="last", - started_at=1.0, - duration_ns=60_000_000_000, - layout="qwerty", - mode=Mode.ADAPTIVE, - lesson_alphabet=(a, s, h), - focus_key=a, - total_keystrokes=1, - correct_keystrokes=1, - words_completed=1, - target_speed_cpm=300, # 60s for one word: far under target, so remedial - weakest_pair=saved, - ) - assert build([last]).focus_key == saved.next_cp - assert build([replace(last, weakest_pair=None)]).focus_key == calibrating.next_cp diff --git a/tests/application/test_session_use_cases.py b/tests/application/test_session_use_cases.py index 6e80257..910e10d 100644 --- a/tests/application/test_session_use_cases.py +++ b/tests/application/test_session_use_cases.py @@ -10,7 +10,6 @@ compute_wpm, latest_session_header, previous_session_header, - session_wpm_below_target, ) from keystrike.application.session_use_cases import ( AbortSession, @@ -25,10 +24,10 @@ from keystrike.domain.aggregate import combine_sessions from keystrike.domain.confidence import confidence_of, target_ms_per_char from keystrike.domain.enums import Mode, SessionState -from keystrike.domain.generator import wpm_from_cpm from keystrike.domain.learn_order import keyboard_order from keystrike.domain.models import ( CONFIDENCE_SESSION_WINDOW, + Bigram, SessionResult, Settings, UnlockTuning, @@ -438,72 +437,6 @@ def test_latest_session_header_none_when_no_sessions(): assert latest_session_header(FakeSessionRepository(), "qwerty") is None -def test_session_wpm_below_target_true_when_slower_than_target(clock, id_gen): - _, result = _drive("hello world", "hello ", clock, id_gen) - result = replace(result, words_completed=1, duration_ns=60_000_000_000, target_speed_cpm=300) - assert session_wpm_below_target(result) is True - - -def test_session_wpm_below_target_false_when_faster_than_target(clock, id_gen): - _, result = _drive("hello world", "hello ", clock, id_gen) - result = replace(result, words_completed=20, duration_ns=1_000_000_000, target_speed_cpm=300) - assert session_wpm_below_target(result) is False - - -def test_session_wpm_below_target_false_for_legacy_session_without_target(): - result = SessionResult( - session_id="s1", - total_keystrokes=1, - correct_keystrokes=1, - **_session_stats_common(), - ) - assert result.target_speed_cpm == 0 - assert session_wpm_below_target(result) is False - - -def _drive_at_pace(text: str, ms_per_keystroke: int, target_speed_cpm: int) -> SessionResult: - clock = FakeClock() - settings_repo = FakeSettingsRepository(Settings(target_speed_cpm=target_speed_cpm)) - session = StartSession(clock=clock, id_gen=FakeIdGenerator())( - text, layout="qwerty", mode=Mode.ADAPTIVE - ) - record = RecordKeystroke(clock=clock) - for ch in text: - clock.advance(ms_per_keystroke * 1_000_000) - record(session, ch) - return FinishSession(clock=clock, settings_repo=settings_repo)(session) - - -# 12 words x 3 letters + 11 spaces = 47 keystrokes (default lesson, 2-4 bounds). -_TWELVE_WORD_LESSON = " ".join(["abc"] * 12) - - -def test_session_wpm_below_target_false_when_every_key_is_at_target_speed(): - """40 WPM in Settings is 120 CPM (500 ms per key). Typing every keystroke, - spaces included, at exactly 500 ms meets the target: the gate counts the - spaces, so it must not fire. WPM against cpm / mean word length (40) would.""" - result = _drive_at_pace(_TWELVE_WORD_LESSON, 500, target_speed_cpm=120) - assert result.correct_keystrokes == 47 - assert result.words_completed == 12 - a = result.stats.keys[ord("a")] - assert a.time_ns / a.samples == 500_000_000 # key speed exactly 1.0 - assert compute_wpm(result) < wpm_from_cpm(120) # the old comparison fired here - assert session_wpm_below_target(result) is False - - -def test_session_wpm_below_target_true_when_keys_slower_than_target(): - result = _drive_at_pace(_TWELVE_WORD_LESSON, 550, target_speed_cpm=120) - assert session_wpm_below_target(result) is True - - -def test_session_wpm_below_target_ignores_word_length_bounds(): - """Keystrokes per minute need no word-length guess, so word-list lessons - (3-10 letters) are judged the same way as generated ones.""" - result = _drive_at_pace(_TWELVE_WORD_LESSON, 500, target_speed_cpm=120) - wide = replace(result, generated_min_len=3, generated_max_len=10) - assert session_wpm_below_target(wide) is False - - def test_finish_session_persists_generated_word_bounds(clock, id_gen): settings_repo = FakeSettingsRepository(Settings(word_gen=WordGenBounds(min_len=3, max_len=8))) finish = FinishSession(clock=clock, settings_repo=settings_repo) @@ -787,84 +720,18 @@ def test_finish_session_alphabet_bump_respects_transition_gate(clock, id_gen): assert order[4] not in unlocked_cps -def test_finish_session_saves_weakest_pair_typed_in_session(clock, id_gen): - settings_repo = FakeSettingsRepository( - Settings( - target_speed_cpm=300, - unlock=UnlockTuning(min_transition_confidence_attempts=2), - ) - ) +def test_finish_session_saves_focus_pair(clock, id_gen): repo = FakeSessionRepository() - finish = FinishSession( - clock=clock, - repo=repo, - settings_repo=settings_repo, - layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), - ) - start = StartSession(clock=clock, id_gen=id_gen) - record = RecordKeystroke(clock=clock) - order = keyboard_order(BUNDLED_LAYOUTS["qwerty"]) - a, b = chr(order[0]), chr(order[1]) - session = start(f"{a}{b}{a}{b}", layout="qwerty", mode=Mode.ADAPTIVE, focus_key=order[0]) - for ch in f"{a}{b}{a}{b}": - clock.advance(2_000_000_000) # far slower than 300 cpm - record(session, ch) - result = finish(session) - assert result.weakest_pair is not None - assert {result.weakest_pair.prev_cp, result.weakest_pair.next_cp} == {order[0], order[1]} - assert repo.headers[0].weakest_pair == result.weakest_pair - - -def _finish_typed(clock, id_gen, repo, text: str, step_ns: int, *, min_pair_attempts: int): - """Type `text` at a fixed pace through Start/Record/FinishSession.""" - finish = FinishSession( - clock=clock, - repo=repo, - settings_repo=FakeSettingsRepository( - Settings( - target_speed_cpm=300, - unlock=UnlockTuning(min_transition_confidence_attempts=min_pair_attempts), - ) - ), - layout_repo=FakeLayoutRepository(dict(BUNDLED_LAYOUTS)), - ) + finish = FinishSession(clock=clock, repo=repo) + pair = Bigram(ord("a"), ord("s")) session = StartSession(clock=clock, id_gen=id_gen)( - text, layout="qwerty", mode=Mode.ADAPTIVE, focus_key=ord(text[0]) + "as", layout="qwerty", mode=Mode.ADAPTIVE, focus_key=pair.next_cp, focus_pair=pair ) record = RecordKeystroke(clock=clock) - for ch in text: - clock.advance(step_ns) + for ch in "as": + clock.advance(100_000_000) record(session, ch) - return finish(session) - - -def test_finish_session_without_slow_pair_saves_none(clock, id_gen): - order = keyboard_order(BUNDLED_LAYOUTS["qwerty"]) - a, b = chr(order[0]), chr(order[1]) - fast = 50_000_000 # well over 300 cpm - result = _finish_typed( - clock, id_gen, FakeSessionRepository(), f"{a}{b}{a}{b}", fast, min_pair_attempts=2 - ) - assert result.weakest_pair is None - - -def test_finish_session_skips_slow_pair_below_attempt_floor(clock, id_gen): - order = keyboard_order(BUNDLED_LAYOUTS["qwerty"]) - a, b = chr(order[0]), chr(order[1]) - slow = 2_000_000_000 - result = _finish_typed( - clock, id_gen, FakeSessionRepository(), f"{a}{b}", slow, min_pair_attempts=4 - ) - assert result.weakest_pair is None - - -def test_finish_session_skips_slow_pair_only_in_history(clock, id_gen): - order = keyboard_order(BUNDLED_LAYOUTS["qwerty"]) - a, b = chr(order[0]), chr(order[1]) - slow = 2_000_000_000 - repo = FakeSessionRepository() - first = _finish_typed(clock, id_gen, repo, f"{a}{b}{a}{b}", slow, min_pair_attempts=2) - assert first.weakest_pair is not None - # Same-key presses only: the slow pair stays in the window but is not typed. - second = _finish_typed(clock, id_gen, repo, f"{a}{a}{a}", slow, min_pair_attempts=2) - assert second.weakest_pair is None + result = finish(session) + assert result.focus_key == pair.next_cp + assert result.focus_pair == pair + assert repo.headers[0].focus_pair == pair diff --git a/tests/domain/test_focus.py b/tests/domain/test_focus.py index fdab52f..b43400e 100644 --- a/tests/domain/test_focus.py +++ b/tests/domain/test_focus.py @@ -1,22 +1,7 @@ import pytest -from keystrike.domain.confidence import ( - MIN_CONFIDENCE_ATTEMPTS, - attempts_of, - skill_from_stats, - skill_of, - target_ms_per_char, -) from keystrike.domain.enums import FocusKind -from keystrike.domain.focus import ( - FocusReason, - blocks_transition_focus, - focus_key_from_transition, - remedial_focus, - select_focus, - select_focus_transition, - weakest_session_pair, -) +from keystrike.domain.focus import FocusReason, select_lesson_focus from keystrike.domain.models import Bigram, KeyStats, TransitionStats @@ -26,6 +11,7 @@ def _stats( error_count: int = 0, *, last_seen: float = 0.0, + attempt_count: int | None = None, ) -> KeyStats: return KeyStats( codepoint=codepoint, @@ -33,7 +19,7 @@ def _stats( mean_time_ns=mean_time_ns, error_count=error_count, last_seen=last_seen, - attempt_count=10 + error_count, + attempt_count=attempt_count if attempt_count is not None else 10 + error_count, ) @@ -85,532 +71,119 @@ def test_focus_reason_key_kind_is_not_transition(): assert reason.is_transition is False -def test_remedial_focus_picks_weakest_key_in_lesson_alphabet(): - """Weakest key within the remedial pool, not the globally weakest key.""" - now = 1_700_000_000.0 - at_target = 200_000_000.0 - a, s, h, d = ord("a"), ord("s"), ord("h"), ord("d") - stats = { - a: KeyStats(a, 10, at_target, 0, now, attempt_count=10), - s: KeyStats(s, 10, at_target, 0, now, attempt_count=10), - h: KeyStats(h, 10, at_target / 0.2, 0, now, attempt_count=10), - d: KeyStats(d, 10, at_target / 0.6, 0, now, attempt_count=10), - } - target = target_ms_per_char(300) - assert select_focus((a, s, h, d), stats, target, now) == h - - result = remedial_focus((a, d), (a, s, h, d), stats, {}, target, now=now) - assert result == (d, None) - - -def test_remedial_focus_none_when_alphabet_disjoint_from_unlocked(): - now = 1_700_000_000.0 - at_target = 200_000_000.0 - a = ord("a") - stats = {a: KeyStats(a, 10, at_target, 0, now, attempt_count=10)} - target = target_ms_per_char(300) - - assert remedial_focus((ord("z"),), (a,), stats, {}, target, now=now) is None - - -def test_select_focus_picks_weakest_unlocked_key(): - stats = { - 1: _stats(1, mean_time_ns=100_000_000.0), # confidence 2.0 - 2: _stats(2, mean_time_ns=400_000_000.0), # confidence 0.5 - } - assert select_focus((1, 2), stats, target=200.0, now=1000.0) == 2 - - -def test_blocks_transition_focus_true_when_key_below_attempt_floor(): - stats = { - ord("t"): KeyStats( - ord("t"), - samples=9, - mean_time_ns=125_000_000.0, - error_count=0, - last_seen=0.0, - attempt_count=9, - ), - } - target = target_ms_per_char(300) - assert skill_of(ord("t"), stats, target=target) == 1.0 - assert blocks_transition_focus((ord("t"),), stats, target=target) is True - - -def test_blocks_transition_focus_true_for_never_practiced(): - stats = {1: _stats(1, mean_time_ns=200_000_000.0)} - assert blocks_transition_focus((1, 2), stats, target=200.0) is True - - -def test_blocks_transition_focus_includes_never_practiced_key(): - a, s, h = ord("a"), ord("s"), ord("h") - stats = { - a: _stats(a, mean_time_ns=200_000_000.0), - s: _stats(s, mean_time_ns=200_000_000.0), - } - assert blocks_transition_focus((a, s, h), stats, target=200.0) is True - - -def test_blocks_transition_focus_true_for_measured_weak_key(): - h = ord("h") - stats = {h: _stats(h, mean_time_ns=400_000_000.0)} - assert skill_of(h, stats, target=200.0) < 1.0 - assert blocks_transition_focus((h,), stats, target=200.0) is True - - -def test_select_focus_prefers_never_practiced_key(): - stats = {1: _stats(1, mean_time_ns=100_000_000.0)} - assert select_focus((1, 2), stats, target=200.0, now=1000.0) == 2 - - -def test_select_focus_stays_on_calibrating_key_over_stale_mastered_peer(): - """A key still short of the attempt floor must not lose focus to an - already-mastered, stale peer -- that peer's review-urgency discount can - make it look "weaker" by raw score, but it has already cleared both - conditions (skill + attempts) so it must not preempt an in-progress key.""" - now = 1_700_000_000.0 - five_days = 5 * 86_400.0 - at_target = 200_000_000.0 - calibrating = KeyStats( - codepoint=1, - samples=8, - mean_time_ns=at_target, - error_count=0, - last_seen=now, - attempt_count=8, - ) - mastered_stale = KeyStats( - codepoint=2, - samples=10, - mean_time_ns=at_target, - error_count=0, - last_seen=now - five_days, - attempt_count=10, - ) - stats = {1: calibrating, 2: mastered_stale} - target = 200.0 - assert skill_of(1, stats, target) == 1.0 - assert attempts_of(calibrating) < MIN_CONFIDENCE_ATTEMPTS - assert skill_of(2, stats, target) == 1.0 - assert attempts_of(mastered_stale) >= MIN_CONFIDENCE_ATTEMPTS - # Without the stickiness fix, key 2's review-urgency discount would drop - # its score below key 1's, stealing focus mid-calibration. - assert select_focus((1, 2), stats, target=target, now=now) == 1 - - -def test_select_focus_picks_stale_over_slightly_weaker_recent(): - now = 1_000_000.0 - five_days = 5 * 86_400.0 - stats = { - 1: _stats(1, mean_time_ns=210_000_000.0, last_seen=now - five_days), # ~0.95 - 2: _stats(2, mean_time_ns=235_000_000.0, last_seen=now), # ~0.85 - } - assert select_focus((1, 2), stats, target=200.0, now=now) == 1 - - -def test_select_focus_transition_ignores_same_key_pairs(): - now = 1_700_000_000.0 - unlocked = (ord("e"), ord("a")) - transitions = { - Bigram(ord("e"), ord("e")): _transition( - ord("e"), - ord("e"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - Bigram(ord("a"), ord("a")): _transition( - ord("a"), - ord("a"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - } - assert select_focus_transition(unlocked, transitions, 200.0, now) is None - - -def test_select_focus_transition_skips_unmeasured_when_unlocked_key_unpracticed(): - now = 1_700_000_000.0 - unlocked = (ord("e"), ord("a")) - transitions = { - Bigram(ord("e"), ord("e")): _transition( - ord("e"), - ord("e"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - } - assert select_focus_transition(unlocked, transitions, 200.0, now) is None - - -def test_select_focus_transition_never_picks_same_key_even_when_weakest(): - now = 1_700_000_000.0 - unlocked = (ord("a"), ord("b")) - fast = 100_000_000.0 - transitions = { - Bigram(ord("a"), ord("a")): _transition( - ord("a"), - ord("a"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - Bigram(ord("a"), ord("b")): _transition( - ord("a"), - ord("b"), - fast, - last_seen=now, - attempt_count=10, - ), - Bigram(ord("b"), ord("a")): _transition( - ord("b"), - ord("a"), - fast, - last_seen=now, - attempt_count=10, - ), - Bigram(ord("b"), ord("b")): _transition( - ord("b"), - ord("b"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - } - result = select_focus_transition(unlocked, transitions, 200.0, now) - assert result is not None - assert result[0] != result[1] - - -def test_select_focus_transition_picks_stale_over_slightly_weaker_recent(): - now = 1_000_000.0 - five_days = 5 * 86_400.0 - unlocked = (ord("a"), ord("b")) - fast = 100_000_000.0 - transitions = { - Bigram(ord("a"), ord("a")): _transition(ord("a"), ord("a"), fast, last_seen=now), - Bigram(ord("a"), ord("b")): _transition( - ord("a"), - ord("b"), - 210_000_000.0, - last_seen=now - five_days, - ), - Bigram(ord("b"), ord("a")): _transition(ord("b"), ord("a"), 235_000_000.0, last_seen=now), - Bigram(ord("b"), ord("b")): _transition(ord("b"), ord("b"), fast, last_seen=now), - } - assert select_focus_transition(unlocked, transitions, 200.0, now) == (ord("a"), ord("b")) - - -def test_select_focus_transition_stays_on_calibrating_pair_over_stale_mastered_peer(): - """Mirrors `test_select_focus_stays_on_calibrating_key_over_stale_mastered_peer` - for bigrams: an in-progress pair (below the attempt floor) must not lose - focus to an already-cleared, merely-stale measured pair.""" - now = 1_700_000_000.0 - five_days = 5 * 86_400.0 - unlocked = (ord("a"), ord("b"), ord("c")) - calibrating = Bigram(ord("a"), ord("b")) - mastered_stale = Bigram(ord("b"), ord("c")) - transitions = { - calibrating: _transition(*calibrating, 200_000_000.0, last_seen=now, attempt_count=2), - mastered_stale: _transition( - *mastered_stale, 200_000_000.0, last_seen=now - five_days, attempt_count=10 - ), - } - target = 200.0 - assert skill_from_stats(transitions[calibrating], target) == 1.0 - assert attempts_of(transitions[calibrating]) < 4 # below MIN_TRANSITION_CONFIDENCE_ATTEMPTS - assert skill_from_stats(transitions[mastered_stale], target) == 1.0 - assert attempts_of(transitions[mastered_stale]) >= 4 - assert select_focus_transition(unlocked, transitions, target, now) == calibrating - - -def test_select_focus_transition_returns_none_without_unlocked_pairs(): - assert select_focus_transition((), {}, target=200.0, now=1000.0) is None - assert select_focus_transition((ord("a"),), {}, target=200.0, now=1000.0) is None - - -def test_select_focus_transition_picks_unmeasured_pair_when_all_keys_practiced(): - stats = { - 1: KeyStats(1, 10, 200_000_000.0, 0, 1.0, attempt_count=10), - 2: KeyStats(2, 10, 200_000_000.0, 0, 1.0, attempt_count=10), - } - assert select_focus_transition((1, 2), {}, target=200.0, now=1000.0, key_stats=stats) == ( - 1, - 2, +TARGET = 200.0 # ms per char +FAST = 100_000_000.0 # ns: skill >= 1.0 +SLOW = 400_000_000.0 # ns: skill 0.5 +NOW = 10 * 86_400.0 +DAY = 86_400.0 +A, S, D = map(ord, "asd") + + +def _focus(keys, transitions=None, *, freq: dict[Bigram, int] | None = None, **kwargs): + weights = freq or {} + return select_lesson_focus( + (A, S, D), + keys, + transitions or {}, + TARGET, + NOW, + pair_frequency=lambda pair: weights.get(pair, 1), + min_attempts=10, + min_transition_attempts=4, + **kwargs, ) -def test_select_focus_transition_skips_unmeasured_pair_on_cold_start(): - assert select_focus_transition((1, 2), {}, target=200.0, now=1000.0) is None - - -def test_select_focus_transition_ignores_unmeasured_pairs(): - unlocked = (ord("a"), ord("b"), ord("c")) - now = 1_700_000_000.0 - fast = 100_000_000.0 - transitions = { - Bigram(ord("a"), ord("b")): _transition( - ord("a"), - ord("b"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - Bigram(ord("b"), ord("c")): _transition( - ord("b"), - ord("c"), - fast, - last_seen=now, - attempt_count=10, - ), - } - assert select_focus_transition(unlocked, transitions, 200.0, now) == (ord("a"), ord("b")) - - -def test_select_focus_transition_prefers_newest_key_over_older_measured_pair(): - """Once a third key is unlocked, its bigrams should win focus over an - already-measured (and merely weak, not unmeasured) older pair, so a - freshly-opened letter gets bigram practice right away.""" - unlocked = (ord("a"), ord("b"), ord("c")) - now = 1_700_000_000.0 - stats = { - ord("a"): KeyStats(ord("a"), 10, 200_000_000.0, 0, now, attempt_count=10), - ord("b"): KeyStats(ord("b"), 10, 200_000_000.0, 0, now, attempt_count=10), - ord("c"): KeyStats(ord("c"), 10, 200_000_000.0, 0, now, attempt_count=10), - } - transitions = { - Bigram(ord("a"), ord("b")): _transition( - ord("a"), - ord("b"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - } - # c is unlocked and practiced solo, but none of its transitions are - # measured yet -- it should still win over the already-measured (a, b). - result = select_focus_transition(unlocked, transitions, 200.0, now, key_stats=stats) - assert result is not None - assert ord("c") in result - - -def test_select_focus_transition_uses_measured_pairs_once_newest_key_has_data(): - """Once the newest key has its own measured transition, ordinary - weakest-pair scoring resumes -- no permanent bias toward the newest key.""" - unlocked = (ord("a"), ord("b"), ord("c")) - now = 1_700_000_000.0 - fast = 100_000_000.0 - stats = { - ord("a"): KeyStats(ord("a"), 10, 200_000_000.0, 0, now, attempt_count=10), - ord("b"): KeyStats(ord("b"), 10, 200_000_000.0, 0, now, attempt_count=10), - ord("c"): KeyStats(ord("c"), 10, 200_000_000.0, 0, now, attempt_count=10), - } - transitions = { - Bigram(ord("a"), ord("b")): _transition( - ord("a"), - ord("b"), - 400_000_000.0, - last_seen=now, - attempt_count=10, - ), - Bigram(ord("b"), ord("c")): _transition( - ord("b"), - ord("c"), - fast, - last_seen=now, - attempt_count=10, - ), - } - assert select_focus_transition(unlocked, transitions, 200.0, now, key_stats=stats) == ( - ord("a"), - ord("b"), - ) +def _cleared_keys(): + return {cp: _stats(cp, FAST, last_seen=NOW) for cp in (A, S, D)} -def test_select_focus_transition_falls_back_to_unmeasured_unlocked_pair(): - unlocked = (ord("a"), ord("b")) - stats = { - ord("a"): KeyStats(ord("a"), 10, 200_000_000.0, 0, 1.0, attempt_count=10), - ord("b"): KeyStats(ord("b"), 10, 200_000_000.0, 0, 1.0, attempt_count=10), +def _cleared_pairs(): + return { + Bigram(p, n): _transition(p, n, FAST, last_seen=NOW) + for p in (A, S, D) + for n in (A, S, D) + if p != n } - transitions = { - Bigram(ord("y"), ord("z")): _transition( - ord("y"), - ord("z"), - 100_000_000.0, - last_seen=1_700_000_000.0, - ), - } - assert select_focus_transition( - unlocked, transitions, 200.0, now=1_700_000_000.0, key_stats=stats - ) == (ord("a"), ord("b")) -def test_focus_key_from_transition_uses_next_endpoint(): - assert focus_key_from_transition(ord("t"), ord("h")) == ord("h") +def test_weakest_key_wins_while_any_key_is_not_cleared(): + keys = _cleared_keys() + keys[S] = _stats(S, SLOW, last_seen=NOW) + assert _focus(keys) == (S, None) -def test_key_attempt_floor_blocks_transition_focus(): - cp = ord("a") - stats = { - cp: KeyStats(cp, 9, 100_000_000.0, 0, 1.0, attempt_count=9), - } - assert blocks_transition_focus((cp,), stats, 200.0, min_attempts=10) +def test_never_practiced_key_is_not_cleared(): + keys = _cleared_keys() + del keys[D] + assert _focus(keys) == (D, None) -def test_sparse_old_transition_does_not_preempt_gating_cohort(): - a, b, c = map(ord, "abc") - gate = Bigram(b, c) - old = Bigram(a, b) - transitions = { - old: _transition(a, b, 400_000_000.0, attempt_count=3), - } - assert ( - select_focus_transition( - (a, b, c), - transitions, - 200.0, - 1_000.0, - gating_candidates=(gate,), - min_attempts=4, - ) - == gate - ) +def test_last_key_is_kept_while_it_still_needs_work(): + keys = _cleared_keys() + keys[A] = _stats(A, SLOW, last_seen=NOW) + keys[S] = _stats(S, 300_000_000.0, last_seen=NOW) + assert _focus(keys) == (A, None) + assert _focus(keys, last_key=S) == (S, None) -def test_genuine_old_transition_regression_preempts_gating_cohort(): - a, b, c = map(ord, "abc") - gate = Bigram(b, c) - old = Bigram(a, b) - transitions = { - old: _transition(a, b, 400_000_000.0, attempt_count=4), - } - assert ( - select_focus_transition( - (a, b, c), - transitions, - 200.0, - 1_000.0, - gating_candidates=(gate,), - min_attempts=4, - ) - == old - ) +def test_stalled_last_key_goes_back_to_the_pool(): + keys = _cleared_keys() + keys[A] = _stats(A, SLOW, last_seen=NOW) + keys[S] = _stats(S, 300_000_000.0, last_seen=NOW, attempt_count=30) + assert _focus(keys, last_key=S) == (A, None) -def test_weakest_session_pair_picks_slowest_pair_with_enough_attempts(): - unlocked = (ord("a"), ord("b"), ord("c")) - slow = Bigram(ord("a"), ord("b")) - slower = Bigram(ord("b"), ord("c")) - calibrating = Bigram(ord("c"), ord("a")) # slowest, but too few attempts - transitions = { - slow: _transition(*slow, 400_000_000.0), - slower: _transition(*slower, 800_000_000.0), - calibrating: _transition(*calibrating, 2_000_000_000.0, attempt_count=2), - } - assert weakest_session_pair(transitions, unlocked, transitions, 200.0) == slower +def test_cleared_last_key_is_not_kept(): + keys = _cleared_keys() + keys[A] = _stats(A, SLOW, last_seen=NOW) + assert _focus(keys, last_key=S) == (A, None) -def test_weakest_session_pair_none_when_all_pairs_meet_target(): - unlocked = (ord("a"), ord("b")) - fast = Bigram(ord("a"), ord("b")) - transitions = {fast: _transition(*fast, 100_000_000.0)} - assert weakest_session_pair(transitions, unlocked, transitions, 200.0) is None +def test_gate_pair_wins_over_other_weak_pairs(): + gate = Bigram(S, D) + transitions = _cleared_pairs() + transitions[Bigram(A, S)] = _transition(A, S, SLOW, last_seen=NOW) + assert _focus(_cleared_keys(), transitions, gate=(gate,)) == (D, gate) -def test_weakest_session_pair_only_considers_pairs_typed_in_the_session(): - unlocked = (ord("a"), ord("b"), ord("c")) - typed = Bigram(ord("a"), ord("b")) - untouched = Bigram(ord("b"), ord("c")) - transitions = { - typed: _transition(*typed, 400_000_000.0), - untouched: _transition(*untouched, 800_000_000.0), - } - assert weakest_session_pair([typed], unlocked, transitions, 200.0) == typed +def test_weak_pairs_rank_by_weakness_times_language_frequency(): + common, rare = Bigram(A, S), Bigram(S, A) + transitions = _cleared_pairs() + del transitions[common] + del transitions[rare] + freq = {common: 100, rare: 1} + assert _focus(_cleared_keys(), transitions, freq=freq) == (S, common) -def test_weakest_session_pair_skips_same_key_and_locked_pairs(): - unlocked = (ord("a"), ord("b")) - same_key = Bigram(ord("a"), ord("a")) - locked = Bigram(ord("a"), ord("z")) - transitions = { - same_key: _transition(*same_key, 800_000_000.0), - locked: _transition(*locked, 800_000_000.0), - } - assert weakest_session_pair(transitions, unlocked, transitions, 200.0) is None +def test_pair_that_never_occurs_in_the_language_is_skipped(): + never, real = Bigram(A, S), Bigram(S, A) + transitions = _cleared_pairs() + del transitions[never] + transitions[real] = _transition(S, A, SLOW, last_seen=NOW) + assert _focus(_cleared_keys(), transitions, freq={never: 0}) == (A, real) -def test_select_focus_transition_prefers_saved_slow_pair_over_calibrating_pair(): - now = 1_700_000_000.0 - unlocked = (ord("a"), ord("b"), ord("c")) - saved = Bigram(ord("a"), ord("b")) - calibrating = Bigram(ord("b"), ord("c")) - transitions = { - saved: _transition(*saved, 400_000_000.0, last_seen=now), - calibrating: _transition(*calibrating, 200_000_000.0, last_seen=now, attempt_count=1), - } - assert select_focus_transition(unlocked, transitions, 200.0, now) == calibrating - assert select_focus_transition(unlocked, transitions, 200.0, now, preferred=saved) == saved - - -def test_select_focus_transition_ignores_saved_pair_that_is_no_longer_slow(): - now = 1_700_000_000.0 - unlocked = (ord("a"), ord("b"), ord("c")) - saved = Bigram(ord("a"), ord("b")) - other = Bigram(ord("b"), ord("c")) - transitions = { - saved: _transition(*saved, 100_000_000.0, last_seen=now), # now fast - other: _transition(*other, 400_000_000.0, last_seen=now), - } - assert select_focus_transition(unlocked, transitions, 200.0, now, preferred=saved) == other +def test_last_pair_is_kept_over_a_more_frequent_weak_pair(): + common, last = Bigram(A, S), Bigram(S, D) + transitions = _cleared_pairs() + del transitions[common] + transitions[last] = _transition(S, D, SLOW, last_seen=NOW) + freq = {common: 100} + assert _focus(_cleared_keys(), transitions, freq=freq) == (S, common) + assert _focus(_cleared_keys(), transitions, freq=freq, last_pair=last) == (D, last) -def test_select_focus_transition_saved_pair_loses_to_newest_key_unmeasured_pairs(): - unlocked = (ord("a"), ord("b"), ord("c")) - now = 1_700_000_000.0 - stats = {cp: KeyStats(cp, 10, 200_000_000.0, 0, now, attempt_count=10) for cp in unlocked} - saved = Bigram(ord("a"), ord("b")) - transitions = {saved: _transition(*saved, 400_000_000.0, last_seen=now)} - result = select_focus_transition( - unlocked, transitions, 200.0, now, key_stats=stats, preferred=saved - ) - assert result is not None - assert ord("c") in result - - -def test_select_focus_transition_ignores_saved_pair_in_gating_mode(): - a, b, c = map(ord, "abc") - gate = Bigram(b, c) - weaker = Bigram(a, b) - saved = Bigram(c, a) - transitions = { - weaker: _transition(*weaker, 800_000_000.0), - saved: _transition(*saved, 400_000_000.0), - } - assert ( - select_focus_transition( - (a, b, c), transitions, 200.0, 1_000.0, gating_candidates=(gate,), preferred=saved - ) - == weaker - ) +def test_most_overdue_key_or_pair_is_reviewed_when_everything_cleared(): + stale_pair = Bigram(D, A) + transitions = _cleared_pairs() + transitions[stale_pair] = _transition(D, A, FAST, last_seen=NOW - 3 * DAY) + keys = _cleared_keys() + keys[S] = _stats(S, FAST, last_seen=NOW - 2 * DAY) + assert _focus(keys, transitions) == (A, stale_pair) + keys[S] = _stats(S, FAST, last_seen=NOW - 5 * DAY) # ties the pair: keys first + assert _focus(keys, transitions) == (S, None) -@pytest.mark.parametrize( - "saved", - [ - Bigram(ord("c"), ord("a")), # no transition data - Bigram(ord("a"), ord("b")), # slow, but too few attempts - ], -) -def test_select_focus_transition_ignores_unmeasured_or_calibrating_saved_pair(saved): - now = 1_700_000_000.0 - unlocked = (ord("a"), ord("b"), ord("c")) - calibrating = Bigram(ord("a"), ord("b")) - weakest = Bigram(ord("b"), ord("c")) - transitions = { - calibrating: _transition(*calibrating, 400_000_000.0, last_seen=now, attempt_count=2), - weakest: _transition(*weakest, 800_000_000.0, last_seen=now, attempt_count=1), - } - assert select_focus_transition(unlocked, transitions, 200.0, now, preferred=saved) == weakest +def test_first_unlocked_key_when_nothing_needs_work(): + assert _focus(_cleared_keys(), _cleared_pairs()) == (A, None) diff --git a/tests/domain/test_markov.py b/tests/domain/test_markov.py index 0a3083d..8ac12cb 100644 --- a/tests/domain/test_markov.py +++ b/tests/domain/test_markov.py @@ -18,6 +18,13 @@ def test_sample_falls_back_to_shorter_context(): assert table.sample("xb", frozenset("bcxz"), rng) == "c" +def test_pair_weight_reads_the_first_letter_row_then_the_global_row(): + table = TransitionTable(order=2, transitions={"a": {"s": 7}, "": {"s": 2}}) + assert table.pair_weight(Bigram(ord("a"), ord("s"))) == 7 + assert table.pair_weight(Bigram(ord("a"), ord("d"))) == 0 + assert table.pair_weight(Bigram(ord("d"), ord("s"))) == 2 + + def test_sample_falls_back_to_global_distribution(): table = TransitionTable(order=2, transitions={"": {"e": 1}}) rng = Random(1) diff --git a/tests/domain/test_newest_key.py b/tests/domain/test_newest_key.py index 86e6979..313897e 100644 --- a/tests/domain/test_newest_key.py +++ b/tests/domain/test_newest_key.py @@ -1,5 +1,4 @@ -from keystrike.domain.focus import newest_key_unmeasured_pairs -from keystrike.domain.models import Bigram, KeyStats, TransitionStats +from keystrike.domain.models import Bigram, KeyStats from keystrike.domain.newest_key import effective_gating_bigram_limit, newest_key_gating_cohort @@ -20,57 +19,6 @@ def _stats( ) -def _transition( - prev_cp: int, - next_cp: int, - mean_time_ns: float, - *, - last_seen: float = 0.0, - error_count: int = 0, - attempt_count: int | None = None, -) -> TransitionStats: - attempts = attempt_count if attempt_count is not None else 10 + error_count - return TransitionStats( - prev_cp=prev_cp, - next_cp=next_cp, - samples=10, - mean_time_ns=mean_time_ns, - error_count=error_count, - last_seen=last_seen, - attempt_count=attempts, - ) - - -def test_newest_key_unmeasured_pairs_excludes_unpracticed_cascade_key(): - """A key unlocked by cascade (no KeyStats yet) shouldn't get bigram - candidacy just for sitting next to the newest practiced key.""" - unlocked = (ord("a"), ord("s"), ord("h"), ord("d"), ord("l")) - now = 1_700_000_000.0 - stats = { - cp: KeyStats(cp, 10, 200_000_000.0, 0, now, attempt_count=10) - for cp in (ord("a"), ord("s"), ord("h"), ord("d")) - } - pairs = newest_key_unmeasured_pairs(unlocked, {}, stats) - assert pairs - assert all(ord("l") not in (p.prev_cp, p.next_cp) for p in pairs) - assert all(ord("d") in (p.prev_cp, p.next_cp) for p in pairs) - - -def test_newest_key_unmeasured_pairs_empty_once_newest_has_any_measured_pair(): - unlocked = (ord("a"), ord("s"), ord("d")) - now = 1_700_000_000.0 - stats = { - cp: KeyStats(cp, 10, 200_000_000.0, 0, now, attempt_count=10) - for cp in (ord("a"), ord("s"), ord("d")) - } - transitions = { - Bigram(ord("s"), ord("d")): _transition( - ord("s"), ord("d"), 200_000_000.0, last_seen=now, attempt_count=10 - ), - } - assert newest_key_unmeasured_pairs(unlocked, transitions, stats) == [] - - def test_gating_cohort_is_bounded_deterministic_and_measurement_independent(): unlocked = tuple(map(ord, "abcd")) stats = {cp: _stats(cp, mean_time_ns=100_000_000.0) for cp in unlocked} diff --git a/tests/domain/test_unlock.py b/tests/domain/test_unlock.py index 7c4a302..f0bd7c5 100644 --- a/tests/domain/test_unlock.py +++ b/tests/domain/test_unlock.py @@ -1,5 +1,5 @@ from keystrike.domain.aggregate import combine_sessions, tally_session -from keystrike.domain.confidence import confidence_of, skill_of +from keystrike.domain.confidence import confidence_of, skill_of, stall_attempts_cap from keystrike.domain.enums import Mode from keystrike.domain.models import ( Bigram, @@ -12,7 +12,6 @@ from keystrike.domain.newest_key import newest_key_gating_cohort from keystrike.domain.unlock import ( compute_unlocked, - default_transition_stall_attempts_cap, gating_bigram_is_ready, newest_key_clears_transition_gate, newest_key_transition_gate_progress, @@ -423,7 +422,7 @@ def test_transition_stall_cap_reachable_at_two_attempts_per_lesson(): 6th lesson in the window, instead of settling near 6.5 weighted ones.""" ea = Bigram(ord("e"), ord("a")) tuning = UnlockTuning() - cap = default_transition_stall_attempts_cap(tuning.min_transition_confidence_attempts) + cap = stall_attempts_cap(tuning.min_transition_confidence_attempts) assert cap == 12 def ready_after(lessons: int) -> bool: diff --git a/tests/infrastructure/test_session_repo_jsonl.py b/tests/infrastructure/test_session_repo_jsonl.py index cc5cb91..d282177 100644 --- a/tests/infrastructure/test_session_repo_jsonl.py +++ b/tests/infrastructure/test_session_repo_jsonl.py @@ -66,18 +66,18 @@ def test_round_trip_single_session(paths): assert headers[0].stats.transitions[Bigram(ord("a"), ord("b"))] == KeyTally(1, 100, 0, 1) -def test_round_trip_weakest_pair(paths): +def test_round_trip_focus_pair(paths): repo = JsonlSessionRepository(paths) - header = replace(_header(), weakest_pair=Bigram(ord("s"), ord("a"))) + header = replace(_header(), focus_pair=Bigram(ord("s"), ord("a"))) repo.save_header(header) assert list(JsonlSessionRepository(paths).iter_headers("qwerty")) == [header] -def test_row_without_weakest_pair_reads_back_as_none(paths): +def test_row_without_focus_pair_reads_back_as_none(paths): repo = JsonlSessionRepository(paths) repo.save_header(_header()) - assert "weakest_pair" not in paths.sessions_index.read_text(encoding="utf-8") - assert next(iter(repo.iter_headers("qwerty"))).weakest_pair is None + assert "focus_pair" not in paths.sessions_index.read_text(encoding="utf-8") + assert next(iter(repo.iter_headers("qwerty"))).focus_pair is None def test_iter_headers_filters_by_layout(paths): @@ -254,7 +254,7 @@ def test_legacy_row_without_stats_reads_as_empty_stats(paths): headers = list(JsonlSessionRepository(paths).iter_headers("qwerty")) assert headers[0].session_id == _VALID_ULID_D assert headers[0].stats.is_empty - assert headers[0].weakest_pair is None + assert headers[0].focus_pair is None @pytest.mark.parametrize( @@ -292,7 +292,7 @@ def test_malformed_stats_skip_the_row_not_the_index(paths, stats_json): '[97, "x"]', # not a codepoint ], ) -def test_malformed_weakest_pair_skips_the_row_not_the_index(paths, pair_json): +def test_malformed_focus_pair_skips_the_row_not_the_index(paths, pair_json): repo = JsonlSessionRepository(paths) repo.save_header(_header(sid=_VALID_ULID_A)) with paths.sessions_index.open("a", encoding="utf-8") as fh: @@ -300,7 +300,7 @@ def test_malformed_weakest_pair_skips_the_row_not_the_index(paths, pair_json): f'{{"schema_version": 5, "session_id": "{_VALID_ULID_E}", "layout": "qwerty", ' '"started_at": 1700000000.0, "duration_ns": 1000000000, "mode": "adaptive", ' '"lesson_alphabet": [], "focus_key": null, "total_keystrokes": 0, ' - f'"correct_keystrokes": 0, "weakest_pair": {pair_json}}}\n' + f'"correct_keystrokes": 0, "focus_pair": {pair_json}}}\n' ) repo.save_header(_header(sid=_VALID_ULID_B)) From 3cbf5efd34bab0babf2fb540c80d3bdac4fdfb31 Mon Sep 17 00:00:00 2001 From: Egno Date: Wed, 7 Oct 2026 15:35:05 +0300 Subject: [PATCH 47/47] Release 2.2.0. Focus now follows one ordered ladder: keys not cleared, unlock-gate pairs, weak pairs by weakness x language frequency, then review; the last focus is kept until it clears or stalls, and sessions save focus_pair for that. Attempt floors count real presses, and accuracy is scored against 95%. Co-Authored-By: Claude Opus 5.5 --- docs/CHANGELOG.md | 24 ++++-------------------- pyproject.toml | 2 +- src/keystrike/domain/version.py | 2 +- uv.lock | 2 +- 4 files changed, 7 insertions(+), 23 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 70739cc..bd7f80d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,7 +5,7 @@ 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. -## Unreleased +## 2.2.0 - **One focus ladder** — `select_lesson_focus` (`domain/focus.py`) replaces `select_focus`, `select_focus_transition`, `remedial_focus` and the @@ -14,10 +14,9 @@ design) stays in `PLAN.md` §5/§6. (`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 (`session_wpm_below_target`), the review - penalty in focus scoring, and the saved `weakest_pair`. Sessions now save - `focus_pair` when the focus was a pair (written only when set; old rows - read back unchanged). + 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, @@ -34,21 +33,6 @@ design) stays in `PLAN.md` §5/§6. 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. -- **Lesson WPM gate compares like with like** — `session_wpm_below_target` - compares correct keystrokes per minute (spaces included) with - `target_speed_cpm`. It used WPM against `cpm / mean word length`, which - leaves out the space, so a lesson typed at exactly target speed on every - key read as about 25% too slow and kept the remedial focus on. Sessions - without a target (`target_speed_cpm == 0`) still never trigger it. -- **Weakest pair saved on the session result** — `FinishSession` now stores - `SessionResult.weakest_pair`: the slowest cross-key pair typed in the - session (enough attempts, skill < 1.0, lowest raw skill; - `domain.focus.weakest_session_pair`). The optional `weakest_pair` field in - the `index.jsonl` row stays schema 5, and rows without it read back as - `None`. After a lesson that missed its WPM target, `remedial_focus` starts - from that pair instead of a calibrating pair with only a few presses. The - newest key's gating cohort still takes priority while the unlock gate is - open. ## 2.1.0 diff --git a/pyproject.toml b/pyproject.toml index a04dd9a..a1a6ee9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keystrike" -version = "2.1.0" +version = "2.2.0" description = "Offline terminal typing tutor with keybr-style adaptive drills and per-layout stats" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keystrike/domain/version.py b/src/keystrike/domain/version.py index 9aa3f90..8a124bf 100644 --- a/src/keystrike/domain/version.py +++ b/src/keystrike/domain/version.py @@ -1 +1 @@ -__version__ = "2.1.0" +__version__ = "2.2.0" diff --git a/uv.lock b/uv.lock index 7f54163..5ebb915 100644 --- a/uv.lock +++ b/uv.lock @@ -79,7 +79,7 @@ wheels = [ [[package]] name = "keystrike" -version = "2.1.0" +version = "2.2.0" source = { editable = "." } dependencies = [ { name = "platformdirs" },