Skip to content

Commit 0aa9ff2

Browse files
ArghDA M3 follow-on: workspace-fed verdicts + staleness in reason (#43)
## What The Flying-Logic reasoning graph can now source **real prover verdicts from a four-state workspace's `proven/` state** without re-running any tool, and demote a **stale-proven** node back to `unknown`. Honest by construction — a verdict only ever comes from a recorded promotion, and staleness always dominates a stale `Proven`. This is the last of the three refinement follow-ons (M4 `.ipkg` roots #41, M6 axiom audit #42, M3 workspace verdicts). ## How - **`reason::workspace_verdicts(dag, proven, stale) -> (verdicts, stale)`** — a pure mapping (no I/O, unit-testable): a node whose file basename is in the workspace's `proven/` set → `Proven`; if that same file is stale (content/closure hash changed since promotion) it is added to the returned stale set, so the existing demote-only fold lowers it to `Unknown`. Matched by file basename (documented collision caveat). Re-exported from `lib.rs`. - **`arghda reason <path> --workspace <ws>`** — reads the proven + stale-proven basename sets off the workspace (`Workspace::list(Proven)` / `Workspace::stale_proven`) and feeds them into `workspace_verdicts`. `--check` stays authoritative: a fresh typecheck overrides the workspace verdict and clears staleness for that node. - **Honest evidence strings** — `"prover: proven"` for a fresh promotion; `"prover: proven; stale: closure hash changed"` for a demoted stale one. ## Verification (actually run) - `cargo fmt --check` — clean - `cargo clippy --all-targets -- -D warnings` — clean - `cargo test` — **130 pass** (+1 reason unit: proven lights the cone, stale demotes) - `bash scripts/check-spdx.sh .` — OK **Dogfooded end-to-end** on a real workspace + Agda source tree: - Scenario A (fresh proven leaf): `Good` → `effective: proven` (`"prover: proven"`); un-proven root `All` → `unknown`. - Scenario B (proven file edited on disk → stale): `Good` demoted to `unknown` with `"prover: proven; stale: closure hash changed"`. ## Scope Additive only. `M3` base was already 100%; this refinement wires the reasoning graph to the triage workspace's ground truth. No change to the `reason/0.1` JSON contract keys. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012MpYSh6Wy8YMBH2E3qVyT7 --- _Generated by [Claude Code](https://claude.ai/code/session_012MpYSh6Wy8YMBH2E3qVyT7)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 58e2e91 commit 0aa9ff2

4 files changed

Lines changed: 145 additions & 9 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,22 @@ milestones = [
100100
# --backend idris2` over an ipkg fixture → crt_roots ['App'], App+Helper wired.
101101
# M4 now 95% (remaining: totality-hole `?name` + per-def `partial` lint).
102102
#
103+
# M3 FOLLOW-ON landed 2026-07-01: workspace-fed verdicts + staleness in `reason`
104+
# — the reasoning graph now sources REAL verdicts from a four-state workspace's
105+
# `proven/` state, no re-check needed. New `arghda reason <path> --workspace
106+
# <ws>`: pure mapping `reason::workspace_verdicts(dag, proven, stale)` sets a
107+
# node whose file basename is in `proven/` to Proven (evidence "prover: proven"),
108+
# and if that same file is stale (content/closure hash changed since promotion,
109+
# via `Workspace::stale_proven`) inserts it into the stale set so the demote-only
110+
# fold lowers it back to Unknown (evidence "prover: proven; stale: closure hash
111+
# changed"). Matched by file basename (documented collision caveat). `--check`
112+
# stays authoritative — a fresh typecheck overrides + clears staleness. Honest by
113+
# construction: verdicts come only from a recorded promotion, never fabricated;
114+
# staleness dominates a stale Proven. 130 tests (+1 reason unit: proven lights
115+
# the cone, stale demotes). Dogfooded: fresh proven leaf → effective proven, un-
116+
# proven root → unknown; edit the proven file on disk → Good demoted to unknown
117+
# with the stale evidence string. M3 base was 100%; this is pure additive refinement.
118+
#
103119
# M7 landed 2026-07-01 (70%): the Echidna dispatch seam — completes the
104120
# engine-spine deliverable (M0-M7 + M11). src/dispatch.rs: `Dispatch { Local,
105121
# Echidna { base_url } }` + `run(backend, file, include_root) -> Outcome`
@@ -260,7 +276,7 @@ actions = [
260276
"M7 follow-on: the real Echidna orchestrator HTTP client (feature-gated), once Echidna's API is confirmed — only Dispatch::run changes.",
261277
"M6 follow-on: #print axioms audit DONE (import-free files promote honestly); remaining = lake-env/LEAN_PATH so imported files can be checked + audited too.",
262278
"M4 follow-on: .ipkg-declared roots DONE; remaining = totality-hole (?name) + per-def `partial` lint.",
263-
"M3 follow-on: feed real verdicts into `reason` from a workspace `proven/` state + wire staleness from the content-hash closure (proven.rs).",
279+
"M3 follow-on: DONE — `reason --workspace <ws>` sources real verdicts from a `proven/` state (workspace_verdicts) + demotes stale-proven via the content-hash closure. Refinement follow-ons bucket now complete.",
264280
"M8-M10 heavy tail (Coq/Rocq, Isabelle, Mizar) — gated on --heavy provisioning (Coq via opam ~1hr; Isabelle ~4GB; Mizar niche).",
265281
]
266282

@@ -270,8 +286,9 @@ last-result = "pass" # unknown | pass | warn | fail
270286
open-warnings = 0
271287
open-failures = 0
272288
# `just check` (fmt-check + clippy -D warnings + build + test + SPDX) green
273-
# post-M6-followon: 95 lib + 34 integration = 129 tests pass. codeql.yml added
274-
# (code-scanning gate fix). Lean #print axioms audit dogfooded. doctor/groove/dispatch
289+
# post-M3-followon: 96 lib + 34 integration = 130 tests pass. codeql.yml added
290+
# (code-scanning gate fix). reason --workspace verdicts+staleness dogfooded.
291+
# Lean #print axioms audit dogfooded. doctor/groove/dispatch
275292
# dogfooded: 6/6 backends runnable; groove writes /.well-known/groove; --dispatch
276293
# local real, echidna honest-stub. All six backends dogfooded via real CLI: agda
277294
# + agda-cubical + idris2 + lean4 (Assistant), z3 + cvc5 (Solver). Dogfooded

src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,7 @@ pub use prover::{
3838
default_backend, Agda, AgdaCubical, Backend, BackendKind, Idris2, Lean, Outcome, Probe, Smt,
3939
Verdict,
4040
};
41-
pub use reason::{build as build_reason, Junct, ReasonDocument, ReasonEdge, ReasonNode};
41+
pub use reason::{
42+
build as build_reason, workspace_verdicts, Junct, ReasonDocument, ReasonEdge, ReasonNode,
43+
};
4244
pub use workspace::{StaleEntry, State, Workspace};

src/main.rs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,12 @@ enum Cmd {
151151
/// `echidna[=<url>]`.
152152
#[arg(long, default_value = "local")]
153153
dispatch: String,
154+
/// A four-state workspace whose `proven/` state supplies verdicts
155+
/// WITHOUT re-checking: a node whose file is proven (and fresh) →
156+
/// `proven`; proven-but-stale (content/closure hash changed) →
157+
/// demoted to `unknown`. Matched by file basename.
158+
#[arg(long)]
159+
workspace: Option<PathBuf>,
154160
},
155161
/// Claim a file: inbox -> working.
156162
Claim { workspace: PathBuf, file: String },
@@ -242,6 +248,7 @@ fn main() -> Result<()> {
242248
backend,
243249
check,
244250
dispatch,
251+
workspace,
245252
} => reason(
246253
&path,
247254
&entry,
@@ -250,6 +257,7 @@ fn main() -> Result<()> {
250257
&backend,
251258
check,
252259
&dispatch,
260+
workspace.as_deref(),
253261
)?,
254262
Cmd::Claim { workspace, file } => {
255263
transition(&workspace, &file, State::Inbox, State::Working)?
@@ -542,6 +550,7 @@ fn dag(
542550
Ok(())
543551
}
544552

553+
#[allow(clippy::too_many_arguments)]
545554
fn reason(
546555
include_root: &Path,
547556
entry: &[PathBuf],
@@ -550,6 +559,7 @@ fn reason(
550559
backend_name: &str,
551560
do_check: bool,
552561
dispatch: &str,
562+
workspace: Option<&Path>,
553563
) -> Result<()> {
554564
let backend = backend_for(backend_name)?;
555565
let (roots, rules, cfg) = resolve_roots_and_rules(
@@ -567,19 +577,37 @@ fn reason(
567577
backend.as_ref(),
568578
)?;
569579

570-
// Real prover verdicts by module id. Empty unless `--check`, in which
571-
// case each node is typechecked for real (honest exit codes) — a green
572-
// node is only ever `proven` because the tool returned 0. Staleness
573-
// needs a workspace `proven/` state, so it stays empty here.
580+
// Real prover verdicts by module id, plus the stale set.
574581
let mut verdicts = std::collections::BTreeMap::new();
575-
let stale = std::collections::BTreeSet::new();
582+
let mut stale = std::collections::BTreeSet::new();
583+
584+
// Source 1 — a workspace's `proven/` state (no re-check). The pure
585+
// mapping lives in `reason::workspace_verdicts`; here we just read the
586+
// proven + stale basename sets off the workspace and feed them in.
587+
if let Some(ws_path) = workspace {
588+
let ws = Workspace::open(ws_path)?;
589+
let proven: std::collections::BTreeSet<String> = ws
590+
.list(State::Proven)?
591+
.iter()
592+
.filter_map(|p| p.file_name().and_then(|s| s.to_str()).map(String::from))
593+
.collect();
594+
let stale_names: std::collections::BTreeSet<String> =
595+
ws.stale_proven()?.into_iter().map(|e| e.file).collect();
596+
let (wv, ws_stale) = arghda_core::workspace_verdicts(&dag_doc, &proven, &stale_names);
597+
verdicts.extend(wv);
598+
stale.extend(ws_stale);
599+
}
600+
601+
// Source 2 — `--check`: typecheck each node for real (honest exit codes).
602+
// A fresh check is authoritative, so it overrides workspace verdicts.
576603
if do_check {
577604
let route = Dispatch::parse(dispatch)?;
578605
for node in &dag_doc.nodes {
579606
let file = include_root.join(&node.file);
580607
let outcome = route.run(backend.as_ref(), &file, include_root)?;
581608
if outcome.available {
582609
verdicts.insert(node.id.clone(), outcome.verdict);
610+
stale.remove(&node.id); // a fresh real verdict is not stale
583611
}
584612
}
585613
}

src/reason/mod.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,37 @@ pub fn build(
316316
}
317317
}
318318

319+
/// Derive reasoning inputs from a workspace's `proven/` state, WITHOUT
320+
/// re-checking: a node whose file basename is in `proven_basenames` gets a
321+
/// `Proven` self-verdict; if that basename is also in `stale_basenames`
322+
/// (content/closure hash changed since promotion) the node id is added to the
323+
/// returned stale set, so [`build`]'s fold demotes it to `Unknown` and
324+
/// propagates. Returns `(verdicts, stale)` for [`build`]. Matched by basename
325+
/// — the workspace stores files flat by basename, so distinct modules that
326+
/// share a basename would collide (a documented limitation).
327+
pub fn workspace_verdicts(
328+
dag: &DagDocument,
329+
proven_basenames: &BTreeSet<String>,
330+
stale_basenames: &BTreeSet<String>,
331+
) -> (BTreeMap<String, Verdict>, BTreeSet<String>) {
332+
let mut verdicts = BTreeMap::new();
333+
let mut stale = BTreeSet::new();
334+
for node in &dag.nodes {
335+
let base = node
336+
.file
337+
.file_name()
338+
.and_then(|s| s.to_str())
339+
.unwrap_or_default();
340+
if proven_basenames.contains(base) {
341+
verdicts.insert(node.id.clone(), Verdict::Proven);
342+
if stale_basenames.contains(base) {
343+
stale.insert(node.id.clone());
344+
}
345+
}
346+
}
347+
(verdicts, stale)
348+
}
349+
319350
/// The set of ids reachable from any root by following adjacency edges.
320351
fn reachable(roots: &[String], adj: &BTreeMap<String, Vec<(String, Junct)>>) -> BTreeSet<String> {
321352
let mut seen: BTreeSet<String> = BTreeSet::new();
@@ -592,4 +623,62 @@ mod tests {
592623
assert!(v["edges"][0].get(k).is_some(), "reason edge missing `{k}`");
593624
}
594625
}
626+
627+
// ── M3 follow-on: workspace-fed verdicts + staleness ─────────────────
628+
fn two_node_dag() -> crate::dag::DagDocument {
629+
use crate::dag::{DagDocument, DagNode, LintSummary};
630+
use crate::graph::Edge;
631+
use std::path::PathBuf;
632+
let mk = |id: &str, file: &str| DagNode {
633+
id: id.to_string(),
634+
file: PathBuf::from(file),
635+
status: "clean",
636+
lint: LintSummary::default(),
637+
headlines: vec![],
638+
};
639+
DagDocument {
640+
version: "0.1",
641+
include_root: PathBuf::from("/r"),
642+
entry_modules: vec![PathBuf::from("/r/All.agda")],
643+
generated_at: "t".to_string(),
644+
nodes: vec![mk("All", "All.agda"), mk("Good", "Good.agda")],
645+
edges: vec![Edge {
646+
from: "All".to_string(),
647+
to: "Good".to_string(),
648+
kind: "imports",
649+
}],
650+
blocked: vec![],
651+
}
652+
}
653+
654+
#[test]
655+
fn workspace_proven_lights_the_cone_and_stale_demotes() {
656+
use crate::prover::Agda;
657+
let dag = two_node_dag();
658+
let proven: BTreeSet<String> = ["Good.agda".to_string()].into_iter().collect();
659+
660+
// Fresh proven: Good → Proven; All (not proven) → not set.
661+
let (v, s) = workspace_verdicts(&dag, &proven, &BTreeSet::new());
662+
assert_eq!(v.get("Good"), Some(&Verdict::Proven));
663+
assert!(!v.contains_key("All"));
664+
assert!(s.is_empty());
665+
let doc = build(dag.clone(), &Agda, &v, &s);
666+
let eff =
667+
|d: &ReasonDocument, id: &str| d.nodes.iter().find(|n| n.id == id).unwrap().effective;
668+
assert_eq!(eff(&doc, "Good"), Verdict::Proven);
669+
assert_eq!(eff(&doc, "All"), Verdict::Unknown); // not proven itself
670+
671+
// Stale proven: Good is proven-but-stale → demoted to Unknown, and it
672+
// drags its importer All down too.
673+
let stale: BTreeSet<String> = ["Good.agda".to_string()].into_iter().collect();
674+
let (v2, s2) = workspace_verdicts(&dag, &proven, &stale);
675+
assert!(s2.contains("Good"));
676+
let doc2 = build(dag, &Agda, &v2, &s2);
677+
assert_eq!(
678+
eff(&doc2, "Good"),
679+
Verdict::Unknown,
680+
"stale proven → unknown"
681+
);
682+
assert_eq!(eff(&doc2, "All"), Verdict::Unknown);
683+
}
595684
}

0 commit comments

Comments
 (0)