#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
use async_trait::async_trait;
use bytes::Bytes;
use cratefield_core::{
Answer, AnswerValue, Calibration, Classifier, ClassifierError, ClassifierProfile, Clock,
DEFAULT_MAX_STATE_CHARS, HttpClient, HttpError, HttpPolicy, MAX_RESPONSE_BYTES, Question,
retry_after, validate_questions,
};
use http::header::{AUTHORIZATION, CONTENT_TYPE};
use http::{Request, StatusCode};
use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
pub const DEFAULT_ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
pub const DEFAULT_MODEL: &str = "jev-1.13.0";
pub const MAX_CHOICE_OPTIONS: usize = 255;
pub const MAX_SCORE_LEVELS: usize = 10;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
const OVERLOADED: u16 = 529;
pub struct TypeSafe {
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
api_key: Option<String>,
model: String,
endpoint: String,
}
impl fmt::Debug for TypeSafe {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TypeSafe")
.field("endpoint", &self.endpoint)
.field("model", &self.model)
.field("api_key_configured", &self.configured_key().is_some())
.finish_non_exhaustive()
}
}
impl TypeSafe {
pub fn new(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>, api_key: Option<String>) -> Self {
Self {
http,
clock,
api_key,
model: DEFAULT_MODEL.to_owned(),
endpoint: DEFAULT_ENDPOINT.to_owned(),
}
}
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
#[must_use]
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = endpoint.into();
self
}
pub fn from_env(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Option<Self> {
let api_key = std::env::var("TYPESAFE_API_KEY").ok()?;
let model = model_from_env(std::env::var("TYPESAFE_MODEL").ok());
Some(Self::new(http, clock, Some(api_key)).with_model(model))
}
fn configured_key(&self) -> Option<&str> {
self.api_key
.as_deref()
.map(str::trim)
.filter(|key| !key.is_empty())
}
}
fn model_from_env(value: Option<String>) -> String {
match value {
Some(model) if !model.trim().is_empty() => model.trim().to_owned(),
_ => DEFAULT_MODEL.to_owned(),
}
}
#[derive(serde::Serialize)]
struct SystemOneRequest<'a> {
state: &'a str,
model: &'a str,
questions: BTreeMap<&'a str, WireQuestion<'a>>,
}
#[derive(serde::Serialize)]
struct WireQuestion<'a> {
#[serde(rename = "type")]
kind: &'static str,
instructions: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
criteria: Option<WireCriteria<'a>>,
}
#[derive(serde::Serialize)]
#[serde(untagged)]
enum WireCriteria<'a> {
Options(BTreeMap<&'a str, &'a str>),
Levels(Vec<&'a str>),
}
fn build_request<'a>(
state: &'a str,
model: &'a str,
questions: &'a BTreeMap<String, Question>,
) -> SystemOneRequest<'a> {
SystemOneRequest {
state,
model,
questions: questions
.iter()
.map(|(id, question)| (id.as_str(), wire_question(question)))
.collect(),
}
}
fn wire_question(question: &Question) -> WireQuestion<'_> {
match question {
Question::Choice {
instructions,
criteria,
} => WireQuestion {
kind: question.kind(),
instructions,
criteria: Some(WireCriteria::Options(
criteria
.iter()
.map(|(name, meaning)| (name.as_str(), meaning.as_str()))
.collect(),
)),
},
Question::Score {
instructions,
levels,
} => WireQuestion {
kind: question.kind(),
instructions,
criteria: Some(WireCriteria::Levels(
levels.iter().map(|(_, meaning)| meaning.as_str()).collect(),
)),
},
Question::Noul { instructions } => WireQuestion {
kind: question.kind(),
instructions,
criteria: None,
},
}
}
fn check_vendor_limits(questions: &BTreeMap<String, Question>) -> Result<(), ClassifierError> {
for (id, question) in questions {
match question {
Question::Choice { criteria, .. } if criteria.len() > MAX_CHOICE_OPTIONS => {
return Err(ClassifierError::Rejected(format!(
"choice question {id:?} carries {} options, over the {MAX_CHOICE_OPTIONS} the vendor accepts",
criteria.len()
)));
}
Question::Score { levels, .. } if levels.len() > MAX_SCORE_LEVELS => {
return Err(ClassifierError::Rejected(format!(
"score question {id:?} carries {} levels, over the {MAX_SCORE_LEVELS} the vendor accepts",
levels.len()
)));
}
_ => {}
}
}
Ok(())
}
#[derive(serde::Deserialize)]
struct SystemOneResponse {
#[serde(default)]
model: String,
answers: BTreeMap<String, WireAnswer>,
#[serde(default)]
usage: Option<WireUsage>,
}
#[derive(serde::Deserialize, Default)]
struct WireUsage {
#[serde(default)]
input_tokens: u32,
#[serde(default)]
output_tokens: u32,
}
#[derive(serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum WireAnswer {
Noul { noul: f32 },
Choice {
choice: String,
probabilities: BTreeMap<String, f32>,
confidence: f32,
},
Score {
score: f32,
probabilities: BTreeMap<String, f32>,
confidence: f32,
},
}
fn answer_kind(answer: &WireAnswer) -> &'static str {
match answer {
WireAnswer::Noul { .. } => "noul",
WireAnswer::Choice { .. } => "choice",
WireAnswer::Score { .. } => "score",
}
}
fn reject(message: String) -> ClassifierError {
tracing::warn!(
provider = "typesafe",
"a typesafe answer does not match the question it answers"
);
ClassifierError::Rejected(message)
}
fn map_answers(
answers: BTreeMap<String, WireAnswer>,
questions: &BTreeMap<String, Question>,
model: &str,
) -> Result<BTreeMap<String, Answer>, ClassifierError> {
let mut mapped = BTreeMap::new();
for (id, answer) in answers {
let Some(question) = questions.get(&id) else {
return Err(reject(format!(
"typesafe answered question id {id:?}, which was not asked"
)));
};
let built = match (question, &answer) {
(Question::Noul { .. }, WireAnswer::Noul { noul }) => noul_answer(&id, *noul)?,
(
Question::Choice { criteria, .. },
WireAnswer::Choice {
choice,
probabilities,
confidence,
},
) => choice_answer(&id, choice, probabilities, *confidence, criteria)?,
(
Question::Score { levels, .. },
WireAnswer::Score {
score,
probabilities,
confidence,
},
) => score_answer(&id, *score, probabilities, *confidence, levels)?,
_ => {
return Err(reject(format!(
"typesafe answered {} question {id:?} with a {} answer",
question.kind(),
answer_kind(&answer),
)));
}
};
mapped.insert(id, built.with_model(model));
}
if mapped.len() != questions.len() {
let missing: Vec<_> = questions
.keys()
.filter(|id| !mapped.contains_key(*id))
.cloned()
.collect();
return Err(reject(format!(
"typesafe did not answer the question(s) {missing:?}"
)));
}
Ok(mapped)
}
fn noul_answer(id: &str, noul: f32) -> Result<Answer, ClassifierError> {
if !noul.is_finite() || !(0.0..=1.0).contains(&noul) {
return Err(reject(format!(
"typesafe answered noul question {id:?} with {noul}, outside [0, 1]"
)));
}
let verdict = noul >= 0.5;
let probabilities =
BTreeMap::from([("true".to_owned(), noul), ("false".to_owned(), 1.0 - noul)]);
Ok(Answer::noul(verdict, probabilities))
}
fn choice_answer(
id: &str,
choice: &str,
probabilities: &BTreeMap<String, f32>,
confidence: f32,
criteria: &BTreeMap<String, String>,
) -> Result<Answer, ClassifierError> {
let checked = checked_probabilities(id, probabilities, criteria.keys().map(String::as_str))?;
if !criteria.contains_key(choice) {
return Err(reject(format!(
"typesafe answered choice question {id:?} with {choice:?}, which is not one of its criteria"
)));
}
if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
return Err(reject(format!(
"typesafe answered choice question {id:?} with confidence {confidence}, outside [0, 1]"
)));
}
Ok(Answer::new(
AnswerValue::Choice(choice.to_owned()),
checked,
confidence,
))
}
fn score_answer(
id: &str,
score: f32,
probabilities: &BTreeMap<String, f32>,
confidence: f32,
levels: &[(String, String)],
) -> Result<Answer, ClassifierError> {
#[allow(clippy::cast_precision_loss)] let top = (levels.len() - 1) as f32;
if !score.is_finite()
|| !(-SCORE_RANGE_TOLERANCE..=top + SCORE_RANGE_TOLERANCE).contains(&score)
{
return Err(reject(format!(
"typesafe scored question {id:?} at {score}, off its 0..={top} scale"
)));
}
if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
return Err(reject(format!(
"typesafe answered score question {id:?} with confidence {confidence}, outside [0, 1]"
)));
}
let mut checked = BTreeMap::new();
for (index, probability) in probabilities {
let Ok(level) = index.parse::<usize>() else {
return Err(reject(format!(
"typesafe answered score question {id:?} under {index:?}, which is not a level index"
)));
};
let Some((name, _)) = levels.get(level) else {
return Err(reject(format!(
"typesafe answered score question {id:?} at level {index}, which it does not have"
)));
};
if !probability.is_finite() || !(0.0..=1.0).contains(probability) {
return Err(reject(format!(
"typesafe answered question {id:?} with probability {probability} for level {index}, outside [0, 1]"
)));
}
checked.insert(name.clone(), *probability);
}
let value = score_on_level_scale(score, levels);
Ok(Answer::new(AnswerValue::Score(value), checked, confidence))
}
fn checked_probabilities<'a>(
id: &str,
probabilities: &BTreeMap<String, f32>,
criteria: impl Iterator<Item = &'a str>,
) -> Result<BTreeMap<String, f32>, ClassifierError> {
let offered: Vec<&str> = criteria.collect();
let mut checked = BTreeMap::new();
for (label, probability) in probabilities {
if !offered.contains(&label.as_str()) {
return Err(reject(format!(
"typesafe answered question {id:?} with label {label:?}, which it does not offer"
)));
}
if !probability.is_finite() || !(0.0..=1.0).contains(probability) {
return Err(reject(format!(
"typesafe answered question {id:?} with probability {probability} for {label:?}, outside [0, 1]"
)));
}
checked.insert(label.clone(), *probability);
}
Ok(checked)
}
const SCORE_RANGE_TOLERANCE: f32 = 1.0e-3;
fn score_on_level_scale(score: f32, levels: &[(String, String)]) -> f32 {
let parsed: Option<Vec<f32>> = levels
.iter()
.map(|(name, _)| name.parse::<f32>().ok().filter(|value| value.is_finite()))
.collect();
let Some(names) = parsed else {
return score;
};
if names.len() < 2 {
return names.first().copied().unwrap_or(score);
}
#[allow(clippy::cast_precision_loss)] let top = (names.len() - 1) as f32;
let score = score.clamp(0.0, top);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let base = (score.floor() as usize).min(names.len() - 2);
#[allow(clippy::cast_precision_loss)] let fraction = score - base as f32;
names[base] + fraction * (names[base + 1] - names[base])
}
#[async_trait]
impl Classifier for TypeSafe {
fn profile(&self) -> ClassifierProfile {
ClassifierProfile::new(Calibration::Classifier, DEFAULT_MAX_STATE_CHARS)
}
async fn ask(
&self,
state: &str,
questions: &BTreeMap<String, Question>,
) -> Result<BTreeMap<String, Answer>, ClassifierError> {
let Some(api_key) = self.configured_key() else {
tracing::info!(
provider = "typesafe",
outcome = "not_configured",
"classifier outcome"
);
return Err(ClassifierError::NotConfigured);
};
validate_questions(questions)?;
check_vendor_limits(questions)?;
let profile = self.profile();
let (state, dropped) = profile.truncate(state);
if dropped {
tracing::warn!(
limit = profile.max_state_chars,
"typesafe classifier truncated the state; the answers see only the first `limit` chars"
);
}
let payload = build_request(state, &self.model, questions);
let body = serde_json::to_vec(&payload)
.map_err(|err| ClassifierError::Transport(err.to_string()))?;
let mut request = Request::builder()
.method(http::Method::POST)
.uri(&self.endpoint)
.header(CONTENT_TYPE, "application/json")
.header(AUTHORIZATION, format!("Bearer {api_key}"))
.body(Bytes::from(body))
.map_err(|err| ClassifierError::Transport(err.to_string()))?;
request.extensions_mut().insert(HttpPolicy {
timeout: REQUEST_TIMEOUT,
max_response_bytes: MAX_RESPONSE_BYTES,
});
let response = self.http.send(request).await.map_err(|err: HttpError| {
tracing::warn!(
provider = "typesafe",
outcome = "transport",
"typesafe transport failure"
);
ClassifierError::Transport(err.to_string())
})?;
let status = response.status();
let retry_after = retry_after(response.headers(), self.clock.as_ref());
let text = String::from_utf8_lossy(response.body()).to_string();
if status == StatusCode::TOO_MANY_REQUESTS
|| status.as_u16() == OVERLOADED
|| status.is_server_error()
{
tracing::warn!(
provider = "typesafe",
code = status.as_u16(),
outcome = "transient",
"classifier outcome"
);
return Err(ClassifierError::Transient { retry_after });
}
if !status.is_success() {
tracing::warn!(
provider = "typesafe",
code = status.as_u16(),
outcome = "rejected",
"classifier outcome"
);
return Err(ClassifierError::Rejected(text));
}
let parsed: SystemOneResponse = match serde_json::from_str(&text) {
Ok(parsed) => parsed,
Err(err) => {
tracing::warn!(
provider = "typesafe",
outcome = "transport",
"a typesafe success body did not survive the hop as an answer set"
);
return Err(ClassifierError::Transport(err.to_string()));
}
};
let resolved_model = if parsed.model.is_empty() {
self.model.clone()
} else {
parsed.model
};
let answers = map_answers(parsed.answers, questions, &resolved_model)?;
let usage = parsed.usage.unwrap_or_default();
tracing::debug!(
provider = "typesafe",
outcome = "answered",
model = %resolved_model,
questions = answers.len(),
input_tokens = usage.input_tokens,
output_tokens = usage.output_tokens,
"classifier outcome"
);
Ok(answers)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn good_set() -> BTreeMap<String, Question> {
BTreeMap::from([
(
"topic".to_owned(),
Question::Choice {
instructions: "Which topic?".to_owned(),
criteria: BTreeMap::from([
("billing".to_owned(), "money, invoices".to_owned()),
("bugs".to_owned(), "something is broken".to_owned()),
]),
},
),
(
"angry".to_owned(),
Question::Noul {
instructions: "Is the writer angry?".to_owned(),
},
),
])
}
fn vendor_answer(id: &str, answer: WireAnswer) -> (String, WireAnswer) {
(id.to_owned(), answer)
}
fn map(
answers: Vec<(String, WireAnswer)>,
questions: &BTreeMap<String, Question>,
) -> Result<BTreeMap<String, Answer>, ClassifierError> {
map_answers(answers.into_iter().collect(), questions, DEFAULT_MODEL)
}
#[test]
fn an_answer_for_an_id_that_was_not_asked_is_refused() {
let answers = vec![vendor_answer("never-asked", WireAnswer::Noul { noul: 1.0 })];
assert!(matches!(
map(answers, &good_set()),
Err(ClassifierError::Rejected(_))
));
}
#[test]
fn a_label_outside_its_question_is_refused() {
let answers = vec![vendor_answer(
"topic",
WireAnswer::Choice {
choice: "shipping".to_owned(),
probabilities: BTreeMap::new(),
confidence: 1.0,
},
)];
assert!(matches!(
map(answers, &good_set()),
Err(ClassifierError::Rejected(_))
));
let answers = vec![vendor_answer(
"topic",
WireAnswer::Choice {
choice: "bugs".to_owned(),
probabilities: BTreeMap::from([("shipping".to_owned(), 1.0_f32)]),
confidence: 1.0,
},
)];
assert!(matches!(
map(answers, &good_set()),
Err(ClassifierError::Rejected(_))
));
}
#[test]
fn a_number_outside_the_unit_interval_is_refused() {
let answers = vec![vendor_answer(
"topic",
WireAnswer::Choice {
choice: "bugs".to_owned(),
probabilities: BTreeMap::from([("bugs".to_owned(), 1.5_f32)]),
confidence: 0.5,
},
)];
assert!(matches!(
map(answers, &good_set()),
Err(ClassifierError::Rejected(_))
));
let answers = vec![vendor_answer(
"topic",
WireAnswer::Choice {
choice: "bugs".to_owned(),
probabilities: BTreeMap::from([("bugs".to_owned(), -0.1_f32)]),
confidence: 0.5,
},
)];
assert!(matches!(
map(answers, &good_set()),
Err(ClassifierError::Rejected(_))
));
let answers = vec![vendor_answer(
"topic",
WireAnswer::Choice {
choice: "bugs".to_owned(),
probabilities: BTreeMap::from([("bugs".to_owned(), 0.5_f32)]),
confidence: f32::INFINITY,
},
)];
assert!(matches!(
map(answers, &good_set()),
Err(ClassifierError::Rejected(_))
));
}
#[test]
fn a_noul_is_derived_from_its_one_number() {
let noul_only = BTreeMap::from([(
"angry".to_owned(),
Question::Noul {
instructions: "Is the writer angry?".to_owned(),
},
)]);
let answers = vec![vendor_answer("angry", WireAnswer::Noul { noul: 0.8 })];
let mapped = map(answers, &noul_only).expect("maps");
let angry = &mapped["angry"];
assert_eq!(angry.value, AnswerValue::Noul(true));
assert!((angry.probabilities["true"] - 0.8).abs() < f32::EPSILON);
assert!((angry.probabilities["false"] - 0.2).abs() < f32::EPSILON);
assert!((angry.confidence - 0.8).abs() < f32::EPSILON, "{angry:?}");
let answers = vec![vendor_answer("angry", WireAnswer::Noul { noul: 0.3 })];
let mapped = map(answers, &noul_only).expect("maps");
let angry = &mapped["angry"];
assert_eq!(angry.value, AnswerValue::Noul(false));
assert!((angry.confidence - 0.7).abs() < f32::EPSILON, "{angry:?}");
}
#[test]
fn a_noul_outside_the_unit_interval_is_refused() {
let noul_only = BTreeMap::from([(
"angry".to_owned(),
Question::Noul {
instructions: "Is the writer angry?".to_owned(),
},
)]);
for noul in [-0.1_f32, 1.1, f32::NAN] {
let answers = vec![vendor_answer("angry", WireAnswer::Noul { noul })];
assert!(
matches!(map(answers, &noul_only), Err(ClassifierError::Rejected(_))),
"noul {noul} refused"
);
}
}
#[test]
fn a_score_re_keys_to_the_level_names_and_interpolates_the_value() {
let words = BTreeMap::from([(
"frustration".to_owned(),
Question::Score {
instructions: "How frustrated?".to_owned(),
levels: vec![
("Calm".to_owned(), "Calm".to_owned()),
("Frustrated".to_owned(), "Frustrated".to_owned()),
("Very angry".to_owned(), "Very angry".to_owned()),
],
},
)]);
let answers = vec![vendor_answer(
"frustration",
WireAnswer::Score {
score: 1.05,
probabilities: BTreeMap::from([
("0".to_owned(), 0.0_f32),
("1".to_owned(), 0.95_f32),
("2".to_owned(), 0.05_f32),
]),
confidence: 0.92,
},
)];
let mapped = map(answers, &words).expect("maps");
let frustration = &mapped["frustration"];
assert!(
(value_score(frustration) - 1.05).abs() < f32::EPSILON,
"{frustration:?}"
);
assert!((frustration.probabilities["Frustrated"] - 0.95).abs() < f32::EPSILON);
assert!((frustration.probabilities["Very angry"] - 0.05).abs() < f32::EPSILON);
assert!((frustration.confidence - 0.92).abs() < f32::EPSILON);
let numeric = BTreeMap::from([(
"severity".to_owned(),
Question::Score {
instructions: "How severe?".to_owned(),
levels: vec![
("1".to_owned(), "a typo".to_owned()),
("5".to_owned(), "data loss".to_owned()),
],
},
)]);
let answers = vec![vendor_answer(
"severity",
WireAnswer::Score {
score: 0.7,
probabilities: BTreeMap::from([
("0".to_owned(), 0.3_f32),
("1".to_owned(), 0.7_f32),
]),
confidence: 0.7,
},
)];
let mapped = map(answers, &numeric).expect("maps");
let severity = &mapped["severity"];
assert!((value_score(severity) - 3.8).abs() < 1.0e-5, "{severity:?}");
assert!((severity.probabilities["1"] - 0.3).abs() < f32::EPSILON);
assert!((severity.probabilities["5"] - 0.7).abs() < f32::EPSILON);
let answers = vec![vendor_answer(
"severity",
WireAnswer::Score {
score: 1.0,
probabilities: BTreeMap::from([
("0".to_owned(), 0.2_f32),
("1".to_owned(), 0.8_f32),
]),
confidence: 0.8,
},
)];
let mapped = map(answers, &numeric).expect("maps");
assert!((value_score(&mapped["severity"]) - 5.0).abs() < f32::EPSILON);
}
#[test]
fn a_level_named_like_a_float_special_is_not_a_number_axis() {
let words = BTreeMap::from([(
"odd".to_owned(),
Question::Score {
instructions: "How odd?".to_owned(),
levels: vec![
("inf".to_owned(), "not much".to_owned()),
("NaN".to_owned(), "very".to_owned()),
],
},
)]);
let answers = vec![vendor_answer(
"odd",
WireAnswer::Score {
score: 0.5,
probabilities: BTreeMap::from([
("0".to_owned(), 0.5_f32),
("1".to_owned(), 0.5_f32),
]),
confidence: 0.5,
},
)];
let mapped = map(answers, &words).expect("maps");
assert!((value_score(&mapped["odd"]) - 0.5).abs() < f32::EPSILON);
}
#[test]
fn a_score_off_its_scale_or_over_its_levels_is_refused() {
let numeric = BTreeMap::from([(
"severity".to_owned(),
Question::Score {
instructions: "How severe?".to_owned(),
levels: vec![
("1".to_owned(), "a typo".to_owned()),
("5".to_owned(), "data loss".to_owned()),
],
},
)]);
let scored = |score: f32, probabilities: BTreeMap<String, f32>| {
vec![vendor_answer(
"severity",
WireAnswer::Score {
score,
probabilities,
confidence: 0.7,
},
)]
};
assert!(matches!(
map(
scored(1.5, BTreeMap::from([("0".to_owned(), 1.0_f32)])),
&numeric
),
Err(ClassifierError::Rejected(_))
));
assert!(matches!(
map(
scored(0.5, BTreeMap::from([("7".to_owned(), 1.0_f32)])),
&numeric
),
Err(ClassifierError::Rejected(_))
));
assert!(matches!(
map(
scored(0.5, BTreeMap::from([("data loss".to_owned(), 1.0_f32)])),
&numeric
),
Err(ClassifierError::Rejected(_))
));
}
#[test]
fn typesafe_model_is_honoured_only_when_set_and_non_blank() {
assert_eq!(model_from_env(None), DEFAULT_MODEL);
assert_eq!(model_from_env(Some(String::new())), DEFAULT_MODEL);
assert_eq!(model_from_env(Some(" ".to_owned())), DEFAULT_MODEL);
assert_eq!(model_from_env(Some("jev-latest".to_owned())), "jev-latest");
assert_eq!(
model_from_env(Some(" jev-preview ".to_owned())),
"jev-preview"
);
}
fn value_score(answer: &Answer) -> f32 {
match answer.value {
AnswerValue::Score(score) => score,
ref other => panic!("not a score: {other:?}"),
}
}
}