Skip to main content

sysml_core/
plan.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
4pub struct PlanStep {
5    pub step: usize,
6    pub command: String,
7    pub purpose: String,
8}
9
10#[derive(Debug, Clone, PartialEq)]
11pub enum QuestionType {
12    Relationship,
13    Reverse,
14    Completeness,
15    Comparison,
16    Impact,
17    Discovery,
18    Global,
19}
20
21pub fn classify_question(question: &str) -> QuestionType {
22    let lower = question.to_lowercase();
23
24    if is_relationship_question(&lower) {
25        QuestionType::Relationship
26    } else if is_reverse_question(&lower) {
27        QuestionType::Reverse
28    } else if is_completeness_question(&lower) {
29        QuestionType::Completeness
30    } else if is_comparison_question(&lower) {
31        QuestionType::Comparison
32    } else if is_impact_question(&lower) {
33        QuestionType::Impact
34    } else if is_global_question(&lower) {
35        QuestionType::Global
36    } else {
37        QuestionType::Discovery
38    }
39}
40
41fn is_relationship_question(q: &str) -> bool {
42    let patterns = [
43        "does",
44        "satisfy",
45        "verify",
46        "allocate",
47        "connect",
48        "bind",
49        "is related",
50        "linked to",
51        "traces to",
52    ];
53    let has_rel_verb = patterns.iter().any(|p| q.contains(p));
54    has_rel_verb
55        && (q.contains("satisfy")
56            || q.contains("verify")
57            || q.contains("allocate")
58            || q.contains("connect")
59            || q.contains("bind")
60            || q.contains("trace"))
61}
62
63fn is_reverse_question(q: &str) -> bool {
64    q.starts_with("what")
65        && (q.contains("require")
66            || q.contains("depend")
67            || q.contains("satisf")
68            || q.contains("verif")
69            || q.contains("use"))
70        && !q.contains("compare")
71        && !q.contains("complete")
72        && !q.contains("coverage")
73        && !q.contains("missing")
74}
75
76fn is_completeness_question(q: &str) -> bool {
77    q.contains("complete")
78        || q.contains("coverage")
79        || q.contains("missing")
80        || q.contains("orphan")
81        || q.contains("gap")
82        || q.contains("unverified")
83        || q.contains("health")
84}
85
86fn is_comparison_question(q: &str) -> bool {
87    q.contains("compare")
88        || q.contains("differ")
89        || q.contains("versus")
90        || q.contains(" vs ")
91        || q.contains("between")
92}
93
94fn is_impact_question(q: &str) -> bool {
95    q.contains("impact")
96        || q.contains("affect")
97        || q.contains("break")
98        || q.contains("change")
99        || q.contains("depend")
100        || (q.contains("what") && q.contains("happen"))
101}
102
103fn is_global_question(q: &str) -> bool {
104    q.contains("how many")
105        || q.contains("overview")
106        || q.contains("summary")
107        || q.contains("all ")
108        || q.contains("list all")
109        || q.contains("statistics")
110}
111
112pub fn decompose(question: &str, index_path: &str) -> Vec<PlanStep> {
113    let qtype = classify_question(question);
114    let entities = extract_entities(question);
115    let idx = format!("--index {}", index_path);
116
117    match qtype {
118        QuestionType::Relationship => plan_relationship(&entities, &idx, question),
119        QuestionType::Reverse => plan_reverse(&entities, &idx, question),
120        QuestionType::Completeness => plan_completeness(&entities, &idx),
121        QuestionType::Comparison => plan_comparison(&entities, &idx),
122        QuestionType::Impact => plan_impact(&entities, &idx),
123        QuestionType::Global => plan_global(&idx),
124        QuestionType::Discovery => plan_discovery(&entities, &idx),
125    }
126}
127
128fn extract_entities(question: &str) -> Vec<String> {
129    let stop_words = [
130        "a",
131        "an",
132        "the",
133        "and",
134        "or",
135        "for",
136        "to",
137        "of",
138        "in",
139        "is",
140        "it",
141        "are",
142        "was",
143        "be",
144        "been",
145        "do",
146        "does",
147        "did",
148        "has",
149        "have",
150        "had",
151        "with",
152        "that",
153        "this",
154        "what",
155        "how",
156        "which",
157        "where",
158        "when",
159        "who",
160        "why",
161        "find",
162        "show",
163        "get",
164        "list",
165        "describe",
166        "identify",
167        "determine",
168        "explain",
169        "all",
170        "any",
171        "each",
172        "if",
173        "then",
174        "than",
175        "but",
176        "so",
177        "as",
178        "on",
179        "at",
180        "about",
181        "into",
182        "can",
183        "should",
184        "would",
185        "could",
186        "will",
187        "not",
188        "no",
189        "from",
190        "by",
191        "i",
192        "me",
193        "my",
194        "we",
195        "us",
196        "you",
197        "your",
198        "between",
199        "compare",
200        "does",
201        "satisfy",
202        "verify",
203        "allocate",
204        "connect",
205        "require",
206        "depend",
207        "use",
208        "impact",
209        "affect",
210        "change",
211        "break",
212        "happen",
213        "complete",
214        "coverage",
215        "missing",
216        "orphan",
217        "gap",
218        "health",
219        "many",
220        "overview",
221        "summary",
222        "statistics",
223        "requirements",
224        "requirement",
225        "what's",
226        "model",
227    ];
228
229    let words: Vec<String> = question
230        .split(|c: char| !c.is_alphanumeric() && c != '_')
231        .filter(|w| !w.is_empty() && w.len() > 1)
232        .filter(|w| !stop_words.contains(&w.to_lowercase().as_str()))
233        .map(|w| w.to_string())
234        .collect();
235
236    let mut entities = Vec::new();
237    for word in &words {
238        if word.chars().next().is_some_and(|c| c.is_uppercase()) || word.contains('_') {
239            entities.push(word.clone());
240        }
241    }
242
243    if entities.is_empty() {
244        entities = words.into_iter().filter(|w| w.len() > 3).take(3).collect();
245    }
246
247    entities
248}
249
250fn plan_relationship(entities: &[String], idx: &str, question: &str) -> Vec<PlanStep> {
251    let lower = question.to_lowercase();
252    let rel_type = if lower.contains("satisfy") {
253        "satisfy"
254    } else if lower.contains("verify") {
255        "verify"
256    } else if lower.contains("allocate") {
257        "allocate"
258    } else if lower.contains("connect") {
259        "connect"
260    } else if lower.contains("bind") {
261        "bind"
262    } else {
263        "satisfy"
264    };
265
266    let mut steps = Vec::new();
267    let mut step = 1;
268
269    for entity in entities.iter().take(2) {
270        steps.push(PlanStep {
271            step,
272            command: format!("nomograph-sysml search \"{}\" {} --limit 5", entity, idx),
273            purpose: format!("Find elements matching '{}'", entity),
274        });
275        step += 1;
276    }
277
278    if entities.len() >= 2 {
279        steps.push(PlanStep {
280            step,
281            command: format!(
282                "nomograph-sysml query --rel {} --source-name \"{}\" --target-name \"{}\" {}",
283                rel_type, entities[0], entities[1], idx
284            ),
285            purpose: format!(
286                "Check {} relationship between '{}' and '{}'",
287                rel_type, entities[0], entities[1]
288            ),
289        });
290    } else if let Some(entity) = entities.first() {
291        steps.push(PlanStep {
292            step,
293            command: format!(
294                "nomograph-sysml query --rel {} --source-name \"{}\" {}",
295                rel_type, entity, idx
296            ),
297            purpose: format!("Find {} relationships from '{}'", rel_type, entity),
298        });
299    }
300
301    steps
302}
303
304fn plan_reverse(entities: &[String], idx: &str, question: &str) -> Vec<PlanStep> {
305    let lower = question.to_lowercase();
306    let rel_types = if lower.contains("satisf") {
307        vec!["satisfy"]
308    } else if lower.contains("verif") {
309        vec!["verify"]
310    } else {
311        vec!["satisfy", "verify"]
312    };
313
314    let mut steps = Vec::new();
315    let mut step = 1;
316
317    if let Some(entity) = entities.first() {
318        steps.push(PlanStep {
319            step,
320            command: format!("nomograph-sysml search \"{}\" {} --limit 5", entity, idx),
321            purpose: format!("Find elements matching '{}'", entity),
322        });
323        step += 1;
324
325        let types_str = rel_types.join(" ");
326        steps.push(PlanStep {
327            step,
328            command: format!(
329                "nomograph-sysml trace \"{}\" --direction backward --types {} {}",
330                entity, types_str, idx
331            ),
332            purpose: format!("Trace backward from '{}' via {}", entity, types_str),
333        });
334    }
335
336    steps
337}
338
339fn plan_completeness(entities: &[String], idx: &str) -> Vec<PlanStep> {
340    let mut steps = Vec::new();
341    let mut step = 1;
342
343    if let Some(entity) = entities.first() {
344        steps.push(PlanStep {
345            step,
346            command: format!("nomograph-sysml search \"{}\" {} --limit 5", entity, idx),
347            purpose: format!("Find elements matching '{}'", entity),
348        });
349        step += 1;
350
351        steps.push(PlanStep {
352            step,
353            command: format!(
354                "nomograph-sysml check all --scope \"{}\" --detail {}",
355                entity, idx
356            ),
357            purpose: format!("Run all checks scoped to '{}'", entity),
358        });
359        step += 1;
360    } else {
361        steps.push(PlanStep {
362            step,
363            command: format!("nomograph-sysml check all --detail {}", idx),
364            purpose: "Run all structural and metamodel checks".to_string(),
365        });
366        step += 1;
367    }
368
369    steps.push(PlanStep {
370        step,
371        command: format!(
372            "nomograph-sysml render --template completeness-report {}",
373            idx
374        ),
375        purpose: "Generate completeness report".to_string(),
376    });
377
378    steps
379}
380
381fn plan_comparison(entities: &[String], idx: &str) -> Vec<PlanStep> {
382    let mut steps = Vec::new();
383    let mut step = 1;
384
385    for entity in entities.iter().take(2) {
386        steps.push(PlanStep {
387            step,
388            command: format!("nomograph-sysml search \"{}\" {} --limit 5", entity, idx),
389            purpose: format!("Find elements matching '{}'", entity),
390        });
391        step += 1;
392    }
393
394    for entity in entities.iter().take(2) {
395        steps.push(PlanStep {
396            step,
397            command: format!(
398                "nomograph-sysml trace \"{}\" --hops 2 --direction both {}",
399                entity, idx
400            ),
401            purpose: format!("Trace relationships around '{}'", entity),
402        });
403        step += 1;
404    }
405
406    steps
407}
408
409fn plan_impact(entities: &[String], idx: &str) -> Vec<PlanStep> {
410    let mut steps = Vec::new();
411    let mut step = 1;
412
413    if let Some(entity) = entities.first() {
414        steps.push(PlanStep {
415            step,
416            command: format!("nomograph-sysml search \"{}\" {} --limit 5", entity, idx),
417            purpose: format!("Find elements matching '{}'", entity),
418        });
419        step += 1;
420
421        steps.push(PlanStep {
422            step,
423            command: format!(
424                "nomograph-sysml trace \"{}\" --hops 5 --direction backward {}",
425                entity, idx
426            ),
427            purpose: format!("Trace what depends on '{}'", entity),
428        });
429        step += 1;
430
431        steps.push(PlanStep {
432            step,
433            command: format!(
434                "nomograph-sysml trace \"{}\" --hops 3 --direction forward {}",
435                entity, idx
436            ),
437            purpose: format!("Trace what '{}' depends on", entity),
438        });
439    }
440
441    steps
442}
443
444fn plan_global(idx: &str) -> Vec<PlanStep> {
445    vec![
446        PlanStep {
447            step: 1,
448            command: format!("nomograph-sysml stat {}", idx),
449            purpose: "Get model overview statistics".to_string(),
450        },
451        PlanStep {
452            step: 2,
453            command: format!("nomograph-sysml check all {}", idx),
454            purpose: "Run all structural checks".to_string(),
455        },
456        PlanStep {
457            step: 3,
458            command: format!(
459                "nomograph-sysml render --template completeness-report {}",
460                idx
461            ),
462            purpose: "Generate completeness report".to_string(),
463        },
464    ]
465}
466
467fn plan_discovery(entities: &[String], idx: &str) -> Vec<PlanStep> {
468    let mut steps = Vec::new();
469    let mut step = 1;
470
471    if entities.is_empty() {
472        steps.push(PlanStep {
473            step,
474            command: format!("nomograph-sysml stat {}", idx),
475            purpose: "Get model overview (no specific entities detected)".to_string(),
476        });
477        return steps;
478    }
479
480    for entity in entities.iter().take(3) {
481        steps.push(PlanStep {
482            step,
483            command: format!("nomograph-sysml search \"{}\" {} --limit 5", entity, idx),
484            purpose: format!("Find elements matching '{}'", entity),
485        });
486        step += 1;
487    }
488
489    if let Some(entity) = entities.first() {
490        steps.push(PlanStep {
491            step,
492            command: format!(
493                "nomograph-sysml trace \"{}\" --hops 2 --direction both {}",
494                entity, idx
495            ),
496            purpose: format!("Explore relationships around '{}'", entity),
497        });
498    }
499
500    steps
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn test_classify_relationship() {
509        assert_eq!(
510            classify_question("Does the shield module satisfy survivability requirements?"),
511            QuestionType::Relationship
512        );
513        assert_eq!(
514            classify_question("Does ShieldModule verify MFRQ01?"),
515            QuestionType::Relationship
516        );
517    }
518
519    #[test]
520    fn test_classify_reverse() {
521        assert_eq!(
522            classify_question("What requires the ShieldModule?"),
523            QuestionType::Reverse
524        );
525        assert_eq!(
526            classify_question("What satisfies MFRQ01?"),
527            QuestionType::Reverse
528        );
529    }
530
531    #[test]
532    fn test_classify_completeness() {
533        assert_eq!(
534            classify_question("Is the mining frigate model complete?"),
535            QuestionType::Completeness
536        );
537        assert_eq!(
538            classify_question("Are there any orphan requirements?"),
539            QuestionType::Completeness
540        );
541        assert_eq!(
542            classify_question("What is the verification coverage?"),
543            QuestionType::Completeness
544        );
545    }
546
547    #[test]
548    fn test_classify_comparison() {
549        assert_eq!(
550            classify_question("Compare ShieldModule and PropulsionModule"),
551            QuestionType::Comparison
552        );
553    }
554
555    #[test]
556    fn test_classify_impact() {
557        assert_eq!(
558            classify_question("What is the impact of changing MFRQ01?"),
559            QuestionType::Impact
560        );
561        assert_eq!(
562            classify_question("What would break if we change ShieldModule?"),
563            QuestionType::Impact
564        );
565    }
566
567    #[test]
568    fn test_classify_global() {
569        assert_eq!(
570            classify_question("How many requirements are there?"),
571            QuestionType::Global
572        );
573        assert_eq!(
574            classify_question("Give me an overview of the model"),
575            QuestionType::Global
576        );
577    }
578
579    #[test]
580    fn test_classify_discovery() {
581        assert_eq!(
582            classify_question("Tell me about the ShieldModule"),
583            QuestionType::Discovery
584        );
585    }
586
587    #[test]
588    fn test_extract_entities_capitalized() {
589        let entities = extract_entities("Does ShieldModule satisfy MFRQ01?");
590        assert!(entities.contains(&"ShieldModule".to_string()));
591        assert!(entities.contains(&"MFRQ01".to_string()));
592    }
593
594    #[test]
595    fn test_extract_entities_fallback() {
596        let entities = extract_entities("shield module propulsion");
597        assert!(!entities.is_empty());
598    }
599
600    #[test]
601    fn test_decompose_relationship() {
602        let steps = decompose("Does ShieldModule satisfy MFRQ01?", ".nomograph/index.json");
603        assert!(steps.len() >= 3);
604        assert!(steps[0].command.contains("search"));
605        assert!(steps.last().unwrap().command.contains("query"));
606        assert!(steps.last().unwrap().command.contains("satisfy"));
607    }
608
609    #[test]
610    fn test_decompose_impact() {
611        let steps = decompose(
612            "What is the impact of changing MFRQ01?",
613            ".nomograph/index.json",
614        );
615        assert!(steps.len() >= 2);
616        assert!(steps.iter().any(|s| s.command.contains("trace")));
617        assert!(steps.iter().any(|s| s.command.contains("backward")));
618    }
619
620    #[test]
621    fn test_decompose_completeness() {
622        let steps = decompose(
623            "Are there any orphan requirements?",
624            ".nomograph/index.json",
625        );
626        assert!(steps.iter().any(|s| s.command.contains("check")));
627        assert!(steps
628            .iter()
629            .any(|s| s.command.contains("completeness-report")));
630    }
631
632    #[test]
633    fn test_decompose_global() {
634        let steps = decompose("Give me an overview of the model", ".nomograph/index.json");
635        assert!(steps.iter().any(|s| s.command.contains("stat")));
636    }
637
638    #[test]
639    fn test_plan_steps_sequential() {
640        let steps = decompose(
641            "Compare ShieldModule and PropulsionModule",
642            ".nomograph/index.json",
643        );
644        for (i, step) in steps.iter().enumerate() {
645            assert_eq!(step.step, i + 1);
646        }
647    }
648
649    #[test]
650    fn test_plan_rel_strings_are_valid_relationship_kinds() {
651        use crate::vocabulary::RELATIONSHIP_KIND_NAMES;
652        let lower_kinds: Vec<String> = RELATIONSHIP_KIND_NAMES
653            .iter()
654            .map(|s| s.to_lowercase())
655            .collect();
656        let plan_strings = ["satisfy", "verify", "allocate", "connect", "bind"];
657        for s in &plan_strings {
658            assert!(
659                lower_kinds.contains(&s.to_string()),
660                "plan.rs hardcodes '{s}' which is not in RELATIONSHIP_KIND_NAMES (lowercased)"
661            );
662        }
663    }
664}