stemedb/crates/stemedb-lens/src/constraints.rs
jordan 55349845d0 refactor: Split all files to enforce 500-line max
Break monolith source files into focused modules:
- stemedb-core/types.rs → types/ directory (assertion, source, gold_standard, etc.)
- stemedb-storage: audit_store, quota_store, trust_rank_store, vector_index, vote_store → module directories
- stemedb-ingest/worker.rs → worker/ with separate test modules
- stemedb-query: engine, materializer, query → module directories
- stemedb-lens: epoch_aware, skeptic → module directories
- stemedb-sim/lib.rs → agent, arenas/, helpers, runner, strategy, types
- stemedb-api/tests: integration_tests → http_basic, http_validation, http_epoch, http_pipeline
- stemedb-api/tests: e2e_flow_test → e2e_full_pipeline, e2e_lens_resolution
- stemedb-query/tests: e2e_pipeline → e2e_pipeline + e2e_decay

Also adds new features: gold standard verification, escalation handlers,
admin endpoints, concept hierarchy spec, arena roadmap, and Go SDK.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 01:13:45 -07:00

500 lines
17 KiB
Rust

//! 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<Assertion>,
/// Assertions with `forbidden:*` predicates. Banned items.
pub forbidden: Vec<Assertion>,
/// Assertions with `prefer:*` predicates. Recommendations.
pub prefer: Vec<Assertion>,
/// 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<Assertion> =
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()));
}
}