Skip to content

Commit 417b011

Browse files
ArghDA M3: Flying-Logic reasoning graph — demote-only verdict propagation (#33)
## What & why The **headline capability** of the "Flying Logic for provers/solvers" epic, built on the M1 `Backend` trait (merged #32). A new `src/reason/` layer *wraps* (does not replace) the DAG: the `DagDocument` is embedded verbatim, and on top go a per-node `Verdict`, `And`/`Or` edge juncts, and a memoised, cycle-safe, **demote-only** propagation fold. ## The honesty invariant (enforced end-to-end) A node's effective verdict can only be **lowered** by its dependencies, never manufactured: - **lint-clean + no typecheck = `Unknown`**, never green. Only a real prover exit-0 makes `Proven`. - Only `Proven` is green; `Admitted`/`Postulated` are amber. - Because the fold is demote-only over `And` import edges, **structural amber infects downstream** — anything that transitively imports a `postulate` (or `missing-safe` / escape-hatch) module is itself capped. Visible *before any prover runs*. - `Or` groups are forgiving (one green route keeps a goal up). Import cycles degrade to `Error` and terminate. Staleness (closure-hash-changed `Proven` → `Unknown`) is applied pre-fold so it propagates. `wired` marks the CRT-root-reachable cone. ## Changes - `src/reason/mod.rs`: `Junct`, `ReasonNode` (`self_verdict`/`effective`/`verdict_evidence`/`soundness`/`wired`), `ReasonEdge`, `ReasonDocument` (`version:"0.1"`, embedded `dag`, `crt_roots`); the rank/demote/promote lattice; lint→verdict caps; the propagation fold. - `arghda reason <path> [--check]`: `--check` runs the backend per node for **real** exit-code verdicts; default is structure+lint-derived (all `unknown` on a clean tree, honestly). - `STATE.a2ml` records M3 = 100%. ## Verified (ran, not inferred) - `cargo test` → **81 passing, 0 failed** (61 lib + 20 integration; +9 reason unit tests covering demote / downstream-amber-infection / Or-forgiveness / cycle-termination / wiring / clean-but-unchecked→Unknown, +6 reason integration). - `cargo clippy -D warnings` clean · `cargo fmt --check` clean · `check-spdx.sh` OK. - **Dogfood** — `arghda reason tests/fixtures/wellformed --check` ran real `agda` typechecks → the whole cone `proven` + `wired`. ### A bug dogfooding caught (and I fixed) The first `--check` dogfood showed **every node `wired=false`** with `crt_roots=['tests.fixtures.wellformed.All']`. The CLI's `discover_roots` yields include-root-*relative* paths, which a stray `include_root.join` double-prefixed — so the CRT-root ids never matched the node ids. The unit/integration tests missed it because they passed *absolute* roots (where the join is a silent no-op). Fixed to match the orphan-module rule's convention (paths go straight to `module_name_of`), plus a regression test that exercises the `discover_roots` path. Re-dogfooded green. ## Next (all unblocked by the stable trait) M4 Idris2 · M5 SMT (Z3/CVC5) · M2 Cubical-Agda · M3 follow-on (feed real verdicts + staleness from a workspace `proven/` state so `reason` lights the cone without `--check`). --- _Generated by [Claude Code](https://claude.ai/code/session_012MpYSh6Wy8YMBH2E3qVyT7)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 75eac52 commit 417b011

5 files changed

Lines changed: 809 additions & 8 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ milestones = [
4343
{ name = "M0: provision-provers.sh (pinned, honest --version verification) + `just provision/doctor` recipes", completion = 80 },
4444
{ name = "M1: Backend trait (Assistant|Solver kinds; Outcome/Verdict); agda.rs -> prover/agda.rs; graph/dag take &dyn Backend (pure refactor, 50 tests stay green)", completion = 100 },
4545
{ name = "M2: Cubical-Agda variant (Agda::cubical(), --cubical --safe island)", completion = 0 },
46-
{ name = "M3: Flying-Logic reasoning graph (src/reason: And|Or juncts, demote-only cycle-safe Verdict propagation, additive studio JSON)", completion = 0 },
46+
{ name = "M3: Flying-Logic reasoning graph (src/reason: And|Or juncts, demote-only cycle-safe Verdict propagation, additive studio JSON)", completion = 100 },
4747
{ name = "M4: Idris2 adapter (core/ABI language: .ipkg/--check/totality; escape-hatches)", completion = 0 },
4848
{ name = "M5: SMT solver backends (Z3, CVC5): SMT-LIB2 -> sat/unsat/unknown -> Verdict", completion = 0 },
4949
{ name = "M6: Lean4 adapter (lake build + #print axioms audit)", completion = 0 },
@@ -60,6 +60,23 @@ milestones = [
6060
# Remaining M0: `arghda doctor` Rust subcommand (interim: `just doctor` shells
6161
# the script's --verify-only table).
6262
#
63+
# M3 landed 2026-07-01: the Flying-Logic reasoning graph (src/reason/) — the
64+
# headline capability. Wraps DagDocument verbatim (ReasonDocument.dag) + adds a
65+
# per-node Verdict, And|Or edge juncts, and a memoised, cycle-safe, DEMOTE-ONLY
66+
# propagation fold: a node's effective verdict can only be LOWERED by deps,
67+
# never manufactured. Only Proven is green; Admitted/Postulated amber. Honesty
68+
# rule enforced end-to-end: lint-clean + no typecheck = Unknown (never green).
69+
# Structural amber (unjustified-postulate → Postulated cap; missing-safe-pragma
70+
# / escape-hatch → Admitted) INFECTS downstream via And import edges — visible
71+
# before any prover runs. `arghda reason <path> [--check]` CLI: --check runs the
72+
# backend per node for REAL exit-code verdicts. Staleness (closure-hash-changed
73+
# proven → Unknown, pre-fold so it propagates) + wiring (reachable-from-CRT-root)
74+
# + crt_roots. 81 tests (61 lib + 20 integration; +9 reason unit +6 reason
75+
# integration), clippy/fmt/SPDX clean. Dogfood: `reason --check` over the
76+
# wellformed fixture ran real agda → all Proven/wired. A wiring bug (relative
77+
# discover_roots paths double-joined) was caught BY dogfooding and fixed +
78+
# regression-tested — the unit tests missed it (they used absolute paths).
79+
#
6380
# M1 landed 2026-07-01 as the promised PURE REFACTOR — behaviour unchanged,
6481
# oracle held: the 64 pre-existing tests (50 lib + 14 integration) all stay
6582
# green, +2 new Agda-backend tests = 66 passing; clippy -D warnings + fmt +
@@ -87,11 +104,11 @@ milestones = [
87104

88105
[critical-next-actions]
89106
actions = [
90-
"M1 keystone DONE (Backend trait, pure refactor, 66 tests green). Next, in parallel now that the trait is stable:",
91-
"Flying-Logic epic M3: src/reason reasoning graph (And|Or juncts + demote-only cycle-safe Verdict propagation over the common Verdict) — independent of new backends, lands the headline capability early.",
107+
"M1 (Backend trait) + M3 (reasoning graph) DONE. Next, all unblocked by the stable trait — pick per priority:",
92108
"Flying-Logic epic M4: Idris2 adapter (core/ABI language: .ipkg/--check/totality; believe_me/assert_total/%partial escape-hatches) — Idris2 v0.7.0 is provisioned.",
93109
"Flying-Logic epic M5: SMT solver backends (Z3, CVC5) — the Solver kind; SMT-LIB2 -> sat(Refuted)/unsat(Proven)/unknown(Unknown). Both binaries are provisioned.",
94110
"Flying-Logic epic M2: Cubical-Agda variant (Agda::cubical(), --cubical --safe island; cross-flag import edges forbidden).",
111+
"M3 follow-on: feed real verdicts into `reason` from a workspace `proven/` state + wire staleness from the content-hash closure (proven.rs) so `arghda reason <workspace>` lights the cone without --check.",
95112
"Finish M0: add the `arghda doctor` Rust subcommand and repoint `just doctor` at it (interim shells provision-provers.sh --verify-only).",
96113
"Serve /.well-known/groove for PanLL discovery (Groove protocol) — folds into M11.",
97114
]
@@ -102,9 +119,10 @@ last-result = "pass" # unknown | pass | warn | fail
102119
open-warnings = 0
103120
open-failures = 0
104121
# `just check` (fmt-check + clippy -D warnings + build + test + SPDX) green
105-
# post-M1: 52 lib + 14 integration = 66 tests pass. `dag`/`check` dogfooded
106-
# via real CLI runs (dag JSON byte-compatible; check ran a real agda typecheck
107-
# -> proven-eligible). Dogfooded historically against echo-types + agda-unused.
122+
# post-M3: 61 lib + 20 integration = 81 tests pass. Dogfooded via real CLI
123+
# runs: `dag` JSON byte-compatible; `check` + `reason --check` ran real agda
124+
# typechecks (proven-eligible / all-Proven+wired cone). Dogfooded historically
125+
# against echo-types + agda-unused.
108126

109127
[ecosystem]
110128
part-of = ["arghda"]

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ pub mod hash;
1919
pub mod lint;
2020
pub mod proven;
2121
pub mod prover;
22+
pub mod reason;
2223
pub mod timestamp;
2324
pub mod unused;
2425
pub mod watcher;
@@ -30,4 +31,5 @@ pub use event::{Event, EventKind};
3031
pub use graph::{build as build_graph, ImportGraph};
3132
pub use lint::{default_rules, rules_with_config, run_lints, LintRule, RuleConfig};
3233
pub use prover::{default_backend, Agda, Backend, BackendKind, Outcome, Verdict};
34+
pub use reason::{build as build_reason, Junct, ReasonDocument, ReasonEdge, ReasonNode};
3335
pub use workspace::{StaleEntry, State, Workspace};

src/main.rs

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
use anyhow::{Context, Result};
55
use arghda_core::lint::LintContext;
66
use arghda_core::{
7-
build_dag, event, run_lints, unused, watcher, Agda, Backend, LintRule, RuleConfig, State,
8-
Workspace,
7+
build_dag, build_reason, event, run_lints, unused, watcher, Agda, Backend, LintRule,
8+
RuleConfig, State, Workspace,
99
};
1010
use clap::{Parser, Subcommand};
1111
use std::path::{Path, PathBuf};
@@ -83,6 +83,31 @@ enum Cmd {
8383
#[arg(long)]
8484
config: Option<PathBuf>,
8585
},
86+
/// Emit the Flying-Logic reasoning graph (JSON) for the tree at PATH:
87+
/// the DAG plus a demote-only verdict propagation. Without `--check`,
88+
/// clean nodes are honestly `unknown`; postulate/pragma/escape-hatch
89+
/// caps still propagate downstream through import edges.
90+
Reason {
91+
/// Directory containing `.agda` files; treated as the include root.
92+
path: PathBuf,
93+
/// Root module for orphan detection; repeatable. Defaults to every
94+
/// `All.agda`/`Smoke.agda` discovered under PATH.
95+
#[arg(long)]
96+
entry: Vec<PathBuf>,
97+
/// Regex a name must match to count as a pinnable headline
98+
/// (`unpinned-headline` rule). Defaults to the spec pattern.
99+
#[arg(long)]
100+
headline_pattern: Option<String>,
101+
/// Path to an `.arghda/config.toml`. Defaults to
102+
/// `<PATH>/.arghda/config.toml` if present.
103+
#[arg(long)]
104+
config: Option<PathBuf>,
105+
/// Run the backend on every node to populate REAL prover verdicts
106+
/// (honest exit codes). Slower — typechecks each module. Off by
107+
/// default, in which case clean nodes are `unknown`.
108+
#[arg(long)]
109+
check: bool,
110+
},
86111
/// Claim a file: inbox -> working.
87112
Claim { workspace: PathBuf, file: String },
88113
/// Promote a file: working -> proven.
@@ -144,6 +169,19 @@ fn main() -> Result<()> {
144169
headline_pattern.as_deref(),
145170
config.as_deref(),
146171
)?,
172+
Cmd::Reason {
173+
path,
174+
entry,
175+
headline_pattern,
176+
config,
177+
check,
178+
} => reason(
179+
&path,
180+
&entry,
181+
headline_pattern.as_deref(),
182+
config.as_deref(),
183+
check,
184+
)?,
147185
Cmd::Claim { workspace, file } => {
148186
transition(&workspace, &file, State::Inbox, State::Working)?
149187
}
@@ -354,6 +392,45 @@ fn dag(
354392
Ok(())
355393
}
356394

395+
fn reason(
396+
include_root: &Path,
397+
entry: &[PathBuf],
398+
headline_pattern: Option<&str>,
399+
config: Option<&Path>,
400+
do_check: bool,
401+
) -> Result<()> {
402+
let backend = Agda;
403+
let (roots, rules, cfg) =
404+
resolve_roots_and_rules(include_root, entry, headline_pattern, config, &backend)?;
405+
let dag_doc = build_dag(
406+
include_root,
407+
&roots,
408+
&rules,
409+
&cfg.headline_pattern,
410+
&backend,
411+
)?;
412+
413+
// Real prover verdicts by module id. Empty unless `--check`, in which
414+
// case each node is typechecked for real (honest exit codes) — a green
415+
// node is only ever `proven` because the tool returned 0. Staleness
416+
// needs a workspace `proven/` state, so it stays empty here.
417+
let mut verdicts = std::collections::BTreeMap::new();
418+
let stale = std::collections::BTreeSet::new();
419+
if do_check {
420+
for node in &dag_doc.nodes {
421+
let file = include_root.join(&node.file);
422+
let outcome = backend.check_file(&file, include_root)?;
423+
if outcome.available {
424+
verdicts.insert(node.id.clone(), outcome.verdict);
425+
}
426+
}
427+
}
428+
429+
let doc = build_reason(dag_doc, &backend, &verdicts, &stale);
430+
println!("{}", serde_json::to_string_pretty(&doc)?);
431+
Ok(())
432+
}
433+
357434
/// Build the lint `RuleConfig` with precedence default < `.arghda/config.toml`
358435
/// < CLI flag. An explicit `--config` that does not exist is an error; the
359436
/// default discovery location (`<include_root>/.arghda/config.toml`) is

0 commit comments

Comments
 (0)