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}