Skip to content

Commit ad7d949

Browse files
ArghDA M8 follow-on: dependency-ordered Coq compilation (M8 → 97%) (#50)
## What Multi-file Coq projects are now checkable. Before this, a bare `coqc B.v` where `B` `Require`s an in-tree sibling `A` errored `Cannot find a physical path bound to logical path A` (ground-truthed) — so any Coq file with a local dependency verdicted `Error`. ## How `check_file` now stages the target and its in-tree transitive `Require` deps into a temp build tree, compiles the deps in **topological order** (deps before dependents; `coq_transitive_deps` is a cycle-safe post-order DFS) so each `.vo` exists, then checks the target against `-R <build> ""`. It all happens under temp, so the source tree stays clean. External stdlib/MML requires resolve on coqc's own load path (skipped as non-in-tree). ## Honesty preserved — a transitive admit is NOT whitewashed This is the load-bearing verification. Dogfooded: a dependent whose dep contains `Admitted` gets a per-file `check` verdict of `proven` (the dependent's own content is sound), **but the reasoning graph demotes its EFFECTIVE verdict to `admitted`** via the And-edge to the admitted dep: ``` Bad self=admitted effective=admitted UsesBad self=proven effective=admitted (edge UsesBad → Bad) ``` So dep-ordering makes projects *checkable* without letting a transitive admit ride through green — the self/effective split does its job. ## Verification (actually run) - `cargo fmt --check` / `clippy -D warnings` — clean - `cargo test` — **174 pass** (+1 topo-order unit) - `check-spdx.sh` — OK **Dogfooded vs real coqc 8.18.0:** | case | result | |---|---| | `check C.v` in a 3-module `A ← B ← C` chain of real `Qed` proofs | `proven` (was `error` before) | | `reason --check` over the chain | A / B / C all `proven` | | dependent of an `Admitted` module | per-file `proven`, **effective `admitted`** | ## Scope M8 → 97%. Residual: `_CoqProject`-driven custom `-Q`/`-R` prefix remapping (the empty-prefix `Foo.Bar` ↔ `Foo/Bar.v` convention is fully working; only non-default logical prefixes remain a follow-on). Part of the heavy-tail completion cuts — next: Mizar prel export (M10), then Isabelle multi-session (M9). 🤖 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 27bbca0 commit ad7d949

2 files changed

Lines changed: 161 additions & 39 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ milestones = [
4848
{ name = "M5: SMT solver backends (Z3, CVC5): SMT-LIB2 -> sat/unsat/unknown -> Verdict", completion = 100 },
4949
{ name = "M6: Lean4 adapter (lake build + #print axioms audit)", completion = 100 },
5050
{ name = "M7: Echidna dispatch seam (optional route to orchestrator :8090, same Outcome)", completion = 70 },
51-
{ name = "M8: Coq/Rocq adapter (--heavy provisioning; Section-aware postulate classifier)", completion = 90 },
51+
{ name = "M8: Coq/Rocq adapter (--heavy provisioning; Section-aware postulate classifier)", completion = 97 },
5252
{ name = "M9: Isabelle adapter (--heavy; dogfood tropical-resource-typing .thy)", completion = 90 },
5353
{ name = "M10: Mizar adapter (detect-only first; highest uncertainty)", completion = 85 },
5454
{ name = "M11: studio JSON freeze (reason/0.1) + Groove manifest", completion = 100 },
@@ -114,6 +114,26 @@ milestones = [
114114
# --backend idris2` over an ipkg fixture → crt_roots ['App'], App+Helper wired.
115115
# M4 now 95% (remaining: totality-hole `?name` + per-def `partial` lint).
116116
#
117+
# M8 FOLLOW-ON landed 2026-07-01 (M8 90% → 97%): dependency-ordered Coq
118+
# compilation — multi-file projects are now checkable. Was: a bare `coqc B.v`
119+
# where B `Require`s an in-tree A errored "Cannot find a physical path bound to
120+
# logical path A" (ground-truthed). Now check_file stages the target + its
121+
# in-tree transitive `Require` deps into a temp build tree, compiles the deps in
122+
# TOPOLOGICAL order (deps before dependents; `coq_transitive_deps` = post-order
123+
# DFS, cycle-safe) so each `.vo` exists, then checks the target against `-R
124+
# <build> ""` — all under temp, source tree stays clean. External stdlib/MML
125+
# requires resolve on coqc's own load path (skipped as non-in-tree). CRUCIAL
126+
# honesty check (dogfooded): a dependent whose dep has an `Admitted` is NOT
127+
# whitewashed — per-file `check UsesBad.v` = proven (UsesBad's own content is
128+
# sound) but the reasoning graph demotes its EFFECTIVE verdict to admitted via
129+
# the And-edge to Bad (self=admitted). So dep-ordering makes projects checkable
130+
# WITHOUT letting a transitive admit ride through green — the self/effective
131+
# split does its job. 174 tests (+1 topo-order unit). Dogfooded vs real coqc
132+
# 8.18.0: a 3-module A←B←C chain of real Qed proofs → C=proven, reason --check
133+
# → A/B/C all proven. Residual 3%: `_CoqProject`-driven custom `-Q`/`-R` prefix
134+
# remapping (the empty-prefix `Foo.Bar`↔`Foo/Bar.v` convention is fully working;
135+
# only non-default logical prefixes remain a follow-on).
136+
#
117137
# M4 FOLLOW-ON landed 2026-07-01 (M4 → 100%): the Idris2 escape-hatch lint now
118138
# also flags a per-definition `partial` modifier (totality opt-out, distinct
119139
# from the already-handled file-wide `%default partial` directive) and totality
@@ -433,7 +453,7 @@ actions = [
433453
"M9 (Isabelle): DONE (90%) — Isabelle2025 provisioned + adapter built + dogfooded (proven/admitted/postulated/error) via session-build; ROOT-based roots, imports-clause edges. Remaining: session-qualified imports + dependency-ordered multi-session builds.",
434454
"M10 (Mizar): DONE (85%) — turned out to be a REAL verify, not detect-only: static i386 verifier runs on x86_64; provisioned + adapter (accom+verifier+.err) + dogfooded (proven/error). HEAVY TAIL M8-M10 COMPLETE. Remaining: local-article prel export.",
435455
"M7 client INVESTIGATED 2026-07-01 → BLOCKED (stub stays): no confirmed Echidna HTTP contract exists (dispatch.rs says API unconfirmed; ECOSYSTEM.a2ml gives no endpoints/schema; the echidna repo is out of this session's access scope). Building a client would mean inventing an unverifiable contract — forbidden. Unblock: a published Echidna API + a reachable instance to dogfood; then wire Dispatch::run behind a cargo feature. ROADMAP TERMINUS — all tractable items done; M7's client is the sole residual, externally blocked.",
436-
"Deeper cuts deliberately scoped out (available if the owner wants them): Coq _CoqProject logical-path resolution + dependency-ordered compilation; Mizar local-article prel export (miz2prel); Isabelle session-qualified imports + multi-session builds; and the arghda-studio visual layer that consumes the frozen reason/0.1 JSON.",
456+
"HEAVY-TAIL COMPLETION CUTS (owner asked for these 2026-07-01, doing foundation-first, one PR each, real-tool dogfood): [DONE] Coq dependency-ordered compilation (M8 90→97%); [NEXT] Mizar local-article prel export via miz2prel so cross-article refs check (M10 85→~100%); [THEN] Isabelle session-qualified imports + multi-session builds (M9 90→~100%). Residual after: Coq _CoqProject custom -Q/-R prefixes (3%); arghda-studio visual layer (separate repo, out of this session's scope; AffineScript).",
437457
]
438458

439459
[maintenance-status]
@@ -442,8 +462,10 @@ last-result = "pass" # unknown | pass | warn | fail
442462
open-warnings = 0
443463
open-failures = 0
444464
# `just check` (fmt-check + clippy -D warnings + build + test + SPDX) green
445-
# post-M4-followon: 130 lib + 43 integration = 173 tests pass. M4 totality-hole
446-
# + partial lint dogfooded vs real idris2 0.7.0 (green --check capped to amber).
465+
# post-M8-deporder: 131 lib + 43 integration = 174 tests pass. M8 dependency-
466+
# ordered Coq compilation dogfooded vs real coqc 8.18.0 (A←B←C chain → all
467+
# proven; transitive admit demotes the dependent's effective verdict, not
468+
# whitewashed). M4 totality-hole + partial lint dogfooded vs real idris2 0.7.0.
447469
# M6 lake-env import resolution dogfooded vs real Lean 4.13.0 + Lake 5.0.0
448470
# (imported file in a built lake project → proven; outside → error). codeql.yml added
449471
# (code-scanning gate fix). M10 Mizar adapter dogfooded vs real Mizar 8.1.15

src/prover/rocq.rs

Lines changed: 135 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
//! that declares `Axiom`/`Parameter`/`Conjecture` postulates — all exit 0.
1010
//! So the honest verdict, ground-truthed against Coq 8.18.0, is:
1111
//! * exit non-zero → [`Verdict::Error`] (the kernel rejected something, or an
12-
//! import could not be resolved — a bare single-file `coqc` cannot build a
13-
//! dependency chain; that is a documented limitation, not a proof).
12+
//! in-tree dependency failed to build — see the dependency-ordered build
13+
//! below; a genuinely unresolved external import is an honest error, not a
14+
//! proof).
1415
//! * exit 0, source contains `Admitted`/`admit` → [`Verdict::Admitted`] (an
1516
//! unfinished proof rode along on a green compile).
1617
//! * exit 0, source contains a genuine unverified postulate → [`Verdict::Postulated`].
@@ -34,10 +35,14 @@
3435
//! Import edges come only from `Require` (with or without `Import`/`Export`,
3536
//! and the `From P Require …` form); a bare `Import`/`Export` is a namespace
3637
//! directive, not a dependency edge (as Lean's `open` is), so it is ignored.
37-
//! `_CoqProject`-driven logical-path resolution and dependency-ordered
38-
//! compilation are documented follow-ons; this baseline compiles a single
39-
//! file with `-R <root> ""` and honestly reports `Error` when a dependency it
40-
//! cannot build is required.
38+
//! Dependency-ordered compilation: [`Backend::check_file`] stages the target
39+
//! and its in-tree transitive `Require` deps into a temp build tree, compiles
40+
//! the deps in topological order (so each `Require`d sibling has its `.vo`),
41+
//! then checks the target against the same load path — all under temp, so the
42+
//! source tree stays clean. External (stdlib/MML) requires resolve on coqc's
43+
//! own load path. `_CoqProject`-driven logical-path remapping (custom `-Q`/`-R`
44+
//! prefixes) remains a documented follow-on; this uses the empty-prefix
45+
//! convention (`Foo.Bar` ↔ `Foo/Bar.v` under the root).
4146
4247
use super::{Backend, BackendKind, Outcome, Verdict};
4348
use crate::graph;
@@ -76,39 +81,61 @@ impl Backend for Coq {
7681
}
7782

7883
fn check_file(&self, file: &Path, include_root: &Path) -> Result<Outcome> {
79-
// Redirect the compiled `.vo` into a temp *directory* so the source
80-
// tree stays clean, and map the include root to the empty logical
81-
// prefix so a sibling `Require` at least has a chance to resolve.
82-
// `coqc -o` insists the target basename equal the source's (only the
83-
// directory may differ), so keep the stem and vary the directory.
84+
// Stage the target and its in-tree `Require` dependencies into a temp
85+
// build tree, compile the deps in topological order (deps before
86+
// dependents) so each `Require`d sibling has its `.vo`, then check the
87+
// target against the same load path. The whole build happens under
88+
// temp, so the source tree stays clean. External (stdlib/MML) requires
89+
// are left for coqc's own load path.
90+
let src = fs::read_to_string(file).unwrap_or_default();
8491
let nanos = SystemTime::now()
8592
.duration_since(UNIX_EPOCH)
8693
.map(|d| d.as_nanos())
8794
.unwrap_or(0);
88-
let out_dir =
95+
let build =
8996
std::env::temp_dir().join(format!("arghda-coq-{}-{}", std::process::id(), nanos));
90-
let _ = fs::create_dir_all(&out_dir);
91-
let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("Check");
92-
let vo_out = out_dir.join(format!("{stem}.vo"));
93-
94-
let output = Command::new("coqc")
95-
.arg("-q") // ignore any coqrc
96-
.arg("-no-glob") // don't drop a .glob next to the source
97-
.arg("-R")
98-
.arg(include_root)
99-
.arg("")
100-
.arg("-o")
101-
.arg(&vo_out)
102-
.arg(file)
103-
.output();
104-
105-
let _ = fs::remove_dir_all(&out_dir);
97+
if fs::create_dir_all(&build).is_err() {
98+
return Ok(Outcome::unavailable(BackendKind::Assistant));
99+
}
100+
101+
// The target's module name (relative to the include root), and its
102+
// in-tree transitive Require deps in dependency-first order.
103+
let target_module = self.module_name_of(file, include_root).unwrap_or_else(|| {
104+
file.file_stem()
105+
.and_then(|s| s.to_str())
106+
.unwrap_or("Check")
107+
.into()
108+
});
109+
let deps = coq_transitive_deps(&parse_requires(&src), include_root);
110+
111+
// Stage every dep + the target into the build tree at its module path.
112+
for m in &deps {
113+
let dst = coq_module_path(m, &build);
114+
if let Some(parent) = dst.parent() {
115+
let _ = fs::create_dir_all(parent);
116+
}
117+
let _ = fs::copy(coq_module_path(m, include_root), dst);
118+
}
119+
let target_dst = coq_module_path(&target_module, &build);
120+
if let Some(parent) = target_dst.parent() {
121+
let _ = fs::create_dir_all(parent);
122+
}
123+
let _ = fs::copy(file, &target_dst);
124+
125+
// Compile the deps first (best-effort: a dep that fails to build
126+
// surfaces honestly as a `Require` error when the target is checked).
127+
for m in &deps {
128+
let _ = coqc_build(&coq_module_path(m, &build), &build);
129+
}
130+
131+
// Check the target against the built deps.
132+
let output = coqc_build(&target_dst, &build);
133+
let _ = fs::remove_dir_all(&build);
106134

107135
match output {
108136
Ok(out) => {
109137
let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
110138
combined.push_str(&String::from_utf8_lossy(&out.stderr));
111-
let src = fs::read_to_string(file).unwrap_or_default();
112139
let verdict = coq_verdict(&src, out.status.success());
113140
Ok(Outcome {
114141
available: true,
@@ -131,12 +158,7 @@ impl Backend for Coq {
131158
}
132159

133160
fn module_to_path(&self, module: &str, include_root: &Path) -> PathBuf {
134-
let mut p = include_root.to_path_buf();
135-
for part in module.split('.') {
136-
p.push(part);
137-
}
138-
p.set_extension("v");
139-
p
161+
coq_module_path(module, include_root)
140162
}
141163

142164
fn direct_imports(&self, file: &Path) -> Result<Vec<String>> {
@@ -175,6 +197,65 @@ impl Backend for Coq {
175197
}
176198
}
177199

200+
/// Dotted Coq logical name → file path under `root` (`Foo.Bar` → `Foo/Bar.v`).
201+
fn coq_module_path(module: &str, root: &Path) -> PathBuf {
202+
let mut p = root.to_path_buf();
203+
for part in module.split('.') {
204+
p.push(part);
205+
}
206+
p.set_extension("v");
207+
p
208+
}
209+
210+
/// Compile one staged `.v` under the build tree, mapping the build root to the
211+
/// empty logical prefix so in-tree `Require`s resolve against the `.vo`s built
212+
/// alongside. `-no-glob` keeps the tree free of `.glob` output.
213+
fn coqc_build(file: &Path, build_root: &Path) -> std::io::Result<std::process::Output> {
214+
Command::new("coqc")
215+
.arg("-q")
216+
.arg("-no-glob")
217+
.arg("-R")
218+
.arg(build_root)
219+
.arg("")
220+
.arg(file)
221+
.output()
222+
}
223+
224+
/// The in-tree transitive `Require` dependencies of a file, given its direct
225+
/// imports, in dependency-first (topological) order — deps appear before the
226+
/// modules that require them, so compiling the list left-to-right satisfies
227+
/// every `Require`. External modules (stdlib/MML, absent under `include_root`)
228+
/// are skipped. Cycle-safe via the visited set (Coq forbids import cycles, but
229+
/// a half-edited file could transiently produce one).
230+
fn coq_transitive_deps(direct: &[String], include_root: &Path) -> Vec<String> {
231+
let mut visited = std::collections::HashSet::new();
232+
let mut order = Vec::new();
233+
for m in direct {
234+
coq_visit_dep(m, include_root, &mut visited, &mut order);
235+
}
236+
order
237+
}
238+
239+
fn coq_visit_dep(
240+
module: &str,
241+
include_root: &Path,
242+
visited: &mut std::collections::HashSet<String>,
243+
order: &mut Vec<String>,
244+
) {
245+
if !visited.insert(module.to_string()) {
246+
return;
247+
}
248+
// Only in-tree modules resolve to a readable file; external ones are left
249+
// for coqc's own load path.
250+
let Ok(src) = fs::read_to_string(coq_module_path(module, include_root)) else {
251+
return;
252+
};
253+
for imp in parse_requires(&src) {
254+
coq_visit_dep(&imp, include_root, visited, order);
255+
}
256+
order.push(module.to_string());
257+
}
258+
178259
/// Map Coq source + exit status to a [`Verdict`], honestly: a green compile is
179260
/// `Proven` (the kernel checked every `Qed`) *unless* an escape hatch is
180261
/// present — `Admitted`/`admit` ⇒ `Admitted`, a genuine postulate ⇒
@@ -524,6 +605,25 @@ End OrderedField.\n";
524605
assert_eq!(count_genuine_postulates("Parameter foo.\n"), 1);
525606
}
526607

608+
#[test]
609+
fn transitive_deps_are_topologically_ordered() {
610+
// C requires B; B requires A; A is a leaf. The build order for C must
611+
// be [A, B] (deps before dependents); external requires are skipped.
612+
let tmp = tempfile::tempdir().unwrap();
613+
let r = tmp.path();
614+
std::fs::write(r.join("A.v"), "Definition a : nat := 0.\n").unwrap();
615+
std::fs::write(r.join("B.v"), "Require Import A.\nDefinition b := a.\n").unwrap();
616+
std::fs::write(
617+
r.join("C.v"),
618+
"Require Import B.\nRequire Import Coq.Lists.List.\nDefinition c := b.\n",
619+
)
620+
.unwrap();
621+
let deps = coq_transitive_deps(&parse_requires("Require Import B.\n"), r);
622+
assert_eq!(deps, vec!["A".to_string(), "B".to_string()]);
623+
// The external stdlib require is not staged as an in-tree dep.
624+
assert!(!deps.iter().any(|m| m.contains("List")));
625+
}
626+
527627
#[test]
528628
fn check_file_is_honest_about_availability() {
529629
let tmp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)