on master help sign in
phonemic: use HashMap lookup for @p syllable decode (O(1) vs O(256))
commit540f20cbc813f356057e1b77c070b6f91a8bd45d authoriko <mail@iko.soy> dateSun 12 Jul 2026, 08:02 (3 months ago) parentb0ffcd8
1 file changed+57−3
core/src/phonemic.rs+57−3
core/src/phonemic.rs+57−3
@@ -3,6 +3,9 @@
33//! Each byte pair `[hi, lo]` encodes as `prefixes[hi] ++ suffixes[lo]` (6 chars);
44//! pairs are joined with `-`. A 32-byte key -> 16 six-char syllables.
55
6+use std::collections::HashMap;
7+use std::sync::OnceLock;
8+
69const PREFIXES: [&str; 256] = [
710 "doz", "mar", "bin", "wan", "sam", "lit", "sig", "hid", "fid", "lis", "sog", "dir", "wac",
811 "sab", "wis", "sib", "rig", "sol", "dop", "mod", "fog", "lid", "hop", "dar", "dor", "lor",
@@ -49,12 +52,40 @@ const SUFFIXES: [&str; 256] = [
4952 "fyr", "mur", "tel", "rep", "teg", "pec", "nel", "nev", "fes",
5053];
5154
55+/// Reverse tables for decoding. Built once, on first `from_ob`.
56+///
57+/// Correctness rests on both tables being duplicate-free: a `HashMap` keeps the
58+/// *last* of any repeated syllable, where the linear scan this replaced returned
59+/// the *first*. `tables_are_full_and_distinct` pins that invariant down.
60+static PREFIX_MAP: OnceLock<HashMap<&'static str, u8>> = OnceLock::new();
61+static SUFFIX_MAP: OnceLock<HashMap<&'static str, u8>> = OnceLock::new();
62+
63+fn prefix_map() -> &'static HashMap<&'static str, u8> {
64+ PREFIX_MAP.get_or_init(|| {
65+ PREFIXES
66+ .iter()
67+ .enumerate()
68+ .map(|(i, &s)| (s, i as u8))
69+ .collect()
70+ })
71+}
72+
73+fn suffix_map() -> &'static HashMap<&'static str, u8> {
74+ SUFFIX_MAP.get_or_init(|| {
75+ SUFFIXES
76+ .iter()
77+ .enumerate()
78+ .map(|(i, &s)| (s, i as u8))
79+ .collect()
80+ })
81+}
82+
5283fn prefix_index(s: &str) -> Option<u8> {
53− PREFIXES.iter().position(|p| *p == s).map(|i| i as u8)
84+ prefix_map().get(s).copied()
5485}
5586
5687fn suffix_index(s: &str) -> Option<u8> {
57− SUFFIXES.iter().position(|p| *p == s).map(|i| i as u8)
88+ suffix_map().get(s).copied()
5889}
5990
6091/// Encode bytes as a hyphen-joined `@p` string. `None` for an odd-length input.
@@ -130,12 +161,29 @@ pub fn try_parse_pubkey(input: &str) -> Option<String> {
130161mod tests {
131162 use super::*;
132163
164+ /// The name always claimed distinctness; now the decode path depends on it.
165+ /// A duplicate syllable would collapse a map entry, so a table of 256 that
166+ /// maps to fewer than 256 keys is the exact failure this must catch.
133167 #[test]
134168 fn tables_are_full_and_distinct() {
135169 assert_eq!(PREFIXES.len(), 256);
136170 assert_eq!(SUFFIXES.len(), 256);
137171 assert_eq!(PREFIXES[0], "doz");
138172 assert_eq!(SUFFIXES[0], "zod");
173+ assert_eq!(prefix_map().len(), 256, "prefix table has a duplicate");
174+ assert_eq!(suffix_map().len(), 256, "suffix table has a duplicate");
175+ }
176+
177+ /// Every index must survive encode -> decode. Guards the map against being
178+ /// built off-by-one or with a truncated cast.
179+ #[test]
180+ fn every_syllable_index_round_trips() {
181+ for i in 0..=255u8 {
182+ assert_eq!(prefix_index(PREFIXES[i as usize]), Some(i));
183+ assert_eq!(suffix_index(SUFFIXES[i as usize]), Some(i));
184+ }
185+ assert_eq!(prefix_index("zod"), None, "suffix must not decode as prefix");
186+ assert_eq!(suffix_index("doz"), None, "prefix must not decode as suffix");
139187 }
140188
141189 #[test]
@@ -214,7 +262,13 @@ mod tests {
214262 let mixed: String = ob
215263 .chars()
216264 .enumerate()
217− .map(|(i, c)| if i % 2 == 0 { c.to_ascii_uppercase() } else { c })
265+ .map(|(i, c)| {
266+ if i.is_multiple_of(2) {
267+ c.to_ascii_uppercase()
268+ } else {
269+ c
270+ }
271+ })
218272 .collect();
219273 assert_eq!(try_parse_pubkey(&mixed), Some(hex_str));
220274 }