Skip to content

Commit 6bbae97

Browse files
ArghDA M2: Cubical-Agda backend — the flag-profile island (#36)
## What & why Cubical Agda (`--cubical --safe`, **not** `--without-K`) as a distinct backend, `agda-cubical`. That completes the Agda family and the fifth backend overall. **Design note:** implemented as a separate `AgdaCubical` type rather than the plan's `Agda::cubical()` constructor. A distinct type expresses the *island* better — a `--cubical --safe` file and a `--safe --without-K` file cannot import each other; Agda enforces this at typecheck and arghda surfaces the resulting `Error` honestly rather than pretending the edge is sound — and it avoids churning ~19 `Agda` value-uses across the tree. ## Changes - **`src/prover/agda.rs`** — `AgdaCubical` shares `invoke_agda()` (now adds `--cubical`) and all graph methods with `Agda`; differs only in name, `safe_mode` (`--cubical --safe`), and lint pack. - **`src/lint/safe_pragma.rs`** — `SafePragma` is now parameterised by required OPTIONS flags: `standard()` = `[--safe, --without-K]`, `cubical()` = `[--cubical, --safe]`. A cubical file no longer trips a spurious "missing --without-K" (which would be *wrong* — cubical is incompatible with it), and a standard file is still flagged if it lacks either. - **`src/lint/mod.rs`** — `agda_cubical_rules()` swaps in the cubical pragma; the rest of the pack is shared via a private `agda_pack()`. - **`src/main.rs`** — `--backend agda-cubical` wired; `about`/help updated. ## Verified (ran, not inferred) - `cargo test` → **107 passing, 0 failed** (79 lib + 28 integration; +3 safe_pragma unit covering the standard/cubical split, +1 cubical-identity unit, +3 cubical integration). - `cargo clippy -D warnings` clean · `cargo fmt --check` clean · `check-spdx.sh` OK. - **Dogfooded real agda:** `check --backend agda-cubical` on a `--cubical --safe` file → `proven-eligible`; the standard `agda` backend on the *same* file → `rejected` (`missing-safe-pragma: missing --without-K`). The island distinction demonstrated end-to-end. ## Milestone status Five backends live across both interaction models: `agda`, `agda-cubical`, `idris2` (Assistant); `z3`, `cvc5` (Solver). Next: finish M0 (`arghda doctor`), then M6 (Lean4) / M11 (studio JSON + Groove). --- _Generated by [Claude Code](https://claude.ai/code/session_012MpYSh6Wy8YMBH2E3qVyT7)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 21f306b commit 6bbae97

9 files changed

Lines changed: 332 additions & 70 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ milestones = [
4242
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 },
45-
{ name = "M2: Cubical-Agda variant (Agda::cubical(), --cubical --safe island)", completion = 0 },
45+
{ name = "M2: Cubical-Agda variant (Agda::cubical(), --cubical --safe island)", completion = 100 },
4646
{ 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 = 90 },
4848
{ name = "M5: SMT solver backends (Z3, CVC5): SMT-LIB2 -> sat/unsat/unknown -> Verdict", completion = 100 },
@@ -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+
# M2 landed 2026-07-01: Cubical-Agda variant. Implemented as a SEPARATE backend
64+
# `AgdaCubical` (name "agda-cubical") rather than the plan's `Agda::cubical()`
65+
# constructor — a distinct type expresses the flag-profile ISLAND better (a
66+
# `--cubical --safe` file and a `--safe --without-K` file cannot import each
67+
# other; Agda enforces this at typecheck and arghda surfaces the resulting
68+
# Error honestly) and avoids churning ~19 `Agda` value-uses. `--cubical --safe`,
69+
# NOT `--without-K`. src/prover/agda.rs: `AgdaCubical` shares invoke_agda()
70+
# (adds `--cubical`) + the graph methods with `Agda`; differs in name/safe_mode/
71+
# lint pack. src/lint/safe_pragma.rs: `SafePragma` parameterised by required
72+
# flags — `standard()` = [--safe,--without-K], `cubical()` = [--cubical,--safe];
73+
# lint::agda_cubical_rules() swaps it in. Wired via `--backend agda-cubical`.
74+
# 107 tests (79 lib + 28 integration; +3 safe_pragma unit +1 cubical-identity
75+
# unit +3 cubical integration). Dogfooded REAL: `check --backend agda-cubical`
76+
# on a --cubical --safe file → proven-eligible; the standard `agda` backend on
77+
# the SAME file → rejected (missing-safe-pragma: missing --without-K). The
78+
# island distinction is demonstrated end-to-end.
79+
#
6380
# M5 landed 2026-07-01: SMT solver backends (Z3, CVC5) — the FIRST Solver-kind
6481
# backends, exercising the other half of the taxonomy. src/prover/smt.rs: one
6582
# `Smt { name, cmd }` type with `Smt::z3()`/`Smt::cvc5()`. Kind=Solver, ext
@@ -135,13 +152,13 @@ milestones = [
135152

136153
[critical-next-actions]
137154
actions = [
138-
"M1 (Backend trait) + M3 (reasoning graph) + M4 (Idris2) + M5 (Z3/CVC5) DONE. Both interaction models (Assistant + Solver) now real. Next:",
139-
"Flying-Logic epic M2: Cubical-Agda variant (Agda::cubical(), --cubical --safe island; cross-flag import edges forbidden). Small — extends the existing Agda backend.",
155+
"M1 + M2 + M3 + M4 + M5 DONE. Five backends across both interaction models: agda, agda-cubical, idris2 (Assistant); z3, cvc5 (Solver). Next:",
156+
"Finish M0: add the `arghda doctor` Rust subcommand — report which of the five backends are actually runnable (probe each binary), repoint `just doctor` at it, feed the Groove manifest. Closes the last M0 gap.",
140157
"Flying-Logic epic M6: Lean4 adapter (lake build + #print axioms audit → honest Unknown soundness when the audit is absent). Lean 4.13.0 provisioned.",
141-
"Finish M0: add the `arghda doctor` Rust subcommand (report which backends are runnable — now agda/idris2/z3/cvc5) and repoint `just doctor` at it.",
158+
"Flying-Logic epic M11: studio JSON freeze (reason/0.1) + Groove /.well-known/groove manifest (the one open v0.1 milestone).",
142159
"M4 follow-on: .ipkg-declared roots; totality-hole (?name) + per-def `partial` lint.",
143160
"M3 follow-on: feed real verdicts into `reason` from a workspace `proven/` state + wire staleness from the content-hash closure (proven.rs).",
144-
"Serve /.well-known/groove for PanLL discovery (Groove protocol) — folds into M11.",
161+
"M8-M10 heavy tail (Coq/Rocq, Isabelle, Mizar) — gated on --heavy provisioning.",
145162
]
146163

147164
[maintenance-status]
@@ -150,9 +167,9 @@ last-result = "pass" # unknown | pass | warn | fail
150167
open-warnings = 0
151168
open-failures = 0
152169
# `just check` (fmt-check + clippy -D warnings + build + test + SPDX) green
153-
# post-M5: 75 lib + 25 integration = 100 tests pass. Dogfooded via real CLI on
154-
# all four backends: agda + idris2 (Assistant), z3 + cvc5 (Solver). Dogfooded
155-
# historically against echo-types + agda-unused.
170+
# post-M2: 79 lib + 28 integration = 107 tests pass. Dogfooded via real CLI on
171+
# all five backends: agda + agda-cubical + idris2 (Assistant), z3 + cvc5
172+
# (Solver). Dogfooded historically against echo-types + agda-unused.
156173

157174
[ecosystem]
158175
part-of = ["arghda"]

src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ pub use diagnostic::{Diagnostic, LintReport, Severity};
3030
pub use event::{Event, EventKind};
3131
pub use graph::{build as build_graph, ImportGraph};
3232
pub use lint::{default_rules, rules_with_config, run_lints, LintRule, RuleConfig};
33-
pub use prover::{default_backend, Agda, Backend, BackendKind, Idris2, Outcome, Smt, Verdict};
33+
pub use prover::{
34+
default_backend, Agda, AgdaCubical, Backend, BackendKind, Idris2, Outcome, Smt, Verdict,
35+
};
3436
pub use reason::{build as build_reason, Junct, ReasonDocument, ReasonEdge, ReasonNode};
3537
pub use workspace::{StaleEntry, State, Workspace};

src/lint/mod.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,10 @@ impl Default for RuleConfig {
4747
}
4848
}
4949

50-
/// The standard lint pack, parameterised by operator config. Fails only if a
51-
/// supplied pattern (e.g. `headline_pattern`) is not a valid regex.
52-
pub fn rules_with_config(cfg: &RuleConfig) -> Result<Vec<Box<dyn LintRule>>> {
50+
/// The Agda lint pack around a given `missing-safe-pragma` profile.
51+
fn agda_pack(cfg: &RuleConfig, safe: safe_pragma::SafePragma) -> Result<Vec<Box<dyn LintRule>>> {
5352
Ok(vec![
54-
Box::new(safe_pragma::SafePragma),
53+
Box::new(safe),
5554
Box::new(orphan_module::OrphanModule),
5655
Box::new(postulate::UnjustifiedPostulate),
5756
Box::new(escape_hatch::EscapeHatch),
@@ -62,6 +61,20 @@ pub fn rules_with_config(cfg: &RuleConfig) -> Result<Vec<Box<dyn LintRule>>> {
6261
])
6362
}
6463

64+
/// The standard `--safe --without-K` Agda lint pack, parameterised by
65+
/// operator config. Fails only if a supplied pattern (e.g.
66+
/// `headline_pattern`) is not a valid regex.
67+
pub fn rules_with_config(cfg: &RuleConfig) -> Result<Vec<Box<dyn LintRule>>> {
68+
agda_pack(cfg, safe_pragma::SafePragma::standard())
69+
}
70+
71+
/// The Cubical-Agda (`--cubical --safe`) lint pack — identical to the
72+
/// standard pack except the `missing-safe-pragma` rule requires `--cubical`
73+
/// instead of `--without-K`.
74+
pub fn agda_cubical_rules(cfg: &RuleConfig) -> Result<Vec<Box<dyn LintRule>>> {
75+
agda_pack(cfg, safe_pragma::SafePragma::cubical())
76+
}
77+
6578
/// The standard lint pack with default config. The default pattern is a
6679
/// known-good literal, so this is infallible.
6780
pub fn default_rules() -> Vec<Box<dyn LintRule>> {

src/lint/safe_pragma.rs

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,43 @@
11
// SPDX-License-Identifier: MPL-2.0
22
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
33

4+
//! `missing-safe-pragma` (hard-block) — require the soundness OPTIONS pragma.
5+
//!
6+
//! Parameterised by the flags a file's `{-# OPTIONS … #-}` must carry.
7+
//! Standard Agda demands `--safe --without-K`; Cubical Agda demands
8+
//! `--cubical --safe` (cubical is incompatible with `--without-K`, so
9+
//! requiring it there would be wrong). Both profiles share the rule name so
10+
//! the reasoning graph / DAG treat them uniformly.
11+
412
use super::{LintContext, LintRule};
513
use crate::diagnostic::{Diagnostic, LintReport, Severity};
614
use anyhow::{Context, Result};
715
use std::fs;
816
use std::path::Path;
917

10-
pub struct SafePragma;
18+
/// Requires a specific set of flags in an OPTIONS pragma.
19+
pub struct SafePragma {
20+
required: &'static [&'static str],
21+
}
1122

1223
const HEAD_LINES_SCANNED: usize = 30;
1324

25+
impl SafePragma {
26+
/// Standard `--safe --without-K` Agda.
27+
pub const fn standard() -> Self {
28+
Self {
29+
required: &["--safe", "--without-K"],
30+
}
31+
}
32+
33+
/// Cubical Agda: `--cubical --safe` (no `--without-K`).
34+
pub const fn cubical() -> Self {
35+
Self {
36+
required: &["--cubical", "--safe"],
37+
}
38+
}
39+
}
40+
1441
impl LintRule for SafePragma {
1542
fn name(&self) -> &'static str {
1643
"missing-safe-pragma"
@@ -20,37 +47,37 @@ impl LintRule for SafePragma {
2047
let contents =
2148
fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
2249

23-
let mut safe_seen = false;
24-
let mut without_k_seen = false;
25-
50+
let mut seen = vec![false; self.required.len()];
2651
for line in contents.lines().take(HEAD_LINES_SCANNED) {
2752
if !line.trim_start().starts_with("{-#") {
2853
continue;
2954
}
3055
if line.contains("OPTIONS") {
31-
if line.contains("--safe") {
32-
safe_seen = true;
33-
}
34-
if line.contains("--without-K") {
35-
without_k_seen = true;
56+
for (i, flag) in self.required.iter().enumerate() {
57+
if line.contains(flag) {
58+
seen[i] = true;
59+
}
3660
}
3761
}
3862
}
3963

40-
if !safe_seen || !without_k_seen {
41-
let missing = match (safe_seen, without_k_seen) {
42-
(false, false) => "--safe and --without-K",
43-
(false, true) => "--safe",
44-
(true, false) => "--without-K",
45-
_ => unreachable!(),
46-
};
64+
let missing: Vec<&str> = self
65+
.required
66+
.iter()
67+
.zip(&seen)
68+
.filter(|(_, &s)| !s)
69+
.map(|(flag, _)| *flag)
70+
.collect();
71+
72+
if !missing.is_empty() {
4773
report.push(Diagnostic {
4874
rule: self.name().to_string(),
4975
severity: Severity::HardBlock,
5076
file: file.to_path_buf(),
5177
message: format!(
5278
"missing {} pragma in first {} lines",
53-
missing, HEAD_LINES_SCANNED
79+
missing.join(" and "),
80+
HEAD_LINES_SCANNED
5481
),
5582
line: None,
5683
});
@@ -59,3 +86,60 @@ impl LintRule for SafePragma {
5986
Ok(())
6087
}
6188
}
89+
90+
#[cfg(test)]
91+
mod tests {
92+
use super::*;
93+
use crate::lint::run_lints;
94+
use std::path::PathBuf;
95+
96+
fn lint_with(rule: SafePragma, body: &str) -> LintReport {
97+
let tmp = tempfile::NamedTempFile::new().unwrap();
98+
std::fs::write(tmp.path(), body).unwrap();
99+
let roots: [PathBuf; 0] = [];
100+
let ctx = LintContext {
101+
include_root: tmp.path().parent().unwrap(),
102+
entry_modules: &roots,
103+
};
104+
let rules: Vec<Box<dyn LintRule>> = vec![Box::new(rule)];
105+
run_lints(tmp.path(), &ctx, &rules).unwrap()
106+
}
107+
108+
#[test]
109+
fn standard_requires_safe_and_without_k() {
110+
let ok = lint_with(
111+
SafePragma::standard(),
112+
"{-# OPTIONS --safe --without-K #-}\nmodule M where\n",
113+
);
114+
assert!(ok.diagnostics.is_empty());
115+
let bad = lint_with(SafePragma::standard(), "module M where\n");
116+
assert!(bad.has_hard_block());
117+
}
118+
119+
#[test]
120+
fn standard_rejects_a_cubical_file() {
121+
// A cubical file lacks --without-K, so the standard rule flags it.
122+
let r = lint_with(
123+
SafePragma::standard(),
124+
"{-# OPTIONS --cubical --safe #-}\nmodule M where\n",
125+
);
126+
assert!(r.has_hard_block());
127+
assert!(r.diagnostics[0].message.contains("--without-K"));
128+
}
129+
130+
#[test]
131+
fn cubical_accepts_cubical_and_rejects_without_k_only() {
132+
let ok = lint_with(
133+
SafePragma::cubical(),
134+
"{-# OPTIONS --cubical --safe #-}\nmodule M where\n",
135+
);
136+
assert!(ok.diagnostics.is_empty(), "cubical pragma must pass");
137+
// A standard --safe --without-K file lacks --cubical: flagged here.
138+
let bad = lint_with(
139+
SafePragma::cubical(),
140+
"{-# OPTIONS --safe --without-K #-}\nmodule M where\n",
141+
);
142+
assert!(bad.has_hard_block());
143+
assert!(bad.diagnostics[0].message.contains("--cubical"));
144+
}
145+
}

src/main.rs

Lines changed: 11 additions & 8 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, build_reason, event, run_lints, unused, watcher, Agda, Backend, Idris2, LintRule,
8-
RuleConfig, Smt, State, Workspace,
7+
build_dag, build_reason, event, run_lints, unused, watcher, Agda, AgdaCubical, Backend, Idris2,
8+
LintRule, RuleConfig, Smt, State, Workspace,
99
};
1010
use clap::{Parser, Subcommand};
1111
use std::path::{Path, PathBuf};
@@ -20,18 +20,21 @@ type RuleSet = Vec<Box<dyn LintRule>>;
2020
fn backend_for(name: &str) -> Result<Box<dyn Backend>> {
2121
match name {
2222
"agda" => Ok(Box::new(Agda)),
23+
"agda-cubical" => Ok(Box::new(AgdaCubical)),
2324
"idris2" => Ok(Box::new(Idris2)),
2425
"z3" => Ok(Box::new(Smt::z3())),
2526
"cvc5" => Ok(Box::new(Smt::cvc5())),
26-
other => anyhow::bail!("unknown backend `{other}` (known: agda, idris2, z3, cvc5)"),
27+
other => {
28+
anyhow::bail!("unknown backend `{other}` (known: agda, agda-cubical, idris2, z3, cvc5)")
29+
}
2730
}
2831
}
2932

3033
#[derive(Parser)]
3134
#[command(
3235
name = "arghda",
3336
version,
34-
about = "Proof-workspace manager for provers/solvers (Agda, Idris2, Z3, CVC5)"
37+
about = "Proof-workspace manager for provers/solvers (Agda, Cubical, Idris2, Z3, CVC5)"
3538
)]
3639
struct Cli {
3740
#[command(subcommand)]
@@ -58,7 +61,7 @@ enum Cmd {
5861
/// `<PATH>/.arghda/config.toml` if present.
5962
#[arg(long)]
6063
config: Option<PathBuf>,
61-
/// Prover/solver backend: `agda` (default), `idris2`, `z3`, `cvc5`.
64+
/// Backend: `agda` (default), `agda-cubical`, `idris2`, `z3`, `cvc5`.
6265
#[arg(long, default_value = "agda")]
6366
backend: String,
6467
/// Also run the external `agda-unused` analyser and re-emit its
@@ -77,7 +80,7 @@ enum Cmd {
7780
/// Include root (search path). Defaults to the file's directory.
7881
#[arg(long)]
7982
include_root: Option<PathBuf>,
80-
/// Prover/solver backend: `agda` (default), `idris2`, `z3`, `cvc5`.
83+
/// Backend: `agda` (default), `agda-cubical`, `idris2`, `z3`, `cvc5`.
8184
#[arg(long, default_value = "agda")]
8285
backend: String,
8386
/// Emit the report as JSON.
@@ -100,7 +103,7 @@ enum Cmd {
100103
/// `<PATH>/.arghda/config.toml` if present.
101104
#[arg(long)]
102105
config: Option<PathBuf>,
103-
/// Prover/solver backend: `agda` (default), `idris2`, `z3`, `cvc5`.
106+
/// Backend: `agda` (default), `agda-cubical`, `idris2`, `z3`, `cvc5`.
104107
#[arg(long, default_value = "agda")]
105108
backend: String,
106109
},
@@ -123,7 +126,7 @@ enum Cmd {
123126
/// `<PATH>/.arghda/config.toml` if present.
124127
#[arg(long)]
125128
config: Option<PathBuf>,
126-
/// Prover/solver backend: `agda` (default), `idris2`, `z3`, `cvc5`.
129+
/// Backend: `agda` (default), `agda-cubical`, `idris2`, `z3`, `cvc5`.
127130
#[arg(long, default_value = "agda")]
128131
backend: String,
129132
/// Run the backend on every node to populate REAL prover verdicts

0 commit comments

Comments
 (0)