⎇ be99ac3bbbf77ecc2035849b099da7a6fd1bc779
crates/modules/shuma/shuma-infer/src/lib.rs
History
Who touched what
raw
feat(shuma): ghosting predictivo en el prompt
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}