//! Constraints Lens: Pre-flight check for must_use/forbidden/prefer. //! //! This lens categorizes assertions by predicate pattern for agent constraint checking. //! Instead of picking a single winner, it groups assertions into constraint categories. //! //! # The Problem //! //! AI agents need to check constraints before taking actions: //! - "What libraries MUST I use for this project?" //! - "What tools are FORBIDDEN?" //! - "What patterns are PREFERRED?" //! //! # Predicate Patterns //! //! | Pattern | Category | Meaning | //! |---------|----------|---------| //! | `must_use:*` | must_use | Required, non-negotiable | //! | `forbidden:*` | forbidden | Explicitly banned | //! | `prefer:*` | prefer | Recommended but optional | //! //! # Example //! //! ```ignore //! // Assertions: //! // - predicate: "must_use:http_client" -> object: "axios" //! // - predicate: "forbidden:http_client" -> object: "requests" //! // - predicate: "prefer:language" -> object: "typescript" //! //! let lens = ConstraintsLens; //! let constraints = lens.resolve_constraints(&candidates); //! // constraints.must_use = [axios assertion] //! // constraints.forbidden = [requests assertion] //! // constraints.prefer = [typescript assertion] //! ``` use crate::traits::{compute_conflict_score, Lens, Resolution}; use stemedb_core::types::Assertion; use tracing::instrument; /// A set of categorized constraints from assertions. /// /// Each category contains assertions matching the corresponding predicate pattern, /// sorted by confidence (highest first). #[derive(Debug, Clone, Default)] pub struct ConstraintSet { /// Assertions with `must_use:*` predicates. Required constraints. pub must_use: Vec, /// Assertions with `forbidden:*` predicates. Banned items. pub forbidden: Vec, /// Assertions with `prefer:*` predicates. Recommendations. pub prefer: Vec, /// Total candidates considered (including non-constraint predicates). pub candidates_count: usize, /// Overall conflict score across all constraint categories. pub conflict_score: f32, } impl ConstraintSet { /// Create an empty constraint set. pub fn empty() -> Self { Self::default() } /// Check if any constraints exist. pub fn has_constraints(&self) -> bool { !self.must_use.is_empty() || !self.forbidden.is_empty() || !self.prefer.is_empty() } /// Total number of constraint assertions. pub fn total_constraints(&self) -> usize { self.must_use.len() + self.forbidden.len() + self.prefer.len() } } /// Constraints Lens: Categorizes assertions by predicate pattern. /// /// # Resolution Strategy (for Lens trait) /// /// When used as a standard `Lens`: /// 1. Returns the highest-confidence `must_use` assertion as the winner /// 2. If no `must_use`, returns highest-confidence `forbidden` /// 3. If neither, returns highest-confidence `prefer` /// 4. If no constraint predicates at all, returns empty /// /// # Rich Result /// /// For the full constraint set, use `resolve_constraints()` instead. /// /// # Predicate Patterns /// /// - `must_use:*` -> must_use category /// - `forbidden:*` -> forbidden category /// - `prefer:*` -> prefer category /// - Other predicates are ignored (not categorized) #[derive(Debug, Clone, Copy, Default)] pub struct ConstraintsLens; impl ConstraintsLens { /// Resolve constraints into a categorized set. /// /// This is the rich result method that returns all constraint categories. /// Use this when you need the full constraint picture. /// /// # Arguments /// /// * `candidates` - Assertions to categorize by predicate pattern /// /// # Returns /// /// A `ConstraintSet` with: /// - `must_use`: Assertions with `must_use:*` predicates (sorted by confidence desc) /// - `forbidden`: Assertions with `forbidden:*` predicates (sorted by confidence desc) /// - `prefer`: Assertions with `prefer:*` predicates (sorted by confidence desc) #[instrument(skip(self, candidates), fields(candidates_count = candidates.len(), lens = "Constraints"))] pub fn resolve_constraints(&self, candidates: &[Assertion]) -> ConstraintSet { if candidates.is_empty() { return ConstraintSet::empty(); } let mut must_use = Vec::new(); let mut forbidden = Vec::new(); let mut prefer = Vec::new(); // Categorize by predicate pattern for assertion in candidates { if assertion.predicate.starts_with("must_use:") { must_use.push(assertion.clone()); } else if assertion.predicate.starts_with("forbidden:") { forbidden.push(assertion.clone()); } else if assertion.predicate.starts_with("prefer:") { prefer.push(assertion.clone()); } // Other predicates are not constraint predicates, ignore them } // Sort each category by confidence (highest first), then by timestamp (newest first) let sort_by_confidence = |a: &Assertion, b: &Assertion| { b.confidence .partial_cmp(&a.confidence) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| b.timestamp.cmp(&a.timestamp)) }; must_use.sort_by(sort_by_confidence); forbidden.sort_by(sort_by_confidence); prefer.sort_by(sort_by_confidence); // Compute conflict score across all constraint assertions // Single-pass collection: avoid intermediate Vec<&Assertion> then clone let constraint_count = must_use.len() + forbidden.len() + prefer.len(); let conflict = if constraint_count <= 1 { 0.0 } else { let all_constraints: Vec = must_use.iter().chain(forbidden.iter()).chain(prefer.iter()).cloned().collect(); compute_conflict_score(&all_constraints) }; ConstraintSet { must_use, forbidden, prefer, candidates_count: candidates.len(), conflict_score: conflict, } } } impl Lens for ConstraintsLens { /// Standard Lens resolution: returns highest-priority constraint as winner. /// /// Priority: must_use > forbidden > prefer /// /// For the full constraint set, use `resolve_constraints()` instead. #[instrument(skip(self, candidates), fields(candidates_count = candidates.len(), lens = "Constraints"))] fn resolve(&self, candidates: &[Assertion]) -> Resolution { let constraints = self.resolve_constraints(candidates); if !constraints.has_constraints() { return Resolution::empty(); } // Priority: must_use > forbidden > prefer let winner = constraints .must_use .first() .or_else(|| constraints.forbidden.first()) .or_else(|| constraints.prefer.first()) .cloned(); match winner { Some(w) => { let confidence = w.confidence; Resolution::with_winner( w, constraints.total_constraints(), confidence, constraints.conflict_score, ) } None => Resolution::empty(), } } fn name(&self) -> &'static str { "Constraints" } } #[cfg(test)] mod tests { use super::*; use stemedb_core::testing::AssertionBuilder; use stemedb_core::types::ObjectValue; fn create_constraint_assertion( subject: &str, predicate: &str, object: &str, confidence: f32, timestamp: u64, ) -> Assertion { AssertionBuilder::new() .subject(subject) .predicate(predicate) .object_text(object) .confidence(confidence) .timestamp(timestamp) .build() } // ======================================================================== // resolve_constraints() Tests // ======================================================================== #[test] fn test_constraints_categorizes_by_predicate() { let lens = ConstraintsLens; let must_axios = create_constraint_assertion( "project_alpha", "must_use:http_client", "axios", 0.95, 1000, ); let forbidden_requests = create_constraint_assertion( "project_alpha", "forbidden:http_client", "requests", 0.9, 1000, ); let prefer_ts = create_constraint_assertion( "project_alpha", "prefer:language", "typescript", 0.8, 1000, ); let constraints = lens.resolve_constraints(&[ must_axios.clone(), forbidden_requests.clone(), prefer_ts.clone(), ]); assert_eq!(constraints.must_use.len(), 1); assert_eq!(constraints.forbidden.len(), 1); assert_eq!(constraints.prefer.len(), 1); assert_eq!(constraints.must_use[0].object, ObjectValue::Text("axios".to_string())); assert_eq!(constraints.forbidden[0].object, ObjectValue::Text("requests".to_string())); assert_eq!(constraints.prefer[0].object, ObjectValue::Text("typescript".to_string())); } #[test] fn test_constraints_empty_categories() { let lens = ConstraintsLens; // Only prefer, no must_use or forbidden let prefer_ts = create_constraint_assertion("project", "prefer:language", "typescript", 0.8, 1000); let constraints = lens.resolve_constraints(&[prefer_ts]); assert!(constraints.must_use.is_empty()); assert!(constraints.forbidden.is_empty()); assert_eq!(constraints.prefer.len(), 1); } #[test] fn test_constraints_non_constraint_predicates_ignored() { let lens = ConstraintsLens; // Regular predicates (not must_use/forbidden/prefer) should be ignored let regular = create_constraint_assertion("project", "uses_framework", "react", 0.9, 1000); let must_use = create_constraint_assertion("project", "must_use:testing", "jest", 0.95, 1000); let constraints = lens.resolve_constraints(&[regular, must_use]); assert_eq!(constraints.must_use.len(), 1); assert!(constraints.forbidden.is_empty()); assert!(constraints.prefer.is_empty()); assert_eq!(constraints.candidates_count, 2); // Both candidates considered } #[test] fn test_constraints_sorted_by_confidence() { let lens = ConstraintsLens; let low = create_constraint_assertion("project", "must_use:db", "sqlite", 0.5, 1000); let high = create_constraint_assertion("project", "must_use:db", "postgres", 0.95, 1000); let medium = create_constraint_assertion("project", "must_use:db", "mysql", 0.75, 1000); let constraints = lens.resolve_constraints(&[low, high, medium]); assert_eq!(constraints.must_use.len(), 3); // Should be sorted by confidence descending assert_eq!(constraints.must_use[0].object, ObjectValue::Text("postgres".to_string())); assert_eq!(constraints.must_use[1].object, ObjectValue::Text("mysql".to_string())); assert_eq!(constraints.must_use[2].object, ObjectValue::Text("sqlite".to_string())); } #[test] fn test_constraints_empty_candidates() { let lens = ConstraintsLens; let constraints = lens.resolve_constraints(&[]); assert!(!constraints.has_constraints()); assert_eq!(constraints.total_constraints(), 0); assert_eq!(constraints.candidates_count, 0); } #[test] fn test_constraints_has_constraints_true() { let lens = ConstraintsLens; let must = create_constraint_assertion("project", "must_use:formatter", "prettier", 0.9, 1000); let constraints = lens.resolve_constraints(&[must]); assert!(constraints.has_constraints()); assert_eq!(constraints.total_constraints(), 1); } #[test] fn test_constraints_all_regular_predicates() { let lens = ConstraintsLens; // No constraint predicates at all let regular1 = create_constraint_assertion("project", "uses", "react", 0.9, 1000); let regular2 = create_constraint_assertion("project", "depends_on", "webpack", 0.8, 1000); let constraints = lens.resolve_constraints(&[regular1, regular2]); assert!(!constraints.has_constraints()); assert_eq!(constraints.total_constraints(), 0); assert_eq!(constraints.candidates_count, 2); } // ======================================================================== // Lens trait Tests // ======================================================================== #[test] fn test_lens_trait_picks_must_use_winner() { let lens = ConstraintsLens; let must = create_constraint_assertion("project", "must_use:formatter", "prettier", 0.95, 1000); let forbidden = create_constraint_assertion("project", "forbidden:formatter", "tslint", 0.9, 1000); let prefer = create_constraint_assertion("project", "prefer:style", "airbnb", 0.85, 1000); let resolution = lens.resolve(&[must.clone(), forbidden, prefer]); assert!(resolution.winner.is_some()); // must_use has priority assert_eq!( resolution.winner.as_ref().map(|a| &a.predicate), Some(&"must_use:formatter".to_string()) ); } #[test] fn test_lens_trait_falls_back_to_forbidden() { let lens = ConstraintsLens; // No must_use, should pick forbidden let forbidden = create_constraint_assertion("project", "forbidden:db", "mongodb", 0.9, 1000); let prefer = create_constraint_assertion("project", "prefer:db", "postgres", 0.85, 1000); let resolution = lens.resolve(&[forbidden.clone(), prefer]); assert!(resolution.winner.is_some()); assert_eq!( resolution.winner.as_ref().map(|a| &a.predicate), Some(&"forbidden:db".to_string()) ); } #[test] fn test_lens_trait_falls_back_to_prefer() { let lens = ConstraintsLens; // Only prefer let prefer = create_constraint_assertion("project", "prefer:runtime", "bun", 0.8, 1000); let resolution = lens.resolve(std::slice::from_ref(&prefer)); assert!(resolution.winner.is_some()); assert_eq!( resolution.winner.as_ref().map(|a| &a.predicate), Some(&"prefer:runtime".to_string()) ); } #[test] fn test_lens_trait_empty_for_no_constraints() { let lens = ConstraintsLens; // No constraint predicates let regular = create_constraint_assertion("project", "uses", "react", 0.9, 1000); let resolution = lens.resolve(&[regular]); assert!(resolution.winner.is_none()); } #[test] fn test_lens_name() { let lens = ConstraintsLens; assert_eq!(lens.name(), "Constraints"); } #[test] fn test_lens_empty_candidates() { let lens = ConstraintsLens; let resolution = lens.resolve(&[]); assert!(resolution.winner.is_none()); assert_eq!(resolution.candidates_count, 0); } // ======================================================================== // Edge Case Tests // ======================================================================== #[test] fn test_multiple_must_use_picks_highest_confidence() { let lens = ConstraintsLens; let low_conf = create_constraint_assertion("project", "must_use:bundler", "webpack", 0.5, 1000); let high_conf = create_constraint_assertion("project", "must_use:bundler", "vite", 0.95, 1000); let resolution = lens.resolve(&[low_conf, high_conf]); assert!(resolution.winner.is_some()); assert_eq!( resolution.winner.as_ref().map(|a| &a.object), Some(&ObjectValue::Text("vite".to_string())) ); } #[test] fn test_confidence_tiebreaker_uses_timestamp() { let lens = ConstraintsLens; let older = create_constraint_assertion("project", "must_use:test", "jest", 0.9, 1000); let newer = create_constraint_assertion("project", "must_use:test", "vitest", 0.9, 2000); let constraints = lens.resolve_constraints(&[older, newer]); // Same confidence, newer timestamp wins (sorted first) assert_eq!(constraints.must_use[0].object, ObjectValue::Text("vitest".to_string())); } #[test] fn test_predicate_pattern_exact_prefix() { let lens = ConstraintsLens; // "must_use_something" should NOT match (not "must_use:*") let wrong_prefix = create_constraint_assertion("project", "must_use_something", "foo", 0.9, 1000); let correct = create_constraint_assertion("project", "must_use:something", "bar", 0.9, 1000); let constraints = lens.resolve_constraints(&[wrong_prefix, correct]); assert_eq!(constraints.must_use.len(), 1); assert_eq!(constraints.must_use[0].object, ObjectValue::Text("bar".to_string())); } }