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    ///
61    /// # Deprecation Notice
62    ///
63    /// This method uses timestamp + process ID + counter for uniqueness.
64    /// For cryptographically random IDs, use `converge-runtime` with a
65    /// `Randomness` implementation.
66    #[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        // Use process ID and a counter for uniqueness without rand
78        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/// The class of problem being solved.
92///
93/// Used to:
94/// - Select eligible agents
95/// - Load domain constraints
96/// - Choose appropriate invariants
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98#[non_exhaustive]
99pub enum IntentKind {
100    /// Growth strategy development and market analysis.
101    GrowthStrategy,
102    /// Scheduling and resource allocation.
103    Scheduling,
104    /// Resource optimization problems.
105    ResourceOptimization,
106    /// Risk assessment and mitigation.
107    RiskAssessment,
108    /// Content generation and curation.
109    ContentGeneration,
110    /// Custom domain (extensibility point).
111    Custom,
112}
113
114impl IntentKind {
115    /// Returns the canonical name for this intent kind.
116    #[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    /// Returns suggested context keys for this intent kind.
129    #[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/// What the system is trying to improve or achieve.
162#[derive(Debug, Clone, PartialEq, Eq, Hash)]
163#[non_exhaustive]
164pub enum Objective {
165    /// Increase demand or market reach.
166    IncreaseDemand,
167    /// Minimize time to completion.
168    MinimizeTime,
169    /// Maximize feasibility of solutions.
170    MaximizeFeasibility,
171    /// Minimize cost.
172    MinimizeCost,
173    /// Maximize coverage.
174    MaximizeCoverage,
175    /// Balance multiple objectives.
176    Balance(Vec<Objective>),
177    /// Custom objective with description.
178    Custom(String),
179}
180
181impl Objective {
182    /// Returns the canonical name for this objective.
183    #[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/// A constraint on the scope of the intent.
198#[derive(Debug, Clone, PartialEq, Eq, Hash)]
199pub enum ScopeConstraint {
200    /// Market segment (e.g., "Nordic B2B").
201    Market(String),
202    /// Geographic region (e.g., "EMEA").
203    Geography(String),
204    /// Product or service category.
205    Product(String),
206    /// Time window for the analysis.
207    TimeWindow {
208        start: Option<String>,
209        end: Option<String>,
210        description: String,
211    },
212    /// Customer segment.
213    CustomerSegment(String),
214    /// Custom scope constraint.
215    Custom { key: String, value: String },
216}
217
218/// Defines what is in-bounds for the intent.
219///
220/// Nothing outside the scope may appear in context.
221#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub struct Scope {
223    /// Scope constraints that define boundaries.
224    constraints: Vec<ScopeConstraint>,
225}
226
227impl Scope {
228    /// Creates an empty scope.
229    #[must_use]
230    pub fn new() -> Self {
231        Self::default()
232    }
233
234    /// Adds a scope constraint.
235    #[must_use]
236    pub fn with_constraint(mut self, constraint: ScopeConstraint) -> Self {
237        self.constraints.push(constraint);
238        self
239    }
240
241    /// Returns the scope constraints.
242    #[must_use]
243    pub fn constraints(&self) -> &[ScopeConstraint] {
244        &self.constraints
245    }
246
247    /// Checks if the scope is defined (has at least one constraint).
248    #[must_use]
249    pub fn is_defined(&self) -> bool {
250        !self.constraints.is_empty()
251    }
252
253    /// Validates that a fact is within scope.
254    ///
255    /// Returns true if the fact is allowed by the scope constraints.
256    /// Default implementation is permissive; domain-specific validation
257    /// should be implemented via invariants.
258    #[must_use]
259    pub fn allows(&self, _fact: &Fact) -> bool {
260        // MVP: All facts are allowed. In production, this would check
261        // fact content against scope constraints (e.g., market mentions,
262        // geographic references, etc.)
263        true
264    }
265}
266
267/// Severity of an intent constraint.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
269pub enum ConstraintSeverity {
270    /// Violation aborts convergence immediately.
271    Hard,
272    /// Violation is logged but doesn't abort.
273    Soft,
274}
275
276/// A constraint on the intent execution.
277#[derive(Debug, Clone, PartialEq, Eq, Hash)]
278pub struct IntentConstraint {
279    /// Unique key for this constraint.
280    pub key: String,
281    /// Human-readable description or value.
282    pub value: String,
283    /// Severity level.
284    pub severity: ConstraintSeverity,
285}
286
287impl IntentConstraint {
288    /// Creates a hard constraint (violation aborts).
289    #[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    /// Creates a soft constraint (violation is logged).
299    #[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/// Defines when the job is considered successful.
310#[derive(Debug, Clone, PartialEq, Eq, Hash)]
311#[non_exhaustive]
312pub enum SuccessCriterion {
313    /// At least one viable strategy must exist.
314    AtLeastOneViableStrategy,
315    /// A valid schedule must be found.
316    ValidScheduleFound,
317    /// All tasks must be allocated.
318    AllTasksAllocated,
319    /// Minimum number of strategies.
320    MinimumStrategies(usize),
321    /// All evaluations must be positive.
322    AllEvaluationsPositive,
323    /// Custom criterion with description.
324    Custom(String),
325}
326
327impl SuccessCriterion {
328    /// Checks if this criterion is satisfied by the context.
329    #[must_use]
330    pub fn is_satisfied(&self, ctx: &Context) -> bool {
331        match self {
332            Self::AtLeastOneViableStrategy => {
333                // Check for at least one strategy with a positive evaluation
334                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 no evaluations, assume strategies are viable
342                if evaluations.is_empty() {
343                    return true;
344                }
345
346                // Check if any evaluation is positive
347                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                // Check constraints are satisfied (no unallocated tasks)
356                !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                // Custom criteria need domain-specific validation
371                // Default to true; use invariants for real checks
372                true
373            }
374        }
375    }
376}
377
378/// Success criteria collection.
379#[derive(Debug, Clone, Default, PartialEq, Eq)]
380pub struct SuccessCriteria {
381    /// Required criteria (all must pass).
382    required: Vec<SuccessCriterion>,
383    /// Optional criteria (logged but don't fail).
384    optional: Vec<SuccessCriterion>,
385}
386
387impl SuccessCriteria {
388    /// Creates empty success criteria.
389    #[must_use]
390    pub fn new() -> Self {
391        Self::default()
392    }
393
394    /// Adds a required criterion.
395    #[must_use]
396    pub fn require(mut self, criterion: SuccessCriterion) -> Self {
397        self.required.push(criterion);
398        self
399    }
400
401    /// Adds an optional criterion.
402    #[must_use]
403    pub fn prefer(mut self, criterion: SuccessCriterion) -> Self {
404        self.optional.push(criterion);
405        self
406    }
407
408    /// Checks if all required criteria are satisfied.
409    #[must_use]
410    pub fn is_satisfied(&self, ctx: &Context) -> bool {
411        self.required.iter().all(|c| c.is_satisfied(ctx))
412    }
413
414    /// Returns unsatisfied required criteria.
415    #[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    /// Returns unsatisfied optional criteria.
424    #[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    /// Checks if criteria are explicitly defined.
433    #[must_use]
434    pub fn is_explicit(&self) -> bool {
435        !self.required.is_empty()
436    }
437}
438
439/// Execution budgets that guarantee termination.
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub struct Budgets {
442    /// Maximum execution cycles.
443    pub max_cycles: u32,
444    /// Maximum agents that can run per cycle.
445    pub max_agents_per_cycle: Option<u32>,
446    /// Maximum total facts in context.
447    pub max_facts: u32,
448    /// Time limit for the entire job.
449    pub time_limit: Option<Duration>,
450    /// Maximum LLM tokens to consume.
451    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    /// Creates budgets with custom cycle limit.
468    #[must_use]
469    pub fn with_max_cycles(mut self, max: u32) -> Self {
470        self.max_cycles = max;
471        self
472    }
473
474    /// Creates budgets with custom fact limit.
475    #[must_use]
476    pub fn with_max_facts(mut self, max: u32) -> Self {
477        self.max_facts = max;
478        self
479    }
480
481    /// Sets a time limit.
482    #[must_use]
483    pub fn with_time_limit(mut self, limit: Duration) -> Self {
484        self.time_limit = Some(limit);
485        self
486    }
487
488    /// Sets a token limit.
489    #[must_use]
490    pub fn with_max_tokens(mut self, max: u64) -> Self {
491        self.max_tokens = Some(max);
492        self
493    }
494
495    /// Converts to engine Budget.
496    #[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/// Error when Root Intent validation fails.
506#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct IntentValidationError {
508    /// What failed validation.
509    pub field: String,
510    /// Why it failed.
511    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/// The Root Intent — constitution of a Converge job.
523///
524/// This is the *only* entry point into a Converge runtime.
525/// It defines the universe of discourse, what is allowed,
526/// and what success means.
527#[derive(Debug, Clone)]
528pub struct RootIntent {
529    /// Unique identifier.
530    pub id: IntentId,
531    /// The class of problem.
532    pub kind: IntentKind,
533    /// What the system should optimize.
534    pub objective: Option<Objective>,
535    /// What is in-bounds.
536    pub scope: Scope,
537    /// Hard and soft constraints.
538    pub constraints: Vec<IntentConstraint>,
539    /// Success criteria.
540    pub success_criteria: SuccessCriteria,
541    /// Execution budgets.
542    pub budgets: Budgets,
543}
544
545impl RootIntent {
546    /// Creates a new Root Intent with the given kind.
547    #[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    /// Sets the intent ID.
562    #[must_use]
563    pub fn with_id(mut self, id: IntentId) -> Self {
564        self.id = id;
565        self
566    }
567
568    /// Sets the objective.
569    #[must_use]
570    pub fn with_objective(mut self, objective: Objective) -> Self {
571        self.objective = Some(objective);
572        self
573    }
574
575    /// Sets the scope.
576    #[must_use]
577    pub fn with_scope(mut self, scope: Scope) -> Self {
578        self.scope = scope;
579        self
580    }
581
582    /// Adds a constraint.
583    #[must_use]
584    pub fn with_constraint(mut self, constraint: IntentConstraint) -> Self {
585        self.constraints.push(constraint);
586        self
587    }
588
589    /// Adds a success criterion (required).
590    #[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    /// Sets the success criteria.
597    #[must_use]
598    pub fn with_success_criteria(mut self, criteria: SuccessCriteria) -> Self {
599        self.success_criteria = criteria;
600        self
601    }
602
603    /// Sets the budgets.
604    #[must_use]
605    pub fn with_budgets(mut self, budgets: Budgets) -> Self {
606        self.budgets = budgets;
607        self
608    }
609
610    /// Validates the Root Intent.
611    ///
612    /// # Errors
613    ///
614    /// Returns error if:
615    /// - Scope is not defined
616    /// - Success criteria are not explicit
617    pub fn validate(&self) -> Result<(), IntentValidationError> {
618        // Scope must be defined
619        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        // Success criteria must be explicit
627        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    /// Creates initial seed facts from this intent.
638    ///
639    /// These facts are added to the context at the start of execution.
640    #[must_use]
641    pub fn to_seed_facts(&self) -> Vec<Fact> {
642        let mut facts = Vec::new();
643
644        // Intent metadata as seed
645        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        // Scope as seeds
658        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        // Hard constraints as constraint facts
677        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    /// Returns hard constraints.
691    #[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    /// Returns soft constraints.
700    #[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    /// Checks if success criteria are satisfied by the context.
709    #[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        // Should have: 1 intent fact + 2 scope facts + 1 constraint fact
834        assert_eq!(facts.len(), 4);
835
836        // Check intent fact
837        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        // Check constraint fact
842        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}