use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use terraphim_automata::matcher::find_matches;
use terraphim_types::{Concept, NormalizedTerm, NormalizedTermValue, Thesaurus};
use tracing::{debug, warn};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExitClass {
Success,
EmptySuccess,
Timeout,
RateLimit,
CompilationError,
TestFailure,
ModelError,
NetworkError,
ResourceExhaustion,
PermissionDenied,
Crash,
ConfigError,
Unknown,
}
impl fmt::Display for ExitClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ExitClass::Success => write!(f, "success"),
ExitClass::EmptySuccess => write!(f, "empty_success"),
ExitClass::Timeout => write!(f, "timeout"),
ExitClass::RateLimit => write!(f, "rate_limit"),
ExitClass::CompilationError => write!(f, "compilation_error"),
ExitClass::TestFailure => write!(f, "test_failure"),
ExitClass::ModelError => write!(f, "model_error"),
ExitClass::NetworkError => write!(f, "network_error"),
ExitClass::ResourceExhaustion => write!(f, "resource_exhaustion"),
ExitClass::PermissionDenied => write!(f, "permission_denied"),
ExitClass::Crash => write!(f, "crash"),
ExitClass::ConfigError => write!(f, "config_error"),
ExitClass::Unknown => write!(f, "unknown"),
}
}
}
impl ExitClass {
fn from_concept_name(name: &str) -> Option<Self> {
match name {
"timeout" => Some(ExitClass::Timeout),
"ratelimit" => Some(ExitClass::RateLimit),
"compilationerror" => Some(ExitClass::CompilationError),
"testfailure" => Some(ExitClass::TestFailure),
"modelerror" => Some(ExitClass::ModelError),
"networkerror" => Some(ExitClass::NetworkError),
"resourceexhaustion" => Some(ExitClass::ResourceExhaustion),
"permissiondenied" => Some(ExitClass::PermissionDenied),
"crash" => Some(ExitClass::Crash),
"configerror" => Some(ExitClass::ConfigError),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunTrigger {
Cron,
Mention,
Flow,
Manual,
}
impl fmt::Display for RunTrigger {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RunTrigger::Cron => write!(f, "cron"),
RunTrigger::Mention => write!(f, "mention"),
RunTrigger::Flow => write!(f, "flow"),
RunTrigger::Manual => write!(f, "manual"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRunRecord {
pub run_id: Uuid,
pub agent_name: String,
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
pub exit_code: Option<i32>,
pub exit_class: ExitClass,
pub model_used: Option<String>,
pub was_fallback: bool,
pub wall_time_secs: f64,
pub output_summary: String,
pub error_summary: String,
pub trigger: RunTrigger,
pub matched_patterns: Vec<String>,
pub confidence: f64,
pub mention_chain_id: Option<String>,
pub mention_depth: Option<u32>,
pub mention_parent_agent: Option<String>,
#[serde(default)]
pub consecutive_config_errors: u32,
}
impl AgentRunRecord {
pub fn should_quarantine(&self) -> bool {
self.consecutive_config_errors >= 3
}
fn truncate(text: &str, max_len: usize) -> String {
if text.len() <= max_len {
text.to_string()
} else {
let mut boundary = max_len;
while boundary > 0 && !text.is_char_boundary(boundary) {
boundary -= 1;
}
format!("{}...", &text[..boundary])
}
}
pub fn summarise_output(lines: &[String]) -> String {
let combined = lines.join("\n");
Self::truncate(&combined, 500)
}
pub fn summarise_errors(lines: &[String]) -> String {
let combined = lines.join("\n");
Self::truncate(&combined, 500)
}
}
pub struct ExitClassifier {
thesaurus: Thesaurus,
}
struct PatternDef {
concept_name: &'static str,
patterns: &'static [&'static str],
}
const EXIT_CLASS_PATTERNS: &[PatternDef] = &[
PatternDef {
concept_name: "timeout",
patterns: &[
"timed out",
"deadline exceeded",
"wall-clock kill",
"context deadline exceeded",
"operation timed out",
"execution expired",
],
},
PatternDef {
concept_name: "ratelimit",
patterns: &[
"429",
"rate limit",
"too many requests",
"quota exceeded",
"rate_limit_exceeded",
"throttled",
"hit your limit",
"you've hit your limit",
"sonnet limit",
"opus limit",
"plan limit",
"tier limit",
"usage cap",
"daily limit",
"hourly limit",
"out of quota",
"quota exhausted",
"subscription quota",
"insufficient balance",
"insufficient_quota",
],
},
PatternDef {
concept_name: "compilationerror",
patterns: &[
"error[E",
"cannot find",
"unresolved import",
"cargo build failed",
"failed to compile",
"aborting due to",
"could not compile",
],
},
PatternDef {
concept_name: "testfailure",
patterns: &[
"test result: FAILED",
"failures:",
"panicked at",
"assertion failed",
"thread 'main' panicked",
"cargo test failed",
],
},
PatternDef {
concept_name: "modelerror",
patterns: &[
"model not found",
"context length exceeded",
"invalid api key",
"invalid_api_key",
"model_not_found",
"content_policy_violation",
"model overloaded",
"server error",
],
},
PatternDef {
concept_name: "networkerror",
patterns: &[
"connection refused",
"dns resolution",
"ECONNRESET",
"ssl handshake",
"network unreachable",
"connection reset",
"ENOTFOUND",
"ETIMEDOUT",
],
},
PatternDef {
concept_name: "resourceexhaustion",
patterns: &[
"out of memory",
"OOM",
"no space left",
"disk full",
"cannot allocate memory",
"memory allocation failed",
],
},
PatternDef {
concept_name: "permissiondenied",
patterns: &[
"permission denied",
"EACCES",
"403 Forbidden",
"access denied",
"insufficient permissions",
"not authorized",
],
},
PatternDef {
concept_name: "crash",
patterns: &[
"SIGSEGV",
"SIGKILL",
"stack overflow",
"SIGABRT",
"segmentation fault",
"bus error",
"SIGBUS",
],
},
PatternDef {
concept_name: "configerror",
patterns: &[
"Config validation error",
"Working directory does not exist",
"configuration error",
"invalid configuration",
"config validation failed",
],
},
];
impl ExitClassifier {
pub fn new() -> Self {
Self {
thesaurus: Self::build_thesaurus(),
}
}
fn build_thesaurus() -> Thesaurus {
let mut thesaurus = Thesaurus::new("exit_classes".to_string());
for def in EXIT_CLASS_PATTERNS {
let concept = Concept::from(def.concept_name.to_string());
let nterm = NormalizedTerm::new(concept.id, concept.value.clone());
thesaurus.insert(concept.value.clone(), nterm.clone());
for pattern in def.patterns {
thesaurus.insert(NormalizedTermValue::new(pattern.to_string()), nterm.clone());
}
}
thesaurus
}
pub fn classify(
&self,
exit_code: Option<i32>,
stdout_lines: &[String],
stderr_lines: &[String],
) -> ExitClassification {
let combined = format!("{}\n{}", stdout_lines.join("\n"), stderr_lines.join("\n"));
if exit_code == Some(0) {
let has_output = stdout_lines.iter().any(|l| !l.trim().is_empty());
if !has_output {
return ExitClassification {
exit_class: ExitClass::EmptySuccess,
matched_patterns: vec![],
confidence: 0.8,
};
}
let classification = self.match_patterns(&combined);
return ExitClassification {
exit_class: ExitClass::Success,
matched_patterns: classification.matched_patterns,
confidence: 1.0,
};
}
let classification = self.match_patterns(&combined);
if classification.exit_class != ExitClass::Unknown {
return classification;
}
let no_output = stdout_lines.iter().all(|l| l.trim().is_empty())
&& stderr_lines.iter().all(|l| l.trim().is_empty());
if no_output {
return ExitClassification {
exit_class: ExitClass::ModelError,
matched_patterns: vec![],
confidence: 0.5,
};
}
ExitClassification {
exit_class: ExitClass::Unknown,
matched_patterns: vec![],
confidence: 0.0,
}
}
fn match_patterns(&self, text: &str) -> ExitClassification {
let matches = match find_matches(text, self.thesaurus.clone(), false) {
Ok(m) => m,
Err(e) => {
warn!(error = %e, "exit class pattern matching failed");
return ExitClassification {
exit_class: ExitClass::Unknown,
matched_patterns: vec![],
confidence: 0.0,
};
}
};
if matches.is_empty() {
return ExitClassification {
exit_class: ExitClass::Unknown,
matched_patterns: vec![],
confidence: 0.0,
};
}
let mut class_counts: HashMap<String, (usize, Vec<String>)> = HashMap::new();
for m in &matches {
let concept_name = m.normalized_term.value.as_str().to_string();
let entry = class_counts
.entry(concept_name)
.or_insert_with(|| (0, Vec::new()));
entry.0 += 1;
let pattern = m.term.clone();
if !entry.1.contains(&pattern) {
entry.1.push(pattern);
}
}
debug!(
matched_classes = ?class_counts.keys().collect::<Vec<_>>(),
total_matches = matches.len(),
"exit class pattern matches"
);
let (best_concept, (count, matched_patterns)) = class_counts
.into_iter()
.max_by_key(|(_, (count, _))| *count)
.expect("non-empty matches guaranteed above");
let exit_class = ExitClass::from_concept_name(&best_concept).unwrap_or(ExitClass::Unknown);
let confidence = if matches.is_empty() {
0.0
} else {
(count as f64) / (matches.len() as f64)
};
ExitClassification {
exit_class,
matched_patterns,
confidence,
}
}
}
impl Default for ExitClassifier {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct ExitClassification {
pub exit_class: ExitClass,
pub matched_patterns: Vec<String>,
pub confidence: f64,
}
#[async_trait::async_trait]
pub trait RunRecordPersistence: Send + Sync {
async fn insert(&self, record: &AgentRunRecord) -> Result<(), RunRecordError>;
async fn query_by_agent(&self, agent_name: &str)
-> Result<Vec<AgentRunRecord>, RunRecordError>;
async fn query_by_exit_class(
&self,
exit_class: ExitClass,
) -> Result<Vec<AgentRunRecord>, RunRecordError>;
async fn count_by_class_since(
&self,
since: DateTime<Utc>,
) -> Result<HashMap<ExitClass, usize>, RunRecordError>;
}
#[derive(Debug, thiserror::Error)]
pub enum RunRecordError {
#[error("storage error: {0}")]
Storage(String),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Default)]
pub struct InMemoryRunRecordStore {
records: std::sync::Mutex<Vec<AgentRunRecord>>,
}
#[async_trait::async_trait]
impl RunRecordPersistence for InMemoryRunRecordStore {
async fn insert(&self, record: &AgentRunRecord) -> Result<(), RunRecordError> {
let mut records = self
.records
.lock()
.map_err(|e| RunRecordError::Storage(e.to_string()))?;
records.push(record.clone());
Ok(())
}
async fn query_by_agent(
&self,
agent_name: &str,
) -> Result<Vec<AgentRunRecord>, RunRecordError> {
let records = self
.records
.lock()
.map_err(|e| RunRecordError::Storage(e.to_string()))?;
Ok(records
.iter()
.filter(|r| r.agent_name == agent_name)
.cloned()
.collect())
}
async fn query_by_exit_class(
&self,
exit_class: ExitClass,
) -> Result<Vec<AgentRunRecord>, RunRecordError> {
let records = self
.records
.lock()
.map_err(|e| RunRecordError::Storage(e.to_string()))?;
Ok(records
.iter()
.filter(|r| r.exit_class == exit_class)
.cloned()
.collect())
}
async fn count_by_class_since(
&self,
since: DateTime<Utc>,
) -> Result<HashMap<ExitClass, usize>, RunRecordError> {
let records = self
.records
.lock()
.map_err(|e| RunRecordError::Storage(e.to_string()))?;
let mut counts: HashMap<ExitClass, usize> = HashMap::new();
for record in records.iter().filter(|r| r.ended_at >= since) {
*counts.entry(record.exit_class).or_insert(0) += 1;
}
Ok(counts)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn classifier() -> ExitClassifier {
ExitClassifier::new()
}
#[test]
fn classify_success_with_output() {
let c = classifier();
let result = c.classify(Some(0), &["review complete, 3 findings".to_string()], &[]);
assert_eq!(result.exit_class, ExitClass::Success);
assert!(result.confidence > 0.9);
}
#[test]
fn classify_empty_success() {
let c = classifier();
let result = c.classify(Some(0), &[], &[]);
assert_eq!(result.exit_class, ExitClass::EmptySuccess);
}
#[test]
fn classify_timeout() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&["error: operation timed out after 300s".to_string()],
);
assert_eq!(result.exit_class, ExitClass::Timeout);
assert!(result.confidence > 0.0);
assert!(
result
.matched_patterns
.iter()
.any(|p| p.contains("timed out"))
);
}
#[test]
fn classify_rate_limit() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&[
"HTTP 429 Too Many Requests".to_string(),
"rate limit exceeded, retrying in 60s".to_string(),
],
);
assert_eq!(result.exit_class, ExitClass::RateLimit);
assert!(result.matched_patterns.len() >= 2);
}
#[test]
fn classify_compilation_error() {
let c = classifier();
let result = c.classify(
Some(101),
&[],
&[
"error[E0433]: failed to resolve: use of undeclared crate or module".to_string(),
"error[E0412]: cannot find type `FooBar`".to_string(),
"error: aborting due to 2 previous errors".to_string(),
],
);
assert_eq!(result.exit_class, ExitClass::CompilationError);
}
#[test]
fn classify_test_failure() {
let c = classifier();
let result = c.classify(
Some(101),
&[
"running 5 tests".to_string(),
"test result: FAILED. 3 passed; 2 failed; 0 ignored".to_string(),
],
&["thread 'main' panicked at 'assertion failed'".to_string()],
);
assert_eq!(result.exit_class, ExitClass::TestFailure);
}
#[test]
fn classify_model_error() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&["Error: model not found: gpt-5-turbo".to_string()],
);
assert_eq!(result.exit_class, ExitClass::ModelError);
}
#[test]
fn classify_network_error() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&["Error: connection refused (os error 111)".to_string()],
);
assert_eq!(result.exit_class, ExitClass::NetworkError);
}
#[test]
fn classify_resource_exhaustion() {
let c = classifier();
let result = c.classify(
Some(137),
&[],
&["fatal: out of memory, malloc failed".to_string()],
);
assert_eq!(result.exit_class, ExitClass::ResourceExhaustion);
}
#[test]
fn classify_permission_denied() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&["Error: permission denied (os error 13)".to_string()],
);
assert_eq!(result.exit_class, ExitClass::PermissionDenied);
}
#[test]
fn classify_crash() {
let c = classifier();
let result = c.classify(
Some(139),
&[],
&["fatal runtime error: stack overflow".to_string()],
);
assert_eq!(result.exit_class, ExitClass::Crash);
}
#[test]
fn classify_unknown_exit() {
let c = classifier();
let result = c.classify(
Some(42),
&["some generic output".to_string()],
&["some generic error".to_string()],
);
assert_eq!(result.exit_class, ExitClass::Unknown);
assert_eq!(result.confidence, 0.0);
}
#[test]
fn classify_mixed_patterns_picks_dominant() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&[
"error: operation timed out".to_string(),
"error[E0433]: cannot find module".to_string(),
"error[E0412]: cannot find type".to_string(),
"error: aborting due to 2 previous errors".to_string(),
],
);
assert_eq!(result.exit_class, ExitClass::CompilationError);
}
#[test]
fn exit_class_display_roundtrip() {
for class in [
ExitClass::Success,
ExitClass::EmptySuccess,
ExitClass::Timeout,
ExitClass::RateLimit,
ExitClass::CompilationError,
ExitClass::TestFailure,
ExitClass::ModelError,
ExitClass::NetworkError,
ExitClass::ResourceExhaustion,
ExitClass::PermissionDenied,
ExitClass::Crash,
ExitClass::Unknown,
] {
let display = class.to_string();
assert!(
!display.is_empty(),
"ExitClass::Display should not be empty"
);
}
}
#[test]
fn exit_class_serialization() {
let class = ExitClass::CompilationError;
let json = serde_json::to_string(&class).unwrap();
assert_eq!(json, r#""compilation_error""#);
let deserialized: ExitClass = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, class);
}
#[test]
fn agent_run_record_serialization() {
let record = AgentRunRecord {
run_id: Uuid::nil(),
agent_name: "test-agent".to_string(),
started_at: Utc::now(),
ended_at: Utc::now(),
exit_code: Some(1),
exit_class: ExitClass::Timeout,
model_used: Some("kimi-k2.5".to_string()),
was_fallback: false,
wall_time_secs: 42.5,
output_summary: "some output".to_string(),
error_summary: "timed out".to_string(),
trigger: RunTrigger::Cron,
matched_patterns: vec!["timed out".to_string()],
confidence: 0.95,
mention_chain_id: None,
mention_depth: None,
mention_parent_agent: None,
consecutive_config_errors: 0,
};
let json = serde_json::to_string(&record).unwrap();
let deserialized: AgentRunRecord = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.exit_class, ExitClass::Timeout);
assert_eq!(deserialized.agent_name, "test-agent");
}
#[test]
fn summarise_truncates_long_output() {
let lines: Vec<String> = (0..100).map(|i| format!("line {}", i)).collect();
let summary = AgentRunRecord::summarise_output(&lines);
assert!(summary.len() <= 504); }
#[test]
fn truncate_does_not_panic_on_multibyte_utf8() {
let emoji_str: String = "😀".repeat(200); let result = AgentRunRecord::truncate(&emoji_str, 500);
assert!(result.ends_with("..."), "result should end with '...'");
assert!(
std::str::from_utf8(result.as_bytes()).is_ok(),
"must be valid UTF-8"
);
}
#[test]
fn truncate_does_not_panic_on_multibyte_utf8_at_boundary() {
let cjk_str: String = "ä¸".repeat(200); let result = AgentRunRecord::truncate(&cjk_str, 500); assert!(result.ends_with("..."));
assert!(std::str::from_utf8(result.as_bytes()).is_ok());
}
#[test]
fn truncate_short_text_unchanged() {
let s = "hello";
let result = AgentRunRecord::truncate(s, 500);
assert_eq!(result, "hello");
}
#[test]
fn summarise_output_with_unicode_does_not_panic() {
let lines: Vec<String> = (0..50).map(|i| format!("emoji {} 🔥", i)).collect();
let summary = AgentRunRecord::summarise_output(&lines);
assert!(std::str::from_utf8(summary.as_bytes()).is_ok());
}
#[test]
fn exit_code_zero_with_oom_pattern_is_success() {
let c = classifier();
let result = c.classify(
Some(0),
&["Infrastructure check complete. OOM risk: low (swap 99% full but 108GiB RAM available).".to_string()],
&[],
);
assert_eq!(result.exit_class, ExitClass::Success);
assert_eq!(result.confidence, 1.0);
assert!(result.matched_patterns.iter().any(|p| p.contains("oom")));
}
#[test]
fn exit_code_zero_with_timeout_pattern_is_success() {
let c = classifier();
let result = c.classify(
Some(0),
&["gtr wiki-list: operation timed out after 30s, skipping".to_string()],
&[],
);
assert_eq!(result.exit_class, ExitClass::Success);
assert_eq!(result.confidence, 1.0);
}
#[test]
fn nonzero_exit_with_oom_pattern_is_resource_exhaustion() {
let c = classifier();
let result = c.classify(
Some(137),
&["out of memory: killed process 1234".to_string()],
&[],
);
assert_eq!(result.exit_class, ExitClass::ResourceExhaustion);
}
#[test]
fn classify_quota_hit_your_limit() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&["You've hit your limit - resets 2am Europe/Berlin".to_string()],
);
assert_eq!(result.exit_class, ExitClass::RateLimit);
assert!(result.confidence > 0.0);
}
#[test]
fn classify_quota_plan_limit() {
let c = classifier();
let result = c.classify(
Some(1),
&["Error: plan limit reached for this billing cycle".to_string()],
&[],
);
assert_eq!(result.exit_class, ExitClass::RateLimit);
}
#[test]
fn classify_quota_out_of_quota() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&["out of quota: cannot process request".to_string()],
);
assert_eq!(result.exit_class, ExitClass::RateLimit);
}
#[test]
fn classify_quota_insufficient_quota_is_rate_limit() {
let c = classifier();
let result = c.classify(Some(1), &["insufficient_quota".to_string()], &[]);
assert_eq!(result.exit_class, ExitClass::RateLimit);
}
#[test]
fn classify_quota_resets_at() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&["rate limited: resets at 14:00 UTC".to_string()],
);
assert_eq!(result.exit_class, ExitClass::RateLimit);
}
#[test]
fn classify_sonnet_limit_stderr_is_rate_limit() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&["You've hit your Sonnet limit - resets 2am Europe/Berlin".to_string()],
);
assert_eq!(result.exit_class, ExitClass::RateLimit);
assert!(result.confidence > 0.0);
}
#[test]
fn classify_empty_nonzero_is_model_error() {
let c = classifier();
let result = c.classify(Some(1), &[], &[]);
assert_eq!(result.exit_class, ExitClass::ModelError);
assert_eq!(result.confidence, 0.5);
assert!(result.matched_patterns.is_empty());
}
#[test]
fn classify_whitespace_only_nonzero_is_model_error() {
let c = classifier();
let result = c.classify(Some(1), &[" ".to_string()], &["\t\n".to_string()]);
assert_eq!(result.exit_class, ExitClass::ModelError);
assert_eq!(result.confidence, 0.5);
}
#[test]
fn classify_empty_exit_zero_is_still_empty_success() {
let c = classifier();
let result = c.classify(Some(0), &[], &[]);
assert_eq!(result.exit_class, ExitClass::EmptySuccess);
}
#[tokio::test]
async fn in_memory_store_insert_and_query() {
let store = InMemoryRunRecordStore::default();
let record = AgentRunRecord {
run_id: Uuid::new_v4(),
agent_name: "test-agent".to_string(),
started_at: Utc::now(),
ended_at: Utc::now(),
exit_code: Some(1),
exit_class: ExitClass::Timeout,
model_used: None,
was_fallback: false,
wall_time_secs: 10.0,
output_summary: String::new(),
error_summary: "timed out".to_string(),
trigger: RunTrigger::Cron,
matched_patterns: vec!["timed out".to_string()],
confidence: 0.9,
mention_chain_id: None,
mention_depth: None,
mention_parent_agent: None,
consecutive_config_errors: 0,
};
store.insert(&record).await.unwrap();
let by_agent = store.query_by_agent("test-agent").await.unwrap();
assert_eq!(by_agent.len(), 1);
assert_eq!(by_agent[0].exit_class, ExitClass::Timeout);
let by_class = store.query_by_exit_class(ExitClass::Timeout).await.unwrap();
assert_eq!(by_class.len(), 1);
let by_class_empty = store.query_by_exit_class(ExitClass::Crash).await.unwrap();
assert!(by_class_empty.is_empty());
}
}