use super::{Restater, ResultShape, Verdict, round_trip};
use async_trait::async_trait;
use saya_agent::CancellationToken;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
#[derive(Clone, Debug)]
enum Call {
Restate {
sql: String,
shape: ResultShape,
},
Judge {
question: String,
restatement: String,
},
}
struct ScriptedRestater {
restate_out: Option<String>,
judge_out: Option<Verdict>,
log: Mutex<Vec<Call>>,
cancel_after: Option<usize>,
token: Option<CancellationToken>,
calls: AtomicUsize,
cancelled: AtomicBool,
}
impl ScriptedRestater {
fn new() -> Self {
Self {
restate_out: None,
judge_out: None,
log: Mutex::new(Vec::new()),
cancel_after: None,
token: None,
calls: AtomicUsize::new(0),
cancelled: AtomicBool::new(false),
}
}
fn restate_returns(mut self, v: Option<String>) -> Self {
self.restate_out = v;
self
}
fn judge_returns(mut self, v: Option<Verdict>) -> Self {
self.judge_out = v;
self
}
fn cancel_after(mut self, n: usize, token: CancellationToken) -> Self {
self.cancel_after = Some(n);
self.token = Some(token);
self
}
fn log(&self) -> Vec<Call> {
self.log.lock().unwrap().clone()
}
fn maybe_cancel(&self) {
let Some(threshold) = self.cancel_after else {
return;
};
let n = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
if n < threshold {
return;
}
let Some(token) = &self.token else {
return;
};
if !self.cancelled.swap(true, Ordering::SeqCst) {
token.cancel();
}
}
}
#[async_trait]
impl Restater for ScriptedRestater {
async fn restate(&self, sql: &str, shape: &ResultShape) -> Option<String> {
self.log.lock().unwrap().push(Call::Restate {
sql: sql.to_string(),
shape: shape.clone(),
});
self.maybe_cancel();
self.restate_out.clone()
}
async fn judge(&self, question: &str, restatement: &str) -> Option<Verdict> {
self.log.lock().unwrap().push(Call::Judge {
question: question.to_string(),
restatement: restatement.to_string(),
});
self.maybe_cancel();
self.judge_out.clone()
}
}
fn shape(columns: &[&str], row_count: usize) -> ResultShape {
ResultShape {
row_count,
columns: columns.iter().map(|s| s.to_string()).collect(),
}
}
#[tokio::test]
async fn restate_is_never_given_the_question() {
let question = "names of high schoolers who have 3 or more friends";
let sql = "SELECT s.name FROM high_schoolers s JOIN friendships f \
ON f.student_id = s.id GROUP BY s.id HAVING COUNT(*) >= 3";
let shape = shape(&["name"], 2);
let restater = ScriptedRestater::new()
.restate_returns(Some("which students have at least three friends?".into()))
.judge_returns(None);
round_trip(question, sql, &shape, &restater, &CancellationToken::new()).await;
let log = restater.log();
let Call::Restate {
sql: seen_sql,
shape: seen_shape,
} = &log[0]
else {
panic!("first call must be restate, got {:?}", log[0]);
};
assert_eq!(
seen_sql.as_str(),
sql,
"restate must receive the SQL verbatim"
);
assert_eq!(seen_shape, &shape);
assert!(
!seen_sql.contains(question),
"question leaked into the SQL handed to restate"
);
assert!(
seen_shape.columns.iter().all(|c| !c.contains(question)),
"question leaked into the column names handed to restate"
);
}
#[tokio::test]
async fn two_steps_restate_then_judge_not_one_call() {
let question = "how many orders did each customer place?";
let sql = "SELECT customer_id, COUNT(*) AS n FROM orders GROUP BY customer_id";
let restatement = "count of orders per customer".to_string();
let shape = shape(&["customer_id", "n"], 5);
let restater = ScriptedRestater::new()
.restate_returns(Some(restatement.clone()))
.judge_returns(Some(Verdict {
agrees: true,
reason: "both count orders per customer".into(),
}));
let rt = round_trip(question, sql, &shape, &restater, &CancellationToken::new()).await;
let log = restater.log();
assert_eq!(log.len(), 2, "exactly two model calls: restate, then judge");
assert!(
matches!(log[0], Call::Restate { .. }),
"first call must be restate"
);
match &log[1] {
Call::Judge {
question: q,
restatement: r,
} => {
assert_eq!(
q, question,
"judge must receive the question that was asked"
);
assert_eq!(
r, &restatement,
"judge must receive the restatement restate produced"
);
}
_ => panic!("second call must be judge, got {:?}", log[1]),
}
assert_eq!(rt.restatement.as_deref(), Some(restatement.as_str()));
assert!(rt.verdict.as_ref().is_some_and(|v| v.agrees));
assert!(!rt.diverged, "an agreeing verdict is not a divergence");
}
#[tokio::test]
async fn no_restatement_means_no_verdict_and_no_divergence() {
let question = "how many orders did each customer place?";
let sql = "SELECT customer_id, COUNT(*) AS n FROM orders GROUP BY customer_id";
let shape = shape(&["customer_id", "n"], 5);
let restater = ScriptedRestater::new()
.restate_returns(None)
.judge_returns(Some(Verdict {
agrees: true,
reason: "judge must not be reached".into(),
}));
let rt = round_trip(question, sql, &shape, &restater, &CancellationToken::new()).await;
let log = restater.log();
assert_eq!(log.len(), 1, "only restate ran; judge must not be called");
assert!(matches!(log[0], Call::Restate { .. }));
assert!(rt.restatement.is_none());
assert!(rt.verdict.is_none());
assert!(!rt.diverged, "no restatement can never be a divergence");
}
#[tokio::test]
async fn missing_verdict_is_not_a_divergence() {
let question = "how many orders did each customer place?";
let sql = "SELECT customer_id, COUNT(*) AS n FROM orders GROUP BY customer_id";
let restatement = "count of orders per customer".to_string();
let shape = shape(&["customer_id", "n"], 5);
let restater = ScriptedRestater::new()
.restate_returns(Some(restatement.clone()))
.judge_returns(None);
let rt = round_trip(question, sql, &shape, &restater, &CancellationToken::new()).await;
let log = restater.log();
assert_eq!(log.len(), 2, "both calls ran");
assert!(matches!(log[1], Call::Judge { .. }));
assert_eq!(rt.restatement.as_deref(), Some(restatement.as_str()));
assert!(rt.verdict.is_none());
assert!(
!rt.diverged,
"a missing verdict is absent evidence, never a disagreement"
);
}
#[tokio::test]
async fn explicit_disagreement_sets_diverged() {
let question = "names of high schoolers who have 3 or more friends";
let sql = "SELECT s.name FROM high_schoolers s JOIN friendships f \
ON f.student_id = s.id GROUP BY s.id HAVING COUNT(*) >= 3";
let restatement =
"students appearing on either side of a friendship at least 3 times".to_string();
let verdict = Verdict {
agrees: false,
reason: "the SQL counts friendships per student, not students per friendship".into(),
};
let restater = ScriptedRestater::new()
.restate_returns(Some(restatement.clone()))
.judge_returns(Some(verdict.clone()));
let rt = round_trip(
question,
sql,
&shape(&["name"], 2),
&restater,
&CancellationToken::new(),
)
.await;
assert_eq!(rt.restatement.as_deref(), Some(restatement.as_str()));
assert!(!rt.verdict.as_ref().unwrap().agrees);
assert_eq!(rt.verdict.as_ref().unwrap().reason, verdict.reason);
assert!(
rt.diverged,
"an explicit disagreement is the only thing that sets diverged"
);
}
#[tokio::test]
async fn cancelled_before_restate_returns_no_divergence() {
let token = CancellationToken::new();
token.cancel();
let restater = ScriptedRestater::new()
.restate_returns(Some("should not be reached".into()))
.judge_returns(Some(Verdict {
agrees: false,
reason: "should not be reached".into(),
}));
let rt = round_trip("q", "SELECT 1 AS a", &shape(&["a"], 0), &restater, &token).await;
assert!(
restater.log().is_empty(),
"no model call should run when cancelled up front"
);
assert!(rt.restatement.is_none());
assert!(rt.verdict.is_none());
assert!(!rt.diverged);
}
#[tokio::test]
async fn cancelled_before_judge_returns_no_divergence() {
let token = CancellationToken::new();
let restater = ScriptedRestater::new()
.restate_returns(Some("count of orders per customer".into()))
.judge_returns(Some(Verdict {
agrees: false,
reason: "should not be reached".into(),
}))
.cancel_after(1, token.clone());
let rt = round_trip(
"how many orders did each customer place?",
"SELECT customer_id, COUNT(*) AS n FROM orders GROUP BY customer_id",
&shape(&["customer_id", "n"], 5),
&restater,
&token,
)
.await;
let log = restater.log();
assert_eq!(log.len(), 1, "restate ran, judge did not");
assert!(matches!(log[0], Call::Restate { .. }));
assert!(
rt.restatement.is_none(),
"a cancelled round-trip carries no restatement"
);
assert!(rt.verdict.is_none());
assert!(!rt.diverged, "cancellation is never a divergence");
}
#[tokio::test]
async fn restate_receives_only_sql_and_shape_never_values() {
let sql = "SELECT name, grade FROM students WHERE grade = 'A'";
let shape = shape(&["name", "grade"], 3);
let cell_value = "Alice";
let restater = ScriptedRestater::new()
.restate_returns(Some("which students got an A?".into()))
.judge_returns(None);
round_trip(
"names and grades of A students",
sql,
&shape,
&restater,
&CancellationToken::new(),
)
.await;
let log = restater.log();
let Call::Restate {
sql: seen_sql,
shape: seen_shape,
} = &log[0]
else {
panic!("first call must be restate, got {:?}", log[0]);
};
assert_eq!(seen_sql.as_str(), sql);
assert_eq!(seen_shape.row_count, shape.row_count);
assert_eq!(seen_shape.columns, shape.columns);
assert!(
!seen_sql.contains(cell_value),
"cell value leaked into the SQL"
);
assert!(
seen_shape.columns.iter().all(|c| !c.contains(cell_value)),
"cell value leaked into the column names"
);
}
#[test]
fn restate_prompt_asks_for_a_plain_language_question_and_nothing_else() {
let p = super::prompts::RESTATE_PROMPT;
assert!(
p.contains("plain-language question"),
"must ask for a plain-language question"
);
assert!(
p.to_lowercase().contains("nothing else"),
"must demand the question and nothing else"
);
assert!(p.contains("hedging"), "must forbid hedging");
assert!(
!p.contains("{question}"),
"the restate prompt must not reference the question"
);
}
#[test]
fn judge_prompt_asks_yes_no_one_sentence_and_judges_substance_not_wording() {
let p = super::prompts::JUDGE_PROMPT;
assert!(p.contains("YES or NO"), "must ask for a yes/no");
assert!(
p.contains("one") && p.contains("sentence"),
"must ask for one sentence alongside the yes/no"
);
assert!(
p.contains("counted") && p.contains("filtered") && p.contains("grouped"),
"must call out counting, filtering, and grouping as the substance that matters"
);
assert!(
p.contains("wording"),
"must say wording differences do not matter"
);
}