1use 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];
28const 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];
35pub const ENOUGH: usize = 200;
37const ISSUES: &str = "https://github.com/markryall/very-normal/issues/new";
38
39#[derive(Debug, Clone)]
42pub struct Options {
43 pub name: String,
44 pub claude: bool,
46 pub projects: PathBuf,
47 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 pub out: Option<PathBuf>,
58 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#[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#[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 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
244struct 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 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 } else if r1 == r2 {
277 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 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 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 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
323type 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 pub score: f64,
348 pub home: f64,
350 pub same_finger: f64,
352 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
383fn 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
419fn 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
461fn 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
534fn 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
563fn 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
588fn vocabulary(raw: &str) -> Vec<(String, usize)> {
591 let (words, names) = dictionary();
592 let mut count: HashMap<String, (usize, usize, usize)> = HashMap::new(); 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
641fn 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
652fn 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
673pub 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#[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 pub activated: bool,
696 pub slug: String,
697}
698
699pub 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
720pub 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 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 ladder: &'a [String],
960 stage_names: &'a [&'a str],
961 restarts: &'a [(f64, Positions)],
962 agree: usize,
963 distinct: usize,
964}
965
966fn 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
1063fn 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
1097fn 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
1116pub 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
1133pub 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}