converge_core/
root_intent.rs

1// Copyright 2024-2025 Aprio One AB, Sweden
2// Author: Kenneth Pernyer, kenneth@aprio.one
3// SPDX-License-Identifier: LicenseRef-Proprietary
4// All rights reserved. This source code is proprietary and confidential.
5// Unauthorized copying, modification, or distribution is strictly prohibited.
6
7//! Root Intent — The constitution of a Converge job.
8//!
9//! The Root Intent is the *only* entry point into a Converge runtime.
10//! It defines:
11//! - The universe of discourse (what can be reasoned about)
12//! - What is allowed to happen (constraints)
13//! - What success means (success criteria)
14//!
15//! # Design Philosophy
16//!
17//! A Root Intent is a **typed declaration**, not a prompt.
18//! Nothing may override it during execution.
19//!
20//! # Example
21//!
22//! ```
23//! use converge_core::root_intent::{
24//!     RootIntent, IntentKind, Objective, Scope, Budgets,
25//!     ScopeConstraint, IntentConstraint, SuccessCriterion,
26//! };
27//!
28//! let intent = RootIntent::new(IntentKind::GrowthStrategy)
29//!     .with_objective(Objective::IncreaseDemand)
30//!     .with_scope(Scope::new()
31//!         .with_constraint(ScopeConstraint::Market("Nordic B2B".into()))
32//!         .with_constraint(ScopeConstraint::TimeWindow {
33//!             start: None,
34//!             end: None,
35//!             description: "Next quarter".into(),
36//!         }))
37//!     .with_constraint(IntentConstraint::hard("budget_class", "Series A"))
38//!     .with_constraint(IntentConstraint::soft("brand_safety", "Family friendly"))
39//!     .with_success_criterion(SuccessCriterion::AtLeastOneViableStrategy)
40//!     .with_budgets(Budgets::default());
41//!
42//! assert!(intent.validate().is_ok());
43//! ```
44
45use crate::context::{Context, ContextKey, Fact};
46use std::time::Duration;
47
48/// Unique identifier for a Root Intent.
49#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub struct IntentId(pub String);
51
52impl IntentId {
53    /// Creates a new intent ID.
54    #[must_use]
55    pub fn new(id: impl Into<String>) -> Self {
56        Self(id.into())
57    }
58
59    /// Generates a new unique intent ID.
60    #[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/// The class of problem being solved.
78///
79/// Used to:
80/// - Select eligible agents
81/// - Load domain constraints
82/// - Choose appropriate invariants
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84#[non_exhaustive]
85pub enum IntentKind {
86    /// Growth strategy development and market analysis.
87    GrowthStrategy,
88    /// Scheduling and resource allocation.
89    Scheduling,
90    /// Resource optimization problems.
91    ResourceOptimization,
92    /// Risk assessment and mitigation.
93    RiskAssessment,
94    /// Content generation and curation.
95    ContentGeneration,
96    /// Custom domain (extensibility point).
97    Custom,
98}
99
100impl IntentKind {
101    /// Returns the canonical name for this intent kind.
102    #[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    /// Returns suggested context keys for this intent kind.
115    #[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/// What the system is trying to improve or achieve.
153#[derive(Debug, Clone, PartialEq, Eq, Hash)]
154#[non_exhaustive]
155pub enum Objective {
156    /// Increase demand or market reach.
157    IncreaseDemand,
158    /// Minimize time to completion.
159    MinimizeTime,
160    /// Maximize feasibility of solutions.
161    MaximizeFeasibility,
162    /// Minimize cost.
163    MinimizeCost,
164    /// Maximize coverage.
165    MaximizeCoverage,
166    /// Balance multiple objectives.
167    Balance(Vec<Objective>),
168    /// Custom objective with description.
169    Custom(String),
170}
171
172impl Objective {
173    /// Returns the canonical name for this objective.
174    #[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/// A constraint on the scope of the intent.
189#[derive(Debug, Clone, PartialEq, Eq, Hash)]
190pub enum ScopeConstraint {
191    /// Market segment (e.g., "Nordic B2B").
192    Market(String),
193    /// Geographic region (e.g., "EMEA").
194    Geography(String),
195    /// Product or service category.
196    Product(String),
197    /// Time window for the analysis.
198    TimeWindow {
199        start: Option<String>,
200        end: Option<String>,
201        description: String,
202    },
203    /// Customer segment.
204    CustomerSegment(String),
205    /// Custom scope constraint.
206    Custom { key: String, value: String },
207}
208
209/// Defines what is in-bounds for the intent.
210///
211/// Nothing outside the scope may appear in context.
212#[derive(Debug, Clone, Default, PartialEq, Eq)]
213pub struct Scope {
214    /// Scope constraints that define boundaries.
215    constraints: Vec<ScopeConstraint>,
216}
217
218impl Scope {
219    /// Creates an empty scope.
220    #[must_use]
221    pub fn new() -> Self {
222        Self::default()
223    }
224
225    /// Adds a scope constraint.
226    #[must_use]
227    pub fn with_constraint(mut self, constraint: ScopeConstraint) -> Self {
228        self.constraints.push(constraint);
229        self
230    }
231
232    /// Returns the scope constraints.
233    #[must_use]
234    pub fn constraints(&self) -> &[ScopeConstraint] {
235        &self.constraints
236    }
237
238    /// Checks if the scope is defined (has at least one constraint).
239    #[must_use]
240    pub fn is_defined(&self) -> bool {
241        !self.constraints.is_empty()
242    }
243
244    /// Validates that a fact is within scope.
245    ///
246    /// Returns true if the fact is allowed by the scope constraints.
247    /// Default implementation is permissive; domain-specific validation
248    /// should be implemented via invariants.
249    #[must_use]
250    pub fn allows(&self, _fact: &Fact) -> bool {
251        // MVP: All facts are allowed. In production, this would check
252        // fact content against scope constraints (e.g., market mentions,
253        // geographic references, etc.)
254        true
255    }
256}
257
258/// Severity of an intent constraint.
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
260pub enum ConstraintSeverity {
261    /// Violation aborts convergence immediately.
262    Hard,
263    /// Violation is logged but doesn't abort.
264    Soft,
265}
266
267/// A constraint on the intent execution.
268#[derive(Debug, Clone, PartialEq, Eq, Hash)]
269pub struct IntentConstraint {
270    /// Unique key for this constraint.
271    pub key: String,
272    /// Human-readable description or value.
273    pub value: String,
274    /// Severity level.
275    pub severity: ConstraintSeverity,
276}
277
278impl IntentConstraint {
279    /// Creates a hard constraint (violation aborts).
280    #[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    /// Creates a soft constraint (violation is logged).
290    #[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/// Defines when the job is considered successful.
301#[derive(Debug, Clone, PartialEq, Eq, Hash)]
302#[non_exhaustive]
303pub enum SuccessCriterion {
304    /// At least one viable strategy must exist.
305    AtLeastOneViableStrategy,
306    /// A valid schedule must be found.
307    ValidScheduleFound,
308    /// All tasks must be allocated.
309    AllTasksAllocated,
310    /// Minimum number of strategies.
311    MinimumStrategies(usize),
312    /// All evaluations must be positive.
313    AllEvaluationsPositive,
314    /// Custom criterion with description.
315    Custom(String),
316}
317
318impl SuccessCriterion {
319    /// Checks if this criterion is satisfied by the context.
320    #[must_use]
321    pub fn is_satisfied(&self, ctx: &Context) -> bool {
322        match self {
323            Self::AtLeastOneViableStrategy => {
324                // Check for at least one strategy with a positive evaluation
325                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 no evaluations, assume strategies are viable
333                if evaluations.is_empty() {
334                    return true;
335                }
336
337                // Check if any evaluation is positive
338                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                // Check constraints are satisfied (no unallocated tasks)
349                !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                // Custom criteria need domain-specific validation
365                // Default to true; use invariants for real checks
366                true
367            }
368        }
369    }
370}
371
372/// Success criteria collection.
373#[derive(Debug, Clone, Default, PartialEq, Eq)]
374pub struct SuccessCriteria {
375    /// Required criteria (all must pass).
376    required: Vec<SuccessCriterion>,
377    /// Optional criteria (logged but don't fail).
378    optional: Vec<SuccessCriterion>,
379}
380
381impl SuccessCriteria {
382    /// Creates empty success criteria.
383    #[must_use]
384    pub fn new() -> Self {
385        Self::default()
386    }
387
388    /// Adds a required criterion.
389    #[must_use]
390    pub fn require(mut self, criterion: SuccessCriterion) -> Self {
391        self.required.push(criterion);
392        self
393    }
394
395    /// Adds an optional criterion.
396    #[must_use]
397    pub fn prefer(mut self, criterion: SuccessCriterion) -> Self {
398        self.optional.push(criterion);
399        self
400    }
401
402    /// Checks if all required criteria are satisfied.
403    #[must_use]
404    pub fn is_satisfied(&self, ctx: &Context) -> bool {
405        self.required.iter().all(|c| c.is_satisfied(ctx))
406    }
407
408    /// Returns unsatisfied required criteria.
409    #[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    /// Returns unsatisfied optional criteria.
418    #[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    /// Checks if criteria are explicitly defined.
427    #[must_use]
428    pub fn is_explicit(&self) -> bool {
429        !self.required.is_empty()
430    }
431}
432
433/// Execution budgets that guarantee termination.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct Budgets {
436    /// Maximum execution cycles.
437    pub max_cycles: u32,
438    /// Maximum agents that can run per cycle.
439    pub max_agents_per_cycle: Option<u32>,
440    /// Maximum total facts in context.
441    pub max_facts: u32,
442    /// Time limit for the entire job.
443    pub time_limit: Option<Duration>,
444    /// Maximum LLM tokens to consume.
445    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    /// Creates budgets with custom cycle limit.
462    #[must_use]
463    pub fn with_max_cycles(mut self, max: u32) -> Self {
464        self.max_cycles = max;
465        self
466    }
467
468    /// Creates budgets with custom fact limit.
469    #[must_use]
470    pub fn with_max_facts(mut self, max: u32) -> Self {
471        self.max_facts = max;
472        self
473    }
474
475    /// Sets a time limit.
476    #[must_use]
477    pub fn with_time_limit(mut self, limit: Duration) -> Self {
478        self.time_limit = Some(limit);
479        self
480    }
481
482    /// Sets a token limit.
483    #[must_use]
484    pub fn with_max_tokens(mut self, max: u64) -> Self {
485        self.max_tokens = Some(max);
486        self
487    }
488
489    /// Converts to engine Budget.
490    #[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/// Error when Root Intent validation fails.
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct IntentValidationError {
502    /// What failed validation.
503    pub field: String,
504    /// Why it failed.
505    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/// The Root Intent — constitution of a Converge job.
517///
518/// This is the *only* entry point into a Converge runtime.
519/// It defines the universe of discourse, what is allowed,
520/// and what success means.
521#[derive(Debug, Clone)]
522pub struct RootIntent {
523    /// Unique identifier.
524    pub id: IntentId,
525    /// The class of problem.
526    pub kind: IntentKind,
527    /// What the system should optimize.
528    pub objective: Option<Objective>,
529    /// What is in-bounds.
530    pub scope: Scope,
531    /// Hard and soft constraints.
532    pub constraints: Vec<IntentConstraint>,
533    /// Success criteria.
534    pub success_criteria: SuccessCriteria,
535    /// Execution budgets.
536    pub budgets: Budgets,
537}
538
539impl RootIntent {
540    /// Creates a new Root Intent with the given kind.
541    #[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    /// Sets the intent ID.
555    #[must_use]
556    pub fn with_id(mut self, id: IntentId) -> Self {
557        self.id = id;
558        self
559    }
560
561    /// Sets the objective.
562    #[must_use]
563    pub fn with_objective(mut self, objective: Objective) -> Self {
564        self.objective = Some(objective);
565        self
566    }
567
568    /// Sets the scope.
569    #[must_use]
570    pub fn with_scope(mut self, scope: Scope) -> Self {
571        self.scope = scope;
572        self
573    }
574
575    /// Adds a constraint.
576    #[must_use]
577    pub fn with_constraint(mut self, constraint: IntentConstraint) -> Self {
578        self.constraints.push(constraint);
579        self
580    }
581
582    /// Adds a success criterion (required).
583    #[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    /// Sets the success criteria.
590    #[must_use]
591    pub fn with_success_criteria(mut self, criteria: SuccessCriteria) -> Self {
592        self.success_criteria = criteria;
593        self
594    }
595
596    /// Sets the budgets.
597    #[must_use]
598    pub fn with_budgets(mut self, budgets: Budgets) -> Self {
599        self.budgets = budgets;
600        self
601    }
602
603    /// Validates the Root Intent.
604    ///
605    /// # Errors
606    ///
607    /// Returns error if:
608    /// - Scope is not defined
609    /// - Success criteria are not explicit
610    pub fn validate(&self) -> Result<(), IntentValidationError> {
611        // Scope must be defined
612        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        // Success criteria must be explicit
620        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    /// Creates initial seed facts from this intent.
631    ///
632    /// These facts are added to the context at the start of execution.
633    #[must_use]
634    pub fn to_seed_facts(&self) -> Vec<Fact> {
635        let mut facts = Vec::new();
636
637        // Intent metadata as seed
638        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        // Scope as seeds
649        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        // Hard constraints as constraint facts
668        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    /// Returns hard constraints.
682    #[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    /// Returns soft constraints.
691    #[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    /// Checks if success criteria are satisfied by the context.
700    #[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        // Should have: 1 intent fact + 2 scope facts + 1 constraint fact
826        assert_eq!(facts.len(), 4);
827
828        // Check intent fact
829        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        // Check constraint fact
834        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}