Skip to main content

very_normal/
optimise.rs

1//! Makes a plan from your own typing: scores layouts on what you type,
2//! searches for a good one with simulated annealing, splits the move from
3//! QWERTY into small stages, and picks drill words from your vocabulary.
4//!
5//! Finding the true best layout is a quadratic assignment problem, and
6//! "best" depends on how the scoring is weighted anyway. Many layouts tie, so
7//! each restart of the search lands somewhere slightly different; the summary
8//! says how much they agreed.
9
10use std::collections::HashMap;
11use std::fmt::Write as _;
12use std::path::PathBuf;
13
14use anyhow::{Context, Result, bail};
15use serde_json::json;
16
17use crate::{corpus, layout, oryx, plans, store};
18
19pub const QWERTY: &str = "qwertyuiopasdfghjkl;zxcvbnm,./";
20const CHARS: &[u8; 30] = b"abcdefghijklmnopqrstuvwxyz;,./";
21pub const DEFAULT_LOCK: &str = "qwzxcv,./";
22const REFERENCE: [(&str, &str); 4] = [
23    ("QWERTY", QWERTY),
24    ("Norman", "qwdfkjurl;asetgynioh zxcvbpm,./"),
25    ("Colemak-DH", "qwfpbjluy;arstgmneiozxcdvkh,./"),
26    ("Very Normal (Mark's)", "qwydgbhaujilstmpnoerzxcvk;f,./"),
27];
28/// Column to finger: 0-3 left, 6-9 right.
29const FINGER: [usize; 10] = [0, 1, 2, 3, 3, 6, 6, 7, 8, 9];
30const EFFORT: [[f64; 10]; 3] = [
31    [2.2, 1.6, 1.3, 1.4, 2.0, 2.0, 1.4, 1.3, 1.6, 2.2],
32    [1.6, 1.2, 1.0, 1.0, 1.6, 1.6, 1.0, 1.0, 1.2, 1.6],
33    [2.0, 1.8, 1.7, 1.5, 2.2, 2.2, 1.5, 1.7, 1.8, 2.0],
34];
35/// Fewer messages than this is too little to optimise for.
36pub const ENOUGH: usize = 200;
37const ISSUES: &str = "https://github.com/markryall/very-normal/issues/new";
38
39// ------------------------------------------------------------------ options
40
41#[derive(Debug, Clone)]
42pub struct Options {
43    pub name: String,
44    /// Read what you typed to Claude Code.
45    pub claude: bool,
46    pub projects: PathBuf,
47    /// Other writing: files, folders, or `-` for stdin.
48    pub text: Vec<String>,
49    pub lock: String,
50    pub oryx_layout: Option<String>,
51    pub home_row_mods: Option<Vec<String>>,
52    pub per_stage: usize,
53    pub restarts: usize,
54    pub iters: usize,
55    pub seed: u64,
56    /// Where the plan goes; by default `plans/<name>.toml` next to your plan.
57    pub out: Option<PathBuf>,
58    /// Where the summary goes; by default next to the plan.
59    pub summary: Option<PathBuf>,
60    pub force: bool,
61    pub corpus_out: Option<PathBuf>,
62}
63
64impl Default for Options {
65    fn default() -> Self {
66        Options {
67            name: "My Layout".into(),
68            claude: true,
69            projects: corpus::claude_projects(),
70            text: Vec::new(),
71            lock: DEFAULT_LOCK.into(),
72            oryx_layout: None,
73            home_row_mods: None,
74            per_stage: 2,
75            restarts: 20,
76            iters: 200_000,
77            seed: 7,
78            out: None,
79            summary: None,
80            force: false,
81            corpus_out: None,
82        }
83    }
84}
85
86pub const USAGE: &str = "\
87usage: very-normal make-plan [--name NAME] [--text FILE|FOLDER|- ...] [--no-claude]
88                             [--lock KEYS] [--oryx-layout ID] [--home-row-mods M,M,...]
89                             [--per-stage N] [--restarts N] [--iters N] [--seed N]
90                             [--out FILE] [--summary FILE] [--force] [--corpus-out FILE]
91
92Makes a layout from your own typing and a plan to get there from QWERTY.
93By default it reads what you typed to Claude Code (never Claude's replies).
94--text adds any other writing: files, folders of .md/.txt/.org files, or -
95for stdin. --no-claude leaves your Claude Code messages out.
96
97--lock         keys that stay where QWERTY has them (default qwzxcv,./ keeps
98               Cmd+Q, W, Z, X, C and V). Each one costs the layout a little.
99--oryx-layout  your Oryx layout id; its home row holds are read from Oryx
100--out          the plan (default ~/.config/very-normal/plans/<name>.toml; private)
101--summary      the shareable summary (default next to the plan, as .md)
102--force        replace a plan you made before with the same name
103
104It never touches the plan you're practising, or your progress. Your first
105plan becomes the active one; after that, switch with `very-normal use <name>`.
106";
107
108impl Options {
109    pub fn parse(args: &[&str]) -> Result<Options> {
110        let mut o = Options::default();
111        let mut i = 0;
112        let value = |i: &mut usize, flag: &str| -> Result<String> {
113            *i += 1;
114            args.get(*i)
115                .map(|s| s.to_string())
116                .with_context(|| format!("{flag} needs a value"))
117        };
118        while i < args.len() {
119            let flag = args[i];
120            match flag {
121                "--name" => o.name = value(&mut i, flag)?,
122                "--no-claude" => o.claude = false,
123                "--projects" => o.projects = value(&mut i, flag)?.into(),
124                "--text" => {
125                    let start = o.text.len();
126                    while let Some(a) = args.get(i + 1).filter(|a| !a.starts_with("--")) {
127                        o.text.push(a.to_string());
128                        i += 1;
129                    }
130                    if o.text.len() == start {
131                        bail!("--text needs at least one file or folder");
132                    }
133                }
134                "--lock" => o.lock = value(&mut i, flag)?,
135                "--oryx-layout" => o.oryx_layout = Some(value(&mut i, flag)?),
136                "--home-row-mods" => {
137                    let mods: Vec<String> = value(&mut i, flag)?
138                        .split(',')
139                        .map(|m| m.trim().to_string())
140                        .collect();
141                    if mods.len() != 10 {
142                        bail!("--home-row-mods needs ten comma-separated entries");
143                    }
144                    o.home_row_mods = Some(mods);
145                }
146                "--per-stage" => o.per_stage = value(&mut i, flag)?.parse()?,
147                "--restarts" => o.restarts = value(&mut i, flag)?.parse()?,
148                "--iters" => o.iters = value(&mut i, flag)?.parse()?,
149                "--seed" => o.seed = value(&mut i, flag)?.parse()?,
150                "--out" => o.out = Some(value(&mut i, flag)?.into()),
151                "--summary" => o.summary = Some(value(&mut i, flag)?.into()),
152                "--force" => o.force = true,
153                "--corpus-out" => o.corpus_out = Some(value(&mut i, flag)?.into()),
154                other => bail!("make-plan doesn't know {other}\n\n{USAGE}"),
155            }
156            i += 1;
157        }
158        if !o.claude && o.text.is_empty() {
159            bail!("--no-claude leaves nothing to read; add --text with some of your writing");
160        }
161        if o.per_stage == 0 || o.restarts == 0 || o.iters == 0 {
162            bail!("--per-stage, --restarts and --iters must be at least 1");
163        }
164        Ok(o)
165    }
166}
167
168// ------------------------------------------------------------------ random
169
170/// SplitMix64: small, seedable, and good enough for a search.
171#[derive(Debug, Clone)]
172pub struct Rng(u64);
173
174impl Rng {
175    pub fn new(seed: u64) -> Rng {
176        Rng(seed)
177    }
178
179    fn next_u64(&mut self) -> u64 {
180        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
181        let mut z = self.0;
182        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
183        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
184        z ^ (z >> 31)
185    }
186
187    fn below(&mut self, n: usize) -> usize {
188        (self.next_u64() % n as u64) as usize
189    }
190
191    fn unit(&mut self) -> f64 {
192        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
193    }
194
195    fn shuffle<T>(&mut self, items: &mut [T]) {
196        for i in (1..items.len()).rev() {
197            items.swap(i, self.below(i + 1));
198        }
199    }
200}
201
202// ------------------------------------------------------------------ scoring
203
204/// How often each key, and each pair of keys in a row, was typed.
205#[derive(Debug, Clone)]
206pub struct Counts {
207    uni: [f64; 30],
208    bi: [[f64; 30]; 30],
209}
210
211fn index(ch: char) -> Option<usize> {
212    CHARS.iter().position(|c| *c as char == ch)
213}
214
215impl Counts {
216    /// Counts lowercase text; anything that isn't one of the 30 keys breaks a pair.
217    pub fn of(text: &str) -> Counts {
218        let mut c = Counts {
219            uni: [0.0; 30],
220            bi: [[0.0; 30]; 30],
221        };
222        let mut prev: Option<usize> = None;
223        for ch in text.chars() {
224            let Some(i) = index(ch) else {
225                prev = None;
226                continue;
227            };
228            c.uni[i] += 1.0;
229            if let Some(p) = prev
230                && p != i
231            {
232                c.bi[p][i] += 1.0;
233            }
234            prev = Some(i);
235        }
236        c
237    }
238
239    fn total(&self) -> f64 {
240        self.uni.iter().sum::<f64>().max(1.0)
241    }
242}
243
244/// The cost of each key, and of each pair of keys typed one after the other.
245struct Model {
246    effort: [f64; 30],
247    pair: [[f64; 30]; 30],
248}
249
250fn row_col(p: usize) -> (usize, usize) {
251    (p / 10, p % 10)
252}
253
254impl Model {
255    fn new() -> Model {
256        let mut m = Model {
257            effort: [0.0; 30],
258            pair: [[0.0; 30]; 30],
259        };
260        for p in 0..30 {
261            let (r1, c1) = row_col(p);
262            m.effort[p] = EFFORT[r1][c1];
263            for q in 0..30 {
264                if p == q {
265                    continue;
266                }
267                let (r2, c2) = row_col(q);
268                let (f1, f2) = (FINGER[c1], FINGER[c2]);
269                let rows = r1.abs_diff(r2) as f64;
270                m.pair[p][q] = if f1 == f2 {
271                    // Same finger, different key: the slow, clunky pairs.
272                    6.0 * (1.0 + 0.5 * rows) + if c1 != c2 { 1.0 } else { 0.0 }
273                } else if (f1 < 5) == (f2 < 5) {
274                    if f1.abs_diff(f2) == 1 && r1.abs_diff(r2) == 2 {
275                        2.0 // scissor
276                    } else if r1 == r2 {
277                        // A roll along one row: inward is nicer.
278                        let inward = if f1 < 5 { f2 > f1 } else { f2 < f1 };
279                        if inward { -0.4 } else { -0.2 }
280                    } else {
281                        0.0
282                    }
283                } else {
284                    0.0
285                };
286            }
287        }
288        m
289    }
290
291    /// Total cost, not yet divided by how much was typed.
292    fn raw(&self, p: &Positions, c: &Counts) -> f64 {
293        let mut s = 0.0;
294        for i in 0..30 {
295            s += c.uni[i] * self.effort[p[i]];
296            for j in 0..30 {
297                s += c.bi[i][j] * self.pair[p[i]][p[j]];
298            }
299        }
300        s
301    }
302
303    /// The part of the raw cost that involves keys `a` or `b`.
304    fn part(&self, p: &Positions, c: &Counts, a: usize, b: usize) -> f64 {
305        let mut s = c.uni[a] * self.effort[p[a]] + c.uni[b] * self.effort[p[b]];
306        for j in 0..30 {
307            s += c.bi[a][j] * self.pair[p[a]][p[j]] + c.bi[j][a] * self.pair[p[j]][p[a]];
308            s += c.bi[b][j] * self.pair[p[b]][p[j]] + c.bi[j][b] * self.pair[p[j]][p[b]];
309        }
310        s - (c.bi[a][b] * self.pair[p[a]][p[b]] + c.bi[b][a] * self.pair[p[b]][p[a]])
311    }
312
313    /// How the raw cost changes if keys `a` and `b` trade places.
314    fn swap_delta(&self, p: &mut Positions, c: &Counts, a: usize, b: usize) -> f64 {
315        let before = self.part(p, c, a, b);
316        p.swap(a, b);
317        let after = self.part(p, c, a, b);
318        p.swap(a, b);
319        after - before
320    }
321}
322
323/// Where each of the 30 keys (in `CHARS` order) sits: 0-9 top row, 10-19
324/// home row, 20-29 bottom row.
325type Positions = [usize; 30];
326
327fn positions(keys: &str) -> Positions {
328    let keys = keys.as_bytes();
329    std::array::from_fn(|i| {
330        keys.iter()
331            .position(|k| *k == CHARS[i])
332            .expect("a layout has all 30 keys")
333    })
334}
335
336fn keys_of(p: &Positions) -> String {
337    let mut grid = [b' '; 30];
338    for (i, pos) in p.iter().enumerate() {
339        grid[*pos] = CHARS[i];
340    }
341    String::from_utf8(grid.to_vec()).expect("ascii")
342}
343
344#[derive(Debug, Clone, Copy)]
345pub struct Metrics {
346    /// Effort per key typed; lower is better.
347    pub score: f64,
348    /// Percentage of keys typed on the home row.
349    pub home: f64,
350    /// Percentage of key pairs typed with the same finger.
351    pub same_finger: f64,
352    /// Percentage of keys typed with a pinky.
353    pub pinky: f64,
354}
355
356fn metrics(model: &Model, keys: &str, c: &Counts) -> Metrics {
357    let p = positions(keys);
358    let total = c.total();
359    let finger = |i: usize| FINGER[p[i] % 10];
360    let (mut home, mut pinky, mut same, mut pairs) = (0.0, 0.0, 0.0, 0.0);
361    for (i, pos) in p.iter().enumerate() {
362        if pos / 10 == 1 {
363            home += c.uni[i];
364        }
365        if matches!(finger(i), 0 | 9) {
366            pinky += c.uni[i];
367        }
368        for j in 0..30 {
369            pairs += c.bi[i][j];
370            if finger(i) == finger(j) {
371                same += c.bi[i][j];
372            }
373        }
374    }
375    Metrics {
376        score: model.raw(&p, c) / total,
377        home: 100.0 * home / total,
378        same_finger: 100.0 * same / pairs.max(1.0),
379        pinky: 100.0 * pinky / total,
380    }
381}
382
383/// Simulated annealing from a shuffle of QWERTY, with locked keys pinned.
384fn anneal(model: &Model, c: &Counts, lock: &str, iters: usize, rng: &mut Rng) -> (f64, Positions) {
385    let mut p = positions(QWERTY);
386    let free: Vec<usize> = (0..30)
387        .filter(|i| !lock.contains(CHARS[*i] as char))
388        .collect();
389    if free.len() < 2 {
390        return (model.raw(&p, c) / c.total(), p);
391    }
392    let mut spots: Vec<usize> = free.iter().map(|i| p[*i]).collect();
393    rng.shuffle(&mut spots);
394    for (i, spot) in free.iter().zip(spots) {
395        p[*i] = spot;
396    }
397    let total = c.total();
398    let mut cur = model.raw(&p, c) / total;
399    let mut best = (cur, p);
400    for k in 0..iters {
401        let t = 0.0005f64.powf(k as f64 / iters as f64);
402        let a = free[rng.below(free.len())];
403        let mut b = free[rng.below(free.len() - 1)];
404        if b == a {
405            b = free[free.len() - 1];
406        }
407        let delta = model.swap_delta(&mut p, c, a, b) / total;
408        if delta < 0.0 || rng.unit() < (-delta / t).exp() {
409            p.swap(a, b);
410            cur += delta;
411            if cur < best.0 {
412                best = (cur, p);
413            }
414        }
415    }
416    (model.raw(&best.1, c) / total, best.1)
417}
418
419// ------------------------------------------------------------------ stages
420
421/// Swaps one key into its final place at a time, most-typed first, and
422/// groups the swaps into stages.
423fn ladder(target: &str, freq: &[usize; 30], per_stage: usize) -> Vec<String> {
424    let mut order: Vec<usize> = (0..30).collect();
425    order.sort_by_key(|i| std::cmp::Reverse(freq[*i]));
426    let mut state = QWERTY.as_bytes().to_vec();
427    let target_bytes = target.as_bytes();
428    let mut swaps = Vec::new();
429    for i in order {
430        let ch = CHARS[i];
431        let want = target_bytes.iter().position(|c| *c == ch).expect("key");
432        let have = state.iter().position(|c| *c == ch).expect("key");
433        if want != have {
434            let other = state[want];
435            state.swap(want, have);
436            swaps.push((ch, other));
437        }
438    }
439    debug_assert_eq!(state, target_bytes);
440    let mut out = vec![QWERTY.to_string()];
441    let mut state = QWERTY.as_bytes().to_vec();
442    for group in swaps.chunks(per_stage) {
443        for (a, b) in group {
444            let i = state.iter().position(|c| c == a).expect("key");
445            let j = state.iter().position(|c| c == b).expect("key");
446            state.swap(i, j);
447        }
448        out.push(String::from_utf8(state.clone()).expect("ascii"));
449    }
450    out
451}
452
453fn upper_list(chars: &[char]) -> String {
454    chars
455        .iter()
456        .map(|c| c.to_ascii_uppercase().to_string())
457        .collect::<Vec<_>>()
458        .join(" and ")
459}
460
461/// Names each stage after the letters that reach their final key in it.
462fn names_and_blurbs(
463    model: &Model,
464    plan_name: &str,
465    ladder: &[String],
466    c: &Counts,
467) -> Vec<(String, String)> {
468    let target = ladder.last().expect("a ladder has stages").as_bytes();
469    let mut out = vec![(
470        "Baseline".to_string(),
471        "Your layout as it is today. Record three 60-second real-text runs so later stages have numbers to beat.".to_string(),
472    )];
473    for s in 1..ladder.len() {
474        let (before, after) = (ladder[s - 1].as_bytes(), ladder[s].as_bytes());
475        let mut moved: Vec<char> = (0..30)
476            .filter(|i| before[*i] != after[*i] && after[*i].is_ascii_alphabetic())
477            .map(|i| after[i] as char)
478            .collect();
479        moved.sort_unstable();
480        moved.dedup();
481        let at = |keys: &[u8], ch: char| keys.iter().position(|k| *k as char == ch);
482        let mut settled: Vec<char> = moved
483            .iter()
484            .copied()
485            .filter(|ch| at(after, *ch) == at(target, *ch))
486            .collect();
487        if settled.is_empty() {
488            settled = moved.clone();
489        }
490        let home: Vec<char> = settled
491            .iter()
492            .copied()
493            .filter(|ch| at(after, *ch).is_some_and(|p| p / 10 == 1))
494            .collect();
495        let name = if s == ladder.len() - 1 {
496            format!("Fully {plan_name}")
497        } else if !home.is_empty() {
498            let two = &home[..home.len().min(2)];
499            format!(
500                "{} {}",
501                upper_list(two),
502                if two.len() > 1 {
503                    "come home"
504                } else {
505                    "comes home"
506                }
507            )
508        } else if !settled.is_empty() {
509            let two = &settled[..settled.len().min(2)];
510            format!(
511                "{} {}",
512                upper_list(two),
513                if two.len() > 1 { "settle" } else { "settles" }
514            )
515        } else {
516            format!("Stage {s}")
517        };
518        let m = metrics(model, &ladder[s], c);
519        let blurb = format!(
520            "{} move. {:.0}% of your typing lands on the home row and {:.1}% of letter pairs use the same finger.",
521            moved
522                .iter()
523                .map(|c| c.to_ascii_uppercase().to_string())
524                .collect::<Vec<_>>()
525                .join(", "),
526            m.home,
527            m.same_finger
528        );
529        out.push((name, blurb));
530    }
531    out
532}
533
534// ------------------------------------------------------------------ words
535
536/// Lowercase dictionary words, and the names to keep out of drills.
537fn dictionary() -> (
538    std::collections::HashSet<String>,
539    std::collections::HashSet<String>,
540) {
541    let read = |p: &str| std::fs::read_to_string(p).unwrap_or_default();
542    let words = ["/usr/share/dict/web2", "/usr/share/dict/words"]
543        .iter()
544        .map(|p| read(p))
545        .find(|t| !t.is_empty())
546        .unwrap_or_default()
547        .lines()
548        .filter(|w| w.starts_with(|c: char| c.is_lowercase()))
549        .map(|w| w.trim().to_string())
550        .collect();
551    let small: std::collections::HashSet<String> = read("/usr/share/dict/connectives")
552        .lines()
553        .map(|w| w.trim().to_lowercase())
554        .collect();
555    let names = read("/usr/share/dict/propernames")
556        .lines()
557        .map(|w| w.trim().to_lowercase())
558        .filter(|w| !small.contains(w))
559        .collect();
560    (words, names)
561}
562
563/// Words like `don't`: letters, optionally an apostrophe and more letters.
564fn tokens(text: &str) -> Vec<&str> {
565    let b = text.as_bytes();
566    let mut out = Vec::new();
567    let mut i = 0;
568    while i < b.len() {
569        if !b[i].is_ascii_alphabetic() {
570            i += 1;
571            continue;
572        }
573        let start = i;
574        while i < b.len() && b[i].is_ascii_alphabetic() {
575            i += 1;
576        }
577        if i + 1 < b.len() && b[i] == b'\'' && b[i + 1].is_ascii_alphabetic() {
578            i += 1;
579            while i < b.len() && b[i].is_ascii_alphabetic() {
580                i += 1;
581            }
582        }
583        out.push(&text[start..i]);
584    }
585    out
586}
587
588/// Your words, most used first: dictionary words only, no names, and none
589/// you mostly write with a capital (which is how names look).
590fn vocabulary(raw: &str) -> Vec<(String, usize)> {
591    let (words, names) = dictionary();
592    let mut count: HashMap<String, (usize, usize, usize)> = HashMap::new(); // (n, capitalised, first seen)
593    for (k, t) in tokens(raw).into_iter().enumerate() {
594        let lower = t.to_ascii_lowercase();
595        let e = count.entry(lower).or_insert((0, 0, k));
596        e.0 += 1;
597        if t.starts_with(|c: char| c.is_ascii_uppercase()) {
598            e.1 += 1;
599        }
600    }
601    let ok = |w: &str, capitals: usize, n: usize| {
602        let (base, tail) = w.split_once('\'').unwrap_or((w, ""));
603        if names.contains(base) || (!words.is_empty() && !words.contains(base)) {
604            return false;
605        }
606        if !matches!(tail, "" | "s" | "t" | "d" | "ll" | "re" | "ve" | "m") {
607            return false;
608        }
609        if base != "i" && capitals * 2 > n {
610            return false;
611        }
612        w.len() > 1 || w == "a" || w == "i"
613    };
614    let mut out: Vec<(String, usize, usize)> = count
615        .into_iter()
616        .filter(|(w, (n, caps, _))| *n >= 3 && ok(w, *caps, *n))
617        .map(|(w, (n, _, first))| (w, n, first))
618        .collect();
619    out.sort_by(|a, b| b.1.cmp(&a.1).then(a.2.cmp(&b.2)));
620    out.into_iter().map(|(w, n, _)| (w, n)).collect()
621}
622
623fn stage_words(vocab: &[(String, usize)], focus: &[char]) -> Vec<String> {
624    let mut scored: Vec<(f64, &str)> = vocab
625        .iter()
626        .filter(|(w, n)| w.len() >= 3 && *n >= 4 && w.chars().any(|c| focus.contains(&c)))
627        .map(|(w, n)| {
628            let hits = w.chars().filter(|c| focus.contains(c)).count() as f64;
629            let s = hits / (w.len() as f64).sqrt() * (*n.min(&50) as f64).powf(0.3);
630            (s, w.as_str())
631        })
632        .collect();
633    scored.sort_by(|a, b| b.0.total_cmp(&a.0).then(b.1.cmp(a.1)));
634    scored
635        .into_iter()
636        .take(80)
637        .map(|(_, w)| w.to_string())
638        .collect()
639}
640
641/// Your own words that use only your new home row: fun layout names.
642fn home_row_words(vocab: &[(String, usize)], target: &str) -> Vec<String> {
643    let home = &target[10..20];
644    let mut found: Vec<&(String, usize)> = vocab
645        .iter()
646        .filter(|(w, _)| w.len() >= 4 && w.chars().all(|c| home.contains(c)))
647        .collect();
648    found.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(b.1.cmp(&a.1)));
649    found.into_iter().take(8).map(|(w, _)| w.clone()).collect()
650}
651
652// ------------------------------------------------------------------ output
653
654fn toml_list(items: &[String]) -> String {
655    let mut lines = Vec::new();
656    let mut line = String::from("  ");
657    for x in items {
658        let piece = format!("{}, ", serde_json::to_string(x).expect("string"));
659        if line.len() + piece.len() > 88 {
660            lines.push(line.trim_end().to_string());
661            line = String::from("  ");
662        }
663        line.push_str(&piece);
664    }
665    lines.push(line.trim_end().trim_end_matches(',').to_string());
666    format!("[\n{}\n]", lines.join("\n"))
667}
668
669fn rows(keys: &str) -> String {
670    format!("{} {} {}", &keys[..10], &keys[10..20], &keys[20..])
671}
672
673/// The layout as three rows split into two hands.
674pub fn pretty(keys: &str) -> String {
675    (0..3)
676        .map(|r| {
677            let row = &keys[r * 10..r * 10 + 10];
678            let hand = |s: &str| s.chars().map(String::from).collect::<Vec<_>>().join(" ");
679            format!("{}   {}", hand(&row[..5]), hand(&row[5..]))
680        })
681        .collect::<Vec<_>>()
682        .join("\n")
683}
684
685/// What making a plan produced.
686#[derive(Debug, Clone)]
687pub struct Made {
688    pub summary: String,
689    pub target: String,
690    pub stages: usize,
691    pub name_ideas: Vec<String>,
692    pub plan_path: PathBuf,
693    pub summary_path: PathBuf,
694    /// It became the plan you're practising, since you had none yet.
695    pub activated: bool,
696    pub slug: String,
697}
698
699/// Reads your writing according to the options: what was found, and where from.
700pub fn gather(o: &Options) -> Result<(Vec<String>, Vec<&'static str>)> {
701    let mut msgs = Vec::new();
702    let mut sources = Vec::new();
703    if o.claude {
704        let typed = corpus::typed_messages(&o.projects);
705        if !typed.is_empty() {
706            sources.push("Claude Code");
707        }
708        msgs.extend(typed);
709    }
710    if !o.text.is_empty() {
711        let text = corpus::text_messages(&o.text)?;
712        if !text.is_empty() {
713            sources.push("other writing");
714        }
715        msgs.extend(text);
716    }
717    Ok((msgs, sources))
718}
719
720/// Makes a plan and its summary, and writes both. Progress goes to stderr.
721pub fn make(o: &Options) -> Result<Made> {
722    let slug = plans::slug(&o.name);
723    let progress = store::default_path();
724    let fresh = plans::fresh(&progress);
725    let out = o.out.clone().unwrap_or_else(|| plans::made_path(&o.name));
726    let summary_out = o.summary.clone().unwrap_or_else(|| plans::summary_of(&out));
727    if o.out.is_none() && !fresh {
728        if slug == plans::active_slug() {
729            bail!(
730                "{} is the plan you're practising; give this one another --name",
731                o.name
732            );
733        }
734        if plans::parked(&progress, &slug).exists() {
735            bail!(
736                "you've practised a plan called {} and its progress is kept; give this one another --name",
737                o.name
738            );
739        }
740    }
741    if out.exists() && !o.force {
742        bail!(
743            "you already made a plan at {}; pass --force to replace it, or give this one another --name",
744            out.display()
745        );
746    }
747    let mods = match (&o.home_row_mods, &o.oryx_layout) {
748        (Some(m), _) => m.clone(),
749        (None, Some(id)) => {
750            let m = oryx::home_row_mods(id)
751                .context("reading your home row holds from Oryx (or pass --home-row-mods)")?;
752            eprintln!("Home row holds from Oryx: {}", m.join(","));
753            m
754        }
755        (None, None) => Vec::new(),
756    };
757
758    let (msgs, sources) = gather(o)?;
759    if msgs.len() < ENOUGH {
760        bail!(
761            "only {} messages or paragraphs found; that's too few to optimise for. Add more of your writing with --text",
762            msgs.len()
763        );
764    }
765    let raw = msgs.join("\n");
766    let letters = raw.chars().filter(|c| c.is_alphabetic()).count();
767    let upper =
768        100.0 * raw.chars().filter(|c| c.is_uppercase()).count() as f64 / letters.max(1) as f64;
769    let text = raw.to_lowercase();
770    let characters = raw.chars().count();
771    if let Some(path) = &o.corpus_out {
772        std::fs::write(path, &raw).with_context(|| format!("writing {}", path.display()))?;
773    }
774    eprintln!(
775        "{} messages, {characters} characters, {upper:.1}% uppercase",
776        msgs.len()
777    );
778
779    let mut rng = Rng::new(o.seed);
780    let mut shuffled = msgs.clone();
781    rng.shuffle(&mut shuffled);
782    let cut = shuffled.len() * 4 / 5;
783    let train = Counts::of(&shuffled[..cut].join("\n").to_lowercase());
784    let test = Counts::of(&shuffled[cut..].join("\n").to_lowercase());
785    let full = Counts::of(&text);
786    let model = Model::new();
787
788    let results: Vec<(f64, Positions)> = std::thread::scope(|s| {
789        let handles: Vec<_> = (0..o.restarts)
790            .map(|r| {
791                let (model, train, lock) = (&model, &train, o.lock.as_str());
792                let seed = o
793                    .seed
794                    .wrapping_add(1 + r as u64)
795                    .wrapping_mul(0x2545_F491_4F6C_DD1D);
796                s.spawn(move || anneal(model, train, lock, o.iters, &mut Rng::new(seed)))
797            })
798            .collect();
799        handles
800            .into_iter()
801            .map(|h| h.join().expect("search thread"))
802            .collect()
803    });
804    for (r, (score, p)) in results.iter().enumerate() {
805        eprintln!(
806            "restart {}/{}: {score:.4}  {}",
807            r + 1,
808            o.restarts,
809            rows(&keys_of(p))
810        );
811    }
812    let best = results
813        .iter()
814        .min_by(|a, b| a.0.total_cmp(&b.0))
815        .expect("at least one restart");
816    let target = keys_of(&best.1);
817    let restart_layouts: Vec<String> = results.iter().map(|(_, p)| keys_of(p)).collect();
818    let agree = (0..30)
819        .filter(|i| {
820            restart_layouts
821                .iter()
822                .all(|l| l.as_bytes()[*i] == target.as_bytes()[*i])
823        })
824        .count();
825    let distinct = {
826        let mut l = restart_layouts.clone();
827        l.sort();
828        l.dedup();
829        l.len()
830    };
831
832    let mut freq = [0usize; 30];
833    for ch in text.chars() {
834        if let Some(i) = index(ch) {
835            freq[i] += 1;
836        }
837    }
838    let steps = ladder(&target, &freq, o.per_stage);
839    let titles = names_and_blurbs(&model, &o.name, &steps, &full);
840    let vocab = vocabulary(&raw);
841    let common: Vec<String> = vocab.iter().take(500).map(|(w, _)| w.clone()).collect();
842    if common.len() < 50 {
843        bail!(
844            "only {} different everyday words in your writing; the drills need at least 50. Add more with --text",
845            common.len()
846        );
847    }
848
849    // The plan: private, since the drill words are your vocabulary.
850    let mut plan = String::new();
851    let _ = writeln!(
852        plan,
853        "# A very-normal plan for {}, made by `very-normal make-plan` from your own\n# writing. The drill words are your vocabulary: keep this file private.\n",
854        o.name
855    );
856    let _ = writeln!(plan, "name = {}", json!(o.name));
857    match &o.oryx_layout {
858        Some(id) => {
859            let _ = writeln!(plan, "oryx_layout = {}", json!(id));
860        }
861        None => plan.push_str(
862            "# oryx_layout = \"abc12\"   # your Oryx layout id, if you have a ZSA board\n",
863        ),
864    }
865    if mods.is_empty() {
866        plan.push_str("# home_row_mods = [\"\", \"ctrl\", \"alt\", \"cmd\", \"\", \"\", \"cmd\", \"alt\", \"ctrl\", \"\"]\n");
867    } else {
868        let _ = writeln!(plan, "home_row_mods = {}", json!(mods));
869    }
870    let _ = writeln!(plan, "\ncommon_words = {}", toml_list(&common));
871    for (s, keys) in steps.iter().enumerate() {
872        let m = metrics(&model, keys, &full);
873        let before = if s > 0 {
874            steps[s - 1].as_bytes()
875        } else {
876            keys.as_bytes()
877        };
878        let mut focus: Vec<char> = (0..30)
879            .filter(|i| before[*i] != keys.as_bytes()[*i])
880            .map(|i| keys.as_bytes()[i] as char)
881            .collect();
882        focus.sort_unstable();
883        let (name, blurb) = &titles[s];
884        let _ = write!(
885            plan,
886            "\n[[stage]]\nname = {}\nblurb = {}\nkeys = {}\nhome = {:.0}\nsame_finger = {:.1}\nvim = {}\n",
887            json!(name),
888            json!(blurb),
889            json!(rows(keys)),
890            m.home,
891            m.same_finger,
892            json!(focus.iter().map(|c| c.to_string()).collect::<Vec<_>>()),
893        );
894        if s > 0 {
895            let _ = writeln!(plan, "words = {}", toml_list(&stage_words(&vocab, &focus)));
896        }
897    }
898    layout::Plan::parse(&plan).context("the plan it made doesn't load; that's a bug")?;
899
900    let summary = summary(&SummaryInput {
901        name: &o.name,
902        target: &target,
903        model: &model,
904        test: &test,
905        full: &full,
906        freq: &freq,
907        messages: msgs.len(),
908        characters,
909        upper,
910        lock: &o.lock,
911        sources: &sources,
912        stages: steps.len() - 1,
913        ladder: &steps,
914        stage_names: &titles.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
915        restarts: &results,
916        agree,
917        distinct,
918    });
919
920    if let Some(dir) = out.parent() {
921        std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
922    }
923    std::fs::write(&out, plan).with_context(|| format!("writing {}", out.display()))?;
924    if let Some(dir) = summary_out.parent() {
925        std::fs::create_dir_all(dir)?;
926    }
927    std::fs::write(&summary_out, &summary)
928        .with_context(|| format!("writing {}", summary_out.display()))?;
929    let activated = o.out.is_none() && fresh;
930    if activated {
931        plans::activate(&out)?;
932    }
933    Ok(Made {
934        summary,
935        name_ideas: home_row_words(&vocab, &target),
936        target,
937        stages: steps.len() - 1,
938        plan_path: out,
939        summary_path: summary_out,
940        activated,
941        slug,
942    })
943}
944
945struct SummaryInput<'a> {
946    name: &'a str,
947    target: &'a str,
948    model: &'a Model,
949    test: &'a Counts,
950    full: &'a Counts,
951    freq: &'a [usize; 30],
952    messages: usize,
953    characters: usize,
954    upper: f64,
955    lock: &'a str,
956    sources: &'a [&'static str],
957    stages: usize,
958    /// Every stage's keys, QWERTY first, and what each is called.
959    ladder: &'a [String],
960    stage_names: &'a [&'a str],
961    restarts: &'a [(f64, Positions)],
962    agree: usize,
963    distinct: usize,
964}
965
966/// The shareable summary: your layout and how it scores. No words, no text.
967fn summary(s: &SummaryInput) -> String {
968    let mut table = String::from(
969        "| Layout | Effort (lower is better) | Home row | Same-finger pairs | Pinky |\n|---|---|---|---|---|\n",
970    );
971    let mut scores = serde_json::Map::new();
972    let ours = [(s.name, s.target)];
973    for (label, keys) in ours.iter().chain(REFERENCE.iter()) {
974        let keys: String = keys.chars().filter(|c| !c.is_whitespace()).collect();
975        let m = metrics(s.model, &keys, s.test);
976        let _ = writeln!(
977            table,
978            "| {label} | {:.3} | {:.0}% | {:.1}% | {:.1}% |",
979            m.score, m.home, m.same_finger, m.pinky
980        );
981        let key = if *label == s.name { "yours" } else { label };
982        scores.insert(
983            key.to_string(),
984            json!({
985                "effort": round(m.score, 4),
986                "home": round(m.home, 1),
987                "same_finger": round(m.same_finger, 2),
988                "pinky": round(m.pinky, 2),
989            }),
990        );
991    }
992    let mut order: Vec<usize> = (0..30).collect();
993    order.sort_by_key(|i| std::cmp::Reverse(s.freq[*i]));
994    let top: String = order[..10].iter().map(|i| CHARS[*i] as char).collect();
995    let total: usize = s.freq.iter().sum::<usize>().max(1);
996    let frequency: serde_json::Map<String, serde_json::Value> = (0..30)
997        .map(|i| {
998            (
999                (CHARS[i] as char).to_string(),
1000                json!(round(100.0 * s.freq[i] as f64 / total as f64, 2)),
1001            )
1002        })
1003        .collect();
1004    let q = metrics(s.model, QWERTY, s.full);
1005    let moved = (0..30)
1006        .filter(|i| s.target.as_bytes()[*i] != QWERTY.as_bytes()[*i])
1007        .count();
1008    let mut runs: Vec<(f64, String)> = s.restarts.iter().map(|(c, p)| (*c, keys_of(p))).collect();
1009    runs.sort_by(|a, b| a.0.total_cmp(&b.0));
1010    let spread = runs.last().map_or(0.0, |w| w.0) - runs.first().map_or(0.0, |b| b.0);
1011    let data = json!({
1012        "format": 1,
1013        "name": s.name,
1014        "layout": rows(s.target),
1015        "lock": s.lock,
1016        "sources": s.sources,
1017        "messages": s.messages,
1018        "characters": s.characters,
1019        "uppercase": round(s.upper, 1),
1020        "key_frequency": frequency,
1021        "scores": scores,
1022        "restarts": distinct_runs(&runs),
1023        "keys_every_restart_agreed_on": s.agree,
1024        "stages": s.ladder.iter().skip(1).map(|k| rows(k)).collect::<Vec<_>>(),
1025        "version": env!("CARGO_PKG_VERSION"),
1026    });
1027    format!(
1028        "# {name}\n\n```\n{pretty}\n```\n\n\
1029         - Typing studied: {messages} messages from {sources}, {chars} characters, {upper:.1}% uppercase\n\
1030         - Ten most-typed keys: `{top}`\n\
1031         - Locked in place: `{lock}`\n\
1032         - Stages from QWERTY: {stages}, moving {moved} keys\n\
1033         - On QWERTY: {qh:.0}% home row, {qs:.1}% same-finger pairs\n\
1034         - The search ran {n} times and found {distinct} different layouts, {spread:.4} apart in effort. \
1035         All {n} put {agree} of the 30 keys in the same place.\n\n\
1036         Scored on 20% of messages the optimiser never saw:\n\n{table}\n\
1037         The stages from QWERTY, most-typed letters first:\n\n{ladder}\n\
1038         <details><summary>The numbers, for comparing layouts</summary>\n\n```json\n{json}\n```\n\n</details>\n\n\
1039         Made with [very-normal](https://github.com/markryall/very-normal) {version}.\n",
1040        name = s.name,
1041        pretty = pretty(s.target),
1042        messages = s.messages,
1043        sources = if s.sources.is_empty() {
1044            "nowhere".to_string()
1045        } else {
1046            s.sources.join(" and ")
1047        },
1048        chars = s.characters,
1049        upper = s.upper,
1050        lock = s.lock,
1051        stages = s.stages,
1052        qh = q.home,
1053        qs = q.same_finger,
1054        n = runs.len(),
1055        json = serde_json::to_string(&data).expect("json"),
1056        ladder = stage_table(s),
1057        version = env!("CARGO_PKG_VERSION"),
1058        distinct = s.distinct,
1059        agree = s.agree,
1060    )
1061}
1062
1063/// Each stage's moves and how it does on your typing.
1064fn stage_table(s: &SummaryInput) -> String {
1065    let mut t = String::from(
1066        "| Stage | | Moves | Home row | Same-finger pairs | Keys |\n|---|---|---|---|---|---|\n",
1067    );
1068    for (i, keys) in s.ladder.iter().enumerate() {
1069        let m = metrics(s.model, keys, s.full);
1070        let moves = if i == 0 {
1071            String::new()
1072        } else {
1073            let before = s.ladder[i - 1].as_bytes();
1074            let mut moved: Vec<char> = (0..30)
1075                .filter(|k| before[*k] != keys.as_bytes()[*k])
1076                .map(|k| keys.as_bytes()[k] as char)
1077                .collect();
1078            moved.sort_unstable();
1079            moved
1080                .iter()
1081                .map(|c| c.to_string())
1082                .collect::<Vec<_>>()
1083                .join(" ")
1084        };
1085        let _ = writeln!(
1086            t,
1087            "| {i} | {} | {moves} | {:.0}% | {:.1}% | `{}` |",
1088            s.stage_names.get(i).copied().unwrap_or(""),
1089            m.home,
1090            m.same_finger,
1091            rows(keys)
1092        );
1093    }
1094    t
1095}
1096
1097/// Each different layout the restarts found, best first, and how many found it.
1098fn distinct_runs(runs: &[(f64, String)]) -> Vec<serde_json::Value> {
1099    let mut out: Vec<(f64, &str, usize)> = Vec::new();
1100    for (c, l) in runs {
1101        match out.iter_mut().find(|(_, k, _)| *k == l) {
1102            Some(e) => e.2 += 1,
1103            None => out.push((*c, l, 1)),
1104        }
1105    }
1106    out.iter()
1107        .map(|(c, l, n)| json!({"effort": round(*c, 4), "layout": rows(l), "times": n}))
1108        .collect()
1109}
1110
1111fn round(x: f64, places: i32) -> f64 {
1112    let f = 10f64.powi(places);
1113    (x * f).round() / f
1114}
1115
1116// ------------------------------------------------------------------ sharing
1117
1118/// A link that opens a new issue on very-normal with your summary filled in.
1119/// You see it, and can change it, before anything is posted.
1120pub fn share_url(summary: &str) -> String {
1121    let title = summary
1122        .lines()
1123        .next()
1124        .and_then(|l| l.strip_prefix("# "))
1125        .unwrap_or("My layout");
1126    format!(
1127        "{ISSUES}?title={}&body={}",
1128        encode(&format!("Layout: {title}")),
1129        encode(summary)
1130    )
1131}
1132
1133/// Browsers and GitHub cope with links up to about this long.
1134pub const LONGEST_LINK: usize = 8000;
1135
1136fn encode(s: &str) -> String {
1137    let mut out = String::new();
1138    for b in s.bytes() {
1139        if b.is_ascii_alphanumeric() || b"-_.~,:;/()*!'@$".contains(&b) {
1140            out.push(b as char);
1141        } else {
1142            let _ = write!(out, "%{b:02X}");
1143        }
1144    }
1145    out
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150    use super::*;
1151
1152    const SAMPLE: &str = "the quick brown fox jumps over the lazy dog, then the dog sleeps. \
1153        we typed this sample so the scoring has something to chew on; it's fine.";
1154
1155    #[test]
1156    fn swap_delta_matches_a_full_rescore() {
1157        let model = Model::new();
1158        let c = Counts::of(SAMPLE);
1159        let mut p = positions(QWERTY);
1160        let mut rng = Rng::new(3);
1161        for _ in 0..200 {
1162            let a = rng.below(30);
1163            let b = (a + 1 + rng.below(29)) % 30;
1164            let before = model.raw(&p, &c);
1165            let delta = model.swap_delta(&mut p, &c, a, b);
1166            p.swap(a, b);
1167            let after = model.raw(&p, &c);
1168            assert!((after - before - delta).abs() < 1e-9, "{a} {b}");
1169        }
1170    }
1171
1172    #[test]
1173    fn annealing_beats_qwerty_and_keeps_locked_keys() {
1174        let model = Model::new();
1175        let c = Counts::of(&SAMPLE.repeat(20));
1176        let (score, p) = anneal(&model, &c, DEFAULT_LOCK, 20_000, &mut Rng::new(1));
1177        let keys = keys_of(&p);
1178        assert!(score < metrics(&model, QWERTY, &c).score);
1179        for ch in DEFAULT_LOCK.chars() {
1180            assert_eq!(keys.find(ch), QWERTY.find(ch), "{ch} moved");
1181        }
1182    }
1183
1184    #[test]
1185    fn the_ladder_climbs_from_qwerty_to_the_target() {
1186        let target = "qwydgbhaujilstmpnoerzxcvk;f,./";
1187        let freq = std::array::from_fn(|i| 30 - i);
1188        let steps = ladder(target, &freq, 2);
1189        assert_eq!(steps.first().map(String::as_str), Some(QWERTY));
1190        assert_eq!(steps.last().map(String::as_str), Some(target));
1191        for w in steps.windows(2) {
1192            let changed = (0..30)
1193                .filter(|i| w[0].as_bytes()[*i] != w[1].as_bytes()[*i])
1194                .count();
1195            assert!(
1196                (2..=4).contains(&changed),
1197                "{changed} keys moved in one stage"
1198            );
1199        }
1200    }
1201
1202    #[test]
1203    fn reference_layouts_are_whole() {
1204        for (name, keys) in REFERENCE {
1205            let keys: String = keys.chars().filter(|c| !c.is_whitespace()).collect();
1206            let mut k: Vec<u8> = keys.into_bytes();
1207            k.sort_unstable();
1208            let mut want = CHARS.to_vec();
1209            want.sort_unstable();
1210            assert_eq!(k, want, "{name}");
1211        }
1212    }
1213
1214    #[test]
1215    fn tokens_keep_contractions() {
1216        assert_eq!(
1217            tokens("don't stop, it's 'fine'"),
1218            vec!["don't", "stop", "it's", "fine"]
1219        );
1220    }
1221
1222    #[test]
1223    fn options_read_text_lists_and_flags() {
1224        let o =
1225            Options::parse(&["--text", "a.md", "notes", "--no-claude", "--name", "Hi"]).unwrap();
1226        assert_eq!(o.text, vec!["a.md", "notes"]);
1227        assert!(!o.claude);
1228        assert_eq!(o.name, "Hi");
1229        assert!(Options::parse(&["--no-claude"]).is_err());
1230        assert!(Options::parse(&["--home-row-mods", "a,b"]).is_err());
1231    }
1232
1233    #[test]
1234    fn share_links_carry_the_summary() {
1235        let url = share_url("# Home Row Hero\n\nqwe & more");
1236        assert!(url.starts_with(ISSUES));
1237        assert!(url.contains("title=Layout:%20Home%20Row%20Hero"));
1238        assert!(url.contains("qwe%20%26%20more"));
1239    }
1240}