gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definitions

⎇ 37ea535cb7ea2ea6b15f11684c48f55462d87c24 crates/modules/shuma/shuma-infer/src/lib.rs Rust · 382 lines · 12.7 KB History Who touched what raw

feat(shuma): shuma-infer — motor de inferencia de intenciones 37ea535cb · 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#[cfg(test)]
237mod tests {
238    use super::*;
239
240    /// Atajo: un `CommandRecord` exitoso.
241    fn ok(line: &str, cwd: &str) -> CommandRecord {
242        CommandRecord::parse(line, cwd, true)
243    }
244
245    #[test]
246    fn parse_splits_binary_and_args() {
247        let r = CommandRecord::parse("git commit -m mensaje", "/p", true);
248        assert_eq!(r.binary, "git");
249        assert_eq!(r.args, vec!["commit", "-m", "mensaje"]);
250    }
251
252    #[test]
253    fn detects_a_repeated_sequence() {
254        // cd → git pull → cargo build, dos veces, en dos directorios.
255        let history = vec![
256            ok("cd /proj/a", "/home"),
257            ok("git pull", "/proj/a"),
258            ok("cargo build", "/proj/a"),
259            ok("cd /proj/b", "/home"),
260            ok("git pull", "/proj/b"),
261            ok("cargo build", "/proj/b"),
262        ];
263        let patterns = detect_patterns(&history, &InferConfig::default());
264        assert_eq!(patterns.len(), 1);
265        let p = &patterns[0];
266        assert_eq!(p.signature, vec!["cd", "git", "cargo"]);
267        assert_eq!(p.occurrences, 2);
268    }
269
270    #[test]
271    fn abstracts_varying_arguments() {
272        let history = vec![
273            ok("cd /proj/a", "/home"),
274            ok("git pull", "/proj/a"),
275            ok("cd /proj/b", "/home"),
276            ok("git pull", "/proj/b"),
277        ];
278        let patterns = detect_patterns(&history, &InferConfig::default());
279        let p = &patterns[0];
280        // El `cd` cambia de argumento → Varies; `git pull` es constante.
281        assert_eq!(p.steps[0].args, StepArgs::Varies);
282        assert_eq!(p.steps[1].args, StepArgs::Fixed(vec!["pull".into()]));
283        assert_eq!(p.steps[0].render(), "cd <…>");
284        assert_eq!(p.steps[1].render(), "git pull");
285    }
286
287    #[test]
288    fn example_is_the_most_recent_occurrence() {
289        let history = vec![
290            ok("cd /proj/a", "/home"),
291            ok("git pull", "/proj/a"),
292            ok("cd /proj/b", "/home"),
293            ok("git pull", "/proj/b"),
294        ];
295        let p = &detect_patterns(&history, &InferConfig::default())[0];
296        // Las líneas reales y ejecutables de la última ocurrencia.
297        assert_eq!(p.example, vec!["cd /proj/b", "git pull"]);
298    }
299
300    #[test]
301    fn a_failed_command_breaks_the_pattern() {
302        let history = vec![
303            ok("cd /proj/a", "/home"),
304            ok("git pull", "/proj/a"),
305            ok("cd /proj/b", "/home"),
306            CommandRecord::parse("git pull", "/proj/b", false), // falló
307        ];
308        // Sólo una ventana [cd, git] exitosa → no se repite → sin patrón.
309        assert!(detect_patterns(&history, &InferConfig::default()).is_empty());
310    }
311
312    #[test]
313    fn no_repetition_yields_no_patterns() {
314        let history = vec![
315            ok("ls", "/a"),
316            ok("pwd", "/a"),
317            ok("date", "/a"),
318        ];
319        assert!(detect_patterns(&history, &InferConfig::default()).is_empty());
320    }
321
322    #[test]
323    fn longer_pattern_subsumes_its_subsequences() {
324        // [cd, git, cargo] repetido → no se reporta también [cd, git].
325        let history = vec![
326            ok("cd /a", "/h"),
327            ok("git pull", "/a"),
328            ok("cargo build", "/a"),
329            ok("cd /b", "/h"),
330            ok("git pull", "/b"),
331            ok("cargo build", "/b"),
332        ];
333        let patterns = detect_patterns(&history, &InferConfig::default());
334        assert_eq!(patterns.len(), 1);
335        assert_eq!(patterns[0].signature.len(), 3);
336    }
337
338    #[test]
339    fn directories_are_collected() {
340        let history = vec![
341            ok("cd /a", "/home"),
342            ok("git pull", "/a"),
343            ok("cd /b", "/work"),
344            ok("git pull", "/b"),
345        ];
346        let p = &detect_patterns(&history, &InferConfig::default())[0];
347        assert_eq!(p.directories, vec!["/home", "/work"]);
348    }
349
350    #[test]
351    fn suggested_name_drops_the_cd() {
352        let history = vec![
353            ok("cd /a", "/h"),
354            ok("git pull", "/a"),
355            ok("cargo build", "/a"),
356            ok("cd /b", "/h"),
357            ok("git pull", "/b"),
358            ok("cargo build", "/b"),
359        ];
360        let p = &detect_patterns(&history, &InferConfig::default())[0];
361        assert_eq!(p.suggested_name(), "git+cargo");
362    }
363
364    #[test]
365    fn score_ranks_longer_and_more_frequent_higher() {
366        let short = EmergingPattern {
367            signature: vec!["a".into(), "b".into()],
368            steps: vec![],
369            example: vec![],
370            occurrences: 2,
371            directories: vec![],
372        };
373        let long = EmergingPattern {
374            signature: vec!["a".into(), "b".into(), "c".into()],
375            steps: vec![],
376            example: vec![],
377            occurrences: 3,
378            directories: vec![],
379        };
380        assert!(long.score() > short.score());
381    }
382}