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 writes on schema integrity: enum \
values for category and severity (serde rejects unknown variants \
at parse time, so by the time we run the checks here those are \
already known to be in the locked set), the focal id resolves in \
the current index, and the rationale field is non-empty (a finding \
without a rationale is noise — silently dropping the requirement \
would let agents emit categorized-but-uninformative critiques). \
Unlike the verify validator there is no proof-tree integrity check \
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 `finding_count` from `findings.len()` \
and `highest_severity` from `findings.iter().map(|f| f.severity).max()`. \
Agents may submit these fields explicitly (the schema accepts them) \
but the SDK overwrites — single source of truth, no agent/SDK \
disagreement on derived state. None when findings is empty.",
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()
}