gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definitions

⎇ be99ac3bbbf77ecc2035849b099da7a6fd1bc779 crates/modules/shuma/shuma-infer/src/lib.rs Rust · 455 lines · 15.4 KB History Who touched what raw

feat(shuma): ghosting predictivo en el prompt be99ac3bb · sergio ·

1//! `shuma-infer` — el motor de inferencia de intenciones secuenciales.
2//!
3//! El shell observa cómo trabajas. Cuando una *coreografía* de comandos
4//! se repite —`cd` a un proyecto, `git pull`, `cargo build`— este motor
5//! la detecta, la abstrae (los argumentos que cambian se vuelven
6//! variables) y la ofrece como un patrón reutilizable. Automatización
7//! que nace de la repetición orgánica, no de escribir scripts.
8//!
9//! Es agnóstico y determinista: recibe el historial reducido a
10//! [`CommandRecord`]s y devuelve [`EmergingPattern`]s. No toca disco, ni
11//! la red, ni ningún frontend — el shell se encarga de eso.
12//!
13//! ```text
14//!   historial ──► detect_patterns ──► [EmergingPattern]
15//!     · firma de binarios (ventana deslizante)
16//!     · sólo ventanas 100% exitosas
17//!     · abstracción: args que varían → Varies
18//!     · se quedan los patrones maximales
19//! ```
20
21#![forbid(unsafe_code)]
22
23use std::collections::BTreeMap;
24
25use serde::{Deserialize, Serialize};
26
27/// Un comando ejecutado, reducido a lo que importa para inferir.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct CommandRecord {
30    /// El binario invocado — la primera palabra de la línea.
31    pub binary: String,
32    /// Los argumentos, en orden.
33    pub args: Vec<String>,
34    /// Directorio en que se ejecutó.
35    pub cwd: String,
36    /// Si terminó con éxito (código 0).
37    pub success: bool,
38}
39
40impl CommandRecord {
41    /// Reduce una línea de comando a un registro. La división es simple
42    /// (`split_whitespace`) — suficiente para comparar firmas.
43    pub fn parse(line: &str, cwd: impl Into<String>, success: bool) -> Self {
44        let mut words = line.split_whitespace().map(str::to_string);
45        let binary = words.next().unwrap_or_default();
46        Self { binary, args: words.collect(), cwd: cwd.into(), success }
47    }
48}
49
50/// Ajustes del detector.
51#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
52pub struct InferConfig {
53    /// Largo mínimo de una secuencia para considerarla patrón.
54    pub min_len: usize,
55    /// Largo máximo de ventana a buscar.
56    pub max_len: usize,
57    /// Cuántas veces debe repetirse una firma para emerger.
58    pub min_occurrences: usize,
59}
60
61impl Default for InferConfig {
62    fn default() -> Self {
63        Self { min_len: 2, max_len: 5, min_occurrences: 2 }
64    }
65}
66
67/// Los argumentos de un paso del patrón, tras la abstracción.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub enum StepArgs {
70    /// Los argumentos son idénticos en todas las ocurrencias.
71    Fixed(Vec<String>),
72    /// Los argumentos cambian entre ocurrencias — son una variable.
73    Varies,
74}
75
76/// Un paso abstracto del patrón: el binario + sus argumentos.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct PatternStep {
79    pub binary: String,
80    pub args: StepArgs,
81}
82
83impl PatternStep {
84    /// Renderiza el paso para mostrarlo — `"git pull"`, `"cd <…>"`.
85    pub fn render(&self) -> String {
86        match &self.args {
87            StepArgs::Fixed(a) if a.is_empty() => self.binary.clone(),
88            StepArgs::Fixed(a) => format!("{} {}", self.binary, a.join(" ")),
89            StepArgs::Varies => format!("{} <…>", self.binary),
90        }
91    }
92}
93
94/// Un patrón de comandos que emergió del historial.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct EmergingPattern {
97    /// Firma: la secuencia de binarios.
98    pub signature: Vec<String>,
99    /// Pasos abstractos — para mostrar al usuario.
100    pub steps: Vec<PatternStep>,
101    /// Las líneas reales de la ocurrencia más reciente — ejecutables.
102    pub example: Vec<String>,
103    /// Cuántas veces apareció el patrón.
104    pub occurrences: usize,
105    /// Directorios donde arrancó el patrón, sin repetir.
106    pub directories: Vec<String>,
107}
108
109impl EmergingPattern {
110    /// Puntaje de interés: más largo y más frecuente, más arriba.
111    pub fn score(&self) -> usize {
112        self.occurrences * self.signature.len()
113    }
114
115    /// Nombre sugerido para el patrón — los binarios significativos
116    /// (sin el `cd` inicial) unidos por `+`.
117    pub fn suggested_name(&self) -> String {
118        let significant: Vec<&str> = self
119            .signature
120            .iter()
121            .filter(|b| b.as_str() != "cd")
122            .map(String::as_str)
123            .collect();
124        if significant.is_empty() {
125            self.signature.join("+")
126        } else {
127            significant.join("+")
128        }
129    }
130}
131
132/// `true` si `needle` aparece como sub-secuencia contigua de `haystack`.
133fn contains_subslice(haystack: &[String], needle: &[String]) -> bool {
134    needle.len() <= haystack.len() && haystack.windows(needle.len()).any(|w| w == needle)
135}
136
137/// Construye el patrón abstracto a partir de su firma y las posiciones
138/// donde ocurrió.
139fn build_pattern(
140    history: &[CommandRecord],
141    signature: &[String],
142    starts: &[usize],
143) -> EmergingPattern {
144    let len = signature.len();
145    let mut steps = Vec::with_capacity(len);
146    for i in 0..len {
147        // Argumentos de este paso a lo largo de todas las ocurrencias.
148        let first = &history[starts[0] + i].args;
149        let all_same = starts.iter().all(|&s| &history[s + i].args == first);
150        let args = if all_same {
151            StepArgs::Fixed(first.clone())
152        } else {
153            StepArgs::Varies
154        };
155        steps.push(PatternStep { binary: signature[i].clone(), args });
156    }
157
158    // La ocurrencia más reciente da las líneas reales, ejecutables.
159    let last = *starts.iter().max().expect("hay ocurrencias");
160    let example: Vec<String> = (0..len)
161        .map(|i| {
162            let c = &history[last + i];
163            if c.args.is_empty() {
164                c.binary.clone()
165            } else {
166                format!("{} {}", c.binary, c.args.join(" "))
167            }
168        })
169        .collect();
170
171    let mut directories: Vec<String> = Vec::new();
172    for &s in starts {
173        let d = &history[s].cwd;
174        if !directories.contains(d) {
175            directories.push(d.clone());
176        }
177    }
178
179    EmergingPattern {
180        signature: signature.to_vec(),
181        steps,
182        example,
183        occurrences: starts.len(),
184        directories,
185    }
186}
187
188/// Detecta los patrones de comandos repetidos en `history`.
189///
190/// Sólo cuentan las ventanas cuyos comandos terminaron todos con éxito.
191/// Se devuelven los patrones *maximales* (uno contenido en otro más
192/// largo no se reporta), ordenados por puntaje descendente.
193pub fn detect_patterns(history: &[CommandRecord], cfg: &InferConfig) -> Vec<EmergingPattern> {
194    // firma → posiciones de inicio de las ventanas que la producen.
195    let mut windows: BTreeMap<Vec<String>, Vec<usize>> = BTreeMap::new();
196    for len in cfg.min_len..=cfg.max_len {
197        if history.len() < len {
198            break;
199        }
200        for start in 0..=history.len() - len {
201            let win = &history[start..start + len];
202            if !win.iter().all(|c| c.success) {
203                continue; // una ventana con un fallo no es un patrón
204            }
205            let signature: Vec<String> = win.iter().map(|c| c.binary.clone()).collect();
206            windows.entry(signature).or_default().push(start);
207        }
208    }
209
210    // Firmas que se repiten lo suficiente.
211    let qualifying: Vec<(Vec<String>, Vec<usize>)> = windows
212        .into_iter()
213        .filter(|(_, starts)| starts.len() >= cfg.min_occurrences)
214        .collect();
215
216    // Sólo las maximales: una firma contenida en otra más larga que
217    // también califica se descarta (la larga la subsume).
218    let mut patterns: Vec<EmergingPattern> = qualifying
219        .iter()
220        .filter(|(sig, _)| {
221            !qualifying
222                .iter()
223                .any(|(other, _)| other.len() > sig.len() && contains_subslice(other, sig))
224        })
225        .map(|(sig, starts)| build_pattern(history, sig, starts))
226        .collect();
227
228    patterns.sort_by(|a, b| {
229        b.score()
230            .cmp(&a.score())
231            .then(a.signature.cmp(&b.signature))
232    });
233    patterns
234}
235
236/// Predice la continuación de un patrón en curso.
237///
238/// Mira el final del historial `recent`: si sus últimos comandos
239/// coinciden con el prefijo de la firma de algún patrón, devuelve las
240/// líneas que faltan para completarlo —tomadas de la ocurrencia más
241/// reciente, así son ejecutables—. Ante varios, gana el patrón cuyo
242/// prefijo coincidente sea más largo. Es lo que alimenta el "ghosting".
243pub fn predict_next(
244    recent: &[CommandRecord],
245    patterns: &[EmergingPattern],
246) -> Option<Vec<String>> {
247    let bins: Vec<&str> = recent.iter().map(|r| r.binary.as_str()).collect();
248    let mut best: Option<(usize, &EmergingPattern)> = None;
249    for p in patterns {
250        // Tiene que quedar al menos un paso por predecir.
251        let max_k = p.signature.len().saturating_sub(1).min(bins.len());
252        for k in (1..=max_k).rev() {
253            let tail = &bins[bins.len() - k..];
254            let prefix_matches = p
255                .signature
256                .iter()
257                .take(k)
258                .map(String::as_str)
259                .eq(tail.iter().copied());
260            if prefix_matches {
261                if best.map(|(bk, _)| k > bk).unwrap_or(true) {
262                    best = Some((k, p));
263                }
264                break;
265            }
266        }
267    }
268    best.map(|(k, p)| p.example[k..].to_vec())
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    /// Atajo: un `CommandRecord` exitoso.
276    fn ok(line: &str, cwd: &str) -> CommandRecord {
277        CommandRecord::parse(line, cwd, true)
278    }
279
280    #[test]
281    fn parse_splits_binary_and_args() {
282        let r = CommandRecord::parse("git commit -m mensaje", "/p", true);
283        assert_eq!(r.binary, "git");
284        assert_eq!(r.args, vec!["commit", "-m", "mensaje"]);
285    }
286
287    #[test]
288    fn detects_a_repeated_sequence() {
289        // cd → git pull → cargo build, dos veces, en dos directorios.
290        let history = vec![
291            ok("cd /proj/a", "/home"),
292            ok("git pull", "/proj/a"),
293            ok("cargo build", "/proj/a"),
294            ok("cd /proj/b", "/home"),
295            ok("git pull", "/proj/b"),
296            ok("cargo build", "/proj/b"),
297        ];
298        let patterns = detect_patterns(&history, &InferConfig::default());
299        assert_eq!(patterns.len(), 1);
300        let p = &patterns[0];
301        assert_eq!(p.signature, vec!["cd", "git", "cargo"]);
302        assert_eq!(p.occurrences, 2);
303    }
304
305    #[test]
306    fn abstracts_varying_arguments() {
307        let history = vec![
308            ok("cd /proj/a", "/home"),
309            ok("git pull", "/proj/a"),
310            ok("cd /proj/b", "/home"),
311            ok("git pull", "/proj/b"),
312        ];
313        let patterns = detect_patterns(&history, &InferConfig::default());
314        let p = &patterns[0];
315        // El `cd` cambia de argumento → Varies; `git pull` es constante.
316        assert_eq!(p.steps[0].args, StepArgs::Varies);
317        assert_eq!(p.steps[1].args, StepArgs::Fixed(vec!["pull".into()]));
318        assert_eq!(p.steps[0].render(), "cd <…>");
319        assert_eq!(p.steps[1].render(), "git pull");
320    }
321
322    #[test]
323    fn example_is_the_most_recent_occurrence() {
324        let history = vec![
325            ok("cd /proj/a", "/home"),
326            ok("git pull", "/proj/a"),
327            ok("cd /proj/b", "/home"),
328            ok("git pull", "/proj/b"),
329        ];
330        let p = &detect_patterns(&history, &InferConfig::default())[0];
331        // Las líneas reales y ejecutables de la última ocurrencia.
332        assert_eq!(p.example, vec!["cd /proj/b", "git pull"]);
333    }
334
335    #[test]
336    fn a_failed_command_breaks_the_pattern() {
337        let history = vec![
338            ok("cd /proj/a", "/home"),
339            ok("git pull", "/proj/a"),
340            ok("cd /proj/b", "/home"),
341            CommandRecord::parse("git pull", "/proj/b", false), // falló
342        ];
343        // Sólo una ventana [cd, git] exitosa → no se repite → sin patrón.
344        assert!(detect_patterns(&history, &InferConfig::default()).is_empty());
345    }
346
347    #[test]
348    fn no_repetition_yields_no_patterns() {
349        let history = vec![
350            ok("ls", "/a"),
351            ok("pwd", "/a"),
352            ok("date", "/a"),
353        ];
354        assert!(detect_patterns(&history, &InferConfig::default()).is_empty());
355    }
356
357    #[test]
358    fn longer_pattern_subsumes_its_subsequences() {
359        // [cd, git, cargo] repetido → no se reporta también [cd, git].
360        let history = vec![
361            ok("cd /a", "/h"),
362            ok("git pull", "/a"),
363            ok("cargo build", "/a"),
364            ok("cd /b", "/h"),
365            ok("git pull", "/b"),
366            ok("cargo build", "/b"),
367        ];
368        let patterns = detect_patterns(&history, &InferConfig::default());
369        assert_eq!(patterns.len(), 1);
370        assert_eq!(patterns[0].signature.len(), 3);
371    }
372
373    #[test]
374    fn directories_are_collected() {
375        let history = vec![
376            ok("cd /a", "/home"),
377            ok("git pull", "/a"),
378            ok("cd /b", "/work"),
379            ok("git pull", "/b"),
380        ];
381        let p = &detect_patterns(&history, &InferConfig::default())[0];
382        assert_eq!(p.directories, vec!["/home", "/work"]);
383    }
384
385    #[test]
386    fn suggested_name_drops_the_cd() {
387        let history = vec![
388            ok("cd /a", "/h"),
389            ok("git pull", "/a"),
390            ok("cargo build", "/a"),
391            ok("cd /b", "/h"),
392            ok("git pull", "/b"),
393            ok("cargo build", "/b"),
394        ];
395        let p = &detect_patterns(&history, &InferConfig::default())[0];
396        assert_eq!(p.suggested_name(), "git+cargo");
397    }
398
399    /// Mundo de prueba: el patrón cd → git pull → cargo build, visto dos
400    /// veces, y la lista de patrones que produce.
401    fn pattern_world() -> Vec<EmergingPattern> {
402        let history = vec![
403            ok("cd /a", "/h"),
404            ok("git pull", "/a"),
405            ok("cargo build", "/a"),
406            ok("cd /b", "/h"),
407            ok("git pull", "/b"),
408            ok("cargo build", "/b"),
409        ];
410        detect_patterns(&history, &InferConfig::default())
411    }
412
413    #[test]
414    fn predicts_the_rest_after_a_cd() {
415        let patterns = pattern_world();
416        // El usuario acaba de hacer `cd` → se predicen los pasos que faltan.
417        let recent = vec![ok("cd /nuevo", "/h")];
418        let next = predict_next(&recent, &patterns).unwrap();
419        assert_eq!(next, vec!["git pull", "cargo build"]);
420    }
421
422    #[test]
423    fn prediction_shrinks_as_the_pattern_advances() {
424        let patterns = pattern_world();
425        let recent = vec![ok("cd /nuevo", "/h"), ok("git pull", "/nuevo")];
426        let next = predict_next(&recent, &patterns).unwrap();
427        assert_eq!(next, vec!["cargo build"]);
428    }
429
430    #[test]
431    fn no_prediction_when_nothing_matches() {
432        let patterns = pattern_world();
433        let recent = vec![ok("ls", "/h"), ok("pwd", "/h")];
434        assert!(predict_next(&recent, &patterns).is_none());
435    }
436
437    #[test]
438    fn score_ranks_longer_and_more_frequent_higher() {
439        let short = EmergingPattern {
440            signature: vec!["a".into(), "b".into()],
441            steps: vec![],
442            example: vec![],
443            occurrences: 2,
444            directories: vec![],
445        };
446        let long = EmergingPattern {
447            signature: vec!["a".into(), "b".into(), "c".into()],
448            steps: vec![],
449            example: vec![],
450            occurrences: 3,
451            directories: vec![],
452        };
453        assert!(long.score() > short.score());
454    }
455}