Skip to content

Commit 949b5ed

Browse files
proof(coupling): close impl⇄spec couplings #1#4 by differential conformance; #5 by-design (#121)
## What Closes the **implementation ⇄ spec coupling** obligations (`proofs/STATUS.md` §"Implementation ⇄ spec coupling") — making "the soundness lives in the compiler" literally true, by **differential conformance against the Coq-extracted verified artifacts** (the honest, reproducible alternative to an infeasible full Rust-in-Coq refinement proof). | # | Coupling | Status | |---|----------|--------| | 1 | Rust checker refines Coq `check` (R5) | ✅ conformance-checked | | 2 | Rust evaluator refines Coq `step` | ✅ conformance-checked | | 3 | Echo modality wired surface → verified checker | ✅ conformance-checked (verified-core surface) | | 4 | Rust session runtime refines Coq `cstep` (binary) | ✅ conformance-checked | | 5 | Concrete `me` surface | ✅ by-design — not a gap | ## Method The verified Coq functions are extracted to OCaml (`Extract.v`, `Separate Extraction`) and used as **independent oracles**; the Rust ports in `crates/my-qtt` are the implementations under test. One harness (`conformance/run.sh`) generates a random corpus and asserts byte-identical results. Where the spec is a *relation* (`step`, `cstep`), a functional mirror is first **proved sound + complete** vs the relation, then conformance-tested: - **`Eval.v`** — `step1` + `step1_sound`/`step1_complete` (vs `step`), axiom-free, in CI. - **`SessionEval.v`** — `cstep1` + `cstep1_sound`/`cstep1_complete` (vs `cstep`), axiom-free, in CI. **60,000 results across 5 seeds agree** (3000 cases × {check, one-step, normal-form, session-step}); the normal-form pass cross-checks the Rust substitution against Coq's extracted `subst`, and echo terms (#3) ride in the corpus. ## Files - Coq (new, CI-gated): `Eval.v`, `SessionEval.v`; `Extract.v` (extraction-only, not in suite). - Harness: `conformance/{oracle.ml,run.sh,README.adoc}`, `crates/my-qtt/src/bin/conformance_gen.rs`. - Rust verified-core: `crates/my-qtt/src/{lib.rs (evaluator),session.rs (new),surface.rs (echo tests)}`. - `proofs/STATUS.md`: rows #1#5 + new `conformance-checked` vocabulary + two mechanised-cores rows. ## Scope / honesty (carried in STATUS.md) Refinement-by-conformance against the *extracted verified algorithm*, not a full Rust-in-Coq proof. The *main* `checker.rs` (Hindley) still doesn't call the verified core by default; echo lacks conventional-compiler *syntax*; the n-ary `nstep`/`gstep` runtimes are a larger follow-on. These are explicitly labelled, not hidden. Requires `coqc` 8.18, `ocamlfind`+`ocamlc`, `cargo` (all in the proof CI image). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwV2DWsjkBiNP3oscimMLV --- _Generated by [Claude Code](https://claude.ai/code/session_01BwV2DWsjkBiNP3oscimMLV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4be6d02 commit 949b5ed

14 files changed

Lines changed: 1534 additions & 16 deletions

File tree

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
//
4+
// Differential-conformance GENERATOR for the QTT checker coupling.
5+
//
6+
// Emits a corpus of random `(ctx, tm)` queries plus the result of the Rust
7+
// `my_qtt::check` on each, in a canonical text form byte-identical to the one
8+
// the OCaml ORACLE (extracted from the verified Coq `check`) prints. The
9+
// harness `conformance/run.sh` then asserts the two streams are equal, i.e.
10+
// ∀ (g,t) in corpus. my_qtt::check g t == (extracted Coq check) g t
11+
// which is the refinement evidence for "the Rust checker refines Coq `check`".
12+
//
13+
// Usage: conformance_gen <count> <seed> <corpus_out> <rust_results_out>
14+
//
15+
// The generator is deterministic in `seed` (a tiny SplitMix64), depends only on
16+
// std, and deliberately produces a MIX of well-typed and ill-typed terms (out-
17+
// of-range vars, quantity/type mismatches) so both the `ok` and `none` branches
18+
// of `check` are exercised on both sides.
19+
20+
use my_qtt::session::{cstep1, Config, Party, Val};
21+
use my_qtt::{check, step1, Mode, Q, Tm, Ty};
22+
use std::fs::File;
23+
use std::io::{BufWriter, Write};
24+
25+
/// normal-form fuel cap — MUST equal the oracle's `eval_nf` cap so a divergent
26+
/// term yields the same capped term on both sides.
27+
const NF_FUEL: u32 = 64;
28+
29+
// ---------- deterministic PRNG (SplitMix64) ----------
30+
struct Rng(u64);
31+
impl Rng {
32+
fn next(&mut self) -> u64 {
33+
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
34+
let mut z = self.0;
35+
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
36+
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
37+
z ^ (z >> 31)
38+
}
39+
fn upto(&mut self, n: u64) -> u64 {
40+
self.next() % n
41+
}
42+
}
43+
44+
// ---------- random AST ----------
45+
fn gen_q(r: &mut Rng) -> Q {
46+
match r.upto(3) {
47+
0 => Q::Zero,
48+
1 => Q::One,
49+
_ => Q::Omega,
50+
}
51+
}
52+
fn gen_m(r: &mut Rng) -> Mode {
53+
if r.upto(2) == 0 { Mode::Linear } else { Mode::Affine }
54+
}
55+
56+
fn gen_ty(r: &mut Rng, fuel: u32) -> Ty {
57+
if fuel == 0 {
58+
return Ty::Unit;
59+
}
60+
match r.upto(6) {
61+
0 => Ty::Unit,
62+
1 => Ty::With(Box::new(gen_ty(r, fuel - 1)), Box::new(gen_ty(r, fuel - 1))),
63+
2 => Ty::Tensor(Box::new(gen_ty(r, fuel - 1)), Box::new(gen_ty(r, fuel - 1))),
64+
3 => Ty::Sum(Box::new(gen_ty(r, fuel - 1)), Box::new(gen_ty(r, fuel - 1))),
65+
4 => Ty::Arr(gen_q(r), Box::new(gen_ty(r, fuel - 1)), Box::new(gen_ty(r, fuel - 1))),
66+
_ => Ty::Echo(gen_m(r), Box::new(gen_ty(r, fuel - 1)), Box::new(gen_ty(r, fuel - 1))),
67+
}
68+
}
69+
70+
// `scope` = number of binders in scope; Var picks 0..scope+1 so some indices
71+
// fall out of range (→ `none`), exercising the ill-typed branch.
72+
fn gen_tm(r: &mut Rng, fuel: u32, scope: usize) -> Tm {
73+
if fuel == 0 {
74+
return if r.upto(2) == 0 {
75+
Tm::Var((r.upto((scope + 1) as u64)) as usize)
76+
} else {
77+
Tm::UnitT
78+
};
79+
}
80+
match r.upto(15) {
81+
0 => Tm::Var((r.upto((scope + 2) as u64)) as usize),
82+
1 => Tm::UnitT,
83+
2 => Tm::Lam(gen_q(r), gen_ty(r, fuel - 1), Box::new(gen_tm(r, fuel - 1, scope + 1))),
84+
3 => Tm::App(Box::new(gen_tm(r, fuel - 1, scope)), Box::new(gen_tm(r, fuel - 1, scope))),
85+
4 => Tm::With(Box::new(gen_tm(r, fuel - 1, scope)), Box::new(gen_tm(r, fuel - 1, scope))),
86+
5 => Tm::Fst(Box::new(gen_tm(r, fuel - 1, scope))),
87+
6 => Tm::Snd(Box::new(gen_tm(r, fuel - 1, scope))),
88+
7 => Tm::Tensor(Box::new(gen_tm(r, fuel - 1, scope)), Box::new(gen_tm(r, fuel - 1, scope))),
89+
8 => Tm::LetPair(Box::new(gen_tm(r, fuel - 1, scope)), Box::new(gen_tm(r, fuel - 1, scope + 2))),
90+
9 => Tm::Inl(gen_ty(r, fuel - 1), Box::new(gen_tm(r, fuel - 1, scope))),
91+
10 => Tm::Inr(gen_ty(r, fuel - 1), Box::new(gen_tm(r, fuel - 1, scope))),
92+
11 => Tm::Case(
93+
Box::new(gen_tm(r, fuel - 1, scope)),
94+
Box::new(gen_tm(r, fuel - 1, scope + 1)),
95+
Box::new(gen_tm(r, fuel - 1, scope + 1)),
96+
),
97+
12 => Tm::Let(gen_q(r), Box::new(gen_tm(r, fuel - 1, scope)), Box::new(gen_tm(r, fuel - 1, scope + 1))),
98+
13 => Tm::MkEcho(gen_m(r), gen_ty(r, fuel - 1), gen_ty(r, fuel - 1), Box::new(gen_tm(r, fuel - 1, scope))),
99+
_ => Tm::Weaken(Box::new(gen_tm(r, fuel - 1, scope))),
100+
}
101+
}
102+
103+
// ---------- canonical printers (MUST match conformance/oracle.ml) ----------
104+
fn show_q(q: Q) -> &'static str {
105+
match q {
106+
Q::Zero => "0",
107+
Q::One => "1",
108+
Q::Omega => "w",
109+
}
110+
}
111+
fn show_m(m: Mode) -> &'static str {
112+
match m {
113+
Mode::Linear => "lin",
114+
Mode::Affine => "aff",
115+
}
116+
}
117+
fn show_ty(t: &Ty) -> String {
118+
match t {
119+
Ty::Unit => "unit".into(),
120+
Ty::With(a, b) => format!("(with {} {})", show_ty(a), show_ty(b)),
121+
Ty::Tensor(a, b) => format!("(tensor {} {})", show_ty(a), show_ty(b)),
122+
Ty::Sum(a, b) => format!("(sum {} {})", show_ty(a), show_ty(b)),
123+
Ty::Arr(q, a, b) => format!("(arr {} {} {})", show_q(*q), show_ty(a), show_ty(b)),
124+
Ty::Echo(m, a, b) => format!("(echo {} {} {})", show_m(*m), show_ty(a), show_ty(b)),
125+
}
126+
}
127+
fn show_uvec(d: &[Q]) -> String {
128+
let parts: Vec<&str> = d.iter().map(|&q| show_q(q)).collect();
129+
format!("[{}]", parts.join(" "))
130+
}
131+
fn show_result(r: &Option<(Ty, Vec<Q>)>) -> String {
132+
match r {
133+
None => "none".into(),
134+
Some((a, d)) => format!("(ok {} {})", show_ty(a), show_uvec(d)),
135+
}
136+
}
137+
138+
// ---------- S-expression query emitters (MUST match the oracle grammar) ----------
139+
fn sexp_ty(t: &Ty) -> String {
140+
show_ty(t) // identical grammar
141+
}
142+
fn sexp_tm(t: &Tm) -> String {
143+
match t {
144+
Tm::Var(n) => format!("(var {})", n),
145+
Tm::UnitT => "star".into(),
146+
Tm::Lam(q, a, b) => format!("(lam {} {} {})", show_q(*q), sexp_ty(a), sexp_tm(b)),
147+
Tm::App(f, x) => format!("(app {} {})", sexp_tm(f), sexp_tm(x)),
148+
Tm::With(a, b) => format!("(with {} {})", sexp_tm(a), sexp_tm(b)),
149+
Tm::Fst(t) => format!("(fst {})", sexp_tm(t)),
150+
Tm::Snd(t) => format!("(snd {})", sexp_tm(t)),
151+
Tm::Tensor(a, b) => format!("(tensor {} {})", sexp_tm(a), sexp_tm(b)),
152+
Tm::LetPair(a, b) => format!("(letpair {} {})", sexp_tm(a), sexp_tm(b)),
153+
Tm::Inl(ty, t) => format!("(inl {} {})", sexp_ty(ty), sexp_tm(t)),
154+
Tm::Inr(ty, t) => format!("(inr {} {})", sexp_ty(ty), sexp_tm(t)),
155+
Tm::Case(s, l, r) => format!("(case {} {} {})", sexp_tm(s), sexp_tm(l), sexp_tm(r)),
156+
Tm::Let(q, a, b) => format!("(let {} {} {})", show_q(*q), sexp_tm(a), sexp_tm(b)),
157+
Tm::MkEcho(m, a, b, t) => {
158+
format!("(mkecho {} {} {} {})", show_m(*m), sexp_ty(a), sexp_ty(b), sexp_tm(t))
159+
}
160+
Tm::Weaken(t) => format!("(weaken {})", sexp_tm(t)),
161+
}
162+
}
163+
fn sexp_ctx(g: &[Ty]) -> String {
164+
let mut s = String::from("(ctx");
165+
for ty in g {
166+
s.push(' ');
167+
s.push_str(&sexp_ty(ty));
168+
}
169+
s.push(')');
170+
s
171+
}
172+
173+
fn show_step(r: &Option<Tm>) -> String {
174+
match r {
175+
None => "none".into(),
176+
Some(t) => format!("(some {})", sexp_tm(t)),
177+
}
178+
}
179+
180+
fn eval_nf(t: &Tm, fuel: u32) -> Tm {
181+
let mut cur = t.clone();
182+
let mut f = fuel;
183+
while f > 0 {
184+
match step1(&cur) {
185+
None => break,
186+
Some(n) => {
187+
cur = n;
188+
f -= 1;
189+
}
190+
}
191+
}
192+
cur
193+
}
194+
195+
// ---------- closed, redex-rich generators (deep substitution coverage) ----------
196+
fn gen_value(r: &mut Rng, fuel: u32) -> Tm {
197+
if fuel == 0 {
198+
return Tm::UnitT;
199+
}
200+
match r.upto(6) {
201+
0 => Tm::UnitT,
202+
1 => Tm::Lam(gen_q(r), gen_ty(r, 1), Box::new(gen_tm(r, fuel - 1, 1))),
203+
2 => Tm::With(Box::new(gen_value(r, fuel - 1)), Box::new(gen_value(r, fuel - 1))),
204+
3 => Tm::Tensor(Box::new(gen_value(r, fuel - 1)), Box::new(gen_value(r, fuel - 1))),
205+
4 => Tm::Inl(gen_ty(r, 1), Box::new(gen_value(r, fuel - 1))),
206+
_ => Tm::MkEcho(gen_m(r), gen_ty(r, 1), gen_ty(r, 1), Box::new(gen_value(r, fuel - 1))),
207+
}
208+
}
209+
210+
// closed (scope 0) terms built around redexes so reduction proceeds and
211+
// exercises subst0/subst2/shift in chains.
212+
fn gen_closed(r: &mut Rng, fuel: u32) -> Tm {
213+
if fuel == 0 {
214+
return gen_value(r, 1);
215+
}
216+
match r.upto(8) {
217+
0 => Tm::App(
218+
Box::new(Tm::Lam(gen_q(r), gen_ty(r, 1), Box::new(gen_tm(r, fuel - 1, 1)))),
219+
Box::new(gen_value(r, fuel - 1)),
220+
),
221+
1 => Tm::Let(gen_q(r), Box::new(gen_value(r, fuel - 1)), Box::new(gen_tm(r, fuel - 1, 1))),
222+
2 => Tm::Fst(Box::new(Tm::With(Box::new(gen_value(r, fuel - 1)), Box::new(gen_value(r, fuel - 1))))),
223+
3 => Tm::Snd(Box::new(Tm::With(Box::new(gen_value(r, fuel - 1)), Box::new(gen_value(r, fuel - 1))))),
224+
4 => Tm::LetPair(
225+
Box::new(Tm::Tensor(Box::new(gen_value(r, fuel - 1)), Box::new(gen_value(r, fuel - 1)))),
226+
Box::new(gen_tm(r, fuel - 1, 2)),
227+
),
228+
5 => Tm::Case(
229+
Box::new(Tm::Inl(gen_ty(r, 1), Box::new(gen_value(r, fuel - 1)))),
230+
Box::new(gen_tm(r, fuel - 1, 1)),
231+
Box::new(gen_tm(r, fuel - 1, 1)),
232+
),
233+
6 => Tm::Weaken(Box::new(Tm::MkEcho(
234+
Mode::Linear,
235+
gen_ty(r, 1),
236+
gen_ty(r, 1),
237+
Box::new(gen_value(r, fuel - 1)),
238+
))),
239+
_ => gen_value(r, fuel - 1),
240+
}
241+
}
242+
243+
// ---------- session configs (coupling #4) ----------
244+
fn sval(v: &Val) -> String {
245+
match v {
246+
Val::Var(n) => format!("(vvar {})", n),
247+
Val::Unit => "vunit".into(),
248+
Val::Bool(true) => "(vbool t)".into(),
249+
Val::Bool(false) => "(vbool f)".into(),
250+
Val::Nat(n) => format!("(vnat {})", n),
251+
}
252+
}
253+
fn sparty(p: &Party) -> String {
254+
match p {
255+
Party::End => "qend".into(),
256+
Party::Send(v, q) => format!("(qsend {} {})", sval(v), sparty(q)),
257+
Party::Recv(q) => format!("(qrecv {})", sparty(q)),
258+
Party::Sel(l, q) => format!("(qsel {} {})", l, sparty(q)),
259+
Party::Bra(bs) => {
260+
let mut s = String::from("(qbra");
261+
for (l, q) in bs {
262+
s.push_str(&format!(" (br {} {})", l, sparty(q)));
263+
}
264+
s.push(')');
265+
s
266+
}
267+
}
268+
}
269+
fn sconfig(c: &Config) -> String {
270+
format!("(conf {} {})", sparty(&c.0), sparty(&c.1))
271+
}
272+
fn show_cstep(r: &Option<Config>) -> String {
273+
match r {
274+
None => "none".into(),
275+
Some(c) => format!("(some {})", sconfig(c)),
276+
}
277+
}
278+
279+
fn gen_val(r: &mut Rng) -> Val {
280+
match r.upto(4) {
281+
0 => Val::Var(r.upto(3) as usize),
282+
1 => Val::Unit,
283+
2 => Val::Bool(r.upto(2) == 0),
284+
_ => Val::Nat(r.upto(5)),
285+
}
286+
}
287+
fn gen_party(r: &mut Rng, fuel: u32) -> Party {
288+
if fuel == 0 {
289+
return Party::End;
290+
}
291+
match r.upto(6) {
292+
0 | 5 => Party::End,
293+
1 => Party::Send(gen_val(r), Box::new(gen_party(r, fuel - 1))),
294+
2 => Party::Recv(Box::new(gen_party(r, fuel - 1))),
295+
3 => Party::Sel(r.upto(3) as usize, Box::new(gen_party(r, fuel - 1))),
296+
_ => {
297+
let n = r.upto(3) as usize;
298+
Party::Bra((0..n).map(|i| (i, gen_party(r, fuel - 1))).collect())
299+
}
300+
}
301+
}
302+
// bias toward dual pairs so the comm/select steps fire (alongside stuck configs)
303+
fn gen_config(r: &mut Rng) -> Config {
304+
let f = 2 + r.upto(2) as u32;
305+
match r.upto(6) {
306+
0 => Config(Box::new(Party::Send(gen_val(r), Box::new(gen_party(r, f)))), Box::new(Party::Recv(Box::new(gen_party(r, f))))),
307+
1 => Config(Box::new(Party::Recv(Box::new(gen_party(r, f)))), Box::new(Party::Send(gen_val(r), Box::new(gen_party(r, f))))),
308+
2 => Config(
309+
Box::new(Party::Sel(r.upto(4) as usize, Box::new(gen_party(r, f)))),
310+
Box::new(gen_party(r, f + 1)),
311+
),
312+
3 => Config(
313+
Box::new(gen_party(r, f + 1)),
314+
Box::new(Party::Sel(r.upto(4) as usize, Box::new(gen_party(r, f)))),
315+
),
316+
_ => Config(Box::new(gen_party(r, f)), Box::new(gen_party(r, f))),
317+
}
318+
}
319+
320+
fn main() {
321+
let args: Vec<String> = std::env::args().collect();
322+
if args.len() != 5 {
323+
eprintln!("usage: conformance_gen <count> <seed> <corpus_out> <rust_results_out>");
324+
std::process::exit(2);
325+
}
326+
let count: u64 = args[1].parse().expect("count");
327+
let seed: u64 = args[2].parse().expect("seed");
328+
let mut corpus = BufWriter::new(File::create(&args[3]).expect("corpus_out"));
329+
let mut results = BufWriter::new(File::create(&args[4]).expect("rust_results_out"));
330+
331+
let mut r = Rng(seed.wrapping_add(0xD1B5_4A32_D192_ED03));
332+
for _ in 0..count {
333+
// (a) checker (#1) + one-step (#2) conformance on a random term that
334+
// may be open and may be ill-typed (exercises both decision branches)
335+
let k = r.upto(4) as usize;
336+
let g: Vec<Ty> = (0..k).map(|_| gen_ty(&mut r, 2)).collect();
337+
let fuel = 2 + (r.upto(3) as u32); // depth 2..=4
338+
let t = gen_tm(&mut r, fuel, k);
339+
writeln!(corpus, "(q {} {})", sexp_ctx(&g), sexp_tm(&t)).unwrap();
340+
writeln!(results, "{}", show_result(&check(&g, &t))).unwrap();
341+
writeln!(corpus, "(s {})", sexp_tm(&t)).unwrap();
342+
writeln!(results, "{}", show_step(&step1(&t))).unwrap();
343+
344+
// (b) normal-form (#2, deep) conformance on a CLOSED redex-rich term,
345+
// cross-checking subst0/subst2/shift against Coq's extracted subst
346+
let ct = gen_closed(&mut r, 3);
347+
writeln!(corpus, "(nf {})", sexp_tm(&ct)).unwrap();
348+
writeln!(results, "{}", sexp_tm(&eval_nf(&ct, NF_FUEL))).unwrap();
349+
350+
// (c) session-config step (#4): Rust my_qtt::session::cstep1 vs the
351+
// extracted Coq cstep1 (proved sound+complete vs the cstep relation)
352+
let cfg = gen_config(&mut r);
353+
writeln!(corpus, "(cstep {})", sconfig(&cfg)).unwrap();
354+
writeln!(results, "{}", show_cstep(&cstep1(&cfg))).unwrap();
355+
}
356+
}

0 commit comments

Comments
 (0)