91 lines
7.4 KiB
Markdown
91 lines
7.4 KiB
Markdown
# Adaptive Drill Word Diversity
|
|
|
|
## Context
|
|
|
|
When adaptive drills focus on characters/bigrams with few matching dictionary words, the same words repeat excessively both within and across drills. Currently:
|
|
|
|
- **Within-drill dedup** uses a sliding window of only 4 words — too small when the matching word pool is small
|
|
- **Cross-drill**: no tracking at all — each drill creates a fresh `PhoneticGenerator` with no memory of previous drills
|
|
- **Dictionary vs phonetic is binary**: if `matching_words >= 15` use dictionary only, if `< 15` use phonetic only. A pool of 16 words gets 100% dictionary (lots of repeats), while 14 gets 0% dictionary
|
|
|
|
## Changes
|
|
|
|
### 1. Cross-drill word history
|
|
|
|
Add `adaptive_word_history: VecDeque<HashSet<String>>` to `App` that tracks words from the last 5 adaptive drills. Pass a flattened `HashSet<String>` into `PhoneticGenerator::new()`.
|
|
|
|
**Word normalization**: Capture words from the generator output *before* capitalization/punctuation/numbers post-processing (the `generator.generate()` call in `generate_text()` produces lowercase-only text). This means words in history are always lowercase ASCII with no punctuation — no normalization function needed since the generator already guarantees this format.
|
|
|
|
**`src/app.rs`**:
|
|
- Add `adaptive_word_history: VecDeque<HashSet<String>>` to `App` struct, initialize empty
|
|
- In `generate_text()`, before creating the generator: flatten history into `HashSet` and pass to constructor
|
|
- After `generator.generate()` returns (before capitalization/punctuation): `split_whitespace()` into a `HashSet`, push to history, pop front if `len > 5`
|
|
|
|
**Lifecycle/reset rules**:
|
|
- Clear `adaptive_word_history` when `drill_mode` changes away from `Adaptive` (i.e., switching to Code/Passage mode)
|
|
- Clear when `drill_scope` changes (switching between branches or global/branch)
|
|
- Do NOT persist across app restarts — session-local only (it's a `VecDeque`, not serialized)
|
|
- Do NOT clear on gradual key unlocks — as the skill tree progresses one key at a time, history should carry over to maintain cross-drill diversity within the same learning progression
|
|
- The effective "adaptive context key" is `(drill_mode, drill_scope)` — history clears when either changes. Other parameters (focus char, focus bigram, filter) change naturally within a learning progression and should not trigger resets
|
|
- This prevents cross-contamination between unrelated drill contexts while preserving continuity during normal adaptive flow
|
|
|
|
**`src/generator/phonetic.rs`**:
|
|
- Add `cross_drill_history: HashSet<String>` field to `PhoneticGenerator`
|
|
- Update constructor to accept it
|
|
- In `pick_tiered_word()`, use weighted suppression instead of hard exclusion:
|
|
- When selecting a candidate word, if it's in within-drill `recent`, always reject
|
|
- If it's in `cross_drill_history`, accept it with reduced probability based on pool coverage:
|
|
- Guard: if pool is empty, skip suppression logic entirely (fall through to phonetic generation in hybrid mode)
|
|
- `history_coverage = cross_drill_history.intersection(pool).count() as f64 / pool.len() as f64`
|
|
- `accept_prob = 0.15 + 0.60 * history_coverage` (range: 15% when history covers few pool words → 75% when history covers most of the pool)
|
|
- This prevents over-suppression in small pools where history covers most words, while still penalizing repeats in large pools
|
|
- Scale attempt count to `pool_size.clamp(6, 12)` with final fallback accepting any non-recent word
|
|
- Compute `accept_prob` once at the start of `generate()` alongside tier categorization (not per-attempt)
|
|
|
|
### 2. Hybrid dictionary + phonetic mode
|
|
|
|
Replace the binary threshold with a gradient that mixes dictionary and phonetic words.
|
|
|
|
**`src/generator/phonetic.rs`**:
|
|
- Change constants: `MIN_REAL_WORDS = 8` (below: phonetic only), add `FULL_DICT_THRESHOLD = 60` (above: dictionary only)
|
|
- Calculate `dict_ratio` as linear interpolation: `(count - 8) / (60 - 8)` clamped to `[0.0, 1.0]`
|
|
- In the word generation loop, for each word: roll against `dict_ratio` to decide dictionary vs phonetic
|
|
- Tier categorization still happens when `count >= MIN_REAL_WORDS` (needed for dictionary picks)
|
|
- Phonetic words also participate in the `recent` dedup window (already handled since all words push to `recent`)
|
|
|
|
### 3. Scale within-drill dedup window
|
|
|
|
Replace the fixed window of 4 with a window proportional to the **filtered dictionary match count** (the `matching_words` vec computed at the top of `generate()`):
|
|
- `pool_size <= 20`: window = `pool_size.saturating_sub(1).max(4)`
|
|
- `pool_size > 20`: window = `(pool_size / 4).min(20)`
|
|
- In hybrid mode, this is based on the dictionary pool size regardless of phonetic mixing — phonetic words add diversity naturally, so the window governs dictionary repeat pressure
|
|
|
|
### 4. Tests
|
|
|
|
All tests use seeded `SmallRng::seed_from_u64()` for determinism (existing pattern in codebase).
|
|
|
|
**Update existing tests**: Add `HashSet::new()` to `PhoneticGenerator::new()` constructor calls (3 tests).
|
|
|
|
**New tests** (all use `SmallRng::seed_from_u64()` for determinism):
|
|
|
|
1. **Cross-drill history suppresses repeats**: Generate drill 1 with seeded RNG and constrained filter (~20 matching words), collect word set. Generate drill 2 with same filter but different seed, no history — compute Jaccard index as baseline. Generate drill 2 again with drill 1's words as history — compute Jaccard index. Assert history Jaccard is at least 0.15 lower than baseline Jaccard (i.e., measurably less overlap). Use 100-word drills.
|
|
|
|
2. **Hybrid mode produces mixed output**: Use a filter that yields ~30 dictionary matches. Generate 500 words with seeded RNG. Collect output words and check against the dictionary match set. With ~30 matches, `dict_ratio ≈ 0.42`. Since the seed is fixed, the output is deterministic — the band of 25%-65% accommodates potential future seed changes rather than runtime variance. Assert dictionary word percentage is within this range, and document the actual observed value for the chosen seed in a comment.
|
|
|
|
3. **Boundary conditions**: With 5 matching words → assert 0% dictionary words (all phonetic). With 100+ matching words → assert 100% dictionary words. Seeded RNG.
|
|
|
|
4. **Weighted suppression graceful degradation**: Create a pool of 10 words with history containing 8 of them. Generate 50 words. Verify no panics, output is non-empty, and history words still appear (suppression is soft, not hard exclusion).
|
|
|
|
## Files to modify
|
|
|
|
- `src/generator/phonetic.rs` — core changes: hybrid mixing, cross-drill history field, weighted suppression in `pick_tiered_word`, dedup window scaling
|
|
- `src/app.rs` — add `adaptive_word_history` field, wire through `generate_text()`, add reset logic on mode/scope changes
|
|
- `src/generator/mod.rs` — no changes (`TextGenerator` trait signature unchanged for API stability; the `cross_drill_history` parameter is internal to `PhoneticGenerator`'s constructor, not the trait interface)
|
|
|
|
## Verification
|
|
|
|
1. `cargo test` — all existing and new tests pass
|
|
2. Manual test: start adaptive drill on an early skill tree branch (few unlocked letters, ~15-30 matching words). Run 5+ consecutive drills. Measure: unique words across 5 drills should be notably higher than before (target: >70% unique across 5 drills for pools of 20+ words)
|
|
3. Full alphabet test: with all keys unlocked, behavior should be essentially unchanged (dict_ratio ≈ 1.0, large pool, no phonetic mixing)
|
|
4. Scope change test: switch between branch drill and global drill, verify no stale history leaks
|