1use crate::context::{Context, ContextKey, Fact};
46use std::time::Duration;
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub struct IntentId(pub String);
51
52impl IntentId {
53 #[must_use]
55 pub fn new(id: impl Into<String>) -> Self {
56 Self(id.into())
57 }
58
59 #[must_use]
61 pub fn generate() -> Self {
62 use std::time::{SystemTime, UNIX_EPOCH};
63 let timestamp = SystemTime::now()
64 .duration_since(UNIX_EPOCH)
65 .unwrap_or_default()
66 .as_nanos();
67 Self(format!("intent-{timestamp:x}"))
68 }
69}
70
71impl std::fmt::Display for IntentId {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 write!(f, "{}", self.0)
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84#[non_exhaustive]
85pub enum IntentKind {
86 GrowthStrategy,
88 Scheduling,
90 ResourceOptimization,
92 RiskAssessment,
94 ContentGeneration,
96 Custom,
98}
99
100impl IntentKind {
101 #[must_use]
103 pub fn name(&self) -> &'static str {
104 match self {
105 Self::GrowthStrategy => "growth_strategy",
106 Self::Scheduling => "scheduling",
107 Self::ResourceOptimization => "resource_optimization",
108 Self::RiskAssessment => "risk_assessment",
109 Self::ContentGeneration => "content_generation",
110 Self::Custom => "custom",
111 }
112 }
113
114 #[must_use]
116 pub fn suggested_context_keys(&self) -> &'static [ContextKey] {
117 match self {
118 Self::GrowthStrategy => &[
119 ContextKey::Seeds,
120 ContextKey::Signals,
121 ContextKey::Competitors,
122 ContextKey::Strategies,
123 ContextKey::Evaluations,
124 ],
125 Self::Scheduling => &[
126 ContextKey::Seeds,
127 ContextKey::Constraints,
128 ContextKey::Strategies,
129 ],
130 Self::ResourceOptimization => &[
131 ContextKey::Seeds,
132 ContextKey::Constraints,
133 ContextKey::Strategies,
134 ContextKey::Evaluations,
135 ],
136 Self::RiskAssessment => &[
137 ContextKey::Seeds,
138 ContextKey::Signals,
139 ContextKey::Hypotheses,
140 ContextKey::Evaluations,
141 ],
142 Self::ContentGeneration => &[
143 ContextKey::Seeds,
144 ContextKey::Constraints,
145 ContextKey::Strategies,
146 ],
147 Self::Custom => &[ContextKey::Seeds],
148 }
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Hash)]
154#[non_exhaustive]
155pub enum Objective {
156 IncreaseDemand,
158 MinimizeTime,
160 MaximizeFeasibility,
162 MinimizeCost,
164 MaximizeCoverage,
166 Balance(Vec<Objective>),
168 Custom(String),
170}
171
172impl Objective {
173 #[must_use]
175 pub fn name(&self) -> String {
176 match self {
177 Self::IncreaseDemand => "increase_demand".into(),
178 Self::MinimizeTime => "minimize_time".into(),
179 Self::MaximizeFeasibility => "maximize_feasibility".into(),
180 Self::MinimizeCost => "minimize_cost".into(),
181 Self::MaximizeCoverage => "maximize_coverage".into(),
182 Self::Balance(_) => "balanced".into(),
183 Self::Custom(name) => name.clone(),
184 }
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Hash)]
190pub enum ScopeConstraint {
191 Market(String),
193 Geography(String),
195 Product(String),
197 TimeWindow {
199 start: Option<String>,
200 end: Option<String>,
201 description: String,
202 },
203 CustomerSegment(String),
205 Custom { key: String, value: String },
207}
208
209#[derive(Debug, Clone, Default, PartialEq, Eq)]
213pub struct Scope {
214 constraints: Vec<ScopeConstraint>,
216}
217
218impl Scope {
219 #[must_use]
221 pub fn new() -> Self {
222 Self::default()
223 }
224
225 #[must_use]
227 pub fn with_constraint(mut self, constraint: ScopeConstraint) -> Self {
228 self.constraints.push(constraint);
229 self
230 }
231
232 #[must_use]
234 pub fn constraints(&self) -> &[ScopeConstraint] {
235 &self.constraints
236 }
237
238 #[must_use]
240 pub fn is_defined(&self) -> bool {
241 !self.constraints.is_empty()
242 }
243
244 #[must_use]
250 pub fn allows(&self, _fact: &Fact) -> bool {
251 true
255 }
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
260pub enum ConstraintSeverity {
261 Hard,
263 Soft,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Hash)]
269pub struct IntentConstraint {
270 pub key: String,
272 pub value: String,
274 pub severity: ConstraintSeverity,
276}
277
278impl IntentConstraint {
279 #[must_use]
281 pub fn hard(key: impl Into<String>, value: impl Into<String>) -> Self {
282 Self {
283 key: key.into(),
284 value: value.into(),
285 severity: ConstraintSeverity::Hard,
286 }
287 }
288
289 #[must_use]
291 pub fn soft(key: impl Into<String>, value: impl Into<String>) -> Self {
292 Self {
293 key: key.into(),
294 value: value.into(),
295 severity: ConstraintSeverity::Soft,
296 }
297 }
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Hash)]
302#[non_exhaustive]
303pub enum SuccessCriterion {
304 AtLeastOneViableStrategy,
306 ValidScheduleFound,
308 AllTasksAllocated,
310 MinimumStrategies(usize),
312 AllEvaluationsPositive,
314 Custom(String),
316}
317
318impl SuccessCriterion {
319 #[must_use]
321 pub fn is_satisfied(&self, ctx: &Context) -> bool {
322 match self {
323 Self::AtLeastOneViableStrategy => {
324 let strategies = ctx.get(ContextKey::Strategies);
326 let evaluations = ctx.get(ContextKey::Evaluations);
327
328 if strategies.is_empty() {
329 return false;
330 }
331
332 if evaluations.is_empty() {
334 return true;
335 }
336
337 evaluations.iter().any(|e| {
339 e.content.to_lowercase().contains("viable")
340 || e.content.to_lowercase().contains("positive")
341 || e.content.to_lowercase().contains("recommended")
342 })
343 }
344 Self::ValidScheduleFound => {
345 ctx.has(ContextKey::Strategies)
346 }
347 Self::AllTasksAllocated => {
348 !ctx.get(ContextKey::Constraints).iter().any(|c| {
350 c.content.to_lowercase().contains("unallocated")
351 })
352 }
353 Self::MinimumStrategies(n) => {
354 ctx.get(ContextKey::Strategies).len() >= *n
355 }
356 Self::AllEvaluationsPositive => {
357 let evaluations = ctx.get(ContextKey::Evaluations);
358 !evaluations.is_empty() && evaluations.iter().all(|e| {
359 !e.content.to_lowercase().contains("negative")
360 && !e.content.to_lowercase().contains("rejected")
361 })
362 }
363 Self::Custom(_) => {
364 true
367 }
368 }
369 }
370}
371
372#[derive(Debug, Clone, Default, PartialEq, Eq)]
374pub struct SuccessCriteria {
375 required: Vec<SuccessCriterion>,
377 optional: Vec<SuccessCriterion>,
379}
380
381impl SuccessCriteria {
382 #[must_use]
384 pub fn new() -> Self {
385 Self::default()
386 }
387
388 #[must_use]
390 pub fn require(mut self, criterion: SuccessCriterion) -> Self {
391 self.required.push(criterion);
392 self
393 }
394
395 #[must_use]
397 pub fn prefer(mut self, criterion: SuccessCriterion) -> Self {
398 self.optional.push(criterion);
399 self
400 }
401
402 #[must_use]
404 pub fn is_satisfied(&self, ctx: &Context) -> bool {
405 self.required.iter().all(|c| c.is_satisfied(ctx))
406 }
407
408 #[must_use]
410 pub fn unsatisfied(&self, ctx: &Context) -> Vec<&SuccessCriterion> {
411 self.required
412 .iter()
413 .filter(|c| !c.is_satisfied(ctx))
414 .collect()
415 }
416
417 #[must_use]
419 pub fn unsatisfied_optional(&self, ctx: &Context) -> Vec<&SuccessCriterion> {
420 self.optional
421 .iter()
422 .filter(|c| !c.is_satisfied(ctx))
423 .collect()
424 }
425
426 #[must_use]
428 pub fn is_explicit(&self) -> bool {
429 !self.required.is_empty()
430 }
431}
432
433#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct Budgets {
436 pub max_cycles: u32,
438 pub max_agents_per_cycle: Option<u32>,
440 pub max_facts: u32,
442 pub time_limit: Option<Duration>,
444 pub max_tokens: Option<u64>,
446}
447
448impl Default for Budgets {
449 fn default() -> Self {
450 Self {
451 max_cycles: 100,
452 max_agents_per_cycle: None,
453 max_facts: 10_000,
454 time_limit: None,
455 max_tokens: None,
456 }
457 }
458}
459
460impl Budgets {
461 #[must_use]
463 pub fn with_max_cycles(mut self, max: u32) -> Self {
464 self.max_cycles = max;
465 self
466 }
467
468 #[must_use]
470 pub fn with_max_facts(mut self, max: u32) -> Self {
471 self.max_facts = max;
472 self
473 }
474
475 #[must_use]
477 pub fn with_time_limit(mut self, limit: Duration) -> Self {
478 self.time_limit = Some(limit);
479 self
480 }
481
482 #[must_use]
484 pub fn with_max_tokens(mut self, max: u64) -> Self {
485 self.max_tokens = Some(max);
486 self
487 }
488
489 #[must_use]
491 pub fn to_engine_budget(&self) -> crate::engine::Budget {
492 crate::engine::Budget {
493 max_cycles: self.max_cycles,
494 max_facts: self.max_facts,
495 }
496 }
497}
498
499#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct IntentValidationError {
502 pub field: String,
504 pub reason: String,
506}
507
508impl std::fmt::Display for IntentValidationError {
509 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510 write!(f, "invalid {}: {}", self.field, self.reason)
511 }
512}
513
514impl std::error::Error for IntentValidationError {}
515
516#[derive(Debug, Clone)]
522pub struct RootIntent {
523 pub id: IntentId,
525 pub kind: IntentKind,
527 pub objective: Option<Objective>,
529 pub scope: Scope,
531 pub constraints: Vec<IntentConstraint>,
533 pub success_criteria: SuccessCriteria,
535 pub budgets: Budgets,
537}
538
539impl RootIntent {
540 #[must_use]
542 pub fn new(kind: IntentKind) -> Self {
543 Self {
544 id: IntentId::generate(),
545 kind,
546 objective: None,
547 scope: Scope::new(),
548 constraints: Vec::new(),
549 success_criteria: SuccessCriteria::new(),
550 budgets: Budgets::default(),
551 }
552 }
553
554 #[must_use]
556 pub fn with_id(mut self, id: IntentId) -> Self {
557 self.id = id;
558 self
559 }
560
561 #[must_use]
563 pub fn with_objective(mut self, objective: Objective) -> Self {
564 self.objective = Some(objective);
565 self
566 }
567
568 #[must_use]
570 pub fn with_scope(mut self, scope: Scope) -> Self {
571 self.scope = scope;
572 self
573 }
574
575 #[must_use]
577 pub fn with_constraint(mut self, constraint: IntentConstraint) -> Self {
578 self.constraints.push(constraint);
579 self
580 }
581
582 #[must_use]
584 pub fn with_success_criterion(mut self, criterion: SuccessCriterion) -> Self {
585 self.success_criteria = self.success_criteria.require(criterion);
586 self
587 }
588
589 #[must_use]
591 pub fn with_success_criteria(mut self, criteria: SuccessCriteria) -> Self {
592 self.success_criteria = criteria;
593 self
594 }
595
596 #[must_use]
598 pub fn with_budgets(mut self, budgets: Budgets) -> Self {
599 self.budgets = budgets;
600 self
601 }
602
603 pub fn validate(&self) -> Result<(), IntentValidationError> {
611 if !self.scope.is_defined() {
613 return Err(IntentValidationError {
614 field: "scope".into(),
615 reason: "scope must have at least one constraint".into(),
616 });
617 }
618
619 if !self.success_criteria.is_explicit() {
621 return Err(IntentValidationError {
622 field: "success_criteria".into(),
623 reason: "at least one success criterion must be defined".into(),
624 });
625 }
626
627 Ok(())
628 }
629
630 #[must_use]
634 pub fn to_seed_facts(&self) -> Vec<Fact> {
635 let mut facts = Vec::new();
636
637 facts.push(Fact {
639 key: ContextKey::Seeds,
640 id: format!("intent:{}", self.id),
641 content: format!(
642 "kind={} objective={}",
643 self.kind.name(),
644 self.objective.as_ref().map_or("unspecified".to_string(), |o| o.name())
645 ),
646 });
647
648 for (i, constraint) in self.scope.constraints().iter().enumerate() {
650 let content = match constraint {
651 ScopeConstraint::Market(m) => format!("market={m}"),
652 ScopeConstraint::Geography(g) => format!("geography={g}"),
653 ScopeConstraint::Product(p) => format!("product={p}"),
654 ScopeConstraint::TimeWindow { description, .. } => {
655 format!("timewindow={description}")
656 }
657 ScopeConstraint::CustomerSegment(s) => format!("segment={s}"),
658 ScopeConstraint::Custom { key, value } => format!("{key}={value}"),
659 };
660 facts.push(Fact {
661 key: ContextKey::Seeds,
662 id: format!("scope:{}:{i}", self.id),
663 content,
664 });
665 }
666
667 for constraint in &self.constraints {
669 if constraint.severity == ConstraintSeverity::Hard {
670 facts.push(Fact {
671 key: ContextKey::Constraints,
672 id: format!("constraint:{}:{}", self.id, constraint.key),
673 content: format!("{}={}", constraint.key, constraint.value),
674 });
675 }
676 }
677
678 facts
679 }
680
681 #[must_use]
683 pub fn hard_constraints(&self) -> Vec<&IntentConstraint> {
684 self.constraints
685 .iter()
686 .filter(|c| c.severity == ConstraintSeverity::Hard)
687 .collect()
688 }
689
690 #[must_use]
692 pub fn soft_constraints(&self) -> Vec<&IntentConstraint> {
693 self.constraints
694 .iter()
695 .filter(|c| c.severity == ConstraintSeverity::Soft)
696 .collect()
697 }
698
699 #[must_use]
701 pub fn is_successful(&self, ctx: &Context) -> bool {
702 self.success_criteria.is_satisfied(ctx)
703 }
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 #[test]
711 fn intent_id_generates_unique_ids() {
712 let id1 = IntentId::generate();
713 let id2 = IntentId::generate();
714 assert_ne!(id1, id2);
715 }
716
717 #[test]
718 fn intent_kind_has_names() {
719 assert_eq!(IntentKind::GrowthStrategy.name(), "growth_strategy");
720 assert_eq!(IntentKind::Scheduling.name(), "scheduling");
721 }
722
723 #[test]
724 fn intent_kind_suggests_context_keys() {
725 let keys = IntentKind::GrowthStrategy.suggested_context_keys();
726 assert!(keys.contains(&ContextKey::Strategies));
727 assert!(keys.contains(&ContextKey::Competitors));
728 }
729
730 #[test]
731 fn scope_tracks_constraints() {
732 let scope = Scope::new()
733 .with_constraint(ScopeConstraint::Market("Nordic B2B".into()))
734 .with_constraint(ScopeConstraint::Geography("EMEA".into()));
735
736 assert!(scope.is_defined());
737 assert_eq!(scope.constraints().len(), 2);
738 }
739
740 #[test]
741 fn intent_constraint_severities() {
742 let hard = IntentConstraint::hard("budget", "1M");
743 let soft = IntentConstraint::soft("brand", "friendly");
744
745 assert_eq!(hard.severity, ConstraintSeverity::Hard);
746 assert_eq!(soft.severity, ConstraintSeverity::Soft);
747 }
748
749 #[test]
750 fn success_criteria_checks_satisfaction() {
751 let mut ctx = Context::new();
752 ctx.add_fact(Fact {
753 key: ContextKey::Strategies,
754 id: "strat-1".into(),
755 content: "growth strategy".into(),
756 })
757 .unwrap();
758 ctx.add_fact(Fact {
759 key: ContextKey::Evaluations,
760 id: "eval-1".into(),
761 content: "viable and recommended".into(),
762 })
763 .unwrap();
764
765 let criteria = SuccessCriteria::new()
766 .require(SuccessCriterion::AtLeastOneViableStrategy);
767
768 assert!(criteria.is_satisfied(&ctx));
769 }
770
771 #[test]
772 fn success_criteria_reports_unsatisfied() {
773 let ctx = Context::new();
774 let criteria = SuccessCriteria::new()
775 .require(SuccessCriterion::MinimumStrategies(2));
776
777 assert!(!criteria.is_satisfied(&ctx));
778 assert_eq!(criteria.unsatisfied(&ctx).len(), 1);
779 }
780
781 #[test]
782 fn root_intent_validates_scope() {
783 let intent = RootIntent::new(IntentKind::GrowthStrategy)
784 .with_success_criterion(SuccessCriterion::AtLeastOneViableStrategy);
785
786 let result = intent.validate();
787 assert!(result.is_err());
788 assert_eq!(result.unwrap_err().field, "scope");
789 }
790
791 #[test]
792 fn root_intent_validates_success_criteria() {
793 let intent = RootIntent::new(IntentKind::GrowthStrategy)
794 .with_scope(Scope::new().with_constraint(ScopeConstraint::Market("B2B".into())));
795
796 let result = intent.validate();
797 assert!(result.is_err());
798 assert_eq!(result.unwrap_err().field, "success_criteria");
799 }
800
801 #[test]
802 fn root_intent_passes_validation() {
803 let intent = RootIntent::new(IntentKind::GrowthStrategy)
804 .with_objective(Objective::IncreaseDemand)
805 .with_scope(Scope::new().with_constraint(ScopeConstraint::Market("B2B".into())))
806 .with_success_criterion(SuccessCriterion::AtLeastOneViableStrategy);
807
808 assert!(intent.validate().is_ok());
809 }
810
811 #[test]
812 fn root_intent_generates_seed_facts() {
813 let intent = RootIntent::new(IntentKind::GrowthStrategy)
814 .with_id(IntentId::new("test-intent"))
815 .with_objective(Objective::IncreaseDemand)
816 .with_scope(
817 Scope::new()
818 .with_constraint(ScopeConstraint::Market("Nordic".into()))
819 .with_constraint(ScopeConstraint::Geography("EMEA".into())),
820 )
821 .with_constraint(IntentConstraint::hard("budget", "1M"));
822
823 let facts = intent.to_seed_facts();
824
825 assert_eq!(facts.len(), 4);
827
828 let intent_fact = facts.iter().find(|f| f.id.starts_with("intent:")).unwrap();
830 assert!(intent_fact.content.contains("growth_strategy"));
831 assert!(intent_fact.content.contains("increase_demand"));
832
833 let constraint_fact = facts
835 .iter()
836 .find(|f| f.key == ContextKey::Constraints)
837 .unwrap();
838 assert!(constraint_fact.content.contains("budget=1M"));
839 }
840
841 #[test]
842 fn budgets_converts_to_engine_budget() {
843 let budgets = Budgets::default()
844 .with_max_cycles(50)
845 .with_max_facts(5000);
846
847 let engine_budget = budgets.to_engine_budget();
848 assert_eq!(engine_budget.max_cycles, 50);
849 assert_eq!(engine_budget.max_facts, 5000);
850 }
851
852 #[test]
853 fn root_intent_checks_success() {
854 let intent = RootIntent::new(IntentKind::GrowthStrategy)
855 .with_success_criterion(SuccessCriterion::MinimumStrategies(1));
856
857 let mut ctx = Context::new();
858 assert!(!intent.is_successful(&ctx));
859
860 ctx.add_fact(Fact {
861 key: ContextKey::Strategies,
862 id: "s1".into(),
863 content: "strategy".into(),
864 })
865 .unwrap();
866
867 assert!(intent.is_successful(&ctx));
868 }
869
870 #[test]
871 fn hard_and_soft_constraints_filtered() {
872 let intent = RootIntent::new(IntentKind::GrowthStrategy)
873 .with_constraint(IntentConstraint::hard("budget", "1M"))
874 .with_constraint(IntentConstraint::soft("brand", "friendly"))
875 .with_constraint(IntentConstraint::hard("compliance", "GDPR"));
876
877 assert_eq!(intent.hard_constraints().len(), 2);
878 assert_eq!(intent.soft_constraints().len(), 1);
879 }
880}