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 #[deprecated(
67 since = "2.0.0",
68 note = "Use converge-runtime with Randomness trait for random ID generation"
69 )]
70 #[must_use]
71 pub fn generate() -> Self {
72 use std::time::{SystemTime, UNIX_EPOCH};
73 let timestamp = SystemTime::now()
74 .duration_since(UNIX_EPOCH)
75 .unwrap_or_default()
76 .as_nanos();
77 static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
79 let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
80 let pid = std::process::id();
81 Self(format!("intent-{timestamp:x}-{pid:08x}-{counter:04x}"))
82 }
83}
84
85impl std::fmt::Display for IntentId {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 write!(f, "{}", self.0)
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98#[non_exhaustive]
99pub enum IntentKind {
100 GrowthStrategy,
102 Scheduling,
104 ResourceOptimization,
106 RiskAssessment,
108 ContentGeneration,
110 Custom,
112}
113
114impl IntentKind {
115 #[must_use]
117 pub fn name(&self) -> &'static str {
118 match self {
119 Self::GrowthStrategy => "growth_strategy",
120 Self::Scheduling => "scheduling",
121 Self::ResourceOptimization => "resource_optimization",
122 Self::RiskAssessment => "risk_assessment",
123 Self::ContentGeneration => "content_generation",
124 Self::Custom => "custom",
125 }
126 }
127
128 #[must_use]
130 pub fn suggested_context_keys(&self) -> &'static [ContextKey] {
131 match self {
132 Self::GrowthStrategy => &[
133 ContextKey::Seeds,
134 ContextKey::Signals,
135 ContextKey::Competitors,
136 ContextKey::Strategies,
137 ContextKey::Evaluations,
138 ],
139 Self::Scheduling | Self::ContentGeneration => &[
140 ContextKey::Seeds,
141 ContextKey::Constraints,
142 ContextKey::Strategies,
143 ],
144 Self::ResourceOptimization => &[
145 ContextKey::Seeds,
146 ContextKey::Constraints,
147 ContextKey::Strategies,
148 ContextKey::Evaluations,
149 ],
150 Self::RiskAssessment => &[
151 ContextKey::Seeds,
152 ContextKey::Signals,
153 ContextKey::Hypotheses,
154 ContextKey::Evaluations,
155 ],
156 Self::Custom => &[ContextKey::Seeds],
157 }
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Hash)]
163#[non_exhaustive]
164pub enum Objective {
165 IncreaseDemand,
167 MinimizeTime,
169 MaximizeFeasibility,
171 MinimizeCost,
173 MaximizeCoverage,
175 Balance(Vec<Objective>),
177 Custom(String),
179}
180
181impl Objective {
182 #[must_use]
184 pub fn name(&self) -> String {
185 match self {
186 Self::IncreaseDemand => "increase_demand".into(),
187 Self::MinimizeTime => "minimize_time".into(),
188 Self::MaximizeFeasibility => "maximize_feasibility".into(),
189 Self::MinimizeCost => "minimize_cost".into(),
190 Self::MaximizeCoverage => "maximize_coverage".into(),
191 Self::Balance(_) => "balanced".into(),
192 Self::Custom(name) => name.clone(),
193 }
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Hash)]
199pub enum ScopeConstraint {
200 Market(String),
202 Geography(String),
204 Product(String),
206 TimeWindow {
208 start: Option<String>,
209 end: Option<String>,
210 description: String,
211 },
212 CustomerSegment(String),
214 Custom { key: String, value: String },
216}
217
218#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub struct Scope {
223 constraints: Vec<ScopeConstraint>,
225}
226
227impl Scope {
228 #[must_use]
230 pub fn new() -> Self {
231 Self::default()
232 }
233
234 #[must_use]
236 pub fn with_constraint(mut self, constraint: ScopeConstraint) -> Self {
237 self.constraints.push(constraint);
238 self
239 }
240
241 #[must_use]
243 pub fn constraints(&self) -> &[ScopeConstraint] {
244 &self.constraints
245 }
246
247 #[must_use]
249 pub fn is_defined(&self) -> bool {
250 !self.constraints.is_empty()
251 }
252
253 #[must_use]
259 pub fn allows(&self, _fact: &Fact) -> bool {
260 true
264 }
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
269pub enum ConstraintSeverity {
270 Hard,
272 Soft,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Hash)]
278pub struct IntentConstraint {
279 pub key: String,
281 pub value: String,
283 pub severity: ConstraintSeverity,
285}
286
287impl IntentConstraint {
288 #[must_use]
290 pub fn hard(key: impl Into<String>, value: impl Into<String>) -> Self {
291 Self {
292 key: key.into(),
293 value: value.into(),
294 severity: ConstraintSeverity::Hard,
295 }
296 }
297
298 #[must_use]
300 pub fn soft(key: impl Into<String>, value: impl Into<String>) -> Self {
301 Self {
302 key: key.into(),
303 value: value.into(),
304 severity: ConstraintSeverity::Soft,
305 }
306 }
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Hash)]
311#[non_exhaustive]
312pub enum SuccessCriterion {
313 AtLeastOneViableStrategy,
315 ValidScheduleFound,
317 AllTasksAllocated,
319 MinimumStrategies(usize),
321 AllEvaluationsPositive,
323 Custom(String),
325}
326
327impl SuccessCriterion {
328 #[must_use]
330 pub fn is_satisfied(&self, ctx: &Context) -> bool {
331 match self {
332 Self::AtLeastOneViableStrategy => {
333 let strategies = ctx.get(ContextKey::Strategies);
335 let evaluations = ctx.get(ContextKey::Evaluations);
336
337 if strategies.is_empty() {
338 return false;
339 }
340
341 if evaluations.is_empty() {
343 return true;
344 }
345
346 evaluations.iter().any(|e| {
348 e.content.to_lowercase().contains("viable")
349 || e.content.to_lowercase().contains("positive")
350 || e.content.to_lowercase().contains("recommended")
351 })
352 }
353 Self::ValidScheduleFound => ctx.has(ContextKey::Strategies),
354 Self::AllTasksAllocated => {
355 !ctx.get(ContextKey::Constraints)
357 .iter()
358 .any(|c| c.content.to_lowercase().contains("unallocated"))
359 }
360 Self::MinimumStrategies(n) => ctx.get(ContextKey::Strategies).len() >= *n,
361 Self::AllEvaluationsPositive => {
362 let evaluations = ctx.get(ContextKey::Evaluations);
363 !evaluations.is_empty()
364 && evaluations.iter().all(|e| {
365 !e.content.to_lowercase().contains("negative")
366 && !e.content.to_lowercase().contains("rejected")
367 })
368 }
369 Self::Custom(_) => {
370 true
373 }
374 }
375 }
376}
377
378#[derive(Debug, Clone, Default, PartialEq, Eq)]
380pub struct SuccessCriteria {
381 required: Vec<SuccessCriterion>,
383 optional: Vec<SuccessCriterion>,
385}
386
387impl SuccessCriteria {
388 #[must_use]
390 pub fn new() -> Self {
391 Self::default()
392 }
393
394 #[must_use]
396 pub fn require(mut self, criterion: SuccessCriterion) -> Self {
397 self.required.push(criterion);
398 self
399 }
400
401 #[must_use]
403 pub fn prefer(mut self, criterion: SuccessCriterion) -> Self {
404 self.optional.push(criterion);
405 self
406 }
407
408 #[must_use]
410 pub fn is_satisfied(&self, ctx: &Context) -> bool {
411 self.required.iter().all(|c| c.is_satisfied(ctx))
412 }
413
414 #[must_use]
416 pub fn unsatisfied(&self, ctx: &Context) -> Vec<&SuccessCriterion> {
417 self.required
418 .iter()
419 .filter(|c| !c.is_satisfied(ctx))
420 .collect()
421 }
422
423 #[must_use]
425 pub fn unsatisfied_optional(&self, ctx: &Context) -> Vec<&SuccessCriterion> {
426 self.optional
427 .iter()
428 .filter(|c| !c.is_satisfied(ctx))
429 .collect()
430 }
431
432 #[must_use]
434 pub fn is_explicit(&self) -> bool {
435 !self.required.is_empty()
436 }
437}
438
439#[derive(Debug, Clone, PartialEq, Eq)]
441pub struct Budgets {
442 pub max_cycles: u32,
444 pub max_agents_per_cycle: Option<u32>,
446 pub max_facts: u32,
448 pub time_limit: Option<Duration>,
450 pub max_tokens: Option<u64>,
452}
453
454impl Default for Budgets {
455 fn default() -> Self {
456 Self {
457 max_cycles: 100,
458 max_agents_per_cycle: None,
459 max_facts: 10_000,
460 time_limit: None,
461 max_tokens: None,
462 }
463 }
464}
465
466impl Budgets {
467 #[must_use]
469 pub fn with_max_cycles(mut self, max: u32) -> Self {
470 self.max_cycles = max;
471 self
472 }
473
474 #[must_use]
476 pub fn with_max_facts(mut self, max: u32) -> Self {
477 self.max_facts = max;
478 self
479 }
480
481 #[must_use]
483 pub fn with_time_limit(mut self, limit: Duration) -> Self {
484 self.time_limit = Some(limit);
485 self
486 }
487
488 #[must_use]
490 pub fn with_max_tokens(mut self, max: u64) -> Self {
491 self.max_tokens = Some(max);
492 self
493 }
494
495 #[must_use]
497 pub fn to_engine_budget(&self) -> crate::engine::Budget {
498 crate::engine::Budget {
499 max_cycles: self.max_cycles,
500 max_facts: self.max_facts,
501 }
502 }
503}
504
505#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct IntentValidationError {
508 pub field: String,
510 pub reason: String,
512}
513
514impl std::fmt::Display for IntentValidationError {
515 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 write!(f, "invalid {}: {}", self.field, self.reason)
517 }
518}
519
520impl std::error::Error for IntentValidationError {}
521
522#[derive(Debug, Clone)]
528pub struct RootIntent {
529 pub id: IntentId,
531 pub kind: IntentKind,
533 pub objective: Option<Objective>,
535 pub scope: Scope,
537 pub constraints: Vec<IntentConstraint>,
539 pub success_criteria: SuccessCriteria,
541 pub budgets: Budgets,
543}
544
545impl RootIntent {
546 #[allow(deprecated)]
548 #[must_use]
549 pub fn new(kind: IntentKind) -> Self {
550 Self {
551 id: IntentId::generate(),
552 kind,
553 objective: None,
554 scope: Scope::new(),
555 constraints: Vec::new(),
556 success_criteria: SuccessCriteria::new(),
557 budgets: Budgets::default(),
558 }
559 }
560
561 #[must_use]
563 pub fn with_id(mut self, id: IntentId) -> Self {
564 self.id = id;
565 self
566 }
567
568 #[must_use]
570 pub fn with_objective(mut self, objective: Objective) -> Self {
571 self.objective = Some(objective);
572 self
573 }
574
575 #[must_use]
577 pub fn with_scope(mut self, scope: Scope) -> Self {
578 self.scope = scope;
579 self
580 }
581
582 #[must_use]
584 pub fn with_constraint(mut self, constraint: IntentConstraint) -> Self {
585 self.constraints.push(constraint);
586 self
587 }
588
589 #[must_use]
591 pub fn with_success_criterion(mut self, criterion: SuccessCriterion) -> Self {
592 self.success_criteria = self.success_criteria.require(criterion);
593 self
594 }
595
596 #[must_use]
598 pub fn with_success_criteria(mut self, criteria: SuccessCriteria) -> Self {
599 self.success_criteria = criteria;
600 self
601 }
602
603 #[must_use]
605 pub fn with_budgets(mut self, budgets: Budgets) -> Self {
606 self.budgets = budgets;
607 self
608 }
609
610 pub fn validate(&self) -> Result<(), IntentValidationError> {
618 if !self.scope.is_defined() {
620 return Err(IntentValidationError {
621 field: "scope".into(),
622 reason: "scope must have at least one constraint".into(),
623 });
624 }
625
626 if !self.success_criteria.is_explicit() {
628 return Err(IntentValidationError {
629 field: "success_criteria".into(),
630 reason: "at least one success criterion must be defined".into(),
631 });
632 }
633
634 Ok(())
635 }
636
637 #[must_use]
641 pub fn to_seed_facts(&self) -> Vec<Fact> {
642 let mut facts = Vec::new();
643
644 facts.push(Fact {
646 key: ContextKey::Seeds,
647 id: format!("intent:{}", self.id),
648 content: format!(
649 "kind={} objective={}",
650 self.kind.name(),
651 self.objective
652 .as_ref()
653 .map_or("unspecified".to_string(), Objective::name)
654 ),
655 });
656
657 for (i, constraint) in self.scope.constraints().iter().enumerate() {
659 let content = match constraint {
660 ScopeConstraint::Market(m) => format!("market={m}"),
661 ScopeConstraint::Geography(g) => format!("geography={g}"),
662 ScopeConstraint::Product(p) => format!("product={p}"),
663 ScopeConstraint::TimeWindow { description, .. } => {
664 format!("timewindow={description}")
665 }
666 ScopeConstraint::CustomerSegment(s) => format!("segment={s}"),
667 ScopeConstraint::Custom { key, value } => format!("{key}={value}"),
668 };
669 facts.push(Fact {
670 key: ContextKey::Seeds,
671 id: format!("scope:{}:{i}", self.id),
672 content,
673 });
674 }
675
676 for constraint in &self.constraints {
678 if constraint.severity == ConstraintSeverity::Hard {
679 facts.push(Fact {
680 key: ContextKey::Constraints,
681 id: format!("constraint:{}:{}", self.id, constraint.key),
682 content: format!("{}={}", constraint.key, constraint.value),
683 });
684 }
685 }
686
687 facts
688 }
689
690 #[must_use]
692 pub fn hard_constraints(&self) -> Vec<&IntentConstraint> {
693 self.constraints
694 .iter()
695 .filter(|c| c.severity == ConstraintSeverity::Hard)
696 .collect()
697 }
698
699 #[must_use]
701 pub fn soft_constraints(&self) -> Vec<&IntentConstraint> {
702 self.constraints
703 .iter()
704 .filter(|c| c.severity == ConstraintSeverity::Soft)
705 .collect()
706 }
707
708 #[must_use]
710 pub fn is_successful(&self, ctx: &Context) -> bool {
711 self.success_criteria.is_satisfied(ctx)
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718
719 #[test]
720 #[allow(deprecated)]
721 fn intent_id_generates_unique_ids() {
722 let id1 = IntentId::generate();
723 let id2 = IntentId::generate();
724 assert_ne!(id1, id2);
725 }
726
727 #[test]
728 fn intent_kind_has_names() {
729 assert_eq!(IntentKind::GrowthStrategy.name(), "growth_strategy");
730 assert_eq!(IntentKind::Scheduling.name(), "scheduling");
731 }
732
733 #[test]
734 fn intent_kind_suggests_context_keys() {
735 let keys = IntentKind::GrowthStrategy.suggested_context_keys();
736 assert!(keys.contains(&ContextKey::Strategies));
737 assert!(keys.contains(&ContextKey::Competitors));
738 }
739
740 #[test]
741 fn scope_tracks_constraints() {
742 let scope = Scope::new()
743 .with_constraint(ScopeConstraint::Market("Nordic B2B".into()))
744 .with_constraint(ScopeConstraint::Geography("EMEA".into()));
745
746 assert!(scope.is_defined());
747 assert_eq!(scope.constraints().len(), 2);
748 }
749
750 #[test]
751 fn intent_constraint_severities() {
752 let hard = IntentConstraint::hard("budget", "1M");
753 let soft = IntentConstraint::soft("brand", "friendly");
754
755 assert_eq!(hard.severity, ConstraintSeverity::Hard);
756 assert_eq!(soft.severity, ConstraintSeverity::Soft);
757 }
758
759 #[test]
760 fn success_criteria_checks_satisfaction() {
761 let mut ctx = Context::new();
762 ctx.add_fact(Fact {
763 key: ContextKey::Strategies,
764 id: "strat-1".into(),
765 content: "growth strategy".into(),
766 })
767 .unwrap();
768 ctx.add_fact(Fact {
769 key: ContextKey::Evaluations,
770 id: "eval-1".into(),
771 content: "viable and recommended".into(),
772 })
773 .unwrap();
774
775 let criteria = SuccessCriteria::new().require(SuccessCriterion::AtLeastOneViableStrategy);
776
777 assert!(criteria.is_satisfied(&ctx));
778 }
779
780 #[test]
781 fn success_criteria_reports_unsatisfied() {
782 let ctx = Context::new();
783 let criteria = SuccessCriteria::new().require(SuccessCriterion::MinimumStrategies(2));
784
785 assert!(!criteria.is_satisfied(&ctx));
786 assert_eq!(criteria.unsatisfied(&ctx).len(), 1);
787 }
788
789 #[test]
790 fn root_intent_validates_scope() {
791 let intent = RootIntent::new(IntentKind::GrowthStrategy)
792 .with_success_criterion(SuccessCriterion::AtLeastOneViableStrategy);
793
794 let result = intent.validate();
795 assert!(result.is_err());
796 assert_eq!(result.unwrap_err().field, "scope");
797 }
798
799 #[test]
800 fn root_intent_validates_success_criteria() {
801 let intent = RootIntent::new(IntentKind::GrowthStrategy)
802 .with_scope(Scope::new().with_constraint(ScopeConstraint::Market("B2B".into())));
803
804 let result = intent.validate();
805 assert!(result.is_err());
806 assert_eq!(result.unwrap_err().field, "success_criteria");
807 }
808
809 #[test]
810 fn root_intent_passes_validation() {
811 let intent = RootIntent::new(IntentKind::GrowthStrategy)
812 .with_objective(Objective::IncreaseDemand)
813 .with_scope(Scope::new().with_constraint(ScopeConstraint::Market("B2B".into())))
814 .with_success_criterion(SuccessCriterion::AtLeastOneViableStrategy);
815
816 assert!(intent.validate().is_ok());
817 }
818
819 #[test]
820 fn root_intent_generates_seed_facts() {
821 let intent = RootIntent::new(IntentKind::GrowthStrategy)
822 .with_id(IntentId::new("test-intent"))
823 .with_objective(Objective::IncreaseDemand)
824 .with_scope(
825 Scope::new()
826 .with_constraint(ScopeConstraint::Market("Nordic".into()))
827 .with_constraint(ScopeConstraint::Geography("EMEA".into())),
828 )
829 .with_constraint(IntentConstraint::hard("budget", "1M"));
830
831 let facts = intent.to_seed_facts();
832
833 assert_eq!(facts.len(), 4);
835
836 let intent_fact = facts.iter().find(|f| f.id.starts_with("intent:")).unwrap();
838 assert!(intent_fact.content.contains("growth_strategy"));
839 assert!(intent_fact.content.contains("increase_demand"));
840
841 let constraint_fact = facts
843 .iter()
844 .find(|f| f.key == ContextKey::Constraints)
845 .unwrap();
846 assert!(constraint_fact.content.contains("budget=1M"));
847 }
848
849 #[test]
850 fn budgets_converts_to_engine_budget() {
851 let budgets = Budgets::default().with_max_cycles(50).with_max_facts(5000);
852
853 let engine_budget = budgets.to_engine_budget();
854 assert_eq!(engine_budget.max_cycles, 50);
855 assert_eq!(engine_budget.max_facts, 5000);
856 }
857
858 #[test]
859 fn root_intent_checks_success() {
860 let intent = RootIntent::new(IntentKind::GrowthStrategy)
861 .with_success_criterion(SuccessCriterion::MinimumStrategies(1));
862
863 let mut ctx = Context::new();
864 assert!(!intent.is_successful(&ctx));
865
866 ctx.add_fact(Fact {
867 key: ContextKey::Strategies,
868 id: "s1".into(),
869 content: "strategy".into(),
870 })
871 .unwrap();
872
873 assert!(intent.is_successful(&ctx));
874 }
875
876 #[test]
877 fn hard_and_soft_constraints_filtered() {
878 let intent = RootIntent::new(IntentKind::GrowthStrategy)
879 .with_constraint(IntentConstraint::hard("budget", "1M"))
880 .with_constraint(IntentConstraint::soft("brand", "friendly"))
881 .with_constraint(IntentConstraint::hard("compliance", "GDPR"));
882
883 assert_eq!(intent.hard_constraints().len(), 2);
884 assert_eq!(intent.soft_constraints().len(), 1);
885 }
886}