use std::collections::HashMap;
use crate::field::FieldMeaning;
use crate::intent::{Intent, IntentHint, IntentScore};
use crate::relationship::{Cardinality, NavigationHint};
use crate::render::is_system_field;
use crate::service::ServiceDef;
const SIGNAL_MONEY_FIELDS: &str = "money_fields";
const SIGNAL_PERCENTAGE_FIELDS: &str = "percentage_fields";
const SIGNAL_QUANTITY_FIELDS: &str = "quantity_fields";
const SIGNAL_FREE_TEXT_FIELDS: &str = "free_text_fields";
const SIGNAL_IMAGE_URL_FIELDS: &str = "image_url_fields";
const SIGNAL_URL_FIELDS: &str = "url_fields";
const SIGNAL_ENTITY_NAME: &str = "entity_name";
const SIGNAL_DATETIME_NUMERIC: &str = "datetime_numeric_cooccurrence";
const SIGNAL_STATUS: &str = "status_field";
const SIGNAL_CATEGORY: &str = "category_field";
const SIGNAL_HIGH_WRITABLE_RATIO: &str = "high_writable_ratio";
const SIGNAL_WRITE_ONLY_FIELDS: &str = "write_only_fields";
const SIGNAL_MOSTLY_READ_ONLY: &str = "mostly_read_only";
const SIGNAL_MORE_READABLE: &str = "more_readable_than_writable";
const SIGNAL_BASELINE: &str = "baseline";
const SIGNAL_NO_STRUCTURAL: &str = "no_structural_signals";
const SIGNAL_HINT_PRIMARY: &str = "intent_hint_primary";
const SIGNAL_GUARDED_TRANSITIONS: &str = "guarded_transitions";
const SIGNAL_BRANCHING_STATES: &str = "branching_states";
const SIGNAL_TRANSITION_TRIGGERS: &str = "transition_triggers";
const SIGNAL_WORKFLOW_STATES: &str = "workflow_states";
const SIGNAL_LINEAR_STATES: &str = "linear_states";
const SIGNAL_HAS_FINAL_STATES: &str = "has_final_states";
const SIGNAL_UNGUARDED_PROGRESSION: &str = "unguarded_progression";
const SIGNAL_COLLECTION_RELATIONSHIPS: &str = "collection_relationships";
const SIGNAL_INLINE_RELATIONSHIPS: &str = "inline_relationships";
const SIGNAL_PARENT_REFERENCES: &str = "parent_references";
const SIGNAL_RICH_RELATIONSHIP_GRAPH: &str = "rich_relationship_graph";
const SIGNAL_WORKFLOW_ACTIONS: &str = "workflow_actions";
const SIGNAL_COMPLEX_INPUT_ACTIONS: &str = "complex_input_actions";
const SIGNAL_GUARDED_ACTIONS: &str = "guarded_actions";
const SIGNAL_SIMPLE_CRUD_ACTIONS: &str = "simple_crud_actions";
const BASELINE_BROWSE: f64 = 0.1;
const BASELINE_FOCUS: f64 = 0.1;
type Signal = (Intent, f64, String);
pub fn derive_intents(service: &ServiceDef) -> Vec<IntentScore> {
let mut all_signals = Vec::new();
all_signals.extend(analyze_field_meanings(service));
all_signals.extend(analyze_writability(service));
all_signals.extend(analyze_state_machine(service));
all_signals.extend(analyze_relationships(service));
all_signals.extend(analyze_actions(service));
let mut raw = aggregate_signals(all_signals);
raw.entry(Intent::Browse).or_insert((0.0, Vec::new())).0 += BASELINE_BROWSE;
raw.get_mut(&Intent::Browse)
.unwrap()
.1
.push(SIGNAL_BASELINE.to_string());
raw.entry(Intent::Focus).or_insert((0.0, Vec::new())).0 += BASELINE_FOCUS;
raw.get_mut(&Intent::Focus)
.unwrap()
.1
.push(SIGNAL_BASELINE.to_string());
let mut scores = normalize_scores(raw);
apply_hints(&mut scores, &service.intent_hints);
if scores.is_empty() {
scores.push(IntentScore {
intent: Intent::Focus,
confidence: 0.5,
matching_signals: vec![SIGNAL_NO_STRUCTURAL.to_string()],
});
}
scores
}
fn analyze_field_meanings(service: &ServiceDef) -> Vec<Signal> {
let mut signals = Vec::new();
let domain_fields: Vec<_> = service
.fields
.iter()
.filter(|f| !is_system_field(&f.meaning))
.collect();
let mut money_count = 0u32;
let mut percentage_count = 0u32;
let mut quantity_count = 0u32;
let mut free_text_count = 0u32;
let mut image_url_count = 0u32;
let mut url_count = 0u32;
let mut entity_name_count = 0u32;
let mut status_count = 0u32;
let mut category_count = 0u32;
let mut has_datetime = false;
let mut has_numeric = false;
for field in &domain_fields {
match &field.meaning {
FieldMeaning::Money => {
money_count += 1;
has_numeric = true;
}
FieldMeaning::Percentage => {
percentage_count += 1;
has_numeric = true;
}
FieldMeaning::Quantity => {
quantity_count += 1;
has_numeric = true;
}
FieldMeaning::FreeText => free_text_count += 1,
FieldMeaning::ImageUrl => image_url_count += 1,
FieldMeaning::Url => url_count += 1,
FieldMeaning::EntityName => entity_name_count += 1,
FieldMeaning::Status => status_count += 1,
FieldMeaning::Category => category_count += 1,
FieldMeaning::DateTime => has_datetime = true,
_ => {}
}
}
let summarize_count = money_count + percentage_count + quantity_count;
if summarize_count > 0 {
signals.push((
Intent::Summarize,
0.3 * f64::from(summarize_count),
format_signal_with_sources(&[
(SIGNAL_MONEY_FIELDS, money_count),
(SIGNAL_PERCENTAGE_FIELDS, percentage_count),
(SIGNAL_QUANTITY_FIELDS, quantity_count),
]),
));
}
let focus_count = free_text_count + image_url_count + url_count;
if focus_count > 0 {
signals.push((
Intent::Focus,
0.25 * f64::from(focus_count),
format_signal_with_sources(&[
(SIGNAL_FREE_TEXT_FIELDS, free_text_count),
(SIGNAL_IMAGE_URL_FIELDS, image_url_count),
(SIGNAL_URL_FIELDS, url_count),
]),
));
}
if entity_name_count > 0 {
signals.push((
Intent::Browse,
0.2 * f64::from(entity_name_count),
SIGNAL_ENTITY_NAME.to_string(),
));
}
if has_datetime && has_numeric {
signals.push((Intent::Analyze, 0.35, SIGNAL_DATETIME_NUMERIC.to_string()));
}
if status_count > 0 {
signals.push((Intent::Track, 0.25, SIGNAL_STATUS.to_string()));
}
if category_count > 0 {
signals.push((
Intent::Browse,
0.1 * f64::from(category_count),
SIGNAL_CATEGORY.to_string(),
));
}
signals
}
fn format_signal_with_sources(sources: &[(&str, u32)]) -> String {
let active: Vec<&str> = sources
.iter()
.filter(|(_, count)| *count > 0)
.map(|(name, _)| *name)
.collect();
active.join("+")
}
fn analyze_writability(service: &ServiceDef) -> Vec<Signal> {
let mut signals = Vec::new();
let non_system: Vec<_> = service
.fields
.iter()
.filter(|f| !is_system_field(&f.meaning))
.collect();
if non_system.is_empty() {
return signals;
}
let total = non_system.len() as f64;
let writable_count = non_system.iter().filter(|f| f.writable).count();
let non_writable_count = non_system.len() - writable_count;
let write_only_count = non_system
.iter()
.filter(|f| !f.readable && f.writable)
.count();
let writable_ratio = writable_count as f64 / total;
let non_writable_ratio = non_writable_count as f64 / total;
if writable_ratio > 0.5 {
signals.push((
Intent::Collect,
0.35,
SIGNAL_HIGH_WRITABLE_RATIO.to_string(),
));
}
if write_only_count > 0 {
signals.push((
Intent::Collect,
0.2 * write_only_count as f64,
SIGNAL_WRITE_ONLY_FIELDS.to_string(),
));
}
if non_writable_ratio > 0.7 {
signals.push((Intent::Summarize, 0.2, SIGNAL_MOSTLY_READ_ONLY.to_string()));
}
let readable_count = non_system.iter().filter(|f| f.readable).count();
if readable_count > writable_count {
signals.push((Intent::Focus, 0.1, SIGNAL_MORE_READABLE.to_string()));
}
signals
}
fn analyze_state_machine(service: &ServiceDef) -> Vec<Signal> {
let mut signals = Vec::new();
let sm = match &service.state_machine {
Some(sm) => sm,
None => return signals,
};
let total_transitions = sm.transitions.len();
if total_transitions == 0 {
return signals;
}
let guarded_count = sm.transitions.iter().filter(|t| t.guard.is_some()).count();
if guarded_count > 0 {
let ratio = guarded_count as f64 / total_transitions as f64;
signals.push((
Intent::Process,
0.4 * ratio,
format!("{guarded_count}/{total_transitions}_{SIGNAL_GUARDED_TRANSITIONS}"),
));
}
let mut outgoing_counts: HashMap<&str, usize> = HashMap::new();
for t in &sm.transitions {
*outgoing_counts.entry(t.from.as_str()).or_default() += 1;
}
let branching_states = outgoing_counts.values().filter(|&&c| c > 1).count();
if branching_states > 0 {
signals.push((
Intent::Process,
0.15,
format!("{branching_states}_{SIGNAL_BRANCHING_STATES}"),
));
}
let trigger_count = service
.actions
.iter()
.filter(|a| a.transition_trigger.is_some())
.count();
let total_actions = service.actions.len();
if trigger_count > 0 && total_actions > 0 {
signals.push((
Intent::Process,
0.25 * (trigger_count as f64 / total_actions as f64),
format!("{trigger_count}_{SIGNAL_TRANSITION_TRIGGERS}"),
));
}
let non_final_count = sm.states.iter().filter(|s| !s.is_final).count();
if non_final_count > 2 {
signals.push((
Intent::Process,
0.10,
format!("{non_final_count}_{SIGNAL_WORKFLOW_STATES}"),
));
}
if non_final_count > 2 && branching_states == 0 {
signals.push((
Intent::Track,
0.3,
format!("{non_final_count}_{SIGNAL_LINEAR_STATES}"),
));
}
if sm.states.iter().any(|s| s.is_final) {
signals.push((Intent::Track, 0.1, SIGNAL_HAS_FINAL_STATES.to_string()));
}
if guarded_count == 0 {
signals.push((Intent::Track, 0.1, SIGNAL_UNGUARDED_PROGRESSION.to_string()));
}
signals
}
fn analyze_relationships(service: &ServiceDef) -> Vec<Signal> {
let mut signals = Vec::new();
if service.relationships.is_empty() {
return signals;
}
let collection_count = service
.relationships
.iter()
.filter(|r| {
matches!(
r.cardinality,
Cardinality::OneToMany | Cardinality::ManyToMany
)
})
.count();
if collection_count > 0 {
signals.push((
Intent::Browse,
0.35 * collection_count as f64,
format!("{collection_count}_{SIGNAL_COLLECTION_RELATIONSHIPS}"),
));
}
let inline_count = service
.relationships
.iter()
.filter(|r| {
r.cardinality == Cardinality::OneToOne && r.navigation == NavigationHint::Inline
})
.count();
if inline_count > 0 {
signals.push((
Intent::Focus,
0.15 * inline_count as f64,
format!("{inline_count}_{SIGNAL_INLINE_RELATIONSHIPS}"),
));
}
let parent_count = service
.relationships
.iter()
.filter(|r| r.cardinality == Cardinality::ManyToOne)
.count();
if parent_count > 0 {
signals.push((
Intent::Focus,
0.1 * parent_count as f64,
format!("{parent_count}_{SIGNAL_PARENT_REFERENCES}"),
));
}
if service.relationships.len() > 3 {
signals.push((
Intent::Browse,
0.1,
SIGNAL_RICH_RELATIONSHIP_GRAPH.to_string(),
));
}
signals
}
fn analyze_actions(service: &ServiceDef) -> Vec<Signal> {
let mut signals = Vec::new();
if service.actions.is_empty() {
return signals;
}
let workflow_count = service
.actions
.iter()
.filter(|a| a.transition_trigger.is_some())
.count();
if workflow_count > 0 {
signals.push((
Intent::Process,
0.15 * workflow_count as f64,
format!("{workflow_count}_{SIGNAL_WORKFLOW_ACTIONS}"),
));
}
let complex_input_count = service
.actions
.iter()
.filter(|a| a.inputs.len() > 2)
.count();
if complex_input_count > 0 {
signals.push((
Intent::Collect,
0.15 * complex_input_count as f64,
format!("{complex_input_count}_{SIGNAL_COMPLEX_INPUT_ACTIONS}"),
));
}
let guarded_action_count = service
.actions
.iter()
.filter(|a| !a.preconditions.is_empty())
.count();
if guarded_action_count > 0 {
signals.push((
Intent::Process,
0.1 * guarded_action_count as f64,
format!("{guarded_action_count}_{SIGNAL_GUARDED_ACTIONS}"),
));
}
if workflow_count == 0 && guarded_action_count == 0 {
signals.push((Intent::Browse, 0.05, SIGNAL_SIMPLE_CRUD_ACTIONS.to_string()));
}
signals
}
fn aggregate_signals(all_signals: Vec<Signal>) -> HashMap<Intent, (f64, Vec<String>)> {
let mut map: HashMap<Intent, (f64, Vec<String>)> = HashMap::new();
for (intent, weight, signal) in all_signals {
let entry = map.entry(intent).or_insert((0.0, Vec::new()));
entry.0 += weight;
entry.1.push(signal);
}
map
}
fn normalize_scores(raw: HashMap<Intent, (f64, Vec<String>)>) -> Vec<IntentScore> {
let max_score = raw.values().map(|(w, _)| *w).fold(0.0_f64, f64::max);
if max_score <= 0.0 {
return Vec::new();
}
let mut scores: Vec<IntentScore> = raw
.into_iter()
.map(|(intent, (weight, signals))| IntentScore {
confidence: weight / max_score,
intent,
matching_signals: signals,
})
.collect();
scores.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| intent_priority(&a.intent).cmp(&intent_priority(&b.intent)))
});
scores
}
fn intent_priority(intent: &Intent) -> u8 {
match intent {
Intent::Process => 0,
Intent::Track => 1,
Intent::Collect => 2,
Intent::Browse => 3,
Intent::Focus => 4,
Intent::Summarize => 5,
Intent::Analyze => 6,
Intent::Custom(_) => 7,
}
}
fn apply_hints(scores: &mut Vec<IntentScore>, hints: &[IntentHint]) {
for hint in hints {
match hint {
IntentHint::Exclude(intent) => {
scores.retain(|s| s.intent != *intent);
}
IntentHint::Primary(intent) => {
scores.retain(|s| s.intent != *intent);
scores.insert(
0,
IntentScore {
intent: intent.clone(),
confidence: 1.0,
matching_signals: vec![SIGNAL_HINT_PRIMARY.to_string()],
},
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::action::ActionDef;
use crate::field::DataType;
fn find_intent<'a>(scores: &'a [IntentScore], intent: &Intent) -> Option<&'a IntentScore> {
scores.iter().find(|s| &s.intent == intent)
}
fn has_signal(score: &IntentScore, signal: &str) -> bool {
score.matching_signals.iter().any(|s| s.contains(signal))
}
#[test]
fn field_meaning_money_percentage_quantity_produce_summarize() {
let service = ServiceDef::new("financials")
.field("total", DataType::Float, FieldMeaning::Money)
.field("margin", DataType::Float, FieldMeaning::Percentage)
.field("qty", DataType::Integer, FieldMeaning::Quantity);
let signals = analyze_field_meanings(&service);
let summarize: Vec<_> = signals
.iter()
.filter(|s| s.0 == Intent::Summarize)
.collect();
assert!(!summarize.is_empty(), "Summarize signal must be present");
let total_weight: f64 = summarize.iter().map(|s| s.1).sum();
assert!(total_weight > 0.0, "Summarize weight must be positive");
assert!(
(total_weight - 0.9).abs() < f64::EPSILON,
"expected 0.9, got {total_weight}"
);
}
#[test]
fn field_meaning_free_text_image_url_produce_focus() {
let service = ServiceDef::new("content")
.field("body", DataType::String, FieldMeaning::FreeText)
.field("photo", DataType::String, FieldMeaning::ImageUrl);
let signals = analyze_field_meanings(&service);
let focus: Vec<_> = signals.iter().filter(|s| s.0 == Intent::Focus).collect();
assert!(!focus.is_empty(), "Focus signal must be present");
let total_weight: f64 = focus.iter().map(|s| s.1).sum();
assert!(
(total_weight - 0.5).abs() < f64::EPSILON,
"expected 0.5, got {total_weight}"
);
}
#[test]
fn field_meaning_entity_name_category_produce_browse() {
let service = ServiceDef::new("catalog")
.field("name", DataType::String, FieldMeaning::EntityName)
.field("type", DataType::String, FieldMeaning::Category);
let signals = analyze_field_meanings(&service);
let browse: Vec<_> = signals.iter().filter(|s| s.0 == Intent::Browse).collect();
assert!(!browse.is_empty(), "Browse signal must be present");
let total_weight: f64 = browse.iter().map(|s| s.1).sum();
assert!(
(total_weight - 0.3).abs() < f64::EPSILON,
"expected 0.3, got {total_weight}"
);
}
#[test]
fn field_meaning_datetime_money_produce_analyze() {
let service = ServiceDef::new("timeseries")
.field("recorded_at", DataType::DateTime, FieldMeaning::DateTime)
.field("revenue", DataType::Float, FieldMeaning::Money);
let signals = analyze_field_meanings(&service);
let analyze: Vec<_> = signals.iter().filter(|s| s.0 == Intent::Analyze).collect();
assert!(!analyze.is_empty(), "Analyze signal must be present");
assert!(
(analyze[0].1 - 0.35).abs() < f64::EPSILON,
"Analyze weight should be 0.35"
);
}
#[test]
fn field_meaning_status_produces_track() {
let service =
ServiceDef::new("orders").field("status", DataType::String, FieldMeaning::Status);
let signals = analyze_field_meanings(&service);
let track: Vec<_> = signals.iter().filter(|s| s.0 == Intent::Track).collect();
assert!(!track.is_empty(), "Track signal must be present");
assert!(
(track[0].1 - 0.25).abs() < f64::EPSILON,
"Track weight should be 0.25"
);
}
#[test]
fn field_meaning_system_fields_excluded() {
let service = ServiceDef::new("system_only")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
.field("updated_at", DataType::DateTime, FieldMeaning::UpdatedAt);
let signals = analyze_field_meanings(&service);
assert!(
signals.is_empty(),
"System-only fields should produce no field meaning signals"
);
}
#[test]
fn writability_high_writable_ratio_produces_collect() {
let service = ServiceDef::new("form")
.field("name", DataType::String, FieldMeaning::EntityName)
.field("email", DataType::String, FieldMeaning::Email)
.field("phone", DataType::String, FieldMeaning::Phone)
.read_only_field("score", DataType::Float, FieldMeaning::Quantity);
let signals = analyze_writability(&service);
let collect: Vec<_> = signals.iter().filter(|s| s.0 == Intent::Collect).collect();
assert!(
!collect.is_empty(),
"Collect signal must be present for high writable ratio"
);
assert!(collect.iter().any(|s| s.2 == SIGNAL_HIGH_WRITABLE_RATIO));
}
#[test]
fn writability_write_only_fields_produce_collect() {
let service = ServiceDef::new("auth")
.field("username", DataType::String, FieldMeaning::EntityName)
.write_only_field("password", DataType::String, FieldMeaning::Sensitive)
.write_only_field("confirm", DataType::String, FieldMeaning::Sensitive);
let signals = analyze_writability(&service);
let wo_collect: Vec<_> = signals
.iter()
.filter(|s| s.0 == Intent::Collect && s.2 == SIGNAL_WRITE_ONLY_FIELDS)
.collect();
assert!(
!wo_collect.is_empty(),
"Write-only Collect signal must be present"
);
assert!(
(wo_collect[0].1 - 0.4).abs() < f64::EPSILON,
"expected 0.4, got {}",
wo_collect[0].1
);
}
#[test]
fn writability_mostly_read_only_produces_summarize() {
let service = ServiceDef::new("dashboard")
.read_only_field("total", DataType::Float, FieldMeaning::Money)
.read_only_field("count", DataType::Integer, FieldMeaning::Quantity)
.read_only_field("rate", DataType::Float, FieldMeaning::Percentage)
.field("notes", DataType::String, FieldMeaning::FreeText);
let signals = analyze_writability(&service);
let summarize: Vec<_> = signals
.iter()
.filter(|s| s.0 == Intent::Summarize && s.2 == SIGNAL_MOSTLY_READ_ONLY)
.collect();
assert!(
!summarize.is_empty(),
"Summarize signal must be present for mostly read-only"
);
}
#[test]
fn writability_balanced_no_strong_collect() {
let service = ServiceDef::new("balanced")
.field("a", DataType::String, FieldMeaning::FreeText)
.field("b", DataType::String, FieldMeaning::FreeText)
.read_only_field("c", DataType::Integer, FieldMeaning::Quantity)
.read_only_field("d", DataType::Integer, FieldMeaning::Quantity);
let signals = analyze_writability(&service);
let high_writable: Vec<_> = signals
.iter()
.filter(|s| s.2 == SIGNAL_HIGH_WRITABLE_RATIO)
.collect();
assert!(
high_writable.is_empty(),
"50/50 should not trigger high_writable_ratio (needs >50%)"
);
}
#[test]
fn normalizer_highest_score_becomes_1() {
let mut raw = HashMap::new();
raw.insert(Intent::Browse, (0.8, vec!["signal_a".to_string()]));
raw.insert(Intent::Focus, (0.4, vec!["signal_b".to_string()]));
let scores = normalize_scores(raw);
let browse = find_intent(&scores, &Intent::Browse).unwrap();
assert!(
(browse.confidence - 1.0).abs() < f64::EPSILON,
"Highest score should normalize to 1.0"
);
}
#[test]
fn normalizer_second_highest_proportional() {
let mut raw = HashMap::new();
raw.insert(Intent::Browse, (0.8, vec!["a".to_string()]));
raw.insert(Intent::Focus, (0.4, vec!["b".to_string()]));
let scores = normalize_scores(raw);
let focus = find_intent(&scores, &Intent::Focus).unwrap();
assert!(
(focus.confidence - 0.5).abs() < f64::EPSILON,
"0.4/0.8 = 0.5, got {}",
focus.confidence
);
}
#[test]
fn normalizer_empty_input_returns_empty() {
let raw: HashMap<Intent, (f64, Vec<String>)> = HashMap::new();
let scores = normalize_scores(raw);
assert!(scores.is_empty());
}
#[test]
fn normalizer_single_intent_gets_confidence_1() {
let mut raw = HashMap::new();
raw.insert(Intent::Track, (0.5, vec!["status".to_string()]));
let scores = normalize_scores(raw);
assert_eq!(scores.len(), 1);
assert!(
(scores[0].confidence - 1.0).abs() < f64::EPSILON,
"Single intent should get confidence 1.0"
);
}
#[test]
fn normalizer_sorted_descending() {
let mut raw = HashMap::new();
raw.insert(Intent::Browse, (0.2, vec!["a".to_string()]));
raw.insert(Intent::Focus, (0.6, vec!["b".to_string()]));
raw.insert(Intent::Track, (0.4, vec!["c".to_string()]));
let scores = normalize_scores(raw);
for i in 1..scores.len() {
assert!(
scores[i - 1].confidence >= scores[i].confidence,
"Scores must be sorted descending: {} >= {}",
scores[i - 1].confidence,
scores[i].confidence
);
}
}
#[test]
fn hint_primary_forces_position_0_confidence_1() {
let mut scores = vec![
IntentScore {
intent: Intent::Summarize,
confidence: 1.0,
matching_signals: vec!["existing".to_string()],
},
IntentScore {
intent: Intent::Browse,
confidence: 0.5,
matching_signals: vec!["existing".to_string()],
},
];
apply_hints(&mut scores, &[IntentHint::Primary(Intent::Browse)]);
assert_eq!(scores[0].intent, Intent::Browse);
assert!((scores[0].confidence - 1.0).abs() < f64::EPSILON);
assert!(has_signal(&scores[0], SIGNAL_HINT_PRIMARY));
assert_eq!(
scores.iter().filter(|s| s.intent == Intent::Browse).count(),
1,
"Browse should appear exactly once"
);
}
#[test]
fn hint_exclude_removes_intent() {
let mut scores = vec![
IntentScore {
intent: Intent::Process,
confidence: 1.0,
matching_signals: vec!["a".to_string()],
},
IntentScore {
intent: Intent::Browse,
confidence: 0.5,
matching_signals: vec!["b".to_string()],
},
];
apply_hints(&mut scores, &[IntentHint::Exclude(Intent::Process)]);
assert!(
find_intent(&scores, &Intent::Process).is_none(),
"Process should be excluded"
);
assert_eq!(scores.len(), 1);
}
#[test]
fn hint_primary_and_exclude_together() {
let mut scores = vec![
IntentScore {
intent: Intent::Process,
confidence: 1.0,
matching_signals: vec!["a".to_string()],
},
IntentScore {
intent: Intent::Browse,
confidence: 0.8,
matching_signals: vec!["b".to_string()],
},
IntentScore {
intent: Intent::Focus,
confidence: 0.5,
matching_signals: vec!["c".to_string()],
},
];
apply_hints(
&mut scores,
&[
IntentHint::Exclude(Intent::Process),
IntentHint::Primary(Intent::Focus),
],
);
assert!(find_intent(&scores, &Intent::Process).is_none());
assert_eq!(scores[0].intent, Intent::Focus);
assert!((scores[0].confidence - 1.0).abs() < f64::EPSILON);
}
#[test]
fn hint_on_empty_scores_primary_adds_exclude_noop() {
let mut scores: Vec<IntentScore> = Vec::new();
apply_hints(&mut scores, &[IntentHint::Exclude(Intent::Process)]);
assert!(scores.is_empty(), "Exclude on empty is no-op");
apply_hints(&mut scores, &[IntentHint::Primary(Intent::Browse)]);
assert_eq!(scores.len(), 1);
assert_eq!(scores[0].intent, Intent::Browse);
assert!((scores[0].confidence - 1.0).abs() < f64::EPSILON);
}
#[test]
fn empty_service_returns_focus_default() {
let service = ServiceDef::new("empty");
let scores = derive_intents(&service);
assert!(!scores.is_empty(), "Must return at least one score");
for s in &scores {
assert!(s.confidence >= 0.0 && s.confidence <= 1.0);
}
}
#[test]
fn service_only_system_fields_returns_baseline_scores() {
let service = ServiceDef::new("system")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
.field("updated_at", DataType::DateTime, FieldMeaning::UpdatedAt);
let scores = derive_intents(&service);
assert!(!scores.is_empty());
assert_eq!(scores[0].intent, Intent::Browse);
assert_eq!(scores[1].intent, Intent::Focus);
assert!((scores[0].confidence - 1.0).abs() < f64::EPSILON);
assert!((scores[1].confidence - 1.0).abs() < f64::EPSILON);
}
#[test]
fn integration_money_entity_name_produces_ranked_scores() {
let service = ServiceDef::new("invoice")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("total", DataType::Float, FieldMeaning::Money)
.field("tax", DataType::Float, FieldMeaning::Percentage)
.field("created_at", DataType::DateTime, FieldMeaning::CreatedAt);
let scores = derive_intents(&service);
assert!(scores.len() >= 2, "Should have multiple intent scores");
for s in &scores {
assert!(
s.confidence >= 0.0 && s.confidence <= 1.0,
"Confidence {} out of range for {:?}",
s.confidence,
s.intent
);
}
for i in 1..scores.len() {
assert!(
scores[i - 1].confidence >= scores[i].confidence,
"Scores must be sorted descending"
);
}
assert!(
find_intent(&scores, &Intent::Summarize).is_some(),
"Summarize should be present from Money+Percentage fields"
);
assert!(
find_intent(&scores, &Intent::Browse).is_some(),
"Browse should be present from EntityName + baseline"
);
}
#[test]
fn integration_derive_intents_with_hints() {
let service = ServiceDef::new("invoice")
.field("total", DataType::Float, FieldMeaning::Money)
.field("name", DataType::String, FieldMeaning::EntityName)
.intent_hint(IntentHint::Primary(Intent::Collect))
.intent_hint(IntentHint::Exclude(Intent::Summarize));
let scores = derive_intents(&service);
assert_eq!(scores[0].intent, Intent::Collect);
assert!((scores[0].confidence - 1.0).abs() < f64::EPSILON);
assert!(
find_intent(&scores, &Intent::Summarize).is_none(),
"Summarize should be excluded by hint"
);
}
#[test]
fn integration_all_confidences_normalized() {
let service = ServiceDef::new("complex")
.field("name", DataType::String, FieldMeaning::EntityName)
.field("description", DataType::String, FieldMeaning::FreeText)
.field("photo", DataType::String, FieldMeaning::ImageUrl)
.field("price", DataType::Float, FieldMeaning::Money)
.field("status", DataType::String, FieldMeaning::Status)
.field("category", DataType::String, FieldMeaning::Category)
.field("recorded_at", DataType::DateTime, FieldMeaning::DateTime);
let scores = derive_intents(&service);
assert!(
(scores[0].confidence - 1.0).abs() < f64::EPSILON,
"Top score should be 1.0, got {}",
scores[0].confidence
);
for s in &scores {
assert!(
!s.matching_signals.is_empty(),
"{:?} has no matching signals",
s.intent
);
}
}
#[test]
fn state_machine_order_workflow_produces_process() {
use crate::state::{StateDef, StateMachine, Transition};
let service = ServiceDef::new("order")
.state_machine(
StateMachine::new("order_lifecycle")
.initial("draft")
.state(StateDef::new("draft"))
.state(StateDef::new("pending"))
.state(StateDef::new("approved"))
.state(StateDef::new("completed").final_state())
.state(StateDef::new("cancelled").final_state())
.transition(
Transition::new("draft", "submit", "pending").guard("has_required_fields"),
)
.transition(
Transition::new("pending", "approve", "approved").guard("is_reviewer"),
)
.transition(Transition::new("approved", "complete", "completed"))
.transition(Transition::new("draft", "cancel", "cancelled")),
)
.action(
ActionDef::new("submit")
.transition_trigger("submit")
.precondition("has_required_fields"),
);
let signals = analyze_state_machine(&service);
let process_signals: Vec<_> = signals.iter().filter(|s| s.0 == Intent::Process).collect();
let track_signals: Vec<_> = signals.iter().filter(|s| s.0 == Intent::Track).collect();
assert!(
!process_signals.is_empty(),
"Process signals must be present for guarded branching workflow"
);
let process_weight: f64 = process_signals.iter().map(|s| s.1).sum();
let track_weight: f64 = track_signals.iter().map(|s| s.1).sum();
assert!(
process_weight > track_weight,
"Process ({process_weight}) should outweigh Track ({track_weight}) for branching workflow"
);
}
#[test]
fn state_machine_shipment_tracking_produces_track() {
use crate::state::{StateDef, StateMachine, Transition};
let service = ServiceDef::new("shipment").state_machine(
StateMachine::new("shipment_tracking")
.initial("created")
.state(StateDef::new("created"))
.state(StateDef::new("picked_up"))
.state(StateDef::new("in_transit"))
.state(StateDef::new("delivered").final_state())
.transition(Transition::new("created", "pick_up", "picked_up"))
.transition(Transition::new("picked_up", "depart", "in_transit"))
.transition(Transition::new("in_transit", "deliver", "delivered")),
);
let signals = analyze_state_machine(&service);
let track_signals: Vec<_> = signals.iter().filter(|s| s.0 == Intent::Track).collect();
let process_signals: Vec<_> = signals.iter().filter(|s| s.0 == Intent::Process).collect();
assert!(
!track_signals.is_empty(),
"Track signals must be present for linear progression"
);
let track_weight: f64 = track_signals.iter().map(|s| s.1).sum();
let process_weight: f64 = process_signals.iter().map(|s| s.1).sum();
assert!(
track_weight > process_weight,
"Track ({track_weight}) should outweigh Process ({process_weight}) for linear tracking"
);
}
#[test]
fn state_machine_none_returns_empty() {
let service = ServiceDef::new("bare");
let signals = analyze_state_machine(&service);
assert!(
signals.is_empty(),
"No state machine should produce no signals"
);
}
#[test]
fn state_machine_trivial_produces_weak_track() {
use crate::state::{StateDef, StateMachine, Transition};
let service = ServiceDef::new("toggle").state_machine(
StateMachine::new("toggle")
.initial("off")
.state(StateDef::new("off"))
.state(StateDef::new("on").final_state())
.transition(Transition::new("off", "activate", "on")),
);
let signals = analyze_state_machine(&service);
let track_signals: Vec<_> = signals.iter().filter(|s| s.0 == Intent::Track).collect();
assert!(
!track_signals.is_empty(),
"Trivial state machine should produce some Track signals"
);
let linear: Vec<_> = track_signals
.iter()
.filter(|s| s.2.contains(SIGNAL_LINEAR_STATES))
.collect();
assert!(
linear.is_empty(),
"Trivial machine should not have linear_states (only 1 non-final)"
);
}
#[test]
fn relationship_has_many_produces_browse() {
let service = ServiceDef::new("category")
.has_many("products", "product")
.has_many("subcategories", "category");
let signals = analyze_relationships(&service);
let browse: Vec<_> = signals
.iter()
.filter(|s| s.0 == Intent::Browse && s.2.contains(SIGNAL_COLLECTION_RELATIONSHIPS))
.collect();
assert!(
!browse.is_empty(),
"has_many relationships should produce Browse collection signal"
);
assert!(
(browse[0].1 - 0.7).abs() < f64::EPSILON,
"expected 0.7, got {}",
browse[0].1
);
}
#[test]
fn relationship_one_to_one_inline_produces_focus() {
let service = ServiceDef::new("user").has_one("profile", "profile");
let signals = analyze_relationships(&service);
let focus: Vec<_> = signals
.iter()
.filter(|s| s.0 == Intent::Focus && s.2.contains(SIGNAL_INLINE_RELATIONSHIPS))
.collect();
assert!(
!focus.is_empty(),
"OneToOne with Inline navigation should produce Focus signal"
);
assert!(
(focus[0].1 - 0.15).abs() < f64::EPSILON,
"expected 0.15, got {}",
focus[0].1
);
}
#[test]
fn relationship_many_to_one_produces_focus_parent() {
let service = ServiceDef::new("order").belongs_to("customer", "customer");
let signals = analyze_relationships(&service);
let focus: Vec<_> = signals
.iter()
.filter(|s| s.0 == Intent::Focus && s.2.contains(SIGNAL_PARENT_REFERENCES))
.collect();
assert!(
!focus.is_empty(),
"ManyToOne should produce Focus parent_references signal"
);
assert!(
(focus[0].1 - 0.1).abs() < f64::EPSILON,
"expected 0.1, got {}",
focus[0].1
);
}
#[test]
fn relationship_rich_graph_produces_browse() {
let service = ServiceDef::new("order")
.belongs_to("customer", "customer")
.has_many("line_items", "line_item")
.has_many("payments", "payment")
.belongs_to("warehouse", "warehouse");
let signals = analyze_relationships(&service);
let rich: Vec<_> = signals
.iter()
.filter(|s| s.2.contains(SIGNAL_RICH_RELATIONSHIP_GRAPH))
.collect();
assert!(
!rich.is_empty(),
"4+ relationships should produce rich_relationship_graph signal"
);
}
#[test]
fn relationship_none_returns_empty() {
let service = ServiceDef::new("bare");
let signals = analyze_relationships(&service);
assert!(
signals.is_empty(),
"No relationships should produce no signals"
);
}
#[test]
fn action_transition_trigger_produces_process() {
let service = ServiceDef::new("order")
.action(ActionDef::new("submit").transition_trigger("submit"))
.action(ActionDef::new("approve").transition_trigger("approve"));
let signals = analyze_actions(&service);
let workflow: Vec<_> = signals
.iter()
.filter(|s| s.0 == Intent::Process && s.2.contains(SIGNAL_WORKFLOW_ACTIONS))
.collect();
assert!(
!workflow.is_empty(),
"Actions with transition_trigger should produce workflow_actions signal"
);
assert!(
(workflow[0].1 - 0.3).abs() < f64::EPSILON,
"expected 0.3, got {}",
workflow[0].1
);
}
#[test]
fn action_complex_inputs_produces_collect() {
use crate::action::InputDef;
let service = ServiceDef::new("registration").action(
ActionDef::new("register")
.input(InputDef::new(
"name",
DataType::String,
FieldMeaning::EntityName,
))
.input(InputDef::new(
"email",
DataType::String,
FieldMeaning::Email,
))
.input(InputDef::new(
"phone",
DataType::String,
FieldMeaning::Phone,
)),
);
let signals = analyze_actions(&service);
let collect: Vec<_> = signals
.iter()
.filter(|s| s.0 == Intent::Collect && s.2.contains(SIGNAL_COMPLEX_INPUT_ACTIONS))
.collect();
assert!(
!collect.is_empty(),
"Actions with >2 inputs should produce complex_input_actions signal"
);
assert!(
(collect[0].1 - 0.15).abs() < f64::EPSILON,
"expected 0.15, got {}",
collect[0].1
);
}
#[test]
fn action_preconditions_produces_process() {
let service = ServiceDef::new("order")
.action(
ActionDef::new("submit")
.precondition("has_items")
.precondition("payment_valid"),
)
.action(ActionDef::new("cancel").precondition("is_cancellable"));
let signals = analyze_actions(&service);
let guarded: Vec<_> = signals
.iter()
.filter(|s| s.0 == Intent::Process && s.2.contains(SIGNAL_GUARDED_ACTIONS))
.collect();
assert!(
!guarded.is_empty(),
"Actions with preconditions should produce guarded_actions signal"
);
assert!(
(guarded[0].1 - 0.2).abs() < f64::EPSILON,
"expected 0.2, got {}",
guarded[0].1
);
}
#[test]
fn action_simple_crud_produces_browse() {
let service = ServiceDef::new("product")
.action(ActionDef::new("create"))
.action(ActionDef::new("update"))
.action(ActionDef::new("delete"));
let signals = analyze_actions(&service);
let simple: Vec<_> = signals
.iter()
.filter(|s| s.0 == Intent::Browse && s.2.contains(SIGNAL_SIMPLE_CRUD_ACTIONS))
.collect();
assert!(
!simple.is_empty(),
"Simple CRUD actions should produce simple_crud_actions signal"
);
}
#[test]
fn action_none_returns_empty() {
let service = ServiceDef::new("bare");
let signals = analyze_actions(&service);
assert!(signals.is_empty(), "No actions should produce no signals");
}
#[test]
fn integration_order_management_all_analyzers_produce_process() {
use crate::action::InputDef;
use crate::state::{StateDef, StateMachine, Transition};
let service = ServiceDef::new("order")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("total", DataType::Float, FieldMeaning::Money)
.field("tax", DataType::Float, FieldMeaning::Money)
.field("status", DataType::String, FieldMeaning::Status)
.field("notes", DataType::String, FieldMeaning::FreeText)
.state_machine(
StateMachine::new("order_lifecycle")
.initial("draft")
.state(StateDef::new("draft"))
.state(StateDef::new("pending"))
.state(StateDef::new("approved"))
.state(StateDef::new("completed").final_state())
.state(StateDef::new("cancelled").final_state())
.transition(Transition::new("draft", "submit", "pending").guard("has_items"))
.transition(
Transition::new("pending", "approve", "approved").guard("is_reviewer"),
)
.transition(Transition::new("approved", "complete", "completed"))
.transition(Transition::new("draft", "cancel", "cancelled"))
.transition(
Transition::new("pending", "cancel", "cancelled")
.guard("cancellation_allowed"),
),
)
.action(
ActionDef::new("submit_order")
.transition_trigger("submit")
.precondition("has_items")
.input(InputDef::new(
"order_id",
DataType::Integer,
FieldMeaning::Identifier,
))
.input(InputDef::new(
"notes",
DataType::String,
FieldMeaning::FreeText,
))
.input(InputDef::new(
"priority",
DataType::String,
FieldMeaning::Category,
)),
)
.action(
ActionDef::new("approve_order")
.transition_trigger("approve")
.precondition("is_reviewer"),
)
.action(ActionDef::new("cancel_order").transition_trigger("cancel"))
.has_many("line_items", "order_line_item")
.belongs_to("customer", "customer");
let scores = derive_intents(&service);
assert_eq!(
scores[0].intent,
Intent::Process,
"Order management with state machine + guards + transition triggers should derive Process as primary. Got {:?} (full: {:?})",
scores[0].intent,
scores.iter().map(|s| (&s.intent, s.confidence)).collect::<Vec<_>>()
);
for i in 0..scores.len() {
assert!(
scores[i].confidence >= 0.0 && scores[i].confidence <= 1.0,
"Confidence {} out of range for {:?}",
scores[i].confidence,
scores[i].intent
);
if i > 0 {
assert!(
scores[i - 1].confidence >= scores[i].confidence,
"Scores must be sorted descending"
);
}
}
assert!(
scores.len() >= 3,
"Multiple analyzers should contribute multiple intents, got {}",
scores.len()
);
for s in &scores {
assert!(
!s.matching_signals.is_empty(),
"{:?} has no matching signals",
s.intent
);
}
}
#[test]
fn is_system_field_identifies_system_meanings() {
assert!(is_system_field(&FieldMeaning::Identifier));
assert!(is_system_field(&FieldMeaning::CreatedAt));
assert!(is_system_field(&FieldMeaning::UpdatedAt));
}
#[test]
fn is_system_field_rejects_domain_meanings() {
assert!(!is_system_field(&FieldMeaning::Money));
assert!(!is_system_field(&FieldMeaning::EntityName));
assert!(!is_system_field(&FieldMeaning::FreeText));
assert!(!is_system_field(&FieldMeaning::Status));
assert!(!is_system_field(&FieldMeaning::Custom("x".into())));
}
#[test]
fn intent_priority_ordering() {
assert!(intent_priority(&Intent::Process) < intent_priority(&Intent::Track));
assert!(intent_priority(&Intent::Track) < intent_priority(&Intent::Collect));
assert!(intent_priority(&Intent::Collect) < intent_priority(&Intent::Browse));
assert!(intent_priority(&Intent::Browse) < intent_priority(&Intent::Focus));
assert!(intent_priority(&Intent::Focus) < intent_priority(&Intent::Summarize));
assert!(intent_priority(&Intent::Summarize) < intent_priority(&Intent::Analyze));
assert!(intent_priority(&Intent::Analyze) < intent_priority(&Intent::Custom("x".into())));
}
mod validation {
use super::*;
use crate::action::InputDef;
use crate::state::{StateDef, StateMachine, Transition};
fn assert_primary_intent(service: &ServiceDef, expected: Intent) {
let scores = derive_intents(service);
assert!(
!scores.is_empty(),
"derive_intents returned empty for '{}'",
service.name
);
assert_eq!(
scores[0].intent, expected,
"Expected primary intent {:?} for '{}', got {:?} (confidence: {:.2}). Signals: {:?}",
expected,
service.name,
scores[0].intent,
scores[0].confidence,
scores[0].matching_signals
);
}
fn order_management() -> (ServiceDef, Intent) {
let service = ServiceDef::new("order_management")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("total", DataType::Float, FieldMeaning::Money)
.field("status", DataType::String, FieldMeaning::Status)
.field("notes", DataType::String, FieldMeaning::FreeText)
.state_machine(
StateMachine::new("order_lifecycle")
.initial("draft")
.state(StateDef::new("draft"))
.state(StateDef::new("submitted"))
.state(StateDef::new("approved"))
.state(StateDef::new("completed").final_state())
.transition(
Transition::new("draft", "submit", "submitted").guard("has_items"),
)
.transition(
Transition::new("submitted", "approve", "approved").guard("is_manager"),
)
.transition(Transition::new("approved", "complete", "completed")),
)
.guard(crate::action::GuardDef::new("has_items"))
.guard(crate::action::GuardDef::new("is_manager"))
.action(
ActionDef::new("submit")
.transition_trigger("submit")
.precondition("has_items"),
)
.action(
ActionDef::new("approve")
.transition_trigger("approve")
.precondition("is_manager"),
);
(service, Intent::Process)
}
#[test]
fn validation_01_order_management_process() {
let (service, expected) = order_management();
assert_primary_intent(&service, expected);
}
fn product_catalog() -> (ServiceDef, Intent) {
let service = ServiceDef::new("product_catalog")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("price", DataType::Float, FieldMeaning::Money)
.field("category", DataType::String, FieldMeaning::Category)
.has_many("reviews", "review");
(service, Intent::Browse)
}
#[test]
fn validation_02_product_catalog_browse() {
let (service, expected) = product_catalog();
assert_primary_intent(&service, expected);
}
fn blog_post() -> (ServiceDef, Intent) {
let service = ServiceDef::new("blog_post")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("body", DataType::String, FieldMeaning::FreeText)
.field("featured_image", DataType::String, FieldMeaning::ImageUrl)
.field("canonical_url", DataType::String, FieldMeaning::Url)
.belongs_to("author", "user");
(service, Intent::Focus)
}
#[test]
fn validation_03_blog_post_focus() {
let (service, expected) = blog_post();
assert_primary_intent(&service, expected);
}
fn user_registration() -> (ServiceDef, Intent) {
let service = ServiceDef::new("user_registration")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("full_name", DataType::String, FieldMeaning::EntityName)
.field("email", DataType::String, FieldMeaning::Email)
.write_only_field("password", DataType::String, FieldMeaning::Sensitive)
.field("phone", DataType::String, FieldMeaning::Phone)
.field("bio", DataType::String, FieldMeaning::FreeText);
(service, Intent::Collect)
}
#[test]
fn validation_04_user_registration_collect() {
let (service, expected) = user_registration();
assert_primary_intent(&service, expected);
}
fn revenue_report() -> (ServiceDef, Intent) {
let service = ServiceDef::new("revenue_report")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.read_only_field("total_revenue", DataType::Float, FieldMeaning::Money)
.read_only_field("margin", DataType::Float, FieldMeaning::Percentage)
.read_only_field("units_sold", DataType::Integer, FieldMeaning::Quantity)
.read_only_field("growth_rate", DataType::Float, FieldMeaning::Percentage);
(service, Intent::Summarize)
}
#[test]
fn validation_05_revenue_report_summarize() {
let (service, expected) = revenue_report();
assert_primary_intent(&service, expected);
}
fn sales_analytics() -> (ServiceDef, Intent) {
let service = ServiceDef::new("sales_analytics")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.read_only_field("order_date", DataType::DateTime, FieldMeaning::DateTime)
.read_only_field("units", DataType::Integer, FieldMeaning::Quantity)
.field("region", DataType::String, FieldMeaning::Category);
(service, Intent::Analyze)
}
#[test]
fn validation_06_sales_analytics_analyze() {
let (service, expected) = sales_analytics();
assert_primary_intent(&service, expected);
}
fn shipment_tracking() -> (ServiceDef, Intent) {
let service = ServiceDef::new("shipment_tracking")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("status", DataType::String, FieldMeaning::Status)
.read_only_field("shipped_at", DataType::DateTime, FieldMeaning::DateTime)
.read_only_field("delivered_at", DataType::DateTime, FieldMeaning::DateTime)
.state_machine(
StateMachine::new("shipment_lifecycle")
.initial("pending")
.state(StateDef::new("pending"))
.state(StateDef::new("shipped"))
.state(StateDef::new("in_transit"))
.state(StateDef::new("delivered").final_state())
.transition(Transition::new("pending", "ship", "shipped"))
.transition(Transition::new("shipped", "depart", "in_transit"))
.transition(Transition::new("in_transit", "deliver", "delivered")),
);
(service, Intent::Track)
}
#[test]
fn validation_07_shipment_tracking_track() {
let (service, expected) = shipment_tracking();
assert_primary_intent(&service, expected);
}
fn comment_thread() -> (ServiceDef, Intent) {
let service = ServiceDef::new("comment_thread")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("content", DataType::String, FieldMeaning::FreeText)
.has_many("replies", "comment")
.belongs_to("author", "user");
(service, Intent::Browse)
}
#[test]
fn validation_08_comment_thread_browse() {
let (service, expected) = comment_thread();
assert_primary_intent(&service, expected);
}
fn support_ticket() -> (ServiceDef, Intent) {
let service = ServiceDef::new("support_ticket")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("status", DataType::String, FieldMeaning::Status)
.field("description", DataType::String, FieldMeaning::FreeText)
.state_machine(
StateMachine::new("ticket_lifecycle")
.initial("open")
.state(StateDef::new("open"))
.state(StateDef::new("in_progress"))
.state(StateDef::new("waiting"))
.state(StateDef::new("resolved"))
.state(StateDef::new("closed").final_state())
.transition(
Transition::new("open", "assign", "in_progress").guard("is_assigned"),
)
.transition(Transition::new("in_progress", "wait", "waiting"))
.transition(
Transition::new("waiting", "respond", "in_progress")
.guard("customer_responded"),
)
.transition(Transition::new("in_progress", "resolve", "resolved"))
.transition(Transition::new("resolved", "close", "closed")),
)
.guard(crate::action::GuardDef::new("is_assigned"))
.guard(crate::action::GuardDef::new("customer_responded"))
.action(
ActionDef::new("assign")
.transition_trigger("assign")
.precondition("is_assigned"),
)
.action(ActionDef::new("resolve").transition_trigger("resolve"))
.action(ActionDef::new("close").transition_trigger("close"));
(service, Intent::Process)
}
#[test]
fn validation_09_support_ticket_process() {
let (service, expected) = support_ticket();
assert_primary_intent(&service, expected);
}
fn user_profile() -> (ServiceDef, Intent) {
let service = ServiceDef::new("user_profile")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.read_only_field("avatar", DataType::String, FieldMeaning::ImageUrl)
.read_only_field("bio", DataType::String, FieldMeaning::FreeText)
.read_only_field("website", DataType::String, FieldMeaning::Url)
.has_one("address", "address");
(service, Intent::Focus)
}
#[test]
fn validation_10_user_profile_focus() {
let (service, expected) = user_profile();
assert_primary_intent(&service, expected);
}
fn survey_form() -> (ServiceDef, Intent) {
let service = ServiceDef::new("survey_form")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("full_name", DataType::String, FieldMeaning::EntityName)
.field("email", DataType::String, FieldMeaning::Email)
.field("phone", DataType::String, FieldMeaning::Phone)
.field("consent", DataType::Boolean, FieldMeaning::Boolean)
.write_only_field(
"answer_1",
DataType::String,
FieldMeaning::Custom("answer".into()),
)
.write_only_field(
"answer_2",
DataType::String,
FieldMeaning::Custom("answer".into()),
)
.action(
ActionDef::new("submit_survey")
.input(InputDef::new(
"full_name",
DataType::String,
FieldMeaning::EntityName,
))
.input(InputDef::new(
"email",
DataType::String,
FieldMeaning::Email,
))
.input(InputDef::new(
"consent",
DataType::Boolean,
FieldMeaning::Boolean,
)),
);
(service, Intent::Collect)
}
#[test]
fn validation_11_survey_form_collect() {
let (service, expected) = survey_form();
assert_primary_intent(&service, expected);
}
fn activity_log() -> (ServiceDef, Intent) {
let service = ServiceDef::new("activity_log")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.read_only_field("event_type", DataType::String, FieldMeaning::Status)
.read_only_field("occurred_at", DataType::DateTime, FieldMeaning::DateTime)
.read_only_field("source", DataType::String, FieldMeaning::Category);
(service, Intent::Track)
}
#[test]
fn validation_12_activity_log_track() {
let (service, expected) = activity_log();
assert_primary_intent(&service, expected);
}
#[test]
fn validation_accuracy_check() {
let fixtures: Vec<(ServiceDef, Intent)> = vec![
order_management(),
product_catalog(),
blog_post(),
user_registration(),
revenue_report(),
sales_analytics(),
shipment_tracking(),
comment_thread(),
support_ticket(),
user_profile(),
survey_form(),
activity_log(),
];
let total = fixtures.len();
let mut correct = 0;
let mut incorrect: Vec<String> = Vec::new();
for (service, expected) in &fixtures {
let scores = derive_intents(service);
if !scores.is_empty() && scores[0].intent == *expected {
correct += 1;
} else {
let actual = if scores.is_empty() {
"EMPTY".to_string()
} else {
format!("{:?} ({:.2})", scores[0].intent, scores[0].confidence)
};
incorrect.push(format!(
" '{}': expected {:?}, got {}",
service.name, expected, actual
));
}
}
let accuracy = correct as f64 / total as f64;
eprintln!(
"\n=== Intent Derivation Accuracy: {correct}/{total} ({:.0}%) ===",
accuracy * 100.0
);
if !incorrect.is_empty() {
eprintln!("Misclassified:");
for line in &incorrect {
eprintln!("{line}");
}
}
assert!(
correct * 100 >= total * 70,
"Accuracy {correct}/{total} ({:.0}%) is below 70% threshold. Misclassified:\n{}",
accuracy * 100.0,
incorrect.join("\n")
);
}
}
#[test]
fn edge_empty_service_def_returns_scores() {
let service = ServiceDef::new("empty");
let scores = derive_intents(&service);
assert!(
!scores.is_empty(),
"Empty ServiceDef must return at least one score"
);
assert_eq!(scores[0].intent, Intent::Browse);
assert!(scores[0].confidence >= 0.0 && scores[0].confidence <= 1.0);
}
#[test]
fn edge_minimal_single_identifier() {
let service =
ServiceDef::new("minimal").field("id", DataType::Integer, FieldMeaning::Identifier);
let scores = derive_intents(&service);
assert!(!scores.is_empty());
assert!(
scores[0].intent == Intent::Browse || scores[0].intent == Intent::Focus,
"Minimal service should default to Browse or Focus baseline, got {:?}",
scores[0].intent
);
}
#[test]
fn edge_maximal_all_field_types() {
use crate::action::InputDef;
use crate::state::{StateDef, StateMachine, Transition};
let service = ServiceDef::new("maximal")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("body", DataType::String, FieldMeaning::FreeText)
.field("photo", DataType::String, FieldMeaning::ImageUrl)
.field("link", DataType::String, FieldMeaning::Url)
.field("total", DataType::Float, FieldMeaning::Money)
.field("margin", DataType::Float, FieldMeaning::Percentage)
.field("qty", DataType::Integer, FieldMeaning::Quantity)
.field("status", DataType::String, FieldMeaning::Status)
.field("category", DataType::String, FieldMeaning::Category)
.field("date", DataType::DateTime, FieldMeaning::DateTime)
.field("email", DataType::String, FieldMeaning::Email)
.field("phone", DataType::String, FieldMeaning::Phone)
.field("toggle", DataType::Boolean, FieldMeaning::Boolean)
.write_only_field("secret", DataType::String, FieldMeaning::Sensitive)
.state_machine(
StateMachine::new("lifecycle")
.initial("a")
.state(StateDef::new("a"))
.state(StateDef::new("b"))
.state(StateDef::new("c"))
.state(StateDef::new("d").final_state())
.transition(Transition::new("a", "go_b", "b").guard("check"))
.transition(Transition::new("b", "go_c", "c"))
.transition(Transition::new("a", "go_c", "c"))
.transition(Transition::new("c", "go_d", "d")),
)
.guard(crate::action::GuardDef::new("check"))
.action(
ActionDef::new("do_thing")
.transition_trigger("go_b")
.precondition("check")
.input(InputDef::new("x", DataType::String, FieldMeaning::FreeText))
.input(InputDef::new("y", DataType::String, FieldMeaning::FreeText))
.input(InputDef::new("z", DataType::String, FieldMeaning::FreeText)),
)
.has_many("children", "child")
.belongs_to("parent", "parent_type")
.has_one("profile", "profile_type");
let scores = derive_intents(&service);
assert!(!scores.is_empty());
assert!(
(scores[0].confidence - 1.0).abs() < f64::EPSILON,
"Maximal service top score should be 1.0"
);
assert!(
scores.len() >= 4,
"Maximal service should produce 4+ intents, got {}",
scores.len()
);
}
#[test]
fn edge_ambiguous_does_not_panic() {
let service = ServiceDef::new("ambiguous")
.field("name", DataType::String, FieldMeaning::EntityName)
.field("body", DataType::String, FieldMeaning::FreeText)
.field("total", DataType::Float, FieldMeaning::Money)
.has_many("items", "item");
let scores = derive_intents(&service);
assert!(!scores.is_empty(), "Ambiguous service must not panic");
if scores.len() >= 2 {
assert!(
scores[1].confidence >= 0.3,
"In ambiguous case, second intent should have reasonable confidence (>= 0.3), got {:.2}",
scores[1].confidence
);
}
}
#[test]
fn edge_hint_primary_overrides_structural() {
let service = ServiceDef::new("hint_override")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.has_many("items", "item")
.intent_hint(IntentHint::Primary(Intent::Process));
let scores = derive_intents(&service);
assert_eq!(scores[0].intent, Intent::Process);
assert!((scores[0].confidence - 1.0).abs() < f64::EPSILON);
}
#[test]
fn edge_hint_exclude_removes_primary() {
use crate::state::{StateDef, StateMachine, Transition};
let service = ServiceDef::new("exclude_process")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("status", DataType::String, FieldMeaning::Status)
.state_machine(
StateMachine::new("sm")
.initial("a")
.state(StateDef::new("a"))
.state(StateDef::new("b"))
.state(StateDef::new("c"))
.state(StateDef::new("d").final_state())
.transition(Transition::new("a", "go", "b").guard("g"))
.transition(Transition::new("b", "next", "c"))
.transition(Transition::new("a", "skip", "c"))
.transition(Transition::new("c", "finish", "d")),
)
.guard(crate::action::GuardDef::new("g"))
.action(
ActionDef::new("go_action")
.transition_trigger("go")
.precondition("g"),
)
.intent_hint(IntentHint::Exclude(Intent::Process));
let scores = derive_intents(&service);
assert!(
find_intent(&scores, &Intent::Process).is_none(),
"Process must be excluded by hint"
);
assert!(!scores.is_empty());
assert_ne!(scores[0].intent, Intent::Process);
}
#[test]
fn edge_multiple_hints_primary_and_exclude() {
let service = ServiceDef::new("multi_hint")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.has_many("items", "item")
.intent_hint(IntentHint::Primary(Intent::Collect))
.intent_hint(IntentHint::Exclude(Intent::Browse));
let scores = derive_intents(&service);
assert_eq!(
scores[0].intent,
Intent::Collect,
"Primary(Collect) should be at top"
);
assert!(
(scores[0].confidence - 1.0).abs() < f64::EPSILON,
"Primary should have 1.0 confidence"
);
assert!(
find_intent(&scores, &Intent::Browse).is_none(),
"Browse must be excluded"
);
}
#[test]
fn edge_all_confidences_in_valid_range() {
use crate::action::InputDef;
use crate::state::{StateDef, StateMachine, Transition};
let fixtures: Vec<ServiceDef> = vec![
ServiceDef::new("order")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("total", DataType::Float, FieldMeaning::Money)
.field("status", DataType::String, FieldMeaning::Status)
.state_machine(
StateMachine::new("sm")
.initial("a")
.state(StateDef::new("a"))
.state(StateDef::new("b"))
.state(StateDef::new("c").final_state())
.transition(Transition::new("a", "go", "b").guard("g"))
.transition(Transition::new("b", "done", "c")),
)
.guard(crate::action::GuardDef::new("g"))
.action(
ActionDef::new("go")
.transition_trigger("go")
.precondition("g"),
),
ServiceDef::new("product")
.field("name", DataType::String, FieldMeaning::EntityName)
.field("price", DataType::Float, FieldMeaning::Money)
.has_many("reviews", "review"),
ServiceDef::new("blog")
.field("body", DataType::String, FieldMeaning::FreeText)
.field("photo", DataType::String, FieldMeaning::ImageUrl),
ServiceDef::new("reg")
.field("name", DataType::String, FieldMeaning::EntityName)
.field("email", DataType::String, FieldMeaning::Email)
.write_only_field("pw", DataType::String, FieldMeaning::Sensitive),
ServiceDef::new("empty"),
ServiceDef::new("max")
.field("name", DataType::String, FieldMeaning::EntityName)
.field("body", DataType::String, FieldMeaning::FreeText)
.field("total", DataType::Float, FieldMeaning::Money)
.field("status", DataType::String, FieldMeaning::Status)
.field("date", DataType::DateTime, FieldMeaning::DateTime)
.has_many("items", "item")
.action(
ActionDef::new("submit")
.input(InputDef::new("a", DataType::String, FieldMeaning::FreeText))
.input(InputDef::new("b", DataType::String, FieldMeaning::FreeText))
.input(InputDef::new("c", DataType::String, FieldMeaning::FreeText)),
),
];
for service in &fixtures {
let scores = derive_intents(service);
for s in &scores {
assert!(
s.confidence >= 0.0 && s.confidence <= 1.0,
"Confidence {:.4} out of [0.0, 1.0] for {:?} in '{}'",
s.confidence,
s.intent,
service.name
);
}
}
}
}