use std::fs;
use std::path::{Path, PathBuf};
use aristo_core::hash::body_hash;
use aristo_core::index::{AnnotationId, IndexEntry, IndexFile, Status};
use aristo_core::proof::{Ground, ProofFile, VerdictType};
use crate::commands::index::atomic_write;
use crate::commands::verify::validator::{
parse_line_range, slice_lines, validate, ValidatorReport,
};
use crate::{CliError, CliResult, Workspace};
pub(crate) fn run_apply_verdicts(
ws: &Workspace,
index: &IndexFile,
rewrite_hashes: bool,
) -> CliResult<()> {
let proofs_dir = ws.aristo_dir().join("proofs");
if !proofs_dir.is_dir() {
println!("ok: no pending verdict files in .aristo/proofs/.");
return Ok(());
}
let mut accepted = 0usize;
let mut rejected = 0usize;
let mut updates: Vec<(AnnotationId, Status)> = Vec::new();
let mut rejections: Vec<(PathBuf, ValidatorReport)> = Vec::new();
let mut parse_errors: Vec<(PathBuf, String)> = Vec::new();
for path in collect_proof_files(&proofs_dir)? {
let id = match id_from_filename(&path) {
Some(id) => id,
None => {
parse_errors.push((path.clone(), "filename is not <id>.proof".into()));
continue;
}
};
let raw = fs::read_to_string(&path).map_err(|e| CliError::Other {
message: format!("read {}: {e}", path.display()),
exit_code: 1,
})?;
let mut pf = match ProofFile::parse(&raw) {
Ok(p) => p,
Err(e) => {
parse_errors.push((path.clone(), format!("parse: {e}")));
continue;
}
};
if rewrite_hashes {
clear_ground_hashes(&mut pf);
}
let report = validate(&id, &pf, index, &ws.root);
if !report.is_empty() {
rejections.push((path, report));
rejected += 1;
continue;
}
let stamped = stamp_ground_hashes(&mut pf, index, &ws.root);
if stamped > 0 || rewrite_hashes {
write_proof_atomic(&path, &pf)?;
}
let new_status = derived_status(&pf);
updates.push((id, new_status));
accepted += 1;
}
if !updates.is_empty() {
apply_status_updates(ws, index, &updates)?;
aristo::intent_stmt!(
"After `--apply-verdicts` accepts a proof, sweep any straggler \
queue state for that id: (a) delete the .proof.bak from the \
prior attempt, (b) clear the claimed/<id>.toml (no-op if the \
worker already cleared it via submit-verdict), (c) clear any \
pending/<id>.toml that survived an out-of-band submit (rare; \
happens if a user manually wrote a .proof file without going \
through the queue pop/submit cycle). Belt-and-suspenders: \
the submit-verdict path is the primary clear, this is the \
safety net for replay and out-of-band-submit cases.",
verify = "neural",
id = "apply_sweeps_queue_stragglers_after_accept"
);
let qdir = crate::pipeline::queue::QueueDir::for_pipeline(
ws,
crate::commands::verify::pending::PIPELINE_NAME,
);
for (id, _) in &updates {
let bak = crate::commands::verify::pending::proof_bak_path_for(ws, id);
if bak.is_file() {
let _ = std::fs::remove_file(&bak);
}
crate::pipeline::queue::submit_done(&qdir, id)?;
let pending_path = qdir.pending_path(id);
if pending_path.is_file() {
let _ = std::fs::remove_file(&pending_path);
}
}
}
print_summary(accepted, rejected, parse_errors.len());
for (path, msg) in &parse_errors {
eprintln!("error: {}: {}", path.display(), msg);
}
for (path, rep) in &rejections {
eprintln!("error: {}: {}", path.display(), rep.render());
}
if rejected > 0 || !parse_errors.is_empty() {
Err(CliError::Silent { exit_code: 1 })
} else {
Ok(())
}
}
#[aristo::intent(
"Migration-only `--rewrite-hashes` clears every stored ground hash \
before validation so the staleness check is skipped, then the \
post-accept stamping pass repopulates them from current source. \
Without this flag, stamped hashes act as freshness anchors and \
mismatches are rejected as staleness — the strict default. The \
flag is documented as migration-only to discourage routine use; \
routine `--apply-verdicts` relies on the staleness check.",
verify = "test",
id = "rewrite_hashes_flag_is_migration_only_strict_default"
)]
fn clear_ground_hashes(pf: &mut ProofFile) {
for_each_step_mut(pf, |step| {
for g in step.grounds.iter_mut() {
match g {
Ground::Intent { at_text_hash, .. } | Ground::Assume { at_text_hash, .. } => {
*at_text_hash = None;
}
Ground::Code { code_text_hash, .. } => *code_text_hash = None,
_ => {}
}
}
});
}
pub(crate) fn stamp_ground_hashes(
pf: &mut ProofFile,
index: &IndexFile,
workspace_root: &Path,
) -> usize {
let mut count = 0;
for_each_step_mut(pf, |step| {
for g in step.grounds.iter_mut() {
match g {
Ground::Intent {
id, at_text_hash, ..
}
| Ground::Assume {
id, at_text_hash, ..
} if at_text_hash.is_none() => {
if let Some(entry) = index.entries.get(id) {
*at_text_hash = Some(entry_text_hash(entry));
count += 1;
}
}
Ground::Code {
file,
lines,
code_text_hash,
..
} if code_text_hash.is_none() => {
if let Some(h) = compute_code_hash(file, lines, workspace_root) {
*code_text_hash = Some(h);
count += 1;
}
}
_ => {}
}
}
});
count
}
fn for_each_step_mut(
pf: &mut ProofFile,
mut visit: impl FnMut(&mut aristo_core::proof::ProofStep),
) {
if let Some(v) = pf.verified.as_mut() {
for s in v.proof.steps.iter_mut() {
visit(s);
}
}
if let Some(c) = pf.counterexample.as_mut() {
for s in c.violation.trigger_steps.iter_mut() {
visit(s);
}
}
if let Some(i) = pf.inconclusive.as_mut() {
if let Some(p) = i.partial_proof.as_mut() {
for s in p.steps.iter_mut() {
visit(s);
}
}
}
}
fn entry_text_hash(entry: &IndexEntry) -> aristo_core::index::Sha256 {
match entry {
IndexEntry::Intent(e) => e.text_hash.clone(),
IndexEntry::Assume(e) => e.text_hash.clone(),
}
}
fn compute_code_hash(
file: &str,
lines: &str,
workspace_root: &Path,
) -> Option<aristo_core::index::Sha256> {
let path = workspace_root.join(file);
let source = fs::read_to_string(&path).ok()?;
let (lo, hi) = parse_line_range(lines)?;
if hi > source.lines().count() {
return None;
}
Some(body_hash(&slice_lines(&source, lo, hi)))
}
pub(crate) fn write_proof_atomic(path: &Path, pf: &ProofFile) -> CliResult<()> {
let toml_text = pf.to_toml().map_err(|e| CliError::Other {
message: format!("serializing {}: {e}", path.display()),
exit_code: 1,
})?;
atomic_write(path, &toml_text)
}
fn collect_proof_files(dir: &Path) -> CliResult<Vec<PathBuf>> {
let mut out = Vec::new();
let entries = fs::read_dir(dir).map_err(|e| CliError::Other {
message: format!("read_dir {}: {e}", dir.display()),
exit_code: 1,
})?;
for entry in entries {
let entry = entry.map_err(|e| CliError::Other {
message: format!("read_dir entry: {e}"),
exit_code: 1,
})?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("proof") {
out.push(path);
}
}
out.sort(); Ok(out)
}
#[aristo::intent(
"Filename ↔ AnnotationId mapping uses `:` → `__` substitution \
(same convention as .aristo/doc/<id-safe>.md per TOOLS.md §I1). \
A refactor that picks a different scheme (e.g., URL-encoding) \
would break every previously-written `.proof` file on disk — \
proof files are tracked in git, so any mapping change is a \
migration, not a free refactor.",
verify = "test",
id = "proof_file_id_uses_colon_underscore_underscore"
)]
fn id_from_filename(path: &Path) -> Option<AnnotationId> {
let stem = path.file_stem()?.to_str()?;
let id_str = stem.replace("__", ":");
AnnotationId::parse(&id_str).ok()
}
fn derived_status(pf: &ProofFile) -> Status {
match pf.verdict.r#type {
VerdictType::Verified => Status::Neural, VerdictType::Counterexample => Status::Counterexample,
VerdictType::Inconclusive => Status::Inconclusive,
}
}
fn apply_status_updates(
ws: &Workspace,
index: &IndexFile,
updates: &[(AnnotationId, Status)],
) -> CliResult<()> {
let mut new_index = index.clone();
for (id, status) in updates {
if let Some(entry) = new_index.entries.get_mut(id) {
set_status(entry, *status);
}
}
let toml_text = toml::to_string_pretty(&new_index).map_err(|e| CliError::Other {
message: format!("serializing index.toml: {e}"),
exit_code: 1,
})?;
atomic_write(&ws.index_path(), &toml_text)
}
fn set_status(entry: &mut IndexEntry, status: Status) {
match entry {
IndexEntry::Intent(e) => e.status = status,
IndexEntry::Assume(e) => e.status = status,
}
}
fn print_summary(accepted: usize, rejected: usize, parse_errors: usize) {
let total = accepted + rejected + parse_errors;
if total == 0 {
println!("ok: no verdict files in .aristo/proofs/.");
} else {
println!(
"applied: {accepted}/{total} verdict(s) ({rejected} rejected, {parse_errors} unparseable)."
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_from_filename_handles_plain_id() {
let p = PathBuf::from("/tmp/foo.proof");
let id = id_from_filename(&p).unwrap();
assert_eq!(id.as_str(), "foo");
}
#[test]
fn id_from_filename_recovers_namespaced_id() {
let p = PathBuf::from("/tmp/aristos__balance_no_dups.proof");
let id = id_from_filename(&p).unwrap();
assert_eq!(id.as_str(), "aristos:balance_no_dups");
}
#[test]
fn derived_status_maps_each_verdict_type() {
fn pf_with(t: VerdictType) -> ProofFile {
use aristo_core::hash::text_hash;
use aristo_core::index::VerifyMethod;
use aristo_core::proof::{
Gap, InconclusiveBody, PropertyKind, VerdictMeta, VerifiedBody,
};
ProofFile {
verdict: VerdictMeta {
r#type: t,
method: VerifyMethod::Neural,
produced_at_text_hash: text_hash(""),
produced_at_body_hash: text_hash(""),
produced_by: "x".into(),
verifier_model: None,
attempts: 1,
property_kind: PropertyKind::Invariant,
},
verified: matches!(t, VerdictType::Verified).then(|| VerifiedBody {
proof: aristo_core::proof::Proof {
conclusion: "x".into(),
steps: vec![],
},
}),
counterexample: None,
inconclusive: matches!(t, VerdictType::Inconclusive).then(|| InconclusiveBody {
partial_proof: None,
gap: Gap {
description: "x".into(),
unfilled_path: "0".into(),
suggested_annotations: vec![],
},
}),
}
}
assert_eq!(
derived_status(&pf_with(VerdictType::Verified)),
Status::Neural
);
assert_eq!(
derived_status(&pf_with(VerdictType::Counterexample)),
Status::Counterexample
);
assert_eq!(
derived_status(&pf_with(VerdictType::Inconclusive)),
Status::Inconclusive
);
}
}