⎇ 37ea535cb7ea2ea6b15f11684c48f55462d87c24
crates/modules/shuma/shuma-infer/src/lib.rs
History
Who touched what
raw
feat(shuma): shuma-infer — motor de inferencia de intenciones
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}