use aristo_core::critique::{CritiqueFile, Severity};
use aristo_core::index::{AnnotationId, IndexEntry, IndexFile};
#[derive(Debug)]
pub(crate) struct CritiqueReport {
pub failures: Vec<CritiqueFailure>,
}
impl CritiqueReport {
fn empty() -> Self {
Self {
failures: Vec::new(),
}
}
fn push(&mut self, location: String, detail: String) {
self.failures.push(CritiqueFailure { location, detail });
}
pub fn is_empty(&self) -> bool {
self.failures.is_empty()
}
pub fn render(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(
out,
"critique rejected ({} check(s) failed):",
self.failures.len()
);
for f in &self.failures {
let _ = writeln!(out, " • {}: {}", f.location, f.detail);
}
out
}
}
#[derive(Debug)]
pub(crate) struct CritiqueFailure {
pub location: String,
pub detail: String,
}
#[aristo::intent(
"The critique validator gates every write on schema integrity: \
category and severity must be known enum variants, the focal id \
must resolve in the current index, and every finding must carry a \
non-empty rationale. The rationale gate is load-bearing — dropping \
it would let agents emit categorized but uninformative critiques, \
which are just noise. There is no proof-tree integrity check as in \
the verify validator, because findings carry no derivations.",
verify = "neural",
id = "critique_validator_checks_schema_and_focal_id_in_index"
)]
pub(crate) fn validate(
focal_id: &AnnotationId,
cf: &CritiqueFile,
index: &IndexFile,
) -> CritiqueReport {
let mut r = CritiqueReport::empty();
let Some(entry) = index.entries.get(focal_id) else {
r.push(
"critique.focal_id".into(),
format!("`{}` is not in the current index", focal_id.as_str()),
);
return r;
};
let cur_text_hash = match entry {
IndexEntry::Intent(e) => &e.text_hash,
IndexEntry::Assume(e) => &e.text_hash,
};
if &cf.critique.critiqued_at_text_hash != cur_text_hash {
r.push(
"critique.critiqued_at_text_hash".into(),
format!(
"differs from current index text_hash; the annotation \
text drifted since this critique was produced — re-run \
`aristo critique --filter id={}` to refresh",
focal_id.as_str()
),
);
}
for (i, f) in cf.critique.findings.iter().enumerate() {
if f.rationale.trim().is_empty() {
r.push(
format!("critique.findings[{i}].rationale"),
"is empty; every finding must include a rationale".into(),
);
}
if matches!(f.category, aristo_core::critique::Category::Rephrasing)
&& f.suggested_text
.as_ref()
.is_none_or(|s| s.trim().is_empty())
{
r.push(
format!("critique.findings[{i}]"),
"category=`rephrasing` requires a non-empty `suggested_text`; \
use category=`clarity` or `info` for findings that don't \
propose specific replacement text"
.into(),
);
}
}
r
}
#[aristo::intent(
"On accept, the SDK derives the finding count and the highest \
severity from the submitted findings: the count is the number of \
findings, and the highest severity is the maximum across them. \
Agents may submit these fields explicitly (the schema accepts \
them), but the SDK overwrites them — a single source of truth, \
with no agent/SDK disagreement on derived state. With no \
findings, the count is zero and the highest severity is absent.",
verify = "neural",
id = "critique_derived_fields_stamped_by_sdk_not_agent"
)]
pub(crate) fn stamp_derived(cf: &mut CritiqueFile) {
cf.critique.finding_count = Some(cf.critique.findings.len() as u32);
cf.critique.highest_severity = highest_severity(&cf.critique.findings);
}
fn highest_severity(findings: &[aristo_core::critique::Finding]) -> Option<Severity> {
findings.iter().map(|f| f.severity).max()
}