Skip to main content

aprender_present_lib/browser/
shell_autocomplete.rs

1//! Shell Command Autocomplete Demo
2//!
3//! Real WASM implementation using the trained aprender-shell-base.apr model.
4//! Uses N-gram Markov model for command prediction.
5//!
6//! Spec: docs/specifications/showcase-demo-aprender-shell-apr.md
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[cfg(target_arch = "wasm32")]
12use wasm_bindgen::prelude::*;
13
14/// APR format header size
15const HEADER_SIZE: usize = 32;
16
17/// Shell command autocomplete using N-gram Markov model
18#[derive(Debug)]
19pub struct ShellAutocomplete {
20    /// N-gram size (typically 3)
21    n: usize,
22    /// N-gram counts: context -> (next_token -> count)
23    ngrams: HashMap<String, HashMap<String, u32>>,
24    /// Command frequency for ranking
25    command_freq: HashMap<String, u32>,
26    /// Prefix trie for fast lookup
27    trie: Trie,
28    /// Total commands in training data
29    total_commands: usize,
30}
31
32/// Simple trie for prefix matching
33#[derive(Debug, Default)]
34struct Trie {
35    children: HashMap<char, Trie>,
36    is_end: bool,
37    command: Option<String>,
38}
39
40impl Trie {
41    fn new() -> Self {
42        Self::default()
43    }
44
45    fn insert(&mut self, word: &str) {
46        let mut node = self;
47        for c in word.chars() {
48            node = node.children.entry(c).or_default();
49        }
50        node.is_end = true;
51        node.command = Some(word.to_string());
52    }
53
54    fn find_prefix(&self, prefix: &str, limit: usize) -> Vec<String> {
55        let mut results = Vec::new();
56        let mut node = self;
57
58        // Navigate to prefix node
59        for c in prefix.chars() {
60            match node.children.get(&c) {
61                Some(child) => node = child,
62                None => return results,
63            }
64        }
65
66        // Collect all commands under this prefix
67        Self::collect_commands_recursive(node, &mut results, limit);
68        results
69    }
70
71    fn collect_commands_recursive(node: &Trie, results: &mut Vec<String>, limit: usize) {
72        if results.len() >= limit {
73            return;
74        }
75        if let Some(ref cmd) = node.command {
76            results.push(cmd.clone());
77        }
78        for child in node.children.values() {
79            Self::collect_commands_recursive(child, results, limit);
80            if results.len() >= limit {
81                return;
82            }
83        }
84    }
85}
86
87/// Serialized model format (bincode)
88#[derive(Debug, Serialize, Deserialize)]
89struct MarkovModelData {
90    n: usize,
91    ngrams: HashMap<String, HashMap<String, u32>>,
92    command_freq: HashMap<String, u32>,
93    total_commands: usize,
94    #[serde(default)]
95    last_trained_pos: usize,
96}
97
98/// Embedded model for testing and convenience
99const SHELL_MODEL_BYTES: &[u8] = include_bytes!("../../assets/aprender-shell-base.apr");
100
101impl ShellAutocomplete {
102    /// Create a new ShellAutocomplete with the embedded model.
103    ///
104    /// This is a convenience method for testing and demos that loads
105    /// the model compiled into the binary.
106    pub fn new() -> Result<Self, String> {
107        Self::load_from_bytes(SHELL_MODEL_BYTES)
108    }
109
110    /// Load ShellAutocomplete from raw .apr bytes.
111    /// This is the primary method for loading the model.
112    pub fn load_from_bytes(bytes: &[u8]) -> Result<Self, String> {
113        // Verify magic bytes and minimum size
114        if bytes.len() < HEADER_SIZE {
115            return Err("Model file too small".to_string());
116        }
117        if &bytes[0..4] != b"APRN" {
118            return Err(format!("Invalid magic bytes: {:?}", &bytes[0..4]));
119        }
120
121        // Parse 32-byte APR header
122        // Offset 0-3: Magic "APRN"
123        // Offset 4-5: Version (major, minor)
124        // Offset 6-7: Model type (u16 LE)
125        // Offset 8-11: Metadata size (u32 LE)
126        // Offset 12-15: Payload size (u32 LE)
127        // Offset 16-19: Uncompressed size (u32 LE)
128        // Offset 20: Compression type
129        // Offset 21: Flags
130        // Offset 22-31: Reserved
131
132        let metadata_size = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
133        let payload_size =
134            u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;
135        let compression = bytes[20];
136
137        // Calculate offsets
138        let metadata_start = HEADER_SIZE;
139        let metadata_end = metadata_start + metadata_size;
140        let payload_start = metadata_end;
141        let payload_end = payload_start + payload_size;
142
143        if payload_end > bytes.len() {
144            return Err(format!(
145                "Payload extends beyond file: {} > {}",
146                payload_end,
147                bytes.len()
148            ));
149        }
150
151        let payload_compressed = &bytes[payload_start..payload_end];
152
153        // Decompress payload if needed
154        let payload_decompressed: Vec<u8> = match compression {
155            0x00 => payload_compressed.to_vec(), // No compression
156            #[cfg(feature = "shell-autocomplete")]
157            0x01 | 0x02 => {
158                // Zstd compression
159                zstd::decode_all(payload_compressed)
160                    .map_err(|e| format!("Failed to decompress: {}", e))?
161            }
162            #[cfg(not(feature = "shell-autocomplete"))]
163            0x01 | 0x02 => {
164                return Err(
165                    "Zstd compression requires the 'shell-autocomplete' feature".to_string()
166                );
167            }
168            _ => return Err(format!("Unknown compression type: 0x{:02X}", compression)),
169        };
170
171        // Deserialize the model data with bincode
172        let model_data: MarkovModelData = bincode::deserialize(&payload_decompressed)
173            .map_err(|e| format!("Failed to deserialize model: {}", e))?;
174
175        // Build trie from commands
176        let mut trie = Trie::new();
177        for cmd in model_data.command_freq.keys() {
178            trie.insert(cmd);
179        }
180
181        Ok(Self {
182            n: model_data.n,
183            ngrams: model_data.ngrams,
184            command_freq: model_data.command_freq,
185            trie,
186            total_commands: model_data.total_commands,
187        })
188    }
189
190    /// Suggest completions for a prefix
191    pub fn suggest(&self, prefix: &str, count: usize) -> Vec<(String, f32)> {
192        let prefix = prefix.trim();
193        let tokens: Vec<&str> = prefix.split_whitespace().collect();
194        let ends_with_space = prefix.is_empty() || prefix.ends_with(' ');
195
196        let capacity = count * 4;
197        let mut suggestions = Vec::with_capacity(capacity);
198        let mut seen = std::collections::HashSet::with_capacity(capacity);
199
200        // Strategy 1: Trie prefix match for exact commands
201        self.suggest_from_trie(prefix, capacity, &mut seen, &mut suggestions);
202
203        // Strategy 2: N-gram prediction for next token (only when prefix ends with space)
204        if !tokens.is_empty() && ends_with_space {
205            let context_start = tokens.len().saturating_sub(self.n - 1);
206            let context = tokens[context_start..].join(" ");
207            self.push_ngram_completions(&context, prefix, None, 0.8, &mut seen, &mut suggestions);
208        }
209
210        // Strategy 3: N-gram prediction with partial token filter
211        if !tokens.is_empty() && !ends_with_space && tokens.len() >= 2 {
212            let partial_token = tokens.last().unwrap_or(&"");
213            let context_tokens = &tokens[..tokens.len() - 1];
214            let context_start = context_tokens.len().saturating_sub(self.n - 1);
215            let context = context_tokens[context_start..].join(" ");
216            let context_prefix = context_tokens.join(" ");
217            self.push_ngram_completions(
218                &context,
219                &context_prefix,
220                Some(partial_token),
221                0.9,
222                &mut seen,
223                &mut suggestions,
224            );
225        }
226
227        // If no prefix and no suggestions, return top commands
228        if prefix.is_empty() && suggestions.is_empty() {
229            suggestions = self.top_commands();
230        }
231
232        // Sort by score and truncate
233        suggestions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
234        suggestions.truncate(count);
235
236        suggestions
237    }
238
239    /// Strategy 1: exact commands from the trie, scored by frequency.
240    fn suggest_from_trie(
241        &self,
242        prefix: &str,
243        capacity: usize,
244        seen: &mut std::collections::HashSet<String>,
245        suggestions: &mut Vec<(String, f32)>,
246    ) {
247        for cmd in self.trie.find_prefix(prefix, capacity) {
248            if Self::is_corrupted_command(&cmd) {
249                continue;
250            }
251            let freq = self.command_freq.get(&cmd).copied().unwrap_or(1);
252            let score = freq as f32 / self.total_commands.max(1) as f32;
253            seen.insert(cmd.clone());
254            suggestions.push((cmd, score));
255        }
256    }
257
258    /// Strategies 2/3: append `base + " " + token` for each n-gram successor of `context`,
259    /// scored by relative count times `weight`. With `partial`, only tokens starting with it
260    /// (and not corrupted) qualify.
261    fn push_ngram_completions(
262        &self,
263        context: &str,
264        base: &str,
265        partial: Option<&str>,
266        weight: f32,
267        seen: &mut std::collections::HashSet<String>,
268        suggestions: &mut Vec<(String, f32)>,
269    ) {
270        let Some(next_tokens) = self.ngrams.get(context) else {
271            return;
272        };
273        let total: u32 = next_tokens.values().sum();
274        for (token, ngram_count) in next_tokens {
275            if let Some(p) = partial {
276                if !token.starts_with(p) || Self::is_corrupted_token(token) {
277                    continue;
278                }
279            }
280            let completion = format!("{base} {token}");
281            if seen.insert(completion.clone()) {
282                let score = *ngram_count as f32 / total as f32;
283                suggestions.push((completion, score * weight));
284            }
285        }
286    }
287
288    /// All known commands by frequency (fallback for an empty prefix).
289    fn top_commands(&self) -> Vec<(String, f32)> {
290        let mut top_cmds: Vec<_> = self
291            .command_freq
292            .iter()
293            .map(|(k, v)| (k.clone(), *v as f32 / self.total_commands.max(1) as f32))
294            .collect();
295        top_cmds.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
296        top_cmds
297    }
298
299    /// Detect corrupted commands
300    fn is_corrupted_command(cmd: &str) -> bool {
301        if cmd.contains("  ") {
302            return true;
303        }
304        if cmd.trim_end().ends_with('\\') {
305            return true;
306        }
307        cmd.split_whitespace().any(Self::is_corrupted_token)
308    }
309
310    /// Detect corrupted tokens
311    fn is_corrupted_token(token: &str) -> bool {
312        if let Some(dash_pos) = token.find('-') {
313            if dash_pos > 0 && dash_pos < token.len() - 1 {
314                let before = &token[..dash_pos];
315                let after = &token[dash_pos + 1..];
316                let subcommands = [
317                    "commit", "checkout", "clone", "push", "pull", "merge", "rebase", "status",
318                    "add", "build", "run", "test", "install",
319                ];
320                if subcommands.contains(&before) && (after.len() <= 2 || after.starts_with('-')) {
321                    return true;
322                }
323            }
324        }
325        false
326    }
327
328    /// Get JSON-formatted suggestions (for WASM interop)
329    pub fn suggest_json(&self, prefix: &str, count: usize) -> String {
330        let suggestions = self.suggest(prefix, count);
331        let items: Vec<_> = suggestions
332            .iter()
333            .map(|(text, score)| {
334                format!(
335                    r#"{{"text":"{}","score":{:.4}}}"#,
336                    text.replace('"', "\\\""),
337                    score
338                )
339            })
340            .collect();
341        format!(r#"{{"suggestions":[{}]}}"#, items.join(","))
342    }
343
344    /// Get model info as JSON
345    pub fn model_info_json(&self) -> String {
346        format!(
347            r#"{{"model_name":"aprender-shell-base","model_type":"ngram_lm","vocab_size":{},"ngram_size":{},"ngram_count":{},"total_commands":{}}}"#,
348            self.vocab_size(),
349            self.n,
350            self.ngram_count(),
351            self.total_commands
352        )
353    }
354
355    /// Vocabulary size (unique commands)
356    pub fn vocab_size(&self) -> usize {
357        self.command_freq.len()
358    }
359
360    /// N-gram count
361    pub fn ngram_count(&self) -> usize {
362        self.ngrams.values().map(HashMap::len).sum()
363    }
364
365    /// N-gram size
366    pub fn ngram_size(&self) -> usize {
367        self.n
368    }
369
370    /// Estimated memory usage
371    pub fn estimated_memory_bytes(&self) -> usize {
372        let ngram_size: usize = self
373            .ngrams
374            .iter()
375            .map(|(k, v)| k.len() + v.keys().map(|k2| k2.len() + 4).sum::<usize>())
376            .sum();
377        let vocab_size: usize = self.command_freq.keys().map(|k| k.len() + 4).sum();
378        ngram_size + vocab_size + std::mem::size_of::<Self>()
379    }
380}
381
382// ============================================================================ //
383// WASM EXPORTS - Browser-accessible API //
384// ============================================================================ //
385
386/// WASM-exported shell autocomplete demo
387#[cfg(target_arch = "wasm32")]
388#[wasm_bindgen]
389pub struct ShellAutocompleteDemo {
390    inner: ShellAutocomplete,
391}
392
393#[cfg(target_arch = "wasm32")]
394#[wasm_bindgen]
395impl ShellAutocompleteDemo {
396    /// Create a new ShellAutocompleteDemo from bytes fetched by JavaScript.
397    ///
398    /// This is the preferred constructor for dynamic model loading:
399    /// ```js
400    /// const response = await fetch('./models/shell.apr');
401    /// const bytes = new Uint8Array(await response.arrayBuffer());
402    /// const demo = ShellAutocompleteDemo.from_bytes(bytes);
403    /// ```
404    #[wasm_bindgen(js_name = "fromBytes")]
405    pub fn from_bytes(bytes: &[u8]) -> Result<ShellAutocompleteDemo, JsValue> {
406        console_error_panic_hook::set_once();
407
408        let inner =
409            ShellAutocomplete::load_from_bytes(bytes).map_err(|e| JsValue::from_str(e.as_str()))?;
410
411        web_sys::console::log_1(
412            &format!(
413                "ShellAutocomplete loaded from bytes: {} commands, {} n-grams",
414                inner.vocab_size(),
415                inner.ngram_count()
416            )
417            .into(),
418        );
419
420        Ok(Self { inner })
421    }
422
423    /// Create with embedded model (for demos/testing).
424    ///
425    /// Uses the model compiled into the WASM binary.
426    /// This constructor is primarily for testing and quick demos where the model
427    /// is hardcoded into the WASM bundle via `include_bytes!`.
428    #[wasm_bindgen(constructor)]
429    pub fn new() -> Result<ShellAutocompleteDemo, JsValue> {
430        console_error_panic_hook::set_once();
431
432        let inner = ShellAutocomplete::new().map_err(|e| JsValue::from_str(&e))?;
433
434        web_sys::console::log_1(
435            &format!(
436                "ShellAutocomplete loaded (embedded): {} commands, {} n-grams",
437                inner.vocab_size(),
438                inner.ngram_count()
439            )
440            .into(),
441        );
442
443        Ok(Self { inner })
444    }
445
446    /// Get suggestions for a prefix (returns JSON)
447    #[wasm_bindgen]
448    pub fn suggest(&self, prefix: &str, count: usize) -> String {
449        self.inner.suggest_json(prefix, count)
450    }
451
452    /// Get model info as JSON
453    #[wasm_bindgen]
454    pub fn model_info(&self) -> String {
455        self.inner.model_info_json()
456    }
457
458    /// Get vocabulary size
459    pub fn vocab_size(&self) -> usize {
460        self.inner.vocab_size()
461    }
462
463    /// Get n-gram count
464    pub fn ngram_count(&self) -> usize {
465        self.inner.ngram_count()
466    }
467
468    /// Get n-gram size (n value)
469    pub fn ngram_size(&self) -> usize {
470        self.inner.ngram_size()
471    }
472
473    /// Get estimated memory usage in bytes
474    pub fn memory_bytes(&self) -> usize {
475        self.inner.estimated_memory_bytes()
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn test_trie_basic() {
485        let mut trie = Trie::new();
486        trie.insert("git status");
487        trie.insert("git commit");
488        trie.insert("cargo build");
489
490        let results = trie.find_prefix("git", 10);
491        assert_eq!(results.len(), 2);
492    }
493
494    #[test]
495    fn test_corrupted_detection() {
496        assert!(ShellAutocomplete::is_corrupted_command("git commit-m"));
497        assert!(!ShellAutocomplete::is_corrupted_command("git commit -m"));
498        assert!(!ShellAutocomplete::is_corrupted_command(
499            "git checkout feature-branch"
500        ));
501    }
502}